/**
 * lark/message.ts
 *
 * Extracts downstream-relevant fields from a raw LarkMessageEvent.
 * This module only extracts — it never downloads attachments, fetches docs,
 * or calls any external service.
 */

import type { LarkMessageEvent } from "./transport.js";

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

export interface AttachmentRef {
  fileKey: string;
  fileName?: string;
  /** Feishu original type: 'image' | 'file' | 'video' | 'audio' etc. */
  fileType: string;
}

export interface ParsedMessage {
  /** Non-empty: client.ts guarantees thread_id is present before dispatching */
  threadId: string;
  chatId: string;
  /**
   * The message id downstream reply/card/reaction calls anchor on. Real
   * inbound events: this is just `event.message_id`, a genuine Feishu id.
   *
   * Bug fix (real-deployment incident, task_stall wake-ups): purely-synthetic
   * events (StallDetector's task_stall, CommentPoller's task_comment — turns
   * with no real antecedent Feishu message) previously ALSO fell through to
   * `event.message_id`, but that field for them is a fake string
   * (`synthetic-task-stall-<threadId>-<ts>`) needed only for
   * ChannelClient's/handler.ts's dedup+in-flight tracking
   * (seenMessageIds/inFlightMessageIds/markHandled/markUnhandled, all keyed
   * on the RAW `event.message_id` directly — see handler.ts's
   * `settleMessageId`, computed BEFORE parseMessage runs, deliberately NOT
   * derived from this field). Posting `im.v1.message.reply` against that fake
   * id 400s ("not a valid open_message_id") — the wake-up turn ran and did
   * real work, but the user never saw a reply in the topic (静默漏管).
   * `event.reply_anchor_message_id` (optional, only set by these two
   * synthetic-event producers in main.ts) lets them supply a REAL id to
   * reply against — the topic's own root message (`turn.threadId`, itself
   * always a genuine Feishu message id) — while `event.message_id` keeps its
   * fake-but-unique value for dedup, so this never collides with real
   * seenMessageIds/inFlightMessageIds tracking.
   */
  messageId: string;
  senderOpenId: string;
  /** Plain text with @-mention placeholders stripped */
  text: string;
  attachments: AttachmentRef[];
  /** Feishu doc URLs found in text */
  feishuDocLinks: string[];
  /** Original event, passed through for logging / debugging */
  raw: LarkMessageEvent;
}

// ---------------------------------------------------------------------------
// Regexes
// ---------------------------------------------------------------------------

/**
 * Strip @-mention placeholders inserted by Feishu (e.g. "@_user_1 ").
 * Feishu compact output uses @_user_N or @_all as inline placeholders.
 */
const AT_PLACEHOLDER_RE = /@_\w+\s*/g;

/**
 * Match Feishu document URLs.
 * Covers example.feishu.cn, feishu.cn, and larkoffice.com variants.
 * Intentionally greedy on the path so we capture the full resource ID.
 */
const FEISHU_DOC_RE =
  /https?:\/\/[\w-]+\.(?:feishu\.cn|larkoffice\.com)\/(?:docs|wiki|sheets|space)\/[\w%-]+(?:\/[\w%-]*)?/gi;

// ---------------------------------------------------------------------------
// Text extraction helpers
// ---------------------------------------------------------------------------

/**
 * Recursively collect text strings from a parsed Feishu post content tree.
 * post content is [[{tag, text?}]] — a 2-D array of paragraph rows.
 */
function extractPostText(value: unknown): string {
  if (typeof value === "string") return value;
  if (Array.isArray(value)) {
    return value
      .map(extractPostText)
      .filter(Boolean)
      .join(" ");
  }
  if (value !== null && typeof value === "object") {
    const obj = value as Record<string, unknown>;
    // Skip @-mention nodes — they are handled by AT_PLACEHOLDER_RE on the
    // final string, but we also avoid emitting the user_id as text.
    if (obj["tag"] === "at") return "";
    if (typeof obj["text"] === "string") return obj["text"];
    // Recurse into any child arrays (e.g. elements, paragraphs)
    const childKeys = ["content", "elements", "body"];
    for (const key of childKeys) {
      if (key in obj) return extractPostText(obj[key]);
    }
  }
  return "";
}

/**
 * Parsed shape of a Feishu task-share message (`msg_type: "todo"` — what
 * 「发送任务到会话」 posts into a chat). Ground truth (2026-07-07, real API
 * fetch — docs/task-handle.md §9.13):
 *
 *   {"task_id":"<task v2 guid>","summary":{"title":"","content":[[{tag,text}]]},"due_time":"0000"}
 *
 * `task_id` IS the task v2 guid, directly usable with the whole task API —
 * the deterministic-binding cornerstone of the v4 任务派单 path (§15).
 * `summary.content` reuses the post 2-D paragraph shape, so extractPostText
 * handles it. Variants that don't parse exist in the wild (§9.13's warning);
 * this returns null for anything it can't positively identify.
 */
export interface TodoShareContent {
  taskGuid: string;
  summaryText: string;
}

/**
 * Best-effort parse of a task-share message's raw content JSON. Null on any
 * shape mismatch — callers must treat null as "not a (recognizable) task
 * share" and fall back to their normal path, never error.
 */
export function parseTodoShareContent(rawContent: string): TodoShareContent | null {
  try {
    const parsed = JSON.parse(rawContent) as Record<string, unknown>;
    const taskGuid = parsed["task_id"];
    if (typeof taskGuid !== "string" || taskGuid.length === 0) return null;
    const summaryText = extractPostText(parsed["summary"]).trim();
    return { taskGuid, summaryText };
  } catch {
    return null;
  }
}

/**
 * Parse event.content (a JSON string) and extract plain text.
 * Falls back to the raw content string and emits a console.warn on parse error.
 */
function extractText(event: LarkMessageEvent): string {
  let parsed: Record<string, unknown>;
  try {
    parsed = JSON.parse(event.content) as Record<string, unknown>;
  } catch {
    console.warn(
      `[lark.message] Failed to JSON.parse content for message_id=${event.message_id}; ` +
        "falling back to raw content string."
    );
    return stripAtPlaceholders(event.content);
  }

  let raw = "";

  // msg_type = "todo" (task share, 「发送任务到会话」): render the summary +
  // guid as text so downstream consumers (rootText capture, gap-fill preview,
  // the agent prompt) see WHAT task this is without another API call. Checked
  // before the generic branches — a todo body has no top-level text/content.
  const todo = typeof parsed["task_id"] === "string" ? parseTodoShareContent(event.content) : null;
  if (todo) {
    return `[飞书任务] ${todo.summaryText || "(无标题)"} (task_guid=${todo.taskGuid})`;
  }

  // message_type = "text": { "text": "..." }
  if (typeof parsed["text"] === "string") {
    raw = parsed["text"];
  }
  // message_type = "post": { "title": "...", "content": [[{tag, text}]] }
  else if (parsed["content"] !== undefined) {
    raw = extractPostText(parsed["content"]);
  }
  // message_type = "post" with a top-level zh_cn / en_us locale key
  else {
    const localeValue =
      parsed["zh_cn"] ?? parsed["en_us"] ?? parsed["zh_hk"] ?? parsed["ja_jp"];
    if (localeValue !== undefined) {
      raw = extractPostText(localeValue);
    }
  }

  // Last resort: stringify and warn
  if (!raw) {
    console.warn(
      `[lark.message] Could not extract text from content for message_id=${event.message_id}; ` +
        "returning empty string."
    );
  }

  return stripAtPlaceholders(raw.trim());
}

function stripAtPlaceholders(text: string): string {
  return text.replace(AT_PLACEHOLDER_RE, "").trim();
}

// ---------------------------------------------------------------------------
// Attachment extraction
// ---------------------------------------------------------------------------

/**
 * Recursively collect all image_key values from a post content tree.
 * Post content shape: [[{tag, image_key?, text?, ...}]] — a 2-D array of
 * paragraph rows, each row being an array of segment objects.
 *
 * A segment carrying an inline image has `tag: "img"` or `tag: "image"`,
 * and carries an `image_key` string field. We also handle any nested arrays
 * or objects for robustness.
 *
 * Returns image_key strings (no duplicates guaranteed by the caller).
 */
function collectPostImageKeys(value: unknown): string[] {
  if (typeof value === "string") return [];
  if (Array.isArray(value)) {
    const keys: string[] = [];
    for (const item of value) {
      keys.push(...collectPostImageKeys(item));
    }
    return keys;
  }
  if (value !== null && typeof value === "object") {
    const obj = value as Record<string, unknown>;
    const keys: string[] = [];
    // Collect image_key from this node if it looks like an image segment
    // Feishu uses tag:"img" in post inline images
    if (typeof obj["image_key"] === "string") {
      keys.push(obj["image_key"]);
    }
    // Recurse into any child arrays/objects (paragraphs, elements, content, etc.)
    for (const v of Object.values(obj)) {
      if (v !== null && (Array.isArray(v) || typeof v === "object")) {
        keys.push(...collectPostImageKeys(v));
      }
    }
    return keys;
  }
  return [];
}

/**
 * Extract attachments from the parsed content object.
 * Handles:
 *   - image message:  { image_key }
 *   - file/video/audio message: { file_key, file_name? }
 *   - post message inline images: recursively walks content tree for image_key fields
 *
 * All image_key values are deduplicated in the returned array.
 */
