/**
 * DingTalk Channel for OpenClaw — Production Grade
 *
 * Robustness features:
 *   ✓ Access token auto-refresh (5 min before expiry)
 *   ✓ DingTalk Stream WS reconnect with exponential backoff
 *   ✓ OpenClaw gateway auto-reconnect on disconnect
 *   ✓ Per-user serial message queue (no cross-user interference)
 *   ✓ Message deduplication (msgId-based, bounded memory)
 *   ✓ Per-user rate limiting (10 msg/min)
 *   ✓ sessionWebhook expiry guard
 *   ✓ Reply chunking (DingTalk 5000-char limit)
 *   ✓ Sanitized error messages (no internal details exposed)
 *   ✓ Graceful shutdown (SIGTERM/SIGINT)
 *
 * Required env vars:
 *   DINGTALK_CLIENT_ID      — DingTalk AppKey
 *   DINGTALK_CLIENT_SECRET  — DingTalk AppSecret
 *   OPENCLAW_GATEWAY_URL    — ws://127.0.0.1:18789 (default)
 *   OPENCLAW_GATEWAY_TOKEN  — Internal gateway auth token
 */

import 'dotenv/config';
import * as https from 'https';
import WebSocket from 'ws';
import { OpenClawClient } from '../openclaw-client';

// ── Config ────────────────────────────────────────────────────────────────────

const CLIENT_ID     = process.env.DINGTALK_CLIENT_ID ?? '';
const CLIENT_SECRET = process.env.DINGTALK_CLIENT_SECRET ?? '';
const OC_GATEWAY    = process.env.OPENCLAW_GATEWAY_URL ?? 'ws://127.0.0.1:18789';
const OC_TOKEN      = process.env.OPENCLAW_GATEWAY_TOKEN ?? '';

const DINGTALK_MAX_CHARS    = 4800; // leave headroom under 5000
const RATE_LIMIT_PER_MIN    = 10;
const TOKEN_REFRESH_BUFFER  = 300;  // seconds before expiry to refresh
const WS_RECONNECT_BASE_MS  = 2_000;
const WS_RECONNECT_MAX_MS   = 60_000;
const OC_RECONNECT_BASE_MS  = 3_000;
const OC_RECONNECT_MAX_MS   = 30_000;
const OC_GATEWAY_RETRIES    = 40;

if (!CLIENT_ID || !CLIENT_SECRET) {
  console.log('[dingtalk] Not configured — channel disabled.');
  process.exit(0);
}

// ── Token Manager — auto-refresh + mutex (no concurrent double-refresh) ───

class TokenManager {
  private token = '';
  private expiresAt = 0;
  private refreshTimer?: NodeJS.Timeout;
  private refreshPromise: Promise<string> | null = null; // mutex

  async get(): Promise<string> {
    if (Date.now() < this.expiresAt - TOKEN_REFRESH_BUFFER * 1000) {
      return this.token;
    }
    // If a refresh is already in-flight, piggyback on it instead of starting another
    if (this.refreshPromise) return this.refreshPromise;
    this.refreshPromise = this.doRefresh().finally(() => { this.refreshPromise = null; });
    return this.refreshPromise;
  }

  private async doRefresh(): Promise<string> {
    const { accessToken, expireIn } = await httpPost<{ accessToken: string; expireIn: number }>(
      'api.dingtalk.com',
      '/v1.0/oauth2/accessToken',
      { appKey: CLIENT_ID, appSecret: CLIENT_SECRET },
    );
    this.token = accessToken;
    this.expiresAt = Date.now() + expireIn * 1000;
    clearTimeout(this.refreshTimer);
    const refreshInMs = Math.max((expireIn - TOKEN_REFRESH_BUFFER) * 1000, 60_000);
    this.refreshTimer = setTimeout(() => {
      this.get().catch((e) => console.error('[dingtalk] Token refresh failed:', e.message));
    }, refreshInMs);
    console.log(`[dingtalk] Token refreshed, valid for ${expireIn}s`);
    return this.token;
  }

  destroy(): void {
    clearTimeout(this.refreshTimer);
  }
}

// ── Per-user Serial Queue — max depth guard prevents memory runaway ────────

const USER_QUEUE_MAX_DEPTH = 5; // max pending tasks per user

