import { getMessageDetail } from './client.js';
import { logger } from '../../utils/logger.js';
/**
 * When the WebSocket event delivers message_type "nonsupport", call the REST API
 * to fetch the real message content and patch the event data in-place.
 *
 * Also handles `interactive`: the WebSocket event only carries a simplified
 * fallback view of cards (often literally "请升级至最新版本客户端，以查看内容"),
 * so we fetch the real card JSON (including v2 `body.elements`) via REST.
 */
export async function resolveNonsupportMessage(data, larkAppId) {
    const type = data.message.message_type;
    if (type !== 'nonsupport' && type !== 'interactive')
        return;
    // Interactive cards: resolve to the COMPLETE merged text (union of both
    // im.message.get representations) so forwarded cards reach the model fully
    // parsed — same resolver `botmux history` uses. resolveEventCard falls back
    // to local user_dsl unwrap if REST is unavailable (e.g. cross-tenant).
    if (type === 'interactive') {
        await resolveEventCard(data, larkAppId);
        return;
    }
    try {
        const detail = await getMessageDetail(larkAppId, data.message.message_id);
        const msg = detail?.items?.[0];
        if (!msg)
            return;
        const realType = msg.msg_type;
        const realContent = msg.body?.content;
        if (realType && realContent) {
            logger.info(`[parser] Resolved ${type} → ${realType} for ${data.message.message_id}`);
            data.message.message_type = realType;
            data.message.content = realContent;
        }
    }
    catch (err) {
        logger.debug(`[parser] Failed to resolve ${type} message ${data.message.message_id}: ${err}`);
    }
}
/**
 * Lark bundles the real v2 card JSON inside a `user_dsl` string on the
 * simplified interactive payload. When present, return the unwrapped v2
 * body so downstream extractors see schema/body.elements directly.
 */
export function unwrapUserDslContent(rawContent) {
    try {
        const outer = JSON.parse(rawContent);
        if (typeof outer?.user_dsl !== 'string')
            return null;
        const inner = JSON.parse(outer.user_dsl);
        if (!inner || typeof inner !== 'object')
            return null;
        if (!inner.body && !inner.elements && !inner.header)
            return null;
        return JSON.stringify(inner);
    }
    catch {
        return null;
    }
}
function unwrapUserDsl(data) {
    const unwrapped = unwrapUserDslContent(data.message.content);
    if (unwrapped === null)
        return false;
    data.message.content = unwrapped;
    logger.info(`[parser] Unwrapped user_dsl for ${data.message.message_id}`);
    return true;
}
/**
 * Lark's simplified "upgrade your client" card fallback marker. When this text
 * shows up in a card's resolved content, the real body was stripped and must be
 * recovered via `im.message.get` (with `card_msg_content_type=user_card_content`).
 */
export const CARD_UPGRADE_FALLBACK = '请升级至最新版本客户端';
/**
 * Broad check — content carries the upgrade notice *somewhere*. Used to decide
 * whether a card needs REST re-resolution. Deliberately a substring match: a
 * false positive only costs one extra `im.message.get`, and this is the only
 * way to catch **embedded** fallbacks — complex cards (e.g. Argos alarm cards
 * with nested sub-cards) render fine at the top level but bury one or more
 * `请升级…` placeholders mid-body where an anchored check would miss them.
 */
export function cardContentHasUpgradeFallback(content) {
    return content.includes(CARD_UPGRADE_FALLBACK);
}
/**
 * Narrow check — content *is* essentially just Lark's upgrade notice, not a
 * body that merely mentions it. Anchored at the start after stripping leading
 * `[图片]` / `[文件 N]` placeholders (the bare fallback renders as
 * `[图片]请升级至最新版本客户端，以查看内容`). Used as the replace gate when
 * re-resolving via REST: it keeps a card that legitimately quotes the phrase
 * mid-text — e.g. a message discussing this very fallback — from being
 * discarded, while still rejecting a REST view that came back as a bare
 * fallback. Makes no structural assumptions, so it's safe for the
 * simplified-but-real Format A shape (no schema/body/header) message.list
 * returns.
 */
