/**
 * Feishu Streaming Card - Card Kit streaming API for real-time text output
 *
 * Features:
 * - Streaming resume: if the card session becomes invalid mid-stream, a new card
 *   is created in the same chat and streaming continues from the last text.
 * - Inline tool hints: active tool names are appended per-line below the response
 *   text while streaming, then stripped from the final static card.
 * - Tool hint deduplication: each formatted hint string is shown at most once per
 *   session, preventing stacking when the same tool fires multiple times.
 */

import type { Client } from "@larksuiteoapi/node-sdk";
import type { FeishuDomain } from "./types.js";

type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
type ReceiveIdType = "open_id" | "user_id" | "union_id" | "email" | "chat_id";

export type FeishuStreamingSessionOptions = {
  /** Prefix prepended to each tool hint line. Defaults to "🔧 ". */
  toolHintPrefix?: string;
  /** Logger for internal events. */
  log?: (msg: string) => void;
};

// Token cache (keyed by domain + appId)
const tokenCache = new Map<string, { token: string; expiresAt: number }>();

function resolveApiBase(domain?: FeishuDomain): string {
  if (domain === "lark") {
    return "https://open.larksuite.com/open-apis";
  }
  if (domain && domain !== "feishu" && domain.startsWith("http")) {
    return `${domain.replace(/\/+$/, "")}/open-apis`;
  }
  return "https://open.feishu.cn/open-apis";
}

async function getToken(creds: Credentials): Promise<string> {
  const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
  const cached = tokenCache.get(key);
  if (cached && cached.expiresAt > Date.now() + 60000) {
    return cached.token;
  }

  const res = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
  });
  const data = (await res.json()) as {
    code: number;
    msg: string;
    tenant_access_token?: string;
    expire?: number;
  };
  if (data.code !== 0 || !data.tenant_access_token) {
    throw new Error(`Token error: ${data.msg}`);
  }
  tokenCache.set(key, {
    token: data.tenant_access_token,
    expiresAt: Date.now() + (data.expire ?? 7200) * 1000,
  });
  return data.tenant_access_token;
}

function truncateSummary(text: string, max = 50): string {
  if (!text) {
    return "";
  }
  const clean = text.replace(/\n/g, " ").trim();
  return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
}

/** Build the display content for the streaming card element.
 *
 * Tool hints are appended below the main text as italic lines so they appear
 * inline without interrupting the response flow. The same hint is never
 * appended twice (dedup enforced by the caller via seenHints).
 */
function buildDisplayContent(mainText: string, hints: string[]): string {
  if (!hints.length) {
    return mainText;
  }
  const hintSection = hints.map((h) => `_${h}_`).join("\n");
  return `${mainText}\n\n${hintSection}`;
}

/** Maximum number of streaming resume attempts per session. */
const MAX_RESUME_ATTEMPTS = 2;

/** Streaming card session manager */
export class FeishuStreamingSession {
  private client: Client;
  private creds: Credentials;
  private state: CardState | null = null;
  private queue: Promise<void> = Promise.resolve();
  private closed = false;
  private log?: (msg: string) => void;
  private lastUpdateTime = 0;
  private pendingText: string | null = null;
  private updateThrottleMs = 100; // Throttle updates to max 10/sec

  // Streaming resume state
  private receiveId: string | null = null;
  private receiveIdType: ReceiveIdType = "chat_id";
  private resumeAttempts = 0;
  private resuming = false;

  // Tool hints state
  private toolHintPrefix: string;
  private activeHints: string[] = []; // Formatted hint strings currently shown
  private seenHints = new Set<string>(); // Deduplicate within the session