class UserQueue {
  private readonly tails  = new Map<string, Promise<void>>();
  private readonly depths = new Map<string, number>();

  /** Returns false if queue is full (caller should reject message) */
  enqueue(userId: string, task: () => Promise<void>): boolean {
    const depth = this.depths.get(userId) ?? 0;
    if (depth >= USER_QUEUE_MAX_DEPTH) return false;

    this.depths.set(userId, depth + 1);
    const tail = this.tails.get(userId) ?? Promise.resolve();
    const next = tail
      .then(task)
      .catch((e) => console.error(`[dingtalk] Queue task error (${userId}):`, e.message))
      .finally(() => {
        const remaining = (this.depths.get(userId) ?? 1) - 1;
        if (remaining <= 0) {
          this.depths.delete(userId);
          this.tails.delete(userId);
        } else {
          this.depths.set(userId, remaining);
        }
      });
    this.tails.set(userId, next);
    return true;
  }
}

// ── Message Deduplication — Map<msgId, timestamp>, O(1) lookup + TTL evict

class MsgDedup {
  private readonly seen = new Map<string, number>(); // msgId → insertedAt ms
  private readonly ttlMs: number;
  private readonly maxSize: number;

  constructor(ttlMs = 10 * 60 * 1000, maxSize = 10_000) { // 10 min TTL, 10k cap
    this.ttlMs = ttlMs;
    this.maxSize = maxSize;
  }

  has(msgId: string): boolean {
    const t = this.seen.get(msgId);
    if (t === undefined) return false;
    if (Date.now() - t > this.ttlMs) { this.seen.delete(msgId); return false; }
    return true;
  }

  add(msgId: string): void {
    if (this.seen.size >= this.maxSize) this.evict();
    this.seen.set(msgId, Date.now());
  }

  private evict(): void {
    const cutoff = Date.now() - this.ttlMs;
    for (const [id, t] of this.seen) {
      if (t < cutoff) this.seen.delete(id);
    }
    // If still over limit, remove oldest entries
    if (this.seen.size >= this.maxSize) {
      const oldest = [...this.seen.entries()]
        .sort((a, b) => a[1] - b[1])
        .slice(0, Math.floor(this.maxSize / 2));
      for (const [id] of oldest) this.seen.delete(id);
    }
  }
}

// ── Per-user Rate Limiter — sliding window, 10 msg/min ───────────────────

class RateLimiter {
  private readonly windows = new Map<string, number[]>();

  allow(userId: string): boolean {
    const now = Date.now();
    const window = 60_000;
    const timestamps = (this.windows.get(userId) ?? []).filter((t) => now - t < window);
    if (timestamps.length >= RATE_LIMIT_PER_MIN) return false;
    timestamps.push(now);
    this.windows.set(userId, timestamps);
    return true;
  }
}

// ── OpenClaw Client Wrapper — auto-reconnect on disconnect ────────────────

class ResilientOcClient {
  private client: OpenClawClient;
  private reconnecting = false;

  constructor() {
    this.client = new OpenClawClient(OC_GATEWAY, OC_TOKEN);
  }

  async connect(): Promise<void> {
    let delay = OC_RECONNECT_BASE_MS;
    for (let i = 0; i < OC_GATEWAY_RETRIES; i++) {
      try {
        await this.client.connect();
        console.log('[dingtalk] Connected to OpenClaw gateway');
        this.setupAutoReconnect();
        return;
      } catch {
        console.warn(`[dingtalk] OC gateway not ready (${i + 1}/${OC_GATEWAY_RETRIES}), retry in ${delay}ms`);
        await sleep(delay);
        delay = Math.min(delay * 1.5, OC_RECONNECT_MAX_MS);
      }
    }
    throw new Error('Could not connect to OpenClaw gateway');
  }

  isConnected(): boolean {
    return this.client.isConnected();
  }

  async rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {
    return this.client.rpc(method, params, timeoutMs);
  }

