/**
 * Telegram channel provider — DIRECT Bot API, no relay in the message path.
 *
 * Unlike Alexa (relay-mediated, degenerate provider), this provider holds the
 * user's OWN @BotFather bot token (pasted at connect time) and long-polls
 * `getUpdates` straight against api.telegram.org — no relay anywhere. Every
 * inbound/outbound message (including media) flows Bloby ↔ Telegram directly.
 * Outbound HTTPS only: works behind NAT, no public URL, no relay egress.
 *
 * It is lighter than the WhatsApp/Baileys provider (no reverse-engineered
 * protocol, no QR, no auth-state files) — just a token string and a poll loop.
 */

import { loadConfig } from '../../shared/config.js';
import { log } from '../../shared/logger.js';
import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';

const TG_API = 'https://api.telegram.org';
const POLL_TIMEOUT_S = 25;          // long-poll hold time
const MAX_MESSAGE_CHARS = 4096;     // Telegram hard limit per sendMessage
const TYPING_REFRESH_MS = 5_000;    // Telegram "typing" expires ~5s

/** Image extracted from an inbound Telegram message. */
export interface TelegramImageAttachment {
  mediaType: string;
  data: string; // base64
}

/** Normalized inbound message handed to the ChannelManager. */
export interface TelegramInbound {
  /** Chat id (string form of the numeric Telegram chat.id). Reply target. */
  chatId: string;
  /** Sender's numeric Telegram user id (string). */
  fromUserId: string;
  /** Display name (first name / @username) if available. */
  senderName?: string;
  text: string;
  isGroup: boolean;
  messageId?: number;
  images?: TelegramImageAttachment[];
}

export type OnTelegramMessage = (msg: TelegramInbound) => void;
export type TranscribeFn = (audioBase64: string) => Promise<string | null>;

export class TelegramChannel implements ChannelProvider {
  readonly type: ChannelType = 'telegram';

  private token: string | null = null;
  private botUsername: string | null = null;
  private connected = false;
  private offset = 0;
  private intentionalDisconnect = false;
  private pollAbort: AbortController | null = null;
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();

  private onMessage: OnTelegramMessage;
  private onStatusChange: (status: ChannelStatus) => void;
  private transcribe: TranscribeFn | null;

  constructor(
    onMessage: OnTelegramMessage,
    onStatusChange: (status: ChannelStatus) => void,
    transcribe?: TranscribeFn,
  ) {
    this.onMessage = onMessage;
    this.onStatusChange = onStatusChange;
    this.transcribe = transcribe || null;
  }

  private api(method: string): string {
    return `${TG_API}/bot${this.token}/${method}`;
  }

  hasCredentials(): boolean {
    return !!loadConfig().channels?.telegram?.botToken;
  }

  getQrCode(): string | null {
    return null;
  }

  getStatus(): ChannelStatus {
    return {
      channel: 'telegram',
      connected: this.connected,
      info: {
        botUsername: this.botUsername || loadConfig().channels?.telegram?.botUsername || null,
        linked: this.hasCredentials(),
        hasCredentials: this.hasCredentials(),
      },
    };
  }

  async connect(): Promise<void> {
    this.intentionalDisconnect = false;
    const cfg = loadConfig().channels?.telegram;
    if (!cfg?.botToken) {
      log.warn('[telegram] No bot token configured — not connecting');
      return;
    }
    this.token = cfg.botToken;
    this.botUsername = cfg.botUsername || null;

    // getMe confirms the token and learns the @username.
    try {
      const me = await this.call('getMe', {});
      if (me?.username) this.botUsername = me.username;
      log.ok(`[telegram] Connected as @${this.botUsername || 'unknown'} (id=${me?.id || '?'})`);
    } catch (err: any) {
      log.warn(`[telegram] getMe failed: ${err.message} — will retry in poll loop`);
    }

    this.connected = true;
    this.emitStatus();
    this.pollLoop(); // fire and forget
  }

  async disconnect(): Promise<void> {
    this.intentionalDisconnect = true;
    if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
    if (this.pollAbort) { try { this.pollAbort.abort(); } catch {} this.pollAbort = null; }
    for (const interval of this.typingIntervals.values()) clearInterval(interval);
    this.typingIntervals.clear();
    this.connected = false;
    this.emitStatus();
  }

  // ── Outbound ──────────────────────────────────────────────────────────────