  constructor(client: Client, creds: Credentials, opts?: FeishuStreamingSessionOptions);
  /** @deprecated Pass opts as third argument instead of bare log function. */
  constructor(client: Client, creds: Credentials, log?: (msg: string) => void);
  constructor(
    client: Client,
    creds: Credentials,
    optsOrLog?: FeishuStreamingSessionOptions | ((msg: string) => void),
  ) {
    this.client = client;
    this.creds = creds;
    if (typeof optsOrLog === "function") {
      this.log = optsOrLog;
      this.toolHintPrefix = "🔧 ";
    } else {
      this.log = optsOrLog?.log;
      this.toolHintPrefix = optsOrLog?.toolHintPrefix ?? "🔧 ";
    }
  }

  async start(receiveId: string, receiveIdType: ReceiveIdType = "chat_id"): Promise<void> {
    if (this.state) {
      return;
    }

    this.receiveId = receiveId;
    this.receiveIdType = receiveIdType;
    await this._createCard(receiveId, receiveIdType);
  }

  private async _createCard(receiveId: string, receiveIdType: ReceiveIdType): Promise<void> {
    const apiBase = resolveApiBase(this.creds.domain);
    const cardJson = {
      schema: "2.0",
      config: {
        streaming_mode: true,
        summary: { content: "[Generating...]" },
        streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 2 } },
      },
      body: {
        elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
      },
    };

    // Create card entity
    const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${await getToken(this.creds)}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
    });
    const createData = (await createRes.json()) as {
      code: number;
      msg: string;
      data?: { card_id: string };
    };
    if (createData.code !== 0 || !createData.data?.card_id) {
      throw new Error(`Create card failed: ${createData.msg}`);
    }
    const cardId = createData.data.card_id;

    // Send card message
    const sendRes = await this.client.im.message.create({
      params: { receive_id_type: receiveIdType },
      data: {
        receive_id: receiveId,
        msg_type: "interactive",
        content: JSON.stringify({ type: "card", data: { card_id: cardId } }),
      },
    });
    if (sendRes.code !== 0 || !sendRes.data?.message_id) {
      throw new Error(`Send card failed: ${sendRes.msg}`);
    }

    this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
    this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
  }

  /**
   * Resume streaming after the card session becomes invalid.
   *
   * Creates a new streaming card in the same chat and picks up from the last
   * known text, so the user sees a continuation rather than a silent failure.
   */
  private async _tryResume(fromText: string): Promise<boolean> {
    if (this.resuming || this.resumeAttempts >= MAX_RESUME_ATTEMPTS || !this.receiveId) {
      return false;
    }
    this.resuming = true;
    this.resumeAttempts += 1;
    this.log?.(`feishu: streaming resume attempt ${this.resumeAttempts}/${MAX_RESUME_ATTEMPTS}`);

    try {
      // Reset state so _createCard treats this as a fresh start
      this.state = null;
      await this._createCard(this.receiveId, this.receiveIdType);

      // Type assertion needed because TS doesn't track that _createCard above
      // re-assigned this.state to a non-null CardState.
      const resumedState = this.state as CardState | null;
      if (resumedState && fromText) {
        // Push accumulated text into the resumed card immediately
        resumedState.currentText = fromText;
        resumedState.sequence += 1;
        const apiBase = resolveApiBase(this.creds.domain);
        await fetch(`${apiBase}/cardkit/v1/cards/${resumedState.cardId}/elements/content/content`, {
          method: "PUT",
          headers: {
            Authorization: `Bearer ${await getToken(this.creds)}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            content: buildDisplayContent(fromText, this.activeHints),
            sequence: resumedState.sequence,
            uuid: `s_${resumedState.cardId}_${resumedState.sequence}`,
          }),
        }).catch((e) => this.log?.(`Resume seed update failed: ${String(e)}`));
      }

      this.log?.(`feishu: streaming resumed on new card ${resumedState?.cardId}`);
      return true;
    } catch (err) {
      this.log?.(`feishu: streaming resume failed: ${String(err)}`);
      return false;
    } finally {
      this.resuming = false;
    }
  }

  async update(text: string): Promise<void> {
    if (!this.state || this.closed) {
      return;
    }
    // Throttle: skip if updated recently, but remember pending text
    const now = Date.now();
    if (now - this.lastUpdateTime < this.updateThrottleMs) {
      this.pendingText = text;
      return;
    }
    this.pendingText = null;
    this.lastUpdateTime = now;

    this.queue = this.queue.then(async () => {
      if (!this.state || this.closed) {
        return;
      }
      this.state.currentText = text;
      this.state.sequence += 1;
      const apiBase = resolveApiBase(this.creds.domain);

      const res = await fetch(
        `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`,
        {
          method: "PUT",
          headers: {
            Authorization: `Bearer ${await getToken(this.creds)}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            content: buildDisplayContent(text, this.activeHints),
            sequence: this.state.sequence,
            uuid: `s_${this.state.cardId}_${this.state.sequence}`,
          }),
        },
      ).catch((e) => {
        this.log?.(`Update failed: ${String(e)}`);
        return null;
      });

      // Attempt streaming resume if the request failed at the network level or
      // the API returned a non-OK code that indicates a dead card session.
      if (res !== null && !res.ok && this.state) {
        const statusCode = res.status;
        // 404 = card not found (expired), 400 may indicate sequence conflict
        if (statusCode === 404 || statusCode === 400) {
          await this._tryResume(text);
        }
      }
    });
    await this.queue;
  }

  /**
   * Set the active tool hints shown inline during streaming.
   *
   * Each hint name is formatted with the configured prefix and appended as its
   * own italic line below the response text. Hints already seen in this session
   * are silently skipped (deduplication), so the same tool name never stacks.
   *
   * Calling with an empty array clears the currently displayed hints.
   */
  setToolHints(names: string[]): void {
    if (!names.length) {
      this.activeHints = [];
      return;
    }

    // Format and deduplicate: only keep hints not yet seen this session
    const newHints: string[] = [];
    for (const name of names) {
      const formatted = `${this.toolHintPrefix}${name}`;
      if (!this.seenHints.has(formatted)) {
        this.seenHints.add(formatted);
        newHints.push(formatted);
      }
    }

    // Replace active hints with the combined deduplicated list
    // (merge newly seen + any that were already active and are in this call)
    const requested = new Set(names.map((n) => `${this.toolHintPrefix}${n}`));
    this.activeHints = [...this.seenHints].filter((h) => requested.has(h));
  }

  async close(finalText?: string): Promise<void> {
    if (!this.state || this.closed) {
      return;
    }
    this.closed = true;
    await this.queue;

    // Clear active hints — the final static card shows clean response text only
    this.activeHints = [];

    // Use finalText, or pending throttled text, or current text
    const text = finalText ?? this.pendingText ?? this.state.currentText;
    const apiBase = resolveApiBase(this.creds.domain);

    // Only send final update if content differs from what's already displayed
    if (text && text !== this.state.currentText) {
      this.state.sequence += 1;
      await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
        method: "PUT",
        headers: {
          Authorization: `Bearer ${await getToken(this.creds)}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          content: text,
          sequence: this.state.sequence,
          uuid: `s_${this.state.cardId}_${this.state.sequence}`,
        }),
      }).catch(() => {});
      this.state.currentText = text;
    }

    // Close streaming mode
    this.state.sequence += 1;
    await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, {
      method: "PATCH",
      headers: {
        Authorization: `Bearer ${await getToken(this.creds)}`,
        "Content-Type": "application/json; charset=utf-8",
      },
      body: JSON.stringify({
        settings: JSON.stringify({
          config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
        }),
        sequence: this.state.sequence,
        uuid: `c_${this.state.cardId}_${this.state.sequence}`,
      }),
    }).catch((e) => this.log?.(`Close failed: ${String(e)}`));

    this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
  }

  isActive(): boolean {
    return this.state !== null && !this.closed;
  }
}