  private setupAutoReconnect(): void {
    // Poll every 10s; the reconnecting flag prevents loop stacking
    const check = setInterval(() => {
      if (this.client.isConnected() || this.reconnecting) return;
      // Atomically claim the reconnect slot
      this.reconnecting = true;
      console.warn('[dingtalk] OC gateway disconnected — reconnecting...');
      // Replace client before connect() so stale callbacks are dropped
      this.client = new OpenClawClient(OC_GATEWAY, OC_TOKEN);
      let delay = OC_RECONNECT_BASE_MS;
      const attempt = async (): Promise<void> => {
        try {
          await this.client.connect();
          console.log('[dingtalk] OC gateway reconnected');
          this.reconnecting = false;
          // setupAutoReconnect is already running via the outer interval — no re-register needed
        } catch {
          delay = Math.min(delay * 1.5, OC_RECONNECT_MAX_MS);
          console.warn(`[dingtalk] OC reconnect failed, retry in ${delay}ms`);
          setTimeout(attempt, delay);
        }
      };
      attempt();
    }, 10_000);
    check.unref();
  }
}

// ── DingTalk Reply — chunked + webhook expiry guard ───────────────────────

function replyToUser(sessionWebhook: string, webhookExpiry: number, content: string): void {
  if (Date.now() > webhookExpiry) {
    console.warn('[dingtalk] sessionWebhook expired, cannot reply');
    return;
  }

  // Sanitize: never expose stack traces or internal paths
  const safe = content.replace(/\s+at\s+\S+:\d+:\d+/g, '').trim();

  // Chunk into 4800-char segments
  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('（空响应）');

  // Send chunks sequentially with 200ms gap to preserve order
  chunks.forEach((chunk, idx) => {
    setTimeout(() => sendWebhook(sessionWebhook, chunk), idx * 200);
  });
}

function sendWebhook(webhook: string, content: string): void {
  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();
      if (res.statusCode && res.statusCode >= 400) {
        console.error(`[dingtalk] Webhook reply ${res.statusCode}`);
      }
    },
  );
  req.on('timeout', () => { req.destroy(); console.error('[dingtalk] Webhook reply timeout'); });
  req.on('error', (e) => console.error('[dingtalk] Webhook error:', e.message));
  req.write(body);
  req.end();
}

// ── DingTalk Stream Connection — reconnect with exponential backoff ───────

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

interface BotMsg {
  senderStaffId: string;
  sessionWebhook: string;
  sessionWebhookExpiredTime: number;
  text?: { content: string };
  conversationId: string;
  msgId: string;
}

class DingTalkStream {
  private ws: WebSocket | null = null;
  private reconnectDelay = WS_RECONNECT_BASE_MS;
  private stopping = false;

  constructor(
    private readonly tokenMgr: TokenManager,
    private readonly ocClient: ResilientOcClient,
    private readonly queue: UserQueue,
    private readonly dedup: MsgDedup,
    private readonly rateLimit: RateLimiter,
  ) {}

  async start(): Promise<void> {
    if (this.stopping) return;

    let token: string;
    try {
      token = await this.tokenMgr.get();
    } catch (e: any) {
      console.error('[dingtalk] Cannot get access token:', e.message);
      this.scheduleReconnect();
      return;
    }

    const subBody = JSON.stringify({
      clientId: CLIENT_ID,
      clientSecret: CLIENT_SECRET,
      subscriptions: [
        { type: 'CALLBACK', topic: '/v1.0/im/bot/messages/get' },
      ],
      ua: 'it0-dingtalk-channel/1.0',
      localIp: '127.0.0.1',
    });

    let wsInfo: { endpoint: string; ticket: string };
    try {
      wsInfo = await httpPost<{ endpoint: string; ticket: string }>(
        'api.dingtalk.com',
        '/v1.0/gateway/connections/open',
        JSON.parse(subBody),
        { 'x-acs-dingtalk-access-token': token },
      );
    } catch (e: any) {
      console.error('[dingtalk] Failed to get stream endpoint:', e.message);
      this.scheduleReconnect();
      return;
    }

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

    ws.on('open', () => {
      console.log('[dingtalk] Stream connected');
      this.reconnectDelay = WS_RECONNECT_BASE_MS; // reset backoff on success
    });

    ws.on('message', (raw) => {
      let frame: DtFrame;
      try { frame = JSON.parse(raw.toString()); } catch { return; }
      this.handleFrame(ws, frame);
    });

    ws.on('close', (code, reason) => {
      if (this.stopping) return;
      console.warn(`[dingtalk] Stream closed (${code}: ${reason.toString()})`);
      this.scheduleReconnect();
    });

    ws.on('error', (e) => {
      console.error('[dingtalk] Stream WS error:', e.message);
      // 'close' will fire after this, triggering reconnect
    });
  }

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

    // Bot message
    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 DingTalk immediately (must be within 1.5s)
      ws.send(JSON.stringify({ code: 200, headers: frame.headers, message: 'OK', data: '' }));

      this.dispatchMessage(msg);
    }
  }

  private dispatchMessage(msg: BotMsg): void {
    const userId = msg.senderStaffId;
    const prompt = msg.text?.content?.trim() ?? '';

    if (!prompt) return;

    // Dedup
    if (this.dedup.has(msg.msgId)) {
      console.log(`[dingtalk] Duplicate msgId ${msg.msgId}, skipped`);
      return;
    }
    this.dedup.add(msg.msgId);

    // Rate limit
    if (!this.rateLimit.allow(userId)) {
      console.warn(`[dingtalk] Rate limit hit for ${userId}`);
      replyToUser(msg.sessionWebhook, msg.sessionWebhookExpiredTime,
        '消息频率过高，请稍后再试（每分钟最多10条）。');
      return;
    }

    console.log(`[dingtalk] Queuing msg from ${userId}: "${prompt.slice(0, 60)}"`);

    // Enqueue into per-user serial queue (max depth guard)
    const accepted = this.queue.enqueue(userId, async () => {
      if (!this.ocClient.isConnected()) {
        replyToUser(msg.sessionWebhook, msg.sessionWebhookExpiredTime,
          '智能体正在启动，请稍等片刻后重试。');
        return;
      }

      let result: unknown;
      try {
        // chat.send — Protocol v3 correct method name
        // sessionKey "agent:main:dt-<userId>" = per-user session isolation
        result = await this.ocClient.rpc('chat.send', {
          sessionKey: `agent:main:dt-${userId}`,
          message: prompt,
          timeoutSeconds: 25,
        }, 30_000); // rpc timeout = 25s agent + 5s buffer
      } catch (e: any) {
        console.error(`[dingtalk] OC rpc error for ${userId}:`, e.message);
        replyToUser(msg.sessionWebhook, msg.sessionWebhookExpiredTime,
          '处理请求时出现错误，请稍后重试。');
        return;
      }

      const reply = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
      replyToUser(msg.sessionWebhook, msg.sessionWebhookExpiredTime, reply);
    });

    if (!accepted) {
      console.warn(`[dingtalk] Queue full for ${userId}, rejecting message`);
      replyToUser(msg.sessionWebhook, msg.sessionWebhookExpiredTime,
        '当前请求排队已满（最多5条），请等待前面的任务完成后再发送。');
    }
  }

  private scheduleReconnect(): void {
    if (this.stopping) return;
    console.log(`[dingtalk] Reconnecting in ${this.reconnectDelay}ms...`);
    setTimeout(() => this.start(), this.reconnectDelay);
    this.reconnectDelay = Math.min(this.reconnectDelay * 2, WS_RECONNECT_MAX_MS);
  }

  stop(): void {
    this.stopping = true;
    this.ws?.close();
  }
}

// ── HTTP helper (no external deps) ───────────────────────────────────────────

function httpPost<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 = '';
        res.on('data', (c) => (data += c));
        res.on('end', () => {
          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('Request timeout')); });
    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

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

// ── Main ──────────────────────────────────────────────────────────────────────

const tokenMgr  = new TokenManager();
const ocClient  = new ResilientOcClient();
const queue     = new UserQueue();
const dedup     = new MsgDedup();
const rateLimit = new RateLimiter();
const stream    = new DingTalkStream(tokenMgr, ocClient, queue, dedup, rateLimit);

// Graceful shutdown
function shutdown(signal: string): void {
  console.log(`[dingtalk] Received ${signal}, shutting down gracefully...`);
  stream.stop();
  tokenMgr.destroy();
  setTimeout(() => process.exit(0), 3_000);
}
process.once('SIGTERM', () => shutdown('SIGTERM'));
process.once('SIGINT',  () => shutdown('SIGINT'));

console.log('[dingtalk] Starting DingTalk channel...');

ocClient.connect()
  .then(() => stream.start())
  .catch((e) => {
    console.error('[dingtalk] Fatal startup error:', e.message);
    process.exit(1);
  });