  async sendMessage(to: string, text: string): Promise<void> {
    if (!this.token) { log.warn('[telegram] Cannot send — no token'); return; }
    this.stopTyping(to);
    // Telegram caps messages at 4096 chars — split on the limit.
    for (const chunk of splitMessage(text, MAX_MESSAGE_CHARS)) {
      try {
        await this.call('sendMessage', { chat_id: to, text: chunk, link_preview_options: { is_disabled: true } });
      } catch (err: any) {
        log.warn(`[telegram] sendMessage to ${to} failed: ${err.message}`);
        return;
      }
    }
    log.info(`[telegram] Sent message to ${to} (${text.length} chars)`);
  }

  /** Send an image natively (used by ChannelManager for <BlobyImage> tags). */
  async sendImage(to: string, image: Buffer, caption?: string, mimetype?: string): Promise<void> {
    if (!this.token) { log.warn('[telegram] Cannot send image — no token'); return; }
    this.stopTyping(to);
    try {
      const form = new FormData();
      form.append('chat_id', to);
      if (caption) form.append('caption', caption.slice(0, 1024));
      const ext = (mimetype?.split('/')[1] || 'png').replace('jpeg', 'jpg');
      form.append('photo', new Blob([new Uint8Array(image)], { type: mimetype || 'image/png' }), `image.${ext}`);
      const r = await fetch(this.api('sendPhoto'), { method: 'POST', body: form });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      log.info(`[telegram] Sent image to ${to}`);
    } catch (err: any) {
      log.warn(`[telegram] sendImage to ${to} failed: ${err.message}`);
    }
  }

  /** Show "typing…" — refreshes every 5s since Telegram's indicator expires. */
  startTyping(to: string): void {
    if (!this.token || !this.connected) return;
    this.stopTyping(to);
    let ticks = 0;
    const send = () => {
      // Cap the refresh (~2 min) so a turn that ends without a reply can't leave a stuck indicator.
      if (++ticks > 24) { this.stopTyping(to); return; }
      this.call('sendChatAction', { chat_id: to, action: 'typing' }).catch(() => {});
    };
    send();
    this.typingIntervals.set(to, setInterval(send, TYPING_REFRESH_MS));
  }

  stopTyping(to: string): void {
    const interval = this.typingIntervals.get(to);
    if (interval) { clearInterval(interval); this.typingIntervals.delete(to); }
  }

  // ── Inbound poll loop ─────────────────────────────────────────────────────

  private async pollLoop(): Promise<void> {
    while (!this.intentionalDisconnect) {
      this.pollAbort = new AbortController();
      try {
        const updates = await this.call('getUpdates', {
          offset: this.offset,
          timeout: POLL_TIMEOUT_S,
          allowed_updates: ['message'],
        }, this.pollAbort.signal, (POLL_TIMEOUT_S + 10) * 1000);

        if (Array.isArray(updates)) {
          for (const update of updates) {
            this.offset = Math.max(this.offset, (update.update_id || 0) + 1);
            try { await this.handleUpdate(update); }
            catch (err: any) { log.warn(`[telegram] handleUpdate error: ${err.message}`); }
          }
        }
      } catch (err: any) {
        if (this.intentionalDisconnect) break;
        // Watchdog timeout (the long-poll exceeded its deadline without a response —
        // e.g. a silently dropped connection). A disconnect-driven abort is caught by
        // the intentionalDisconnect check above, so any AbortError here is the timeout:
        // re-open a fresh long-poll immediately rather than treating it as fatal.
        if (err?.name === 'AbortError') continue;
        // 409 = another getUpdates/webhook is consuming this bot — do not hot-loop.
        if (String(err.message).includes('409')) {
          log.warn('[telegram] getUpdates conflict (409) — another consumer holds this bot. Stopping poll.');
          this.connected = false;
          this.emitStatus();
          return;
        }
        log.warn(`[telegram] getUpdates error: ${err.message} — retrying in 5s`);
        await sleep(5000);
      }
    }
  }