export function isPureCardUpgradeFallback(content) {
    const stripped = content.replace(/^(?:\s*\[(?:图片|文件)[^\]]*\])+/, '').trimStart();
    return stripped.startsWith(CARD_UPGRADE_FALLBACK);
}
export function createImgNumberer() {
    const map = new Map();
    let imgCounter = 0;
    let fileCounter = 0;
    return {
        assign(key) {
            const existing = map.get(key);
            if (existing !== undefined)
                return { num: existing, isNew: false };
            // Key prefix selects the counter so image/file numbering stays
            // independent (mirrors formatAttachmentsHint's per-type imgN/fileN).
            // Unknown prefixes share the image counter as a safe default.
            const num = key.startsWith('file:') ? ++fileCounter : ++imgCounter;
            map.set(key, num);
            return { num, isNew: true };
        },
    };
}
export function extractResources(msgType, rawContent, numberer) {
    const content = normalizeApiMessageContent(msgType, rawContent);
    const nb = numberer ?? createImgNumberer();
    const pushIfNew = (resources, r) => {
        if (nb.assign(`${r.type}:${r.key}`).isNew)
            resources.push(r);
    };
    try {
        const parsed = JSON.parse(content);
        if (msgType === 'image') {
            const resources = [];
            const imageKey = parsed.image_key;
            if (imageKey)
                pushIfNew(resources, { type: 'image', key: imageKey, name: `${imageKey}.jpg` });
            return resources;
        }
        if (msgType === 'file') {
            const resources = [];
            const fileKey = parsed.file_key;
            if (fileKey)
                pushIfNew(resources, { type: 'file', key: fileKey, name: parsed.file_name ?? fileKey });
            return resources;
        }
        if (msgType === 'post') {
            const resources = [];
            const { content: contentBlocks } = resolvePostBody(parsed);
            for (const block of contentBlocks) {
                const nodes = Array.isArray(block) ? block : [block];
                for (const node of nodes) {
                    if ((node.tag === 'img' || node.tag === 'media') && node.image_key) {
                        pushIfNew(resources, { type: 'image', key: node.image_key, name: `${node.image_key}.jpg` });
                    }
                    if (node.tag === 'file' && node.file_key) {
                        pushIfNew(resources, { type: 'file', key: node.file_key, name: node.file_name ?? node.file_key });
                    }
                }
            }
            return resources;
        }
        if (msgType === 'interactive') {
            const resources = [];
            // v2 cards nest elements under `body`; fall back to legacy top-level.
            const rootElements = Array.isArray(parsed.body?.elements)
                ? parsed.body.elements
                : Array.isArray(parsed.elements) ? parsed.elements : null;
            if (rootElements) {
                const isApiFormat = rootElements.length > 0 && Array.isArray(rootElements[0]);
                if (isApiFormat) {
                    // Format A: [[{tag:"img",image_key:"..."}, ...], ...]
                    for (const block of rootElements) {
                        if (!Array.isArray(block))
                            continue;
                        for (const node of block) {
                            const key = node.image_key ?? node.img_key;
                            if ((node.tag === 'img' || node.tag === 'image') && key) {
                                pushIfNew(resources, { type: 'image', key, name: `${key}.jpg` });
                            }
                        }
                    }
                }
                else {
                    for (const el of rootElements) {
                        extractElementImages(el, resources, pushIfNew);
                    }
                }
            }
            return resources;
        }
    }
    catch {
        // ignore parse errors
    }
    return [];
}
export function parseEventMessage(data) {
    const { sender, message } = data;
    // Trace non-text messages at debug only (DEBUG=1 gated). The raw card/post
    // content can be many KB and may include attachment metadata that's
    // noisy or sensitive — truncate to ~500 chars so DEBUG logs stay scannable.
    if (message.message_type !== 'text' && logger.isDebug()) {
        const raw = message.content ?? '';
        const trimmed = raw.length > 500 ? raw.slice(0, 500) + `…(+${raw.length - 500}b)` : raw;
        logger.debug(`[parser] type=${message.message_type} content=${trimmed} keys=${Object.keys(message).join(',')}`);
    }
    // Share numberer so in-body [图片 N] placeholders use the same numbers as
    // the attachment list. Resources first → numbers assigned; text second →
    // reuses them.
    const numberer = createImgNumberer();
    const resources = extractResources(message.message_type, message.content, numberer);
    // Extract structured mentions
    const mentions = message.mentions && message.mentions.length > 0
        ? message.mentions.map(m => ({
            key: m.key,
            name: m.name,
            openId: m.id?.open_id,
        }))
        : undefined;
    const parsed = {
        messageId: message.message_id,
        rootId: message.root_id ?? '',
        threadId: message.thread_id || undefined,
        parentId: message.parent_id || undefined,
        senderId: sender.sender_id?.open_id ?? '',
        senderUnionId: sender.sender_id?.union_id,
        senderType: sender.sender_type,
        msgType: message.message_type,
        content: extractTextContent(message.message_type, message.content, message.mentions, numberer),
        createTime: message.create_time,
        mentions,
    };
    return { parsed, resources };
}
export function parseApiMessage(msg, numberer) {
    const msgType = msg.msg_type ?? 'text';
    const rawContent = msg.body?.content ?? '';
    return {
        messageId: msg.message_id ?? '',
        rootId: msg.root_id ?? msg.thread_id ?? '',
        senderId: msg.sender?.id ?? '',
        senderType: msg.sender?.sender_type ?? 'unknown',
        msgType,
        content: extractTextContent(msgType, normalizeApiMessageContent(msgType, rawContent), undefined, numberer),
        createTime: msg.create_time ?? '',
    };
}
function normalizeApiMessageContent(msgType, rawContent) {
    if (msgType !== 'interactive')
        return rawContent;
    return unwrapUserDslContent(rawContent) ?? rawContent;
}
/** Resolve post body from either wrapped {"zh_cn":{title,content}} or unwrapped {title,content} format */
function resolvePostBody(parsed) {
    // Unwrapped: has content array directly
    if (Array.isArray(parsed.content)) {
        return { title: parsed.title ?? '', content: parsed.content };
    }
    // Wrapped in language key: {"zh_cn": {title, content}}
    for (const key of Object.keys(parsed)) {
        const val = parsed[key];
        if (val && typeof val === 'object' && Array.isArray(val.content)) {
            return { title: val.title ?? '', content: val.content };
        }
    }
    return { title: '', content: [] };
}
/**
 * Strip leading `@<name>` mentions from a resolved-content string so callers
 * can detect daemon `/commands` even when the user @-mentioned the bot first.
 *
 * Uses the structured mentions list when available (handles names with spaces);
 * falls back to a `@\S+` regex for cases where Lark didn't populate mentions
 * (e.g. some post messages where the at-tag becomes a plain `@<user_name>`
 * string in the rendered text).
 */
