import { createHash } from "node:crypto";
import type { Bot } from "grammy";
import type { LanguageCode } from "grammy/types";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
  normalizeOptionalString,
  readStringValue,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js";

const TELEGRAM_MAX_COMMANDS = 100;
export const TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET = 5700;
const TELEGRAM_COMMAND_RETRY_RATIO = 0.8;
const TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH = 1;
const TELEGRAM_MAX_COMMAND_DESCRIPTION_LENGTH = 256;
const TELEGRAM_MENU_RESULT_CACHE_MAX = 128;

export type TelegramMenuCommand = {
  command: string;
  description: string;
  descriptionLocalizations?: Record<string, string>;
};

type TelegramCommandMenuScope =
  | { label: "default"; options?: undefined }
  | { label: "all_group_chats"; options: { scope: { type: "all_group_chats" } } };

type TelegramPluginCommandSpec = {
  name: unknown;
  description: unknown;
  descriptionLocalizations?: Record<string, string>;
};

const TELEGRAM_COMMAND_MENU_SCOPES: readonly TelegramCommandMenuScope[] = [
  { label: "default" },
  { label: "all_group_chats", options: { scope: { type: "all_group_chats" } } },
];

const cappedTelegramMenuCache = new Map<
  string,
  ReturnType<typeof buildUncachedCappedTelegramMenuCommands>
>();

function countTelegramCommandText(value: string): number {
  let count = 0;
  for (let index = 0; index < value.length; ) {
    const codePoint = value.codePointAt(index);
    index += codePoint && codePoint > 0xffff ? 2 : 1;
    count += 1;
  }
  return count;
}

function truncateTelegramCommandText(value: string, maxLength: number): string {
  if (maxLength <= 0) {
    return "";
  }

  const suffix = maxLength > 1 ? "…" : "";
  const prefixLimit = maxLength - countTelegramCommandText(suffix);
  let count = 0;
  let prefixEnd = 0;
  for (const char of value) {
    count += 1;
    if (count <= prefixLimit) {
      prefixEnd += char.length;
    }
    if (count > maxLength) {
      return `${value.slice(0, prefixEnd)}${suffix}`;
    }
  }
  return value;
}

function fitTelegramCommandsWithinTextBudget(
  commands: TelegramMenuCommand[],
  maxTotalChars: number,
): {
  commands: TelegramMenuCommand[];
  descriptionTrimmed: boolean;
  textBudgetDropCount: number;
} {
  let candidateCommands = [...commands];
  while (candidateCommands.length > 0) {
    const commandNameChars = candidateCommands.reduce(
      (total, command) => total + countTelegramCommandText(command.command),
      0,
    );
    const descriptionBudget = maxTotalChars - commandNameChars;
    const minimumDescriptionBudget =
      candidateCommands.length * TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH;
    if (descriptionBudget < minimumDescriptionBudget) {
      candidateCommands = candidateCommands.slice(0, -1);
      continue;
    }

    const descriptionCap = Math.max(
      TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH,
      Math.floor(descriptionBudget / candidateCommands.length),
    );
    let descriptionTrimmed = false;
    const fittedCommands = candidateCommands.map((command) => {
      const description = truncateTelegramCommandText(
        command.description,
        Math.min(descriptionCap, TELEGRAM_MAX_COMMAND_DESCRIPTION_LENGTH),
      );
      if (description !== command.description) {
        descriptionTrimmed = true;
        return Object.assign({}, command, { description });
      }
      return command;
    });
    return {
      commands: fittedCommands,
      descriptionTrimmed,
      textBudgetDropCount: commands.length - fittedCommands.length,
    };
  }

  return {
    commands: [],
    descriptionTrimmed: false,
    textBudgetDropCount: commands.length,
  };
}

function readErrorTextField(value: unknown, key: "description" | "message"): string | undefined {
  if (!value || typeof value !== "object" || !(key in value)) {
    return undefined;
  }
  return readStringValue((value as Record<"description" | "message", unknown>)[key]);
}

function isBotCommandsTooMuchError(err: unknown): boolean {
  if (!err) {
    return false;
  }
  const pattern = /\bBOT_COMMANDS_TOO_MUCH\b/i;
  if (typeof err === "string") {
    return pattern.test(err);
  }
  if (err instanceof Error) {
    if (pattern.test(err.message)) {
      return true;
    }
  }
  const description = readErrorTextField(err, "description");
  if (description && pattern.test(description)) {
    return true;
  }
  const message = readErrorTextField(err, "message");
  if (message && pattern.test(message)) {
    return true;
  }
  return false;
}

function formatTelegramCommandRetrySuccessLog(params: {
  initialCount: number;
  acceptedCount: number;
}): string {
  const omittedCount = Math.max(0, params.initialCount - params.acceptedCount);
  return (
    `Telegram accepted ${params.acceptedCount} commands after BOT_COMMANDS_TOO_MUCH ` +
    `(started with ${params.initialCount}; omitted ${omittedCount}). ` +
    "Reduce plugin/skill/custom commands to expose more menu entries."
  );
}

export function buildPluginTelegramMenuCommands(params: {
  specs: TelegramPluginCommandSpec[];
  existingCommands: Set<string>;
}): { commands: TelegramMenuCommand[]; issues: string[] } {
  const { specs, existingCommands } = params;
  const commands: TelegramMenuCommand[] = [];
  const issues: string[] = [];
  const pluginCommandNames = new Set<string>();

  for (const spec of specs) {
    const rawName = typeof spec.name === "string" ? spec.name : "";
    const normalized = normalizeTelegramCommandName(rawName);
    if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
      const invalidName = rawName.trim() ? rawName : "<unknown>";
      issues.push(
        `Plugin command "/${invalidName}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`,
      );
      continue;
    }
    const description = normalizeOptionalString(spec.description) ?? "";
    if (!description) {
      issues.push(`Plugin command "/${normalized}" is missing a description.`);
      continue;
    }
    if (existingCommands.has(normalized)) {
      if (pluginCommandNames.has(normalized)) {
        issues.push(`Plugin command "/${normalized}" is duplicated.`);
      } else {
        issues.push(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`);
      }
      continue;
    }
    pluginCommandNames.add(normalized);
    existingCommands.add(normalized);
    const menuCommand: TelegramMenuCommand = { command: normalized, description };
    if (spec.descriptionLocalizations) {
      menuCommand.descriptionLocalizations = spec.descriptionLocalizations;
    }
    commands.push(menuCommand);
  }

  return { commands, issues };
}

export function buildCappedTelegramMenuCommands(params: {
  allCommands: TelegramMenuCommand[];
  maxCommands?: number;
  maxTotalChars?: number;
}): ReturnType<typeof buildUncachedCappedTelegramMenuCommands> {
  const maxCommands = params.maxCommands ?? TELEGRAM_MAX_COMMANDS;
  const maxTotalChars = params.maxTotalChars ?? TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET;
  const cacheKey = buildTelegramMenuResultCacheKey({
    allCommands: params.allCommands,
    maxCommands,
    maxTotalChars,
  });
  const cached = cappedTelegramMenuCache.get(cacheKey);
  if (cached) {
    return cached;
  }
  const result = buildUncachedCappedTelegramMenuCommands({
    allCommands: params.allCommands,
    maxCommands,
    maxTotalChars,
  });
  rememberCappedTelegramMenuResult(cacheKey, result);
  return result;
}

function buildUncachedCappedTelegramMenuCommands(params: {
  allCommands: TelegramMenuCommand[];
  maxCommands: number;
  maxTotalChars: number;
}): {
  commandsToRegister: TelegramMenuCommand[];
  totalCommands: number;
  maxCommands: number;
  overflowCount: number;
  maxTotalChars: number;
  descriptionTrimmed: boolean;
  textBudgetDropCount: number;
} {
  const { allCommands } = params;
  const { maxCommands, maxTotalChars } = params;
  const totalCommands = allCommands.length;
  const overflowCount = Math.max(0, totalCommands - maxCommands);
  const {
    commands: commandsToRegister,
    descriptionTrimmed,
    textBudgetDropCount,
  } = fitTelegramCommandsWithinTextBudget(allCommands.slice(0, maxCommands), maxTotalChars);
  return {
    commandsToRegister,
    totalCommands,
    maxCommands,
    overflowCount,
    maxTotalChars,
    descriptionTrimmed,
    textBudgetDropCount,
  };
}

function buildTelegramMenuResultCacheKey(params: {
  allCommands: TelegramMenuCommand[];
  maxCommands: number;
  maxTotalChars: number;
}): string {
  const digest = createHash("sha256");
  updateTelegramCommandDigestField(digest, String(params.maxCommands));
  updateTelegramCommandDigestField(digest, String(params.maxTotalChars));
  for (const command of params.allCommands) {
    updateTelegramCommandDigestField(digest, command.command);
    updateTelegramCommandDigestField(digest, command.description);
    updateTelegramCommandLocalizationDigest(digest, command.descriptionLocalizations);
  }
  return digest.digest("hex").slice(0, 16);
}

function updateTelegramCommandDigestField(
  digest: ReturnType<typeof createHash>,
  value: string,
): void {
  digest.update(String(value.length));
  digest.update(":");
  digest.update(value);
}

function updateTelegramCommandLocalizationDigest(
  digest: ReturnType<typeof createHash>,
  localizations: Record<string, string> | undefined,
): void {
  const entries = Object.entries(localizations ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
  updateTelegramCommandDigestField(digest, String(entries.length));
  for (const [locale, description] of entries) {
    updateTelegramCommandDigestField(digest, locale);
    updateTelegramCommandDigestField(digest, description);
  }
}

function rememberCappedTelegramMenuResult(
  key: string,
  result: ReturnType<typeof buildUncachedCappedTelegramMenuCommands>,
): void {
  cappedTelegramMenuCache.set(key, result);
  if (cappedTelegramMenuCache.size <= TELEGRAM_MENU_RESULT_CACHE_MAX) {
    return;
  }
  const oldestKey = cappedTelegramMenuCache.keys().next().value;
  if (oldestKey) {
    cappedTelegramMenuCache.delete(oldestKey);
  }
}

export function hashCommandList(commands: TelegramMenuCommand[]): string {
  const sorted = [...commands].toSorted((a, b) => a.command.localeCompare(b.command));
  return createHash("sha256").update(JSON.stringify(sorted)).digest("hex").slice(0, 16);
}

// Keep the sync cache process-local so restarts always re-register commands.
const syncedCommandHashes = new Map<string, string>();

function getCommandHashKey(accountId?: string, botIdentity?: string): string {
  return `${accountId ?? "default"}:${botIdentity ?? ""}`;
}

function readCachedCommandHash(accountId?: string, botIdentity?: string): string | null {
  const key = getCommandHashKey(accountId, botIdentity);
  return syncedCommandHashes.get(key) ?? null;
}

function writeCachedCommandHash(
  accountId: string | undefined,
  botIdentity: string | undefined,
  hash: string,
): void {
  const key = getCommandHashKey(accountId, botIdentity);
  syncedCommandHashes.set(key, hash);
}

function normalizeTelegramLanguageCode(languageCode: string): string | null {
  const normalized = languageCode.trim().toLowerCase();
  return /^[a-z]{2}$/.test(normalized) ? normalized : null;
}

function readLocalizedDescription(
  command: TelegramMenuCommand,
  languageCode: string,
): string | undefined {
  for (const [rawLanguageCode, rawDescription] of Object.entries(
    command.descriptionLocalizations ?? {},
  )) {
    if (normalizeTelegramLanguageCode(rawLanguageCode) !== languageCode) {
      continue;
    }
    const description = normalizeOptionalString(rawDescription);
    if (description) {
      return description;
    }
  }
  return undefined;
}

function toTelegramBotCommands(commands: TelegramMenuCommand[]): Array<{
  command: string;
  description: string;
}> {
  return commands.map((command) => ({
    command: command.command,
    description: command.description,
  }));
}

function buildLocalizedCommandVariants(commands: TelegramMenuCommand[]): {
  variants: Array<{ languageCode: string; commands: TelegramMenuCommand[] }>;
  unsupportedLanguageCodes: string[];
} {
  const locales = new Set<string>();
  const unsupportedLanguageCodes = new Set<string>();
  for (const cmd of commands) {
    if (cmd.descriptionLocalizations) {
      for (const lang of Object.keys(cmd.descriptionLocalizations)) {
        const normalized = normalizeTelegramLanguageCode(lang);
        if (normalized) {
          locales.add(normalized);
        } else {
          unsupportedLanguageCodes.add(lang);
        }
      }
    }
  }
  const variants = [...locales].toSorted().map((languageCode) => {
    const localizedCommands = commands.map((cmd) => ({
      command: cmd.command,
      description: readLocalizedDescription(cmd, languageCode) ?? cmd.description,
    }));
    return {
      languageCode,
      commands: fitTelegramCommandsWithinTextBudget(
        localizedCommands,
        TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET,
      ).commands,
    };
  });
  return {
    variants,
    unsupportedLanguageCodes: [...unsupportedLanguageCodes].toSorted(),
  };
}

function formatTelegramCommandScopeOperation(
  operation: "deleteMyCommands" | "setMyCommands",
  scope: TelegramCommandMenuScope,
  languageCode?: string,
): string {
  const base = scope.label === "default" ? operation : `${operation}(${scope.label})`;
  return languageCode ? `${base}(${languageCode})` : base;
}

async function deleteTelegramMenuCommandsForScopes(params: {
  bot: Bot;
  runtime: RuntimeEnv;
}): Promise<boolean> {
  const { bot, runtime } = params;
  if (typeof bot.api.deleteMyCommands !== "function") {
    return true;
  }

  let allDeleted = true;
  for (const scope of TELEGRAM_COMMAND_MENU_SCOPES) {
    const deleted = await withTelegramApiErrorLogging({
      operation: formatTelegramCommandScopeOperation("deleteMyCommands", scope),
      runtime,
      fn: () =>
        scope.options ? bot.api.deleteMyCommands(scope.options) : bot.api.deleteMyCommands(),
    })
      .then(() => true)
      .catch(() => false);
    allDeleted &&= deleted;
  }
  return allDeleted;
}

async function setTelegramMenuCommandsForScopes(params: {
  bot: Bot;
  runtime: RuntimeEnv;
  commands: TelegramMenuCommand[];
  languageCode?: string;
  shouldLog?: (err: unknown) => boolean;
}): Promise<void> {
  const { bot, runtime, commands, languageCode, shouldLog } = params;
  for (const scope of TELEGRAM_COMMAND_MENU_SCOPES) {
    await withTelegramApiErrorLogging({
      operation: formatTelegramCommandScopeOperation("setMyCommands", scope, languageCode),
      runtime,
      shouldLog,
      fn: () => {
        const botCommands = toTelegramBotCommands(commands);
        const opts = {
          ...scope.options,
          ...(languageCode ? { language_code: languageCode as LanguageCode } : undefined),
        };
        return Object.keys(opts).length > 0
          ? bot.api.setMyCommands(botCommands, opts)
          : bot.api.setMyCommands(botCommands);
      },
    });
  }
}

export function syncTelegramMenuCommands(params: {
  bot: Bot;
  runtime: RuntimeEnv;
  commandsToRegister: TelegramMenuCommand[];
  accountId?: string;
  botIdentity?: string;
}): void {
  const { bot, runtime, commandsToRegister, accountId, botIdentity } = params;
  const sync = async () => {
    // Skip sync if the command list hasn't changed since the last successful
    // sync. This prevents hitting Telegram's 429 rate limit when the gateway
    // is restarted several times in quick succession.
    // See: openclaw/openclaw#32017
    const currentHash = hashCommandList(commandsToRegister);
    const cachedHash = readCachedCommandHash(accountId, botIdentity);
    if (cachedHash === currentHash) {
      logVerbose("telegram: command menu unchanged; skipping sync");
      return;
    }

    // Keep delete -> set ordering to avoid stale deletions racing after fresh registrations.
    const deleteSucceeded = await deleteTelegramMenuCommandsForScopes({ bot, runtime });

    if (commandsToRegister.length === 0) {
      if (!deleteSucceeded) {
        runtime.log?.("telegram: deleteMyCommands failed; skipping empty-menu hash cache write");
        return;
      }
      if (typeof bot.api.deleteMyCommands !== "function") {
        await setTelegramMenuCommandsForScopes({ bot, runtime, commands: [] });
      }
      writeCachedCommandHash(accountId, botIdentity, currentHash);
      return;
    }

    let retryCommands = commandsToRegister;
    let acceptedCommands: TelegramMenuCommand[] | null = null;
    const initialCommandCount = commandsToRegister.length;
    while (retryCommands.length > 0) {
      try {
        await setTelegramMenuCommandsForScopes({
          bot,
          runtime,
          commands: retryCommands,
          shouldLog: (err) => !isBotCommandsTooMuchError(err),
        });
        if (retryCommands.length < initialCommandCount) {
          runtime.log?.(
            formatTelegramCommandRetrySuccessLog({
              initialCount: initialCommandCount,
              acceptedCount: retryCommands.length,
            }),
          );
        }
        acceptedCommands = retryCommands;
        break;
      } catch (err) {
        if (!isBotCommandsTooMuchError(err)) {
          throw err;
        }
        const nextCount = Math.floor(retryCommands.length * TELEGRAM_COMMAND_RETRY_RATIO);
        const reducedCount =
          nextCount < retryCommands.length ? nextCount : retryCommands.length - 1;
        if (reducedCount <= 0) {
          runtime.error?.(
            "Telegram rejected native command registration (BOT_COMMANDS_TOO_MUCH); leaving menu empty. Reduce commands or disable channels.telegram.commands.native.",
          );
          return;
        }
        runtime.log?.(
          `Telegram rejected ${retryCommands.length} commands (BOT_COMMANDS_TOO_MUCH); retrying with ${reducedCount}.`,
        );
        retryCommands = retryCommands.slice(0, reducedCount);
      }
    }

    if (!acceptedCommands) {
      return;
    }

    const { variants, unsupportedLanguageCodes } = buildLocalizedCommandVariants(acceptedCommands);
    if (unsupportedLanguageCodes.length > 0) {
      runtime.log?.(
        `Telegram command menu ignored unsupported description localization codes: ${unsupportedLanguageCodes.join(", ")}.`,
      );
    }

    for (const variant of variants) {
      await setTelegramMenuCommandsForScopes({
        bot,
        runtime,
        commands: variant.commands,
        languageCode: variant.languageCode,
      });
    }
    writeCachedCommandHash(accountId, botIdentity, currentHash);
  };

  void sync().catch((err) => {
    runtime.error?.(`Telegram command sync failed: ${String(err)}`);
  });
}
