/**
 * DingTalk Router Service
 *
 * IT0 unified DingTalk bot — one central connection for all agent instances.
 *
 * Responsibilities:
 *   1. Maintain a DingTalk Stream connection using IT0's official App credentials.
 *   2. Handle binding codes: user sends code → maps their DingTalk ID to an agent instance.
 *   3. Route regular messages: looks up bound instance → POSTs to bridge /task → replies.
 *
 * Required env vars:
 *   IT0_DINGTALK_CLIENT_ID     — IT0 official DingTalk AppKey
 *   IT0_DINGTALK_CLIENT_SECRET — IT0 official DingTalk AppSecret
 *
 * Binding flow:
 *   1. Frontend calls POST /api/v1/agent/channels/dingtalk/bind/:instanceId
 *   2. Backend returns { code: "A3K9F2", expiresAt }
 *   3. User opens DingTalk → IT0 bot → sends "A3K9F2"
 *   4. This service matches the code → saves dingTalkUserId → replies "✅ 绑定成功"
 *   5. Frontend polls GET /api/v1/agent/channels/dingtalk/status/:instanceId → { bound: true }
 *
 * Robustness guarantees:
 *   - WS message handlers fully wrapped in try/catch — no uncaught exceptions
 *   - Stale-WS guard: close/message events from old WS are ignored after reconnect
 *   - Old WS terminated before creating new one (no orphaned connections)
 *   - DISCONNECT frame handled: immediately reconnects
 *   - Per-user queue: promise chain guaranteed to always resolve (no dead-tail bug)
 *   - sessionWebhookExpiredTime unit auto-detected (seconds or ms)
 *   - DingTalk API response capped at 256 KB (prevents memory spike on bad response)
 *   - Periodic cleanup for all in-memory maps (5 min interval)
 */

import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as https from 'https';
import * as crypto from 'crypto';
import * as http from 'http';
import WebSocket from 'ws';
import { AgentInstanceRepository } from '../repositories/agent-instance.repository';

// ── Types ─────────────────────────────────────────────────────────────────────

interface DtFrame {
  type: string;
  headers: Record<string, string>;
  data?: string;
}

interface BotMsg {
  senderStaffId: string;
  sessionWebhook: string;
  /**
   * Webhook expiry — DingTalk API has returned both seconds and ms depending on version.
   * We auto-detect the unit: values < 1e11 are treated as seconds.
   */
  sessionWebhookExpiredTime: number;
  text?: { content: string };
  msgtype?: string;
  conversationId: string;
  msgId: string;
}

interface BindingEntry {
  instanceId: string;
  expiresAt: number;
}

// ── Constants ─────────────────────────────────────────────────────────────────

const DINGTALK_MAX_CHARS    = 4800;
const CODE_TTL_MS           = 5 * 60 * 1000;   // 5 min
const TOKEN_REFRESH_BUFFER  = 300;               // seconds before expiry to proactively refresh
const WS_RECONNECT_BASE_MS  = 2_000;
const WS_RECONNECT_MAX_MS   = 60_000;
const TASK_TIMEOUT_S        = 30;
const DEDUP_TTL_MS          = 10 * 60 * 1000;
const RATE_LIMIT_PER_MIN    = 10;
const QUEUE_MAX_DEPTH       = 5;
const CLEANUP_INTERVAL_MS   = 5 * 60 * 1000;
const MAX_RESPONSE_BYTES    = 256 * 1024;        // 256 KB cap for DingTalk API responses

// ── Service ───────────────────────────────────────────────────────────────────