export function stripLeadingMentions(content, mentions) {
    let s = content.trimStart();
    if (mentions && mentions.length > 0) {
        // Sort by name length desc so "@Claude分身" wins over "@Claude" when both
        // could startsWith — otherwise the short name eats "@Claude" and leaves
        // "分身 @CoCo /close" stranded, breaking slash-command detection in
        // multi-bot @ chains like "@Claude @Claude分身 @CoCo /close".
        const sortedMentions = [...mentions].sort((a, b) => b.name.length - a.name.length);
        let changed = true;
        while (changed) {
            changed = false;
            for (const m of sortedMentions) {
                const tag = `@${m.name}`;
                if (s.startsWith(tag)) {
                    s = s.slice(tag.length).trimStart();
                    changed = true;
                    break;
                }
            }
        }
        return s;
    }
    // No mentions list (e.g. some post messages) — best-effort strip leading
    // single-word @<word> patterns. Multi-word names without a mentions list
    // can't be reliably detected and will be left in place.
    let changed = true;
    while (changed) {
        changed = false;
        const m = s.match(/^@\S+/);
        if (m) {
            s = s.slice(m[0].length).trimStart();
            changed = true;
        }
    }
    return s;
}
function resolveMentions(text, mentions) {
    if (!mentions || mentions.length === 0) {
        // No mention info available — strip placeholders
        return text.replace(/@_user_\d+/g, '').replace(/[^\S\r\n]{2,}/g, ' ').trim();
    }
    let result = text;
    for (const m of mentions) {
        result = result.replace(m.key, `@${m.name}`);
    }
    return result.trim();
}
function normalizeFenceLanguage(lang) {
    return typeof lang === 'string' ? lang.trim().replace(/\s+/g, '_') : '';
}
function renderPostCodeBlock(node) {
    const raw = typeof node.text === 'string'
        ? node.text
        : typeof node.content === 'string'
            ? node.content
            : typeof node.code === 'string'
                ? node.code
                : '';
    const code = raw.replace(/\n+$/, '');
    const lang = normalizeFenceLanguage(node.language ?? node.lang);
    const longestFence = Math.max(2, ...[...code.matchAll(/`+/g)].map(m => m[0].length));
    const fence = '`'.repeat(longestFence + 1);
    return `\n${fence}${lang}\n${code}\n${fence}\n`;
}
function renderPostNode(node, numberer) {
    if (node.tag === 'text')
        return node.text ?? '';
    if (node.tag === 'a')
        return node.text ?? node.href ?? '';
    if (node.tag === 'at')
        return `@${node.user_name ?? 'unknown'}`;
    if (node.tag === 'code_block')
        return renderPostCodeBlock(node);
    if (node.tag === 'img' || node.tag === 'media') {
        const key = node.image_key ?? node.file_key;
        if (key && numberer)
            return `[图片 ${numberer.assign(`image:${key}`).num}]`;
        return '[图片]';
    }
    if (node.tag === 'file') {
        const key = node.file_key;
        const name = node.file_name ?? '';
        if (key && numberer) {
            const n = numberer.assign(`file:${key}`).num;
            return name ? `[文件 ${n}: ${name}]` : `[文件 ${n}]`;
        }
        return name ? `[文件: ${name}]` : '[文件]';
    }
    return '';
}
function joinPostNodeText(parts) {
    return parts.join('').replace(/\n{3,}/g, '\n\n').trim();
}
function extractTextContent(msgType, rawContent, mentions, numberer) {
    try {
        if (msgType === 'text') {
            const parsed = JSON.parse(rawContent);
            return resolveMentions(parsed.text ?? rawContent, mentions);
        }
        if (msgType === 'post') {
            const parsed = JSON.parse(rawContent);
            const { title, content } = resolvePostBody(parsed);
            const body = content
                .map((paragraph) => {
                const nodes = Array.isArray(paragraph) ? paragraph : [paragraph];
                return joinPostNodeText(nodes.map((node) => renderPostNode(node, numberer)));
            })
                .filter(Boolean)
                .join('\n');
            return title ? `${title}\n${body}` : body;
        }
        if (msgType === 'image') {
            try {
                const p = JSON.parse(rawContent);
                if (p.image_key && numberer)
                    return `[图片 ${numberer.assign(`image:${p.image_key}`).num}]`;
            }
            catch { /* fall through */ }
            return '[图片]';
        }
        if (msgType === 'file') {
            try {
                const p = JSON.parse(rawContent);
                if (p.file_key && numberer) {
                    const n = numberer.assign(`file:${p.file_key}`).num;
                    return p.file_name ? `[文件 ${n}: ${p.file_name}]` : `[文件 ${n}]`;
                }
                return `[文件: ${p.file_name ?? 'unknown'}]`;
            }
            catch {
                return '[文件]';
            }
        }
        if (msgType === 'interactive') {
            return extractCardContent(rawContent, numberer);
        }
        if (msgType === 'merge_forward') {
            return '[合并转发消息]';
        }
        return rawContent;
    }
    catch {
        return rawContent;
    }
}
/**
 * botmux-generated card footer signature. Every card `botmux send` /
 * buildMarkdownCard emits ends with a small grey note linking back to the repo
 * (`[botmux](https://github.com/deepcoldy/botmux)`, optionally `· 发送给：@owner`).
 * That footer is human-facing chrome — when another bot receives the card it
 * must NOT leak into the receiving bot's prompt (it surfaces as a stray
 * `<font color='grey'>botmux</font>` block and duplicates mention info). Both
 * Lark render formats carry the canonical repo URL on that footer line (Format B
 * as the markdown link target, Format A as the `<a href>`), so the URL is a
 * reliable, format-agnostic marker. Anchored on the full repo URL so genuine
 * body text merely mentioning "botmux" survives. Known, accepted trade-off: a
 * card whose own body puts this exact repo URL on a line would lose that line
 * too — vanishingly rare versus the value of a simple format-agnostic anchor.
 */
const BOTMUX_FOOTER_MARKER = 'github.com/deepcoldy/botmux';
function isBotmuxFooterLine(line) {
    return line.includes(BOTMUX_FOOTER_MARKER);
}
/**
 * Extract human-readable text from an interactive card.
 *
 * Lark API returns card content in a **simplified format** (not the original card JSON):
 *   { title: "...", elements: [[{tag:"text",text:"..."}, ...], ...] }
 * This is similar to post message body.  We also handle the original card JSON
 * (header/config/elements with tag objects) for locally-cached cards.
 */
function extractCardContent(rawContent, numberer) {
    try {
        const card = JSON.parse(rawContent);
        // Pre-resolved merged text injected by resolveMergedCardContent (the
        // A+B union for complex cards). Return it verbatim so the merge flows
        // through both parseEventMessage (live) and parseApiMessage (history).
        if (typeof card[RESOLVED_TEXT_KEY] === 'string')
            return card[RESOLVED_TEXT_KEY];
        // Template-based card — no inline content to extract
        if (card.type === 'template') {
            return '[卡片 (模板)]';
        }
        const parts = [];
        // --- Format A: Lark API simplified format ---
        // { title: "...", elements: [[{tag,text}, ...], ...] }
        // 只有 title 存在时才 push 标识行；没 title 的卡片（例如 markdown 自动转
        // 卡片的 outgoing 消息）让 elements 内容自己说话，避免在正文前堆一行
        // 多余的 `[卡片]`。整张卡片真的没内容时下方 `parts.join('\n') || '[卡片]'`
        // 会兜底返回占位。
        const title = card.title ?? card.header?.title?.content;
        if (title)
            parts.push(`[卡片: ${title}]`);
        // v2 cards nest elements under `body`; fall back to legacy top-level.
        const rootElements = Array.isArray(card.body?.elements)
            ? card.body.elements
            : Array.isArray(card.elements) ? card.elements : null;
        const imgLabel = (key) => numberer ? `[图片 ${numberer.assign(`image:${key}`).num}]` : '[图片]';
        if (rootElements) {
            const isApiFormat = rootElements.length > 0 && Array.isArray(rootElements[0]);
            if (isApiFormat) {
                // Format A: [[{tag:"text",text:"..."}, {tag:"img",...}, {tag:"button",...}], ...]
                for (const paragraph of rootElements) {
                    if (!Array.isArray(paragraph))
                        continue;
                    const textNodes = [];
                    const buttons = [];
                    for (const node of paragraph) {
                        if (node.tag === 'text') {
                            if (node.text)
                                textNodes.push(node.text);
                        }
                        else if (node.tag === 'a') {
                            // Keep the href so links survive — Format A separates text/href,
                            // and dropping href loses real content (规则配置/详情/Trace 链接).
                            const t = node.text ?? '';
                            textNodes.push(node.href && t ? `${t}(${node.href})` : (t || node.href || ''));
                        }
                        else if (node.tag === 'at')
                            textNodes.push(`@${node.user_name ?? 'unknown'}`);
                        else if (node.tag === 'img' || node.tag === 'image') {
                            const k = node.image_key ?? node.img_key;
                            if (k)
                                textNodes.push(imgLabel(k));
                        }
                        else if (node.tag === 'button') {
                            const btnText = typeof node.text === 'string' ? node.text : node.text?.content;
                            if (btnText)
                                buttons.push(`[${btnText}]`);
                        }
                        else if (node.tag === 'input') {
                            const ph = typeof node.placeholder === 'string' ? node.placeholder : node.placeholder?.content;
                            if (ph)
                                buttons.push(`[输入框: ${ph}]`);
                        }
                        else if (node.tag === 'select_static' || node.tag === 'multi_select_static' || node.tag === 'overflow') {
                            const ph = typeof node.placeholder === 'string' ? node.placeholder : node.placeholder?.content;
                            const opts = Array.isArray(node.options)
                                ? node.options.map((o) => (typeof o.text === 'string' ? o.text : o.text?.content)).filter(Boolean)
                                : [];
                            const head = ph ? `[下拉: ${ph}` : '[下拉';
                            buttons.push(opts.length ? `${head} | 选项: ${opts.join(' / ')}]` : `${head}]`);
                        }
                    }
                    const line = textNodes.join('').trim();
                    if (line)
                        parts.push(line);
                    if (buttons.length)
                        parts.push(buttons.join(' '));
                }
            }
            else {
                for (const el of rootElements) {
                    extractElementText(el, parts, imgLabel);
                }
            }
        }
        // Drop the botmux footer chrome so a receiving bot's prompt isn't polluted
        // by the grey `botmux` badge / `发送给：@owner` line. Line-level (not
        // part-level) so a footer never takes adjacent real content with it.
        const cleaned = parts
            .join('\n')
            .split('\n')
            .filter(line => !isBotmuxFooterLine(line))
            .join('\n');
        return cleaned || '[卡片]';
    }
    catch {
        return '[卡片]';
    }
}
// ─── Complete card resolution: union of both Lark representations ──────────
/** Sentinel key carrying pre-merged card text through extractCardContent. */
const RESOLVED_TEXT_KEY = '__botmux_card_text__';
/**
 * Marker for sub-cards Lark renders only client-side (collapsible panels,
 * lazy "展开" sections). Neither `im.message.get` representation returns their
 * body — Format A shows the upgrade fallback, Format B omits them — so we
 * surface an honest placeholder instead of a misleading blank or raw fallback.
 */
export const CARD_EMBEDDED_PLACEHOLDER = '[卡片内嵌组件，需在飞书客户端展开查看]';
/** Wrap merged text so extractCardContent returns it verbatim downstream. */
export function wrapResolvedCardText(text) {
    return JSON.stringify({ [RESOLVED_TEXT_KEY]: text });
}
/** Strip inline markup (font/text_tag tags, bold markers) but keep text,
 *  brackets and links so labels and values stay readable. */
function stripInlineMarkup(s) {
    return s.replace(/<\/?[a-z_]+[^>]*>/gi, '').replace(/\*\*/g, '');
}
/** Normalize for content-presence checks: drop whitespace, punctuation, inline
 *  markup and link URLs so the same content rendered by the two API formats
 *  compares equal regardless of cosmetic differences. */
function normalizeForDedup(s) {
    return stripInlineMarkup(s)
        .replace(/\((https?:[^)]+)\)/g, '') // (https://…) link targets
        .replace(/\s+/g, '')
        .replace(/[，,：:。.\[\]()（）|/、]/g, '');
}
/** Find the value Format A rendered for a `label:` field — the text after the
 *  first occurrence of the label up to the line end. Returns '' if not found. */
function findLabelValue(strippedA, label) {
    const idx = strippedA.indexOf(label);
    if (idx < 0)
        return '';
    const rest = strippedA.slice(idx + label.length);
    return rest.split('\n')[0].trim();
}
/**
 * Merge the two Lark card renderings into one complete text. Format B (full
 * structured) is the base — it preserves links, sub-card bodies and select
 * options. From Format A (server-rendered) we recover only the field VALUES B
 * left blank (e.g. 值班人 names) via targeted label-fill: a B label line whose
 * value is empty is filled from A only when A's value isn't already present in
 * B (so fields B already renders aren't duplicated). Sub-cards Lark serves
 * client-side only — which A shows as upgrade holes and B omits — get one
 * honest placeholder rather than a silent drop or raw "请升级" text.
 */
export function mergeCardText(textA, textB) {
    const markHoles = (t) => t.split('\n').map(l => isPureCardUpgradeFallback(l) ? CARD_EMBEDDED_PLACEHOLDER : l).join('\n');
    const a = (textA || '').trim();
    const b = (textB || '').trim();
    if (!b || isPureCardUpgradeFallback(b))
        return markHoles(a) || a;
    if (!a || isPureCardUpgradeFallback(a))
        return markHoles(b) || b;
    // Drop A's holes from the base, count them so we can flag client-only content.
    const aHoleCount = a.split('\n').filter(isPureCardUpgradeFallback).length;
    const strippedA = stripInlineMarkup(a);
    const baseLines = b.split('\n').filter(l => !isPureCardUpgradeFallback(l));
    const bAll = baseLines.map(normalizeForDedup).join('');
    const filled = baseLines.map(line => {
        const sm = stripInlineMarkup(line).trimEnd();
        // empty-value field label, e.g. "值班人:" — but not bracketed section
        // headers like "[ 检测结果 ]:" whose values live on following lines.
        const m = sm.match(/^([^[\]\n]{1,16}?[:：])$/);
        if (!m)
            return line;
        const value = findLabelValue(strippedA, m[1]);
        if (!value)
            return line;
        // Only fill when B genuinely lacks this value (avoids duplicating fields
        // whose values B already renders on adjacent lines, e.g. Tags/检测结果).
        if (normalizeForDedup(value) && bAll.includes(normalizeForDedup(value)))
            return line;
        return `${line.replace(/\s*$/, '')} ${value}`;
    });
    // One honest marker if A carried sub-cards Lark only renders client-side.
    if (aHoleCount > 0)
        filled.push(CARD_EMBEDDED_PLACEHOLDER);
    return filled.join('\n');
}
/**
 * Resolve a card to its most complete text by unioning both `im.message.get`
 * representations (server-rendered Format A + full structured Format B). Used
 * by BOTH the live event path and `botmux history` so a single message_id
 * resolves identically everywhere. Returns null when neither representation
 * could be fetched (caller keeps whatever it already had).
 */
export async function resolveMergedCardContent(larkAppId, messageId, numberer) {
    const [aRes, bRes] = await Promise.all([
        getMessageDetail(larkAppId, messageId, { userCardContent: false }).catch(() => null),
        getMessageDetail(larkAppId, messageId, { userCardContent: true }).catch(() => null),
    ]);
    const aContent = aRes?.items?.[0]?.body?.content;
    const bContent = bRes?.items?.[0]?.body?.content;
    if (!aContent && !bContent)
        return null;
    const textA = aContent ? extractCardContent(normalizeApiMessageContent('interactive', aContent), numberer) : '';
    const textB = bContent ? extractCardContent(normalizeApiMessageContent('interactive', bContent), numberer) : '';
    const merged = mergeCardText(textA, textB);
    if (!merged)
        return null;
    // Carry the structured card JSON (B preferred) alongside the merged text so
    // resource extraction (image_key/file_key) keeps working — extractResources
    // walks elements/body, extractCardContent short-circuits on the text key.
    return { text: merged, structuredContent: (bContent ?? aContent) };
}
/**
 * Resolve an interactive event's card to its complete merged text in place.
 * Stores a sentinel that carries BOTH the merged text (for extractCardContent)
 * and the structured card JSON (for extractResources image/file extraction).
 * Falls back to local user_dsl unwrap if the REST merge yields nothing. Shared
 * by the live daemon path so forwarded cards reach the model fully parsed.
 */
export async function resolveEventCard(data, larkAppId) {
    let resolved = null;
    try {
        resolved = await resolveMergedCardContent(larkAppId, data.message.message_id);
    }
    catch { /* fall through to local unwrap */ }
    if (resolved) {
        const structured = (() => { try {
            return JSON.parse(resolved.structuredContent);
        }
        catch {
            return {};
        } })();
        data.message.content = JSON.stringify({ ...structured, [RESOLVED_TEXT_KEY]: resolved.text });
        return;
    }
    unwrapUserDsl(data);
}
/** Recursively extract image resources from an original-format card element. */
function extractElementImages(el, resources, pushIfNew) {
    if (!el || typeof el !== 'object')
        return;
    const tag = el.tag;
    const key = el.image_key ?? el.img_key;
    if ((tag === 'img' || tag === 'image') && key) {
        pushIfNew(resources, { type: 'image', key, name: `${key}.jpg` });
    }
    // div.extra can contain an image
    if (el.extra)
        extractElementImages(el.extra, resources, pushIfNew);
    // column_set / column — recurse into nested elements
    if (Array.isArray(el.columns)) {
        for (const col of el.columns) {
            if (Array.isArray(col.elements)) {
                for (const child of col.elements)
                    extractElementImages(child, resources, pushIfNew);
            }
        }
    }
    if (Array.isArray(el.elements)) {
        for (const child of el.elements)
            extractElementImages(child, resources, pushIfNew);
    }
}
/** Recursively extract readable text from an original-format card element. */
function extractElementText(el, parts, imgLabel) {
    if (!el || typeof el !== 'object')
        return;
    const tag = el.tag;
    // botmux card footer: the only element rendered as a small grey notation
    // (text_size 'notation_small_v2' + a grey <font> wrapper). Drop it
    // structurally — brand-agnostic, so a peer bot's *custom* brandLabel footer
    // is stripped from cross-bot / quote / history prompts without us needing to
    // know its label (the receiving bot can't see the sender's config). The
    // repo-URL line filter below still covers the default brand in the simplified
    // Format A representation, which carries no text_size.
    if ((tag === 'markdown' || tag === 'div' || tag === 'plain_text') && el.text_size === 'notation_small_v2') {
        const c = el.text?.content ?? el.content ?? '';
        if (/color=['"]grey['"]/i.test(c))
            return;
    }
    // div / markdown / plain_text blocks
    if (tag === 'div' || tag === 'markdown' || tag === 'plain_text') {
        const text = el.text?.content ?? el.content;
        if (text)
            parts.push(text);
    }
    // div.fields[] — v2 cards put most body text in a fields array of lark_md
    // cells (规则/报警时间/Tags/检测结果/详情链接…), NOT in el.text. Without this
    // the entire detail section of field-based cards (e.g. Argos alarm cards)
    // silently vanishes. A div can carry both el.text and el.fields, so this is
    // additive rather than an else-branch.
    if (Array.isArray(el.fields)) {
        for (const f of el.fields) {
            const t = f?.text?.content ?? f?.content;
            if (t)
                parts.push(t);
        }
    }
    // button — text may be a plain_text object (v2) or a string (v1 simplified).
    if (tag === 'button') {
        const btnText = typeof el.text === 'string' ? el.text : el.text?.content;
        if (btnText)
            parts.push(`[${btnText}]`);
    }
    // input — surface the placeholder so the reader knows the field is there.
    if (tag === 'input') {
        const ph = el.placeholder?.content;
        if (ph)
            parts.push(`[输入框: ${ph}]`);
    }
    // select / multi-select / overflow — surface placeholder + selectable
    // options so the choices the card offers aren't lost.
    if (tag === 'select_static' || tag === 'multi_select_static' || tag === 'overflow') {
        const ph = el.placeholder?.content;
        const opts = Array.isArray(el.options)
            ? el.options.map((o) => o.text?.content).filter(Boolean)
            : [];
        const head = ph ? `[下拉: ${ph}` : '[下拉';
        parts.push(opts.length ? `${head} | 选项: ${opts.join(' / ')}]` : `${head}]`);
    }
    // image — emit a numbered placeholder matching the attachment list order.
    if (tag === 'img' || tag === 'image') {
        const k = el.image_key ?? el.img_key;
        if (k)
            parts.push(imgLabel(k));
    }
    // note blocks (v1 only — v2 removed the tag but we still parse v1 cards)
    if (tag === 'note' && Array.isArray(el.elements)) {
        const noteTexts = el.elements
            .map((n) => n.content ?? n.text?.content ?? '')
            .filter(Boolean);
        if (noteTexts.length)
            parts.push(noteTexts.join(' '));
    }
    // div.extra can host an image
    if (el.extra)
        extractElementText(el.extra, parts, imgLabel);
    // column_set / column — recurse into nested elements
    if (Array.isArray(el.columns)) {
        for (const col of el.columns) {
            if (Array.isArray(col.elements)) {
                for (const child of col.elements)
                    extractElementText(child, parts, imgLabel);
            }
        }
    }
    // action blocks hold their children in `actions`, not `elements` — recurse so
    // their buttons / inputs / selects (确认/创建群组/驾驶舱…) get surfaced.
    if (Array.isArray(el.actions)) {
        for (const child of el.actions)
            extractElementText(child, parts, imgLabel);
    }
    if (Array.isArray(el.elements) && tag !== 'note') {
        for (const child of el.elements)
            extractElementText(child, parts, imgLabel);
    }
}
//# sourceMappingURL=message-parser.js.map