// Suppress deprecated content-type warning when sending files via the Telegram bot API.
// https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#sending-files
process.env.NTBA_FIX_350 = 1;
const TelegramBot = require("node-telegram-bot-api");
const {
  ExternalCommunicationConnector,
} = require("../../models/externalCommunicationConnector");
const { BackgroundService } = require("../BackgroundWorkers");
const { MessageQueue } = require("./utils/messageQueue");
const { decryptToken } = require("./utils");
const {
  WorkspaceAgentInvocation,
} = require("../../models/workspaceAgentInvocation");
const {
  isVerified,
  sendPairingRequest,
  approveUser,
  denyUser,
  revokeUser,
} = require("./utils/verification");
const { BOT_COMMANDS } = require("./utils/commands");
const { handleKeyboardQueryCallback } = require("./utils/navigation");
const {
  downloadTelegramFile,
  transcribeAudio,
  documentToText,
  photoToAttachment,
} = require("./utils/media");

class TelegramBotService {
  static _instance = null;
  static #MAX_POLLING_RETRIES = 10;
  static #BASE_RETRY_DELAY_MS = 1000;
  static #MAX_RETRY_DELAY_MS = 5 * 60 * 1000; // 5 minutes
  static #NETWORK_ERROR_PATTERNS = [
    "EPIPE",
    "EPROTO",
    "ECONNABORTED",
    "EHOSTDOWN",
    "ENETDOWN",
    "EADDRNOTAVAIL",
    "ETIMEDOUT",
    "ECONNRESET",
    "ECONNREFUSED",
    "ENOTFOUND",
    "ENETUNREACH",
    "EHOSTUNREACH",
    "EAI_AGAIN",
    "EFATAL",
    "socket hang up",
    "network",
    "timeout",
    "bad gateway",
    "flood",
    "429",
    "409",
    "500",
    "501",
    "502",
    "503",
    "504",
    "520",
    "521",
    "522",
    "523",
    "524",
  ];

  /** @type {TelegramBot|null} */
  #bot = null;
  #config = null;
  #queue = new MessageQueue();
  #pollingRetry = { timer: null, count: 0 };
  // Per-chat state: { workspaceSlug, threadSlug }
  #chatState = new Map();
  // Pending pairing requests: chatId -> { code, telegramUsername, firstName }
  #pendingPairings = new Map();
  // Active workers per chat: chatId -> { worker, jobId }
  #activeWorkers = new Map();
  // Pending tool approval requests: requestId -> { worker, chatId, messageId }
  #pendingToolApprovals = new Map();

  constructor() {
    if (TelegramBotService._instance) return TelegramBotService._instance;
    TelegramBotService._instance = this;
  }

  get isRunning() {
    return this.#bot !== null;
  }

  get pendingPairings() {
    const pairings = [];
    for (const [chatId, data] of this.#pendingPairings) {
      pairings.push({ chatId: String(chatId), ...data });
    }
    return pairings;
  }

  #log(text, ...args) {
    console.log(`\x1b[35m[TelegramBot]\x1b[0m ${text}`, ...args);
  }

  async start(config) {
    if (this.#bot) await this.stop();
    this.#config = config;

    // Clear pending updates on startup, keeping only the last message per chat
    // This prevents processing a backlog of messages when the bot restarts.
    this.#bot = new TelegramBot(config.bot_token, { polling: false });
    const lastMessages = await this.#clearPendingUpdates();
    this.#bot.startPolling();

    // Restore per-user workspace/thread state from saved config
    for (const user of config.approved_users || []) {
      if (user.active_workspace) {
        this.#chatState.set(Number(user.chatId), {
          workspaceSlug: user.active_workspace,
          threadSlug: user.active_thread || null,
        });
      }
    }

    this.#setupHandlers();
    await this.#registerCommands();
    this.#log(`Started polling as @${config.bot_username || "unknown"}`);

    // Process only the last message from each chat that was pending
    if (lastMessages.size > 0) {
      this.#log(
        `Processing ${lastMessages.size} pending message(s) from startup`
      );
      const ctx = this.#createContext();
      for (const [chatId, msg] of lastMessages) {
        if (!isVerified(this.#config.approved_users, chatId)) continue;
        this.#processPendingMessage(ctx, msg);
      }
    }
  }

  /**
   * Process a single pending message from startup.
   * Handles both commands and regular messages.
   */
  #processPendingMessage(ctx, msg) {
    const text = msg.text || "";

    // Handle commands
    if (text.startsWith("/")) {
      const commandMatch = text.match(/^\/(\w+)/);
      if (!commandMatch) return;

      const commandName = commandMatch[1];
      const command = BOT_COMMANDS.find((c) => c.command === commandName);
      if (command) {
        const handler = command.initHandler();
        handler(ctx, msg.chat.id, text);
        return;
      }
    }

    // Handle regular messages
    this.#handleMessage(ctx, msg);
  }

  /**
   * Check if the instance is running in multi-user mode
   * @returns {Promise<boolean>}
   */
  async checkMultiUserMode() {
    const { SystemSettings } = require("../../models/systemSettings");
    return await SystemSettings.isMultiUserMode();
  }

  updateConfig(updates) {
    if (!this.#config) return;
    Object.assign(this.#config, updates);
  }

  /**
   * Stop the bot and clear all state.
   * @returns {Promise<void>}
   */
  async stop() {
    if (!this.#bot) return;

    // Clear any pending retry timer
    if (this.#pollingRetry.timer) {
      clearTimeout(this.#pollingRetry.timer);
      this.#pollingRetry.timer = null;
    }
    this.#pollingRetry.count = 0;

    try {
      await this.#bot.stopPolling();
    } catch {
      // Polling may already be stopped
    }
    // Kill any active workers before clearing state
    for (const chatId of this.#activeWorkers.keys()) {
      this.abortChat(chatId);
    }
    this.#bot = null;
    this.#config = null;
    this.#queue.clear();
    this.#chatState.clear();
    this.#pendingPairings.clear();
    this.#activeWorkers.clear();
    this.#pendingToolApprovals.clear();
    this.#log("Stopped");
  }

  /**
   * Self-cleanup when the bot token becomes invalid (e.g., bot deleted).
   * Stops polling and removes the connector from the database.
   */
  async #selfCleanup(reason) {
    this.#log(`Self-cleanup triggered: ${reason}`);
    await this.stop();
    await ExternalCommunicationConnector.delete("telegram");
    this.#log("Connector deleted due to invalid token");
  }

  /**
   * Check if an error is a transient network issue that warrants retry.
   */
  #isNetworkError(error) {
    const msg = (error.message || "").toLowerCase();
    return TelegramBotService.#NETWORK_ERROR_PATTERNS.some(
      (p) => msg.includes(p.toLowerCase()) || error.code === p
    );
  }

  /**
   * Handle polling errors with retry logic for network issues.
   * - 401 errors: Self-cleanup and delete connector (token invalid)
   * - Network errors (ETIMEDOUT, ECONNRESET, etc.): Retry with exponential backoff
   * - Other errors: Stop polling immediately
   */
  async #handlePollingError(error) {
    // Ignore errors while already waiting to retry
    if (this.#pollingRetry.timer) return;
    this.#log("Polling error:", error.message);

    // 401 = invalid token, cleanup and stop
    if (error.message?.includes("401")) {
      this.#log(
        "Got 401 - bot token invalid. Stopping and deleting connector."
      );
      return this.#selfCleanup("401 Unauthorized");
    }

    // For non-network errors, stop immediately, but don't delete the connector
    if (!this.#isNetworkError(error)) {
      this.#log(`Got HTTP error ${error.message}. Stopping polling.`);
      return await this.stop();
    }

    // Network error - attempt retry with exponential backoff
    const maxRetries = TelegramBotService.#MAX_POLLING_RETRIES;
    this.#pollingRetry.count++;
    if (this.#pollingRetry.count > maxRetries) {
      this.#log(
        `Network error. Max retries (${maxRetries}) exceeded. Stopping.`
      );
      this.#pollingRetry.count = 0;
      return await this.stop();
    }

    const delay = Math.min(
      TelegramBotService.#BASE_RETRY_DELAY_MS *
        Math.pow(2, this.#pollingRetry.count - 1),
      TelegramBotService.#MAX_RETRY_DELAY_MS
    );
    this.#log(
      `Network error. Retry ${this.#pollingRetry.count}/${maxRetries} in ${Math.round(delay / 1000)}s...`
    );

    this.#pollingRetry.timer = setTimeout(async () => {
      this.#pollingRetry.timer = null;
      if (!this.#bot || !this.#config) return;

      try {
        await this.#bot.stopPolling();
      } catch {}

      this.#log("Attempting to restart polling...");
      try {
        await this.#bot.startPolling();
        this.#log("Polling restarted successfully.");
      } catch (err) {
        this.#log("Failed to restart polling:", err.message);
        await this.stop();
      }
    }, delay);
  }

  /**
   * Clear pending updates on startup, keeping only the last user message per chat.
   * This prevents processing a backlog of messages when the bot restarts.
   * @returns {Promise<Map<number, object>>} Map of chatId -> last message to process
   */
  async #clearPendingUpdates() {
    const lastMessages = new Map();
    try {
      // Fetch all pending updates (up to 100)
      const updates = await this.#bot.getUpdates({ limit: 100, timeout: 0 });
      if (!updates || updates.length === 0) return lastMessages;

      this.#log(`Found ${updates.length} pending update(s) on startup`);

      // Find the last message per chat (including commands)
      for (const update of updates) {
        const msg = update.message;
        if (!msg) continue;

        const chatId = msg.chat.id;
        // Keep overwriting to get the last message per chat
        lastMessages.set(chatId, msg);
      }

      // Mark all updates as processed by requesting with offset past the last one
      const lastUpdateId = updates[updates.length - 1].update_id;
      await this.#bot.getUpdates({
        offset: lastUpdateId + 1,
        limit: 1,
        timeout: 0,
      });

      this.#log(
        `Cleared pending updates, will process ${lastMessages.size} last message(s)`
      );
    } catch (error) {
      this.#log("Failed to clear pending updates:", error.message);
    }
    return lastMessages;
  }

  async #registerCommands() {
    try {
      const commands = BOT_COMMANDS.map((c) => ({
        command: c.command,
        description: c.description,
      }));
      await this.#bot.setMyCommands(commands);
    } catch (error) {
      this.#log("Failed to register commands:", error.message);
    }
  }

  #getState(chatId) {
    if (!this.#chatState.has(chatId)) {
      this.#chatState.set(chatId, {
        workspaceSlug: this.#config.default_workspace,
        threadSlug: null,
      });
    }
    return this.#chatState.get(chatId);
  }

  #setState(chatId, updates) {
    const state = this.#getState(chatId);
    Object.assign(state, updates);
    this.#persistChatState(chatId, state);
  }

  async #persistChatState(chatId, state) {
    const approved = (this.#config.approved_users || []).map((u) => {
      if (String(u.chatId) === String(chatId)) {
        return {
          ...u,
          active_workspace: state.workspaceSlug,
          active_thread: state.threadSlug,
        };
      }
      return u;
    });
    this.#config.approved_users = approved;
    await ExternalCommunicationConnector.updateConfig("telegram", {
      approved_users: approved,
    });
  }

  /**
   * Build a context object that handler modules use to access
   * the bot instance, config, and state helpers.
   */
  #createContext() {
    return {
      bot: this.#bot,
      config: this.#config,
      getState: (chatId) => this.#getState(chatId),
      setState: (chatId, updates) => this.#setState(chatId, updates),
      log: (text, ...args) => this.#log(text, ...args),
    };
  }

  async approvePendingUser(chatId) {
    await approveUser(this.#bot, chatId, this.#config, this.#pendingPairings);
  }

  async denyPendingUser(chatId) {
    await denyUser(this.#bot, chatId, this.#pendingPairings);
  }

  async revokeExistingUser(chatId) {
    await revokeUser(chatId, this.#config);
  }

  /**
   * Assert that the bot is running in single-user mode.
   * If the instance is running in multi-user mode, it will stop the bot and delete the connector.
   * - Returns true if the bot is running in single-user mode.
   * - Returns false if the bot is running in multi-user mode.
   * @returns {Promise<boolean>}
   */
  async #assertSingleUserMode() {
    const isMultiUserMode = await this.checkMultiUserMode();
    if (!isMultiUserMode) return true;

    this.#log(
      "Invalid state: Multi-user mode detected. Cleaning up and deleting connector."
    );
    await this.stop();
    await ExternalCommunicationConnector.delete("telegram");
    return false;
  }

  /**
   * Reset the polling retry state and clear the timer if it exists.
   */
  #resetPollingRetry() {
    this.#pollingRetry.count = 0;
    if (this.#pollingRetry.timer) clearTimeout(this.#pollingRetry.timer);
    this.#pollingRetry.timer = null;
  }

  #setupHandlers() {
    const ctx = this.#createContext();
    const guard = async (msg, handler) => {
      if (!this.#config) return;
      this.#resetPollingRetry(); // Reset the polling on successful message receipt

      if (!isVerified(this.#config.approved_users, msg.chat.id)) {
        sendPairingRequest(this.#bot, msg, this.#pendingPairings);
        return;
      }

      const isSingleUserMode = await this.#assertSingleUserMode();
      if (!isSingleUserMode) return;
      handler();
    };

    // Register all commands (history is registered separately below)
    for (const command of BOT_COMMANDS) {
      if (command.skipAutoSetup) continue;
      const handler = command.initHandler();
      this.#bot.onText(new RegExp(`\\/${command.command}`), (msg) =>
        guard(msg, () => handler(ctx, msg.chat.id, msg.text))
      );
    }

    // Register /history separately so we can pass the message text for argument parsing
    // Ex: /history 25 shows last 25 messages
    this.#bot.onText(/\/history(.*)/, (msg) => {
      const handler = BOT_COMMANDS.find(
        (c) => c.command === "history"
      ).initHandler();
      guard(msg, () => handler(ctx, msg.chat.id, msg.text));
    });

    // Register callback queries, used for workspace/thread selection, tool approval, etc.
    this.#bot.on("callback_query", (query) =>
      handleKeyboardQueryCallback(ctx, query, {
        pendingToolApprovals: this.#pendingToolApprovals,
        log: this.#log.bind(this),
      })
    );

    this.#bot.on("message", (msg) => {
      if (msg.text?.startsWith("/")) return;
      guard(msg, () => this.#handleMessage(ctx, msg));
    });

    this.#bot.on("polling_error", (error) => {
      this.#handlePollingError(error);
    });
  }

  async #runChatJob(ctx, chatId, payload) {
    const state = this.#getState(chatId);
    try {
      const bgService = new BackgroundService();
      const jobId = `handle-telegram-chat-${Date.now()}`;
      let invocationUuid = null;
      let wasAborted = false;

      await bgService.bree.add({
        name: jobId,
        path: require("path").resolve(
          __dirname,
          "../../jobs/handle-telegram-chat.js"
        ),
      });

      await bgService.bree.run(jobId);
      const worker = bgService.bree.workers.get(jobId);

      if (worker && typeof worker.send === "function") {
        worker.send({
          botToken: this.#config.bot_token,
          chatId,
          workspaceSlug: state.workspaceSlug,
          threadSlug: state.threadSlug,
          ...payload,
        });
      }

      if (worker) {
        worker.on("message", (msg) => {
          if (msg?.type === "closeInvocation") invocationUuid = msg.uuid;
          if (msg?.type === "toolApprovalRequest") {
            this.#handleToolApprovalRequest(worker, msg);
          }
        });
        this.#activeWorkers.set(chatId, { worker, jobId, bgService });
      }

      await new Promise((resolve, reject) => {
        worker.on("exit", async (code) => {
          this.#activeWorkers.delete(chatId);
          try {
            await bgService.bree.remove(jobId);
          } catch {}
          if (code === 0 || wasAborted) resolve();
          else reject(new Error(`Job ${jobId} exited with code ${code}`));
        });

        worker.on("error", async (err) => {
          this.#activeWorkers.delete(chatId);
          try {
            await bgService.bree.remove(jobId);
          } catch {}
          reject(err);
        });

        const active = this.#activeWorkers.get(chatId);
        if (active) {
          active.markAborted = () => {
            wasAborted = true;
          };
        }
      });

      if (invocationUuid) await WorkspaceAgentInvocation.close(invocationUuid);
    } catch (error) {
      this.#activeWorkers.delete(chatId);
      if (error.message?.includes("aborted")) return;
      this.#log("Chat worker error:", error.message);
      await ctx.bot.sendMessage(
        chatId,
        "Sorry, something went wrong. Please try again."
      );
    }
  }

  /**
   * Abort any active LLM worker for a given chat.
   * @param {number} chatId
   * @returns {boolean} True if a worker was aborted, false otherwise.
   */
  abortChat(chatId) {
    const active = this.#activeWorkers.get(chatId);
    if (!active) return false;

    const { worker, jobId, bgService, markAborted } = active;
    this.#log(`Aborting worker for chat ${chatId} (job: ${jobId})`);

    if (markAborted) markAborted();

    try {
      worker.kill("SIGTERM");
    } catch (err) {
      this.#log(`Failed to kill worker: ${err.message}`);
    }

    this.#activeWorkers.delete(chatId);

    try {
      bgService.bree.remove(jobId).catch(() => {});
    } catch {}

    return true;
  }

  /**
   * Handle a tool approval request from a worker process.
   * Sends a Telegram message with Approve/Deny inline keyboard buttons.
   * @param {Worker} worker - The worker process requesting approval
   * @param {Object} msg - The tool approval request message
   */
  async #handleToolApprovalRequest(worker, msg) {
    const { requestId, chatId, skillName, payload, description, timeoutMs } =
      msg;

    this.#log(
      `Tool approval request received: ${skillName} (requestId: ${requestId})`
    );

    try {
      const payloadText =
        payload && Object.keys(payload).length > 0
          ? `\n\n<b>Parameters:</b>\n<code>${JSON.stringify(payload, null, 2)}</code>`
          : "";

      const descText = description ? `\n${description}` : "";

      const messageText =
        `🔧 <b>Tool Approval Required</b>\n\n` +
        `The agent wants to execute: <b>${skillName}</b>${descText}${payloadText}\n\n` +
        `Do you want to allow this action?`;

      const keyboard = {
        inline_keyboard: [
          [
            {
              text: "✅ Approve",
              callback_data: `tool:approve:${requestId}`,
            },
            { text: "❌ Deny", callback_data: `tool:deny:${requestId}` },
          ],
        ],
      };

      const sent = await this.#bot.sendMessage(chatId, messageText, {
        parse_mode: "HTML",
        reply_markup: keyboard,
      });

      this.#pendingToolApprovals.set(requestId, {
        worker,
        chatId,
        messageId: sent.message_id,
        skillName,
      });

      // Auto-cleanup if timeout expires (worker will also timeout)
      setTimeout(() => {
        if (this.#pendingToolApprovals.has(requestId)) {
          this.#pendingToolApprovals.delete(requestId);
          this.#bot
            .editMessageText(
              `⏱️ Tool approval for <b>${skillName}</b> timed out.`,
              {
                chat_id: chatId,
                message_id: sent.message_id,
                parse_mode: "HTML",
              }
            )
            .catch(() => {});
        }
      }, timeoutMs + 1000);
    } catch (error) {
      this.#log("Failed to send tool approval request:", error.message);
      // Send denial back to worker if we can't show the UI
      try {
        const response = {
          type: "toolApprovalResponse",
          requestId,
          approved: false,
        };
        if (worker && typeof worker.send === "function") {
          worker.send(response);
        } else if (worker && typeof worker.postMessage === "function") {
          worker.postMessage(response);
        }
      } catch {}
    }
  }

  #shouldVoiceRespond(isVoiceMessage) {
    if (!this.#config) return false;
    const mode = this.#config.voice_response_mode || "text_only";
    if (mode === "always_voice") return true;
    if (mode === "mirror" && isVoiceMessage) return true;
    return false;
  }

  #handleMessage(ctx, msg) {
    const chatId = msg.chat.id;

    // Voice messages: transcribe then send to LLM
    if (msg.voice || msg.audio) {
      this.#queue.enqueue(chatId, async () => {
        try {
          const audioInfo = msg.voice || msg.audio;
          const fileId = audioInfo.file_id;
          const mimeType = audioInfo.mime_type || "audio/ogg";
          await ctx.bot.sendChatAction(chatId, "typing");
          const audioBuffer = await downloadTelegramFile(ctx.bot, fileId);
          const transcription = await transcribeAudio(audioBuffer, mimeType);
          if (!transcription?.trim()) {
            await ctx.bot.sendMessage(
              chatId,
              "Could not transcribe the voice message."
            );
            return;
          }
          await this.#runChatJob(ctx, chatId, {
            message: transcription,
            voiceResponse: this.#shouldVoiceRespond(true),
          });
        } catch (error) {
          this.#log("Voice handling error:", error.message);
          const isConfigError =
            error.message.includes("transcription") ||
            error.message.includes("Whisper") ||
            error.message.includes("OpenAI");
          await ctx.bot.sendMessage(
            chatId,
            isConfigError
              ? error.message
              : "Failed to process voice message. Please try again."
          );
        }
      });
      return;
    }

    // Photo messages: extract image and send to LLM with vision
    if (msg.photo) {
      this.#queue.enqueue(chatId, async () => {
        try {
          await ctx.bot.sendChatAction(chatId, "typing");
          const attachment = await photoToAttachment(ctx.bot, msg.photo);
          await this.#runChatJob(ctx, chatId, {
            message: msg.caption || "Describe this image.",
            attachments: [attachment],
            voiceResponse: this.#shouldVoiceRespond(false),
          });
        } catch (error) {
          this.#log("Photo handling error:", error.message);
          await ctx.bot.sendMessage(
            chatId,
            "Failed to process the image. Please try again."
          );
        }
      });
      return;
    }

    // Document messages: parse and send extracted text to LLM
    if (msg.document) {
      this.#queue.enqueue(chatId, async () => {
        try {
          await ctx.bot.sendChatAction(chatId, "typing");
          const filename = msg.document.file_name || "document";
          const docBuffer = await downloadTelegramFile(
            ctx.bot,
            msg.document.file_id
          );
          const { text, filename: docName } = await documentToText(
            docBuffer,
            filename
          );

          const userPrompt = msg.caption?.trim()
            ? msg.caption.trim()
            : "Summarize this document.";
          const fullMessage = `The user has shared a document named "${docName}". Here is the extracted content:\n\n---\n${text}\n---\n\nUser's request: ${userPrompt}`;

          await this.#runChatJob(ctx, chatId, {
            message: fullMessage,
            voiceResponse: this.#shouldVoiceRespond(false),
          });
        } catch (error) {
          this.#log("Document handling error:", error.message);
          await ctx.bot.sendMessage(
            chatId,
            error.message.includes("collector")
              ? error.message
              : "Failed to process the document. Please try again."
          );
        }
      });
      return;
    }

    if (!msg.text) return;
    this.#queue.enqueue(chatId, async () => {
      await this.#runChatJob(ctx, chatId, {
        message: msg.text,
        voiceResponse: this.#shouldVoiceRespond(false),
      });
    });
  }

  /**
   * Verify a bot token with the Telegram API without starting polling.
   */
  static async verifyToken(token) {
    try {
      const bot = new TelegramBot(token, { polling: false });
      const me = await bot.getMe();
      return { valid: true, username: me.username, error: null };
    } catch (error) {
      return { valid: false, username: null, error: error.message };
    }
  }

  /**
   * Boot the bot from database config on server startup.
   * Decrypts the stored bot token before starting.
   * If the instance is running in multi-user mode, it will skip boot and delete the connector if it exists.
   * @returns {Promise<void>}
   */
  static async bootIfActive() {
    const service = new TelegramBotService();
    try {
      const connector = await ExternalCommunicationConnector.get("telegram");
      if (!connector || !connector.active || !connector.config?.bot_token)
        return;

      // If there is a valid config, but the instance is running in multi-user mode - skip boot
      // but also cleanup the config and approved users
      const isSingleUserMode = await service.#assertSingleUserMode();
      if (!isSingleUserMode) return;

      const config = { ...connector.config };
      config.bot_token = decryptToken(config.bot_token);
      if (!config.bot_token) {
        service.#log("Failed to decrypt bot token. Re-connect to fix.");
        return;
      }

      await service.start(config);
    } catch (error) {
      service.#log("Failed to boot:", error.message);
    }
  }
}

module.exports = { TelegramBotService };