  private async handleUpdate(update: any): Promise<void> {
    const message = update.message;
    if (!message) return;

    const from = message.from || {};
    if (from.is_bot) return; // ignore other bots (our own sends never come back here anyway)

    const chat = message.chat || {};
    const chatId = String(chat.id);
    const fromUserId = String(from.id ?? '');
    const isGroup = chat.type === 'group' || chat.type === 'supergroup';
    const senderName = from.first_name
      ? (from.last_name ? `${from.first_name} ${from.last_name}` : from.first_name)
      : (from.username || undefined);

    let rawText: string = message.text || message.caption || '';
    const images: TelegramImageAttachment[] = [];

    // Photo: download the largest available size.
    if (Array.isArray(message.photo) && message.photo.length > 0) {
      const largest = message.photo[message.photo.length - 1];
      const img = await this.downloadFile(largest.file_id).catch(() => null);
      if (img) images.push({ mediaType: 'image/jpeg', data: img.toString('base64') });
    }

    // Voice note / audio: download + transcribe.
    const voice = message.voice || message.audio;
    if (!rawText && voice?.file_id) {
      if (!this.transcribe) {
        await this.sendMessage(chatId, 'Voice transcription is off — add an OpenAI API key in your Bloby chat settings (the three-dots menu) to enable it.');
        return;
      }
      const buf = await this.downloadFile(voice.file_id).catch(() => null);
      if (buf) {
        const transcript = await this.transcribe(buf.toString('base64')).catch(() => null);
        if (transcript) {
          rawText = transcript;
          log.info(`[telegram] Transcribed voice: "${rawText.slice(0, 80)}"`);
        } else {
          await this.sendMessage(chatId, "I couldn't transcribe that voice message — if this keeps happening, add an OpenAI API key in your Bloby chat settings (the three-dots menu) to enable voice transcription.");
          return;
        }
      }
    }

    if (!rawText && images.length === 0) return;
    if (!rawText && images.length > 0) rawText = '(image)';

    const text = escapeMessageText(rawText);

    log.info(`[telegram] Message from ${fromUserId} (chat=${chatId}, group=${isGroup}, images=${images.length}): ${text.slice(0, 80)}`);

    this.onMessage({
      chatId,
      fromUserId,
      senderName,
      text,
      isGroup,
      messageId: message.message_id,
      images: images.length > 0 ? images : undefined,
    });
  }

  /** Resolve a Telegram file_id to its bytes (getFile → download from the file CDN). */
  private async downloadFile(fileId: string): Promise<Buffer | null> {
    const file = await this.call('getFile', { file_id: fileId });
    const filePath = file?.file_path;
    if (!filePath) return null;
    const r = await fetch(`${TG_API}/file/bot${this.token}/${filePath}`);
    if (!r.ok) throw new Error(`file download HTTP ${r.status}`);
    const buf = Buffer.from(await r.arrayBuffer());
    log.info(`[telegram] Downloaded file (${Math.round(buf.length / 1024)}KB)`);
    return buf;
  }

  /** Call a Bot API method, returning `result` or throwing on `ok:false`. */
  private async call(method: string, params: Record<string, any>, signal?: AbortSignal, timeoutMs = 30_000): Promise<any> {
    // Always apply the watchdog timeout, AND honor the caller's abort signal (disconnect)
    // when present — abort whichever fires first. The previous version dropped the timeout
    // whenever a signal was passed, leaving the long-poll with no deadline.
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), timeoutMs);
    const onExternalAbort = () => ctrl.abort();
    if (signal) {
      if (signal.aborted) ctrl.abort();
      else signal.addEventListener('abort', onExternalAbort, { once: true });
    }
    try {
      const r = await fetch(this.api(method), {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(params),
        signal: ctrl.signal,
      });
      const data = await r.json().catch(() => ({}));
      if (!r.ok || !data.ok) {
        throw new Error(`${method} → ${r.status} ${data.description || ''}`.trim());
      }
      return data.result;
    } finally {
      clearTimeout(timer);
      if (signal) signal.removeEventListener('abort', onExternalAbort);
    }
  }

  private emitStatus() {
    this.onStatusChange(this.getStatus());
  }
}

// ── helpers ──────────────────────────────────────────────────────────────────

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/** Split a long message into <=limit-char chunks, preferring newline boundaries. */
function splitMessage(text: string, limit: number): string[] {
  if (text.length <= limit) return [text];
  const chunks: string[] = [];
  let rest = text;
  while (rest.length > limit) {
    let cut = rest.lastIndexOf('\n', limit);
    if (cut < limit * 0.5) cut = limit; // no good newline — hard cut
    chunks.push(rest.slice(0, cut));
    rest = rest.slice(cut).replace(/^\n/, '');
  }
  if (rest) chunks.push(rest);
  return chunks;
}

/** Mirror WhatsApp's anti-injection escaping so message content can't fake channel/role tags. */
function escapeMessageText(text: string): string {
  return text
    .replace(/\[Telegram\s*\|/gi, '(Telegram|')
    .replace(/\[WhatsApp\s*\|/gi, '(WhatsApp|')
    .replace(/\[\s*(admin|customer)\s*\]/gi, '($1)');
}