function extractAttachments(
  event: LarkMessageEvent,
  parsed: Record<string, unknown> | null
): AttachmentRef[] {
  if (!parsed) return [];

  const attachments: AttachmentRef[] = [];
  const seenImageKeys = new Set<string>();

  // image message — top-level image_key
  if (typeof parsed["image_key"] === "string") {
    seenImageKeys.add(parsed["image_key"]);
    attachments.push({
      fileKey: parsed["image_key"],
      fileType: "image",
    });
  }

  // file / video / audio message
  if (typeof parsed["file_key"] === "string") {
    // Feishu sometimes includes a "file_type" field; otherwise infer from
    // the message shape. We keep the raw type from event if available.
    const fileType =
      typeof parsed["file_type"] === "string"
        ? parsed["file_type"]
        : inferFileType(event, parsed);

    const ref: AttachmentRef = {
      fileKey: parsed["file_key"],
      fileType,
    };
    if (typeof parsed["file_name"] === "string") {
      ref.fileName = parsed["file_name"];
    }
    attachments.push(ref);
  }

  // post message — recursively extract inline image_key from content tree
  // post shape: { "content": [[{tag: "img", image_key: "..."}]] }
  // or locale-keyed: { "zh_cn": { "content": [...] } }
  const postContent =
    parsed["content"] ??
    (parsed["zh_cn"] as Record<string, unknown> | undefined)?.["content"] ??
    (parsed["en_us"] as Record<string, unknown> | undefined)?.["content"] ??
    (parsed["zh_hk"] as Record<string, unknown> | undefined)?.["content"] ??
    (parsed["ja_jp"] as Record<string, unknown> | undefined)?.["content"];

  if (postContent !== undefined) {
    const inlineKeys = collectPostImageKeys(postContent);
    for (const key of inlineKeys) {
      if (!seenImageKeys.has(key)) {
        seenImageKeys.add(key);
        attachments.push({ fileKey: key, fileType: "image" });
      }
    }
  }

  return attachments;
}

/**
 * Best-effort file type inference when Feishu doesn't include an explicit
 * "file_type" field in the content JSON. Falls back to "file".
 */
function inferFileType(
  event: LarkMessageEvent,
  parsed: Record<string, unknown>
): string {
  // Some compact outputs include a top-level "message_type" on the event
  const msgType = event["message_type"] as string | undefined;
  if (msgType && msgType !== "text" && msgType !== "post") {
    return msgType; // e.g. "video", "audio", "file"
  }
  // Infer from file_name extension
  const fileName = parsed["file_name"];
  if (typeof fileName === "string") {
    const ext = fileName.split(".").pop()?.toLowerCase();
    if (ext) {
      if (["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video";
      if (["mp3", "wav", "m4a", "ogg", "aac", "amr"].includes(ext)) return "audio";
    }
  }
  return "file";
}

// ---------------------------------------------------------------------------
// Feishu doc link extraction
// ---------------------------------------------------------------------------

function extractFeishuDocLinks(text: string): string[] {
  const matches = text.match(FEISHU_DOC_RE);
  if (!matches) return [];
  // Deduplicate while preserving order
  return [...new Set(matches)];
}

// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------

export function parseMessage(event: LarkMessageEvent): ParsedMessage {
  // Parse content JSON once; keep null on failure (individual helpers handle null)
  let parsedContent: Record<string, unknown> | null = null;
  try {
    parsedContent = JSON.parse(event.content) as Record<string, unknown>;
  } catch {
    // extractText will also attempt the parse and emit its own warn
  }

  const text = extractText(event);
  const attachments = extractAttachments(event, parsedContent);
  const feishuDocLinks = extractFeishuDocLinks(text);

  return {
    // Session key — groups all messages in the same logical conversation:
    //   - top-level @bot      → root_id is empty   → use message_id (this msg IS the root)
    //   - reply inside thread → root_id is set     → use root_id (points back to the first @)
    // This makes session continuity match what the user expects: "this whole
    // back-and-forth is one task". Field name stays threadId for compatibility,
    // but its semantic is now "session key", not strictly Feishu thread_id.
    threadId: (typeof event.root_id === "string" && event.root_id)
                ? event.root_id
                : event.message_id,
    chatId: event.chat_id,
    // Deliberately NOT always event.message_id — see reply_anchor_message_id's
    // own doc comment below. `event.message_id` itself is left untouched here
    // (handler.ts reads it directly, separately, for markHandled/markUnhandled
    // dedup) — this only changes what downstream reply/card/reaction calls
    // anchor on.
    messageId:
      (typeof event.reply_anchor_message_id === "string" && event.reply_anchor_message_id) ||
      event.message_id,
    senderOpenId: senderOpenIdOf(event.sender_id),
    text,
    attachments,
    feishuDocLinks,
    raw: event,
  };
}

function senderOpenIdOf(senderId: LarkMessageEvent["sender_id"]): string {
  if (typeof senderId === "string") return senderId;
  if (senderId && typeof senderId === "object") {
    const obj = senderId as Record<string, unknown>;
    for (const key of ["open_id", "id", "user_id", "union_id"]) {
      if (typeof obj[key] === "string") return obj[key];
    }
  }
  return "";
}