@Injectable()
export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(DingTalkRouterService.name);
  private readonly clientId: string;
  private readonly clientSecret: string;
  private readonly enabled: boolean;

  // Token
  private token = '';
  private tokenExpiresAt = 0;
  private tokenRefreshTimer?: NodeJS.Timeout;
  private tokenRefreshPromise: Promise<string> | null = null;

  // WS
  private ws: WebSocket | null = null;
  private connecting = false;   // prevents concurrent connectStream() calls
  private reconnectDelay = WS_RECONNECT_BASE_MS;
  private stopping = false;
  private reconnectTimer?: NodeJS.Timeout;
  private cleanupTimer?: NodeJS.Timeout;

  // State
  private readonly bindingCodes = new Map<string, BindingEntry>(); // code → entry
  private readonly dedup        = new Map<string, number>();        // msgId → ts
  private readonly rateWindows  = new Map<string, number[]>();      // userId → timestamps
  private readonly queueTails   = new Map<string, Promise<void>>(); // userId → tail
  private readonly queueDepths  = new Map<string, number>();        // userId → depth

  constructor(
    private readonly configService: ConfigService,
    private readonly instanceRepo: AgentInstanceRepository,
  ) {
    this.clientId     = this.configService.get<string>('IT0_DINGTALK_CLIENT_ID', '');
    this.clientSecret = this.configService.get<string>('IT0_DINGTALK_CLIENT_SECRET', '');
    this.enabled      = !!(this.clientId && this.clientSecret);
  }

  onModuleInit(): void {
    if (!this.enabled) {
      this.logger.warn('IT0_DINGTALK_CLIENT_ID/SECRET not set — DingTalk router disabled');
      return;
    }
    this.logger.log('DingTalk router starting...');
    this.connectStream().catch((e: Error) =>
      this.logger.error('Initial stream connection failed:', e.message),
    );
    this.cleanupTimer = setInterval(() => this.periodicCleanup(), CLEANUP_INTERVAL_MS);
    if (this.cleanupTimer.unref) this.cleanupTimer.unref();
  }

  onModuleDestroy(): void {
    this.stopping = true;
    clearInterval(this.cleanupTimer);
    clearTimeout(this.reconnectTimer);
    clearTimeout(this.tokenRefreshTimer);
    this.terminateCurrentWs();
  }

  isEnabled(): boolean {
    return this.enabled;
  }

  // ── Binding code API ───────────────────────────────────────────────────────

  generateBindingCode(instanceId: string): { code: string; expiresAt: number } {
    // Invalidate any existing code for this instance to avoid confusion
    for (const [code, entry] of this.bindingCodes) {
      if (entry.instanceId === instanceId) this.bindingCodes.delete(code);
    }
    const code      = crypto.randomBytes(3).toString('hex').toUpperCase(); // e.g. "A3K9F2"
    const expiresAt = Date.now() + CODE_TTL_MS;
    this.bindingCodes.set(code, { instanceId, expiresAt });
    return { code, expiresAt };
  }

  // ── Token management ───────────────────────────────────────────────────────

  private async getToken(): Promise<string> {
    if (this.token && Date.now() < this.tokenExpiresAt - TOKEN_REFRESH_BUFFER * 1000) {
      return this.token;
    }
    if (this.tokenRefreshPromise) return this.tokenRefreshPromise;
    this.tokenRefreshPromise = this.refreshToken().finally(() => {
      this.tokenRefreshPromise = null;
    });
    return this.tokenRefreshPromise;
  }

  private async refreshToken(): Promise<string> {
    const { accessToken, expireIn } = await this.httpsPost<{ accessToken: string; expireIn: number }>(
      'api.dingtalk.com', '/v1.0/oauth2/accessToken',
      { appKey: this.clientId, appSecret: this.clientSecret },
    );
    this.token = accessToken;
    this.tokenExpiresAt = Date.now() + expireIn * 1000;
    clearTimeout(this.tokenRefreshTimer);
    const refreshInMs = Math.max((expireIn - TOKEN_REFRESH_BUFFER) * 1000, 60_000);
    this.tokenRefreshTimer = setTimeout(() => {
      this.getToken().catch((e: Error) => this.logger.error('Proactive token refresh failed:', e.message));
    }, refreshInMs);
    if (this.tokenRefreshTimer.unref) this.tokenRefreshTimer.unref();
    this.logger.log(`DingTalk token refreshed, valid ${expireIn}s`);
    return this.token;
  }

  // ── Stream connection ──────────────────────────────────────────────────────

  private async connectStream(): Promise<void> {
    if (this.stopping || this.connecting) return;
    this.connecting = true;
    try {
      await this.doConnect();
    } finally {
      this.connecting = false;
    }
  }

  private async doConnect(): Promise<void> {
    let token: string;
    try {
      token = await this.getToken();
    } catch (e: any) {
      this.logger.error('Cannot get DingTalk token:', e.message);
      this.scheduleReconnect();
      return;
    }

    let wsInfo: { endpoint: string; ticket: string };
    try {
      wsInfo = await this.httpsPost<{ endpoint: string; ticket: string }>(
        'api.dingtalk.com', '/v1.0/gateway/connections/open',
        {
          clientId: this.clientId,
          clientSecret: this.clientSecret,
          subscriptions: [{ type: 'CALLBACK', topic: '/v1.0/im/bot/messages/get' }],
          ua: 'it0-dingtalk-router/1.0',
          localIp: '127.0.0.1',
        },
        { 'x-acs-dingtalk-access-token': token },
      );
    } catch (e: any) {
      this.logger.error('Failed to get stream endpoint:', e.message);
      this.scheduleReconnect();
      return;
    }

    // ── Close stale WS before creating new one ─────────────────────────────
    // Terminating the old WS now means its 'close' event will fire, but the
    // stale-WS guard (ws !== this.ws) will prevent it from triggering reconnect.
    this.terminateCurrentWs();

    const ws = new WebSocket(`${wsInfo.endpoint}?ticket=${encodeURIComponent(wsInfo.ticket)}`);
    this.ws = ws;

    ws.on('open', () => {
      if (ws !== this.ws) return; // stale
      this.logger.log('DingTalk stream connected');
      this.reconnectDelay = WS_RECONNECT_BASE_MS;
    });

    // Fully wrapped — no uncaught exceptions can reach the EventEmitter boundary
    ws.on('message', (raw) => {
      try {
        if (ws !== this.ws) return; // stale WS, ignore
        let frame: DtFrame;
        try { frame = JSON.parse(raw.toString()); } catch { return; }
        this.handleFrame(ws, frame);
      } catch (e: any) {
        this.logger.error('Uncaught error in WS message handler:', e.message);
      }
    });

    ws.on('close', (code, reason) => {
      if (this.stopping || ws !== this.ws) return; // stale or intentional shutdown
      this.logger.warn(`Stream closed (${code}: ${reason.toString()})`);
      this.scheduleReconnect();
    });

    ws.on('error', (e) => {
      // 'close' fires after 'error' — reconnect is handled there
      this.logger.error('Stream WS error:', e.message);
    });
  }

  /** Force-close the current WS without triggering our reconnect logic */
  private terminateCurrentWs(): void {
    const old = this.ws;
    this.ws = null;
    if (old && old.readyState !== WebSocket.CLOSED) {
      try { old.terminate(); } catch { /* ignore */ }
    }
  }

  private scheduleReconnect(): void {
    if (this.stopping) return;
    clearTimeout(this.reconnectTimer);
    this.reconnectTimer = setTimeout(() => {
      this.connectStream().catch((e: Error) =>
        this.logger.error('Reconnect attempt failed:', e.message),
      );
    }, this.reconnectDelay);
    this.reconnectDelay = Math.min(this.reconnectDelay * 2, WS_RECONNECT_MAX_MS);
  }

  // ── Frame handling ─────────────────────────────────────────────────────────

  private handleFrame(ws: WebSocket, frame: DtFrame): void {
    // Server keepalive
    if (frame.type === 'PING') {
      this.wsSend(ws, JSON.stringify({ code: 200, headers: frame.headers, message: 'OK', data: '' }));
      return;
    }

    // Server requests graceful disconnect (e.g. deployment rollover, load balancing)
    if (frame.type === 'DISCONNECT') {
      this.logger.warn('DingTalk server requested DISCONNECT — reconnecting');
      // Nullify reference so close event is treated as "current" and triggers reconnect
      // (we're about to terminate, which fires close; with ws === this.ws it proceeds)
      ws.terminate();
      return;
    }

    if (
      frame.type === 'CALLBACK' &&
      frame.headers?.['topic'] === '/v1.0/im/bot/messages/get' &&
      frame.data
    ) {
      let msg: BotMsg;
      try { msg = JSON.parse(frame.data); } catch { return; }

      // ACK MUST be sent within 1.5s — synchronously before any async work
      this.wsSend(ws, JSON.stringify({ code: 200, headers: frame.headers, message: 'OK', data: '' }));
      this.dispatchMessage(msg);
    }
  }

  /**
   * Safe WebSocket send — never throws.
   * Checks readyState before calling send() to avoid "WebSocket is not open" errors
   * that would otherwise propagate as uncaught exceptions through EventEmitter.
   */
  private wsSend(ws: WebSocket, data: string): void {
    try {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(data);
      }
    } catch (e: any) {
      this.logger.warn('wsSend failed:', e.message);
    }
  }

  private dispatchMessage(msg: BotMsg): void {
    const userId = msg.senderStaffId?.trim();
    if (!userId) {
      this.logger.warn('Received message with empty senderStaffId, ignoring');
      return;
    }

    // Non-text message types (image, file, richText, audio, video, @mention, etc.)
    const text = msg.text?.content?.trim() ?? '';
    if (!text) {
      this.reply(msg, '我目前只能处理文字消息，请发送文字与小龙虾沟通。');
      return;
    }

    // Deduplication (DingTalk may deliver duplicates on reconnect)
    if (this.dedup.has(msg.msgId)) return;
    this.dedup.set(msg.msgId, Date.now());

    // Binding code check (6-char hex, case-insensitive, exact match only)
    const upperText = text.toUpperCase();
    const entry = this.bindingCodes.get(upperText);
    if (entry) {
      if (Date.now() > entry.expiresAt) {
        this.bindingCodes.delete(upperText);
        this.reply(msg, '验证码已过期，请重新在 IT0 App 中生成新的验证码。');
        return;
      }
      this.bindingCodes.delete(upperText);
      this.completeBinding(entry.instanceId, userId, msg).catch((e: Error) =>
        this.logger.error('completeBinding unhandled error:', e.message),
      );
      return;
    }

    // Rate limit
    if (!this.rateAllow(userId)) {
      this.reply(msg, '消息频率过高，请稍后再试（每分钟最多10条）。');
      return;
    }

    // Route to agent container (serial per-user queue)
    const accepted = this.enqueue(userId, () => this.routeToAgent(userId, text, msg));
    if (!accepted) {
      this.reply(msg, '当前请求排队已满（最多5条），请稍后再试。');
    }
  }

  // ── Binding completion ─────────────────────────────────────────────────────

  private async completeBinding(instanceId: string, dingTalkUserId: string, msg: BotMsg): Promise<void> {
    try {
      const instance = await this.instanceRepo.findById(instanceId);
      if (!instance) {
        this.reply(msg, '绑定失败：智能体实例不存在，请重新操作。');
        return;
      }
      instance.dingTalkUserId = dingTalkUserId;
      await this.instanceRepo.save(instance);
      this.logger.log(`Bound instance ${instanceId} to DingTalk user ${dingTalkUserId}`);
      this.reply(
        msg,
        `✅ 绑定成功！\n\n你的小龙虾「${instance.name}」已与钉钉绑定。\n\n现在直接发消息给我，我会帮你转达给它！`,
      );
    } catch (e: any) {
      this.logger.error('Binding failed:', e.message);
      this.reply(msg, '绑定时出现错误，请稍后重试。');
    }
  }

  // ── Message routing ────────────────────────────────────────────────────────

  private async routeToAgent(userId: string, text: string, msg: BotMsg): Promise<void> {
    const instance = await this.instanceRepo.findByDingTalkUserId(userId);
    if (!instance) {
      this.reply(
        msg,
        '你还没有绑定小龙虾。\n\n请在 IT0 App 中创建一只小龙虾，然后点击「绑定钉钉」获取验证码。',
      );
      return;
    }

    if (instance.status !== 'running') {
      this.reply(msg, `小龙虾「${instance.name}」当前状态为 ${instance.status}，暂时无法接收指令。`);
      return;
    }

    if (!instance.serverHost) {
      this.logger.error(`Instance ${instance.id} has no serverHost configured`);
      this.reply(msg, '小龙虾配置异常（缺少服务器地址），请联系管理员。');
      return;
    }

    const bridgeUrl = `http://${instance.serverHost}:${instance.hostPort}/task`;
    let reply: string;

    try {
      const result = await this.httpPostJson<{ ok: boolean; result?: unknown; error?: string }>(
        bridgeUrl,
        {
          prompt: text,
          sessionKey: `agent:main:dt-${userId}`,
          timeoutSeconds: TASK_TIMEOUT_S,
        },
        (TASK_TIMEOUT_S + 5) * 1000,
      );

      if (result.ok && result.result !== undefined) {
        reply = typeof result.result === 'string'
          ? result.result
          : JSON.stringify(result.result, null, 2);
      } else {
        reply = result.error ?? '智能体没有返回内容。';
      }
    } catch (e: any) {
      this.logger.error(`Bridge call failed for instance ${instance.id}:`, e.message);
      reply = '与小龙虾通信时出现错误，请稍后重试。';
    }

    this.reply(msg, reply);
  }

  // ── Reply (chunked) ────────────────────────────────────────────────────────

  private reply(msg: BotMsg, content: string): void {
    // Auto-detect unit: DingTalk has returned both seconds and ms depending on API version.
    // Values < 1e11 (year 1973 in ms) are treated as Unix seconds.
    const expiryMs = msg.sessionWebhookExpiredTime > 1e11
      ? msg.sessionWebhookExpiredTime
      : msg.sessionWebhookExpiredTime * 1000;

    if (Date.now() > expiryMs) {
      this.logger.warn(`sessionWebhook expired for msgId=${msg.msgId}, skipping reply`);
      return;
    }

    // Strip stack-trace lines to avoid leaking internals
    const safe = content.replace(/\s+at\s+\S+:\d+:\d+/g, '').trim();
    const chunks: string[] = [];
    for (let i = 0; i < safe.length; i += DINGTALK_MAX_CHARS) {
      chunks.push(safe.slice(i, i + DINGTALK_MAX_CHARS));
    }
    if (chunks.length === 0) chunks.push('（空响应）');
    chunks.forEach((chunk, idx) =>
      setTimeout(() => this.sendWebhook(msg.sessionWebhook, chunk), idx * 300),
    );
  }

  private sendWebhook(webhook: string, content: string): void {
    try {
      const url  = new URL(webhook);
      const body = JSON.stringify({ msgtype: 'text', text: { content } });
      const req  = https.request(
        {
          hostname: url.hostname,
          path: url.pathname + url.search,
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Content-Length': Buffer.byteLength(body),
          },
          timeout: 10_000,
        },
        (res) => { res.resume(); },
      );
      req.on('timeout', () => { req.destroy(); });
      req.on('error', (e) => this.logger.error('Webhook send error:', e.message));
      req.write(body);
      req.end();
    } catch (e: any) {
      this.logger.error('sendWebhook failed:', e.message);
    }
  }

  // ── Per-user serial queue ──────────────────────────────────────────────────

  private enqueue(userId: string, task: () => Promise<void>): boolean {
    const depth = this.queueDepths.get(userId) ?? 0;
    if (depth >= QUEUE_MAX_DEPTH) return false;
    this.queueDepths.set(userId, depth + 1);
    const tail = this.queueTails.get(userId) ?? Promise.resolve();
    const next = tail
      .then(task)
      .catch((e: Error) => {
        // Safe catch: this handler must not throw, or the queue tail becomes a dead rejected promise
        try { this.logger.error(`Queue task error (${userId}):`, e.message); } catch { /* ignore */ }
      })
      .finally(() => {
        const remaining = (this.queueDepths.get(userId) ?? 1) - 1;
        if (remaining <= 0) {
          this.queueDepths.delete(userId);
          this.queueTails.delete(userId);
        } else {
          this.queueDepths.set(userId, remaining);
        }
      })
      // Guarantee: `next` always resolves, even if finally() somehow throws.
      // This prevents a permanently dead queue tail that silently drops all future tasks.
      .catch(() => {});
    this.queueTails.set(userId, next);
    return true;
  }

  // ── Rate limiter (sliding window) ─────────────────────────────────────────

  private rateAllow(userId: string): boolean {
    const now = Date.now();
    const timestamps = (this.rateWindows.get(userId) ?? []).filter((t) => now - t < 60_000);
    if (timestamps.length >= RATE_LIMIT_PER_MIN) {
      this.rateWindows.set(userId, timestamps); // persist pruned list
      return false;
    }
    timestamps.push(now);
    this.rateWindows.set(userId, timestamps);
    return true;
  }

  // ── Periodic cleanup ───────────────────────────────────────────────────────

  private periodicCleanup(): void {
    const now = Date.now();

    for (const [id, ts] of this.dedup) {
      if (now - ts > DEDUP_TTL_MS) this.dedup.delete(id);
    }

    for (const [userId, timestamps] of this.rateWindows) {
      const fresh = timestamps.filter((t) => now - t < 60_000);
      if (fresh.length === 0) this.rateWindows.delete(userId);
      else this.rateWindows.set(userId, fresh);
    }

    for (const [code, entry] of this.bindingCodes) {
      if (now > entry.expiresAt) this.bindingCodes.delete(code);
    }
  }

  // ── HTTP helpers ───────────────────────────────────────────────────────────

  /** HTTPS POST to DingTalk API. Response body capped at MAX_RESPONSE_BYTES. */
  private httpsPost<T>(
    hostname: string,
    path: string,
    payload: object,
    extraHeaders: Record<string, string> = {},
  ): Promise<T> {
    return new Promise((resolve, reject) => {
      const body = JSON.stringify(payload);
      const req  = https.request(
        {
          hostname, path, method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Content-Length': Buffer.byteLength(body),
            ...extraHeaders,
          },
          timeout: 10_000,
        },
        (res) => {
          let data = '';
          let totalBytes = 0;
          res.on('data', (chunk: Buffer) => {
            totalBytes += chunk.length;
            if (totalBytes <= MAX_RESPONSE_BYTES) data += chunk.toString();
          });
          res.on('end', () => {
            if (totalBytes > MAX_RESPONSE_BYTES) {
              reject(new Error(`DingTalk API response too large (${totalBytes} bytes)`));
              return;
            }
            try {
              const json = JSON.parse(data);
              if (res.statusCode && res.statusCode >= 400) {
                reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
              } else {
                resolve(json as T);
              }
            } catch (e) { reject(e); }
          });
        },
      );
      req.on('timeout', () => { req.destroy(); reject(new Error('DingTalk API request timeout')); });
      req.on('error', reject);
      req.write(body);
      req.end();
    });
  }

  /** HTTP POST to an internal bridge container (plain http, no TLS). */
  private httpPostJson<T>(url: string, payload: object, timeoutMs = 35_000): Promise<T> {
    return new Promise((resolve, reject) => {
      const parsed = new URL(url);
      const body   = JSON.stringify(payload);
      const req    = http.request(
        {
          hostname: parsed.hostname,
          port: parseInt(parsed.port, 10),
          path: parsed.pathname,
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Content-Length': Buffer.byteLength(body),
          },
          timeout: timeoutMs,
        },
        (res) => {
          let data = '';
          res.on('data', (c: Buffer) => (data += c.toString()));
          res.on('end', () => {
            try {
              const json = JSON.parse(data);
              if (res.statusCode && res.statusCode >= 400) {
                reject(new Error(`Bridge HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
              } else {
                resolve(json as T);
              }
            } catch (e) { reject(e); }
          });
        },
      );
      req.on('timeout', () => { req.destroy(); reject(new Error('Bridge request timeout')); });
      req.on('error', reject);
      req.write(body);
      req.end();
    });
  }
}
