/**
 * Markdown -> TipTap JSON parsing.
 * Parses markdown (with optional YAML frontmatter) into TipTap document JSON.
 *
 * Node identity is reassigned via the matcher when the frontmatter carries a
 * `nodes` array (the new path). For legacy docs without `nodes`, trailing
 * `^id` caret anchors and `<!-- ^id -->` empty-paragraph markers are still
 * recognized as ID sources so existing files keep their identities through
 * the migration. Once a migrated doc is saved, the body is clean and all
 * identity lives in frontmatter.
 *
 * adr: adr/node-identity-matcher.md
 */
import MarkdownIt from 'markdown-it';
import matter from 'gray-matter';
import markdownItIns from 'markdown-it-ins';
import markdownItMark from 'markdown-it-mark';
import markdownItSub from 'markdown-it-sub';
import markdownItSup from 'markdown-it-sup';
import markdownItFootnote from 'markdown-it-footnote';
import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
import { nodeText } from './markdown-serialize.js';
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
import { matchNodes } from './node-matcher.js';
import { enrichEntries, enrichSlimArray, fingerprintAll, isLegacyRawEntry, anyLegacyRaw, } from './node-fingerprint.js';
// ============================================================================
// Markdown -> TipTap
// ============================================================================
const md = new MarkdownIt({ linkify: false, html: true });
md.enable('strikethrough');
md.use(markdownItIns);
md.use(markdownItMark);
md.use(markdownItSub);
md.use(markdownItSup);
md.use(markdownItFootnote);
/**
 * Normalize blank lines INSIDE markdown tables before parsing.
 *
 * Per CommonMark, a blank line terminates a table block. Agents writing
 * markdown content frequently insert blank lines between table rows for
 * readability (e.g. `| row |\n\n| row |`), which the strict parser then
 * splits into "table with 1 header row" + N orphan paragraphs that happen
 * to contain pipe characters. The broken structure persists across saves
 * because every serialize → re-parse cycle re-breaks it.
 *
 * This pre-pass detects "table region" (saw a separator row `| --- |`,
 * haven't hit a non-pipe-row yet) and strips blank lines between pipe-rows
 * inside that region. Code fences (` ``` `, `~~~`) are honored — pipes
 * inside them stay untouched.
 *
 * Self-healing: a doc on disk with blank-separated rows loads as a proper
 * N-row table; the next save writes contiguous markdown. No migration
 * script needed.
 */
function normalizeTableBlankLines(markdown) {
    if (!markdown.includes('|'))
        return markdown;
    const lines = markdown.split('\n');
    const out = [];
    let inFence = false;
    let inTable = false; // true between a separator row and the next non-pipe-row
    const isPipeRow = (s) => /^\s*\|.*\|\s*$/.test(s);
    const isSeparator = (s) => /^\s*\|[\s:|-]+\|\s*$/.test(s);
    const isBlank = (s) => s.trim() === '';
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        // Code-fence toggle — pipes inside fences stay verbatim
        if (/^[`~]{3,}/.test(line)) {
            inFence = !inFence;
            inTable = false;
            out.push(line);
            continue;
        }
        if (inFence) {
            out.push(line);
            continue;
        }
        if (isSeparator(line)) {
            // Separator confirms we're in a table region from here on
            inTable = true;
            out.push(line);
            continue;
        }
        if (inTable && isBlank(line)) {
            // Look ahead past additional blanks for the next non-blank line
            let j = i + 1;
            while (j < lines.length && lines[j].trim() === '')
                j++;
            // If the next non-blank line is another pipe-row, it's EITHER a
            // continuation row (merge across the blank) OR the header of a NEW
            // table (the blank is the boundary, don't merge). We tell them apart
            // by peeking one further: if line j+1 is a separator row, j is a new
            // table's header — preserve the blank and exit the current table region.
            if (j < lines.length && isPipeRow(lines[j])) {
                if (j + 1 < lines.length && isSeparator(lines[j + 1])) {
                    // New table starting — keep the boundary blank, end current region
                    inTable = false;
                    out.push(line);
                    continue;
                }
                // Continuation row — drop the blank
                continue;
            }
            // Lookahead found non-pipe content — table region is ending
            inTable = false;
            out.push(line);
            continue;
        }
        if (inTable && !isPipeRow(line)) {
            // Non-pipe content ends the table region
            inTable = false;
        }
        out.push(line);
    }
    return out.join('\n');
}
/**
 * Parse a raw markdown file into a TipTap document. Returns CANONICAL-ONLY
 * — the per-docId sidecar overlay (`_pending/{docId}.json`) is NOT loaded
 * here. Callers that want "the user-visible doc" (any read surface — MCP
 * tools, HTTP fetches, anything that shows content to the user) must use
 * `loadDocFromDisk` from pending-overlay.ts instead. Bare callers of this
 * function are the persistence-layer internals — save-time matcher,
 * sync-check roundtripping, on-disk identity reads — that deliberately
 * want pre-overlay canonical shape.
 *
 * The legacy rehydrate at line 176 handles the pre-fb666e6 in-frontmatter
 * `pending:` field for migration purposes only. New writes never produce
 * that field; the sidecar is authoritative.
 *
 * adr: adr/pending-overlay-model.md
 */
export function markdownToTiptap(markdown) {
    const result = matter(markdown);
    const { data, content } = result;
    const title = data.title || 'Untitled';
    const normalizedContent = normalizeTableBlankLines(content);
    const tokens = md.parse(normalizedContent, {});
    const docContent = tokensToTiptap(tokens);
    const doc = {
        type: 'doc',
        content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
    };
    // Resolve identity graph from frontmatter. Two on-disk formats live in the
    // wild: ultra-lean slim tuples (current) and legacy verbose objects (v0.14
    // and v0.15). Legacy entries get positionally re-fingerprinted from the
    // freshly-parsed body — the body IS the previous state at load time, and
    // re-fingerprinting produces hashes the matcher can pin against cleanly.
    // adr: adr/node-identity-matcher.md
    const blocksForEnrich = tiptapToBlocks(doc);
    const previousNodes = resolvePreviousNodes(data.nodes, blocksForEnrich);
    const graveyard = resolveGraveyard(data.graveyard);
    if (previousNodes.length > 0) {
        applyMatcher(doc, previousNodes, graveyard);
    }
    // Rehydrate pending state from frontmatter into node attrs
    if (data.pending) {
        rehydratePendingState(doc, data.pending);
    }
    // Strip consumed keys from returned metadata
    const metadata = { ...data };
    delete metadata.pending;
    delete metadata.nodes;
    delete metadata.graveyard;
    return {
        title,
        metadata,
        document: doc,
        rawFrontmatter: result.matter || null,
        graveyard,
        previousNodes,
    };
}
/**
 * Resolve `nodes:` frontmatter into rich NodeEntry[] suitable for the matcher.
 *
 * Two on-disk formats:
 *   - Ultra-lean: each entry is an array tuple. enrichSlimArray derives all
 *     positional/structural fields from the slim array itself — no body parse
 *     needed. The slim array IS the previous state (position = array index,
 *     parent = most-recent unfilled container, neighbors = slim[i±1]).
 *   - Legacy (v0.14/v0.15): each entry is an object with `id` and `fp` keys.
 *     We re-fingerprint positionally from the body — the body IS the previous
 *     state at load time, and a fresh fingerprint over the same body produces
 *     hashes the matcher can pin against. After the next save, disk is in the
 *     ultra-lean format and the body-parse cost drops away.
 *
 * `blocks` is only consulted for the legacy path; slim path ignores it. Pass
 * an empty array when you only have slim input to avoid the body parse cost.
 */
export function resolvePreviousNodes(raw, blocks) {
    if (!Array.isArray(raw) || raw.length === 0)
        return [];
    if (anyLegacyRaw(raw)) {
        // Positional re-fingerprint: take each legacy entry's id, assign it to a
        // freshly-computed fingerprint at the same position in the body.
        const freshFps = fingerprintAll(blocks);
        const out = [];
        for (let i = 0; i < raw.length; i++) {
            const r = raw[i];
            const id = isLegacyRawEntry(r) ? r.id : (Array.isArray(r) ? r[0] : null);
            if (!id || typeof id !== 'string' || !freshFps[i])
                continue;
            out.push({ id, fingerprint: freshFps[i] });
        }
        return out;
    }
    // Ultra-lean: walk the slim array directly. No body parse required.
    return enrichSlimArray(raw).map((e) => ({
        id: e.id,
        fingerprint: e.fingerprint,
    }));
}
/**
 * Resolve `graveyard:` frontmatter into rich NodeEntry[].
 *
 * Ultra-lean tuples enrich without block context (deleted blocks have no
 * body). Derived fields default to safe values; matcher rules for graveyard
 * restore only consult type + sentences + structureSig + childTypes, all
 * carried in slim. Legacy graveyard entries are dropped — their stored
 * fingerprints don't translate to the new hash semantics (terminator is now
 * folded into the hash), so they'd never match a fresh paste-back anyway.
 */
export function resolveGraveyard(raw) {
    if (!Array.isArray(raw) || raw.length === 0)
        return [];
    if (anyLegacyRaw(raw)) {
        // Mixed input: drop legacy entries, enrich slim ones.
        const slimOnly = raw.filter((r) => Array.isArray(r));
        return enrichEntries(slimOnly, []).map((e) => ({ id: e.id, fingerprint: e.fingerprint }));
    }
    return enrichEntries(raw, []).map((e) => ({
        id: e.id,
        fingerprint: e.fingerprint,
    }));
}
/**
 * Run the matcher: compare frontmatter `nodes` (previous fingerprints) to
 * the current TipTap tree's blocks, then apply pinned IDs back onto the tree.
 *
 * Graveyard is passed through so paste-back of recently-deleted content
 * restores the original ID (matched by exact fingerprint).
 */
function applyMatcher(doc, previousNodes, graveyard) {
    if (previousNodes.length === 0)
        return;
    const newBlocks = tiptapToBlocks(doc);
    const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
    const pinnedByPosition = new Map();
    for (const p of matchResult.pinned) {
        pinnedByPosition.set(p.position, p.id);
    }
    applyIdsToTiptap(doc, pinnedByPosition);
}
/**
 * Rehydrate pending state from frontmatter into leaf block node attrs.
 * Uses text fingerprint matching to survive position shifts caused by
 * empty paragraphs disappearing during markdown round-trips.
 */
function rehydratePendingState(doc, pending) {
    // Build ordered list of leaf blocks with text fingerprints
    const leaves = [];
    function collect(nodes) {
        if (!nodes)
            return;
        for (const node of nodes) {
            if (LEAF_BLOCK_TYPES.has(node.type)) {
                leaves.push({ node, text: nodeText(node) });
            }
            else if (node.content) {
                collect(node.content);
            }
        }
    }
    collect(doc.content);
    const used = new Set();
    for (const [posStr, entry] of Object.entries(pending)) {
        const pos = parseInt(posStr, 10);
        let target = null;
        // 1. Try position match (with text verification if fingerprint exists)
        if (pos < leaves.length && !used.has(pos)) {
            if (!entry.t || leaves[pos].text === entry.t) {
                target = leaves[pos].node;
                used.add(pos);
            }
        }
        // 2. Fallback: search by text fingerprint
        if (!target && entry.t) {
            for (let i = 0; i < leaves.length; i++) {
                if (!used.has(i) && leaves[i].text === entry.t) {
                    target = leaves[i].node;
                    used.add(i);
                    break;
                }
            }
        }
        if (target) {
            target.attrs = target.attrs || {};
            target.attrs.pendingStatus = entry.s;
            if (entry.o) {
                target.attrs.pendingOriginalContent = entry.o;
            }
            if (entry.g) {
                target.attrs.pendingGroupId = entry.g;
            }
            // Selection range attrs (sub-paragraph enhance)
            if (entry.sf != null)
                target.attrs.pendingSelectionFrom = entry.sf;
            if (entry.st != null)
                target.attrs.pendingSelectionTo = entry.st;
            if (entry.of != null)
                target.attrs.pendingOriginalFrom = entry.of;
            if (entry.ot != null)
                target.attrs.pendingOriginalTo = entry.ot;
        }
    }
}
/** Parse a markdown string into TipTap block nodes (no frontmatter). */
export function markdownToNodes(markdown) {
    const tokens = md.parse(markdown, {});
    const nodes = tokensToTiptap(tokens);
    return nodes.length > 0 ? nodes : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }];
}
// ---- Token tree walker ----
function tokensToTiptap(tokens) {
    const nodes = [];
    let i = 0;
    while (i < tokens.length) {
        const token = tokens[i];
        if (token.type === 'heading_open') {
            const level = parseInt(token.tag.slice(1));
            const inlineToken = tokens[i + 1];
            const rawContent = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
            const { content, id } = extractTrailingNodeId(rawContent);
            nodes.push({ type: 'heading', attrs: { id: id || generateNodeId(), level }, content });
            i += 3;
        }
        else if (token.type === 'paragraph_open') {
            const inlineToken = tokens[i + 1];
            const rawContent = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
            const { content, id } = extractTrailingNodeId(rawContent);
            // Check for solo image — promote to block-level image node
            if (content.length === 1 && content[0].type === 'image') {
                nodes.push(content[0]);
            }
            else {
                // Heal `<br><br>` paragraph fusion. A run of 2+ consecutive hardBreaks
                // visually renders as a blank-line gap — i.e. a paragraph break — and
                // the tweet editor's TweetEnterHardBreak keymap actively prevents
                // authoring this state at the keyboard. The only way it lands in a
                // body is the pre-6d0a75e mergeParagraphsToHardBreaks behavior that
                // collapsed tweet writes into a single node before serializing, so
                // splitting on import restores the original per-paragraph review
                // unit (and the next save writes clean `\n\n` to disk).
                // Single hardBreaks stay inline — they're legitimate intra-paragraph
                // soft breaks (tweet line break, poem line).
                nodes.push(...splitParagraphOnDoubleBreaks(content, id));
            }
            i += 3;
        }
        else if (token.type === 'bullet_list_open') {
            const end = findClosingToken(tokens, i, 'bullet_list');
            const items = parseListItems(tokens.slice(i + 1, end));
            const listNode = { type: 'bulletList', attrs: { id: generateNodeId() }, content: items };
            // Try converting to taskList if all items start with checkboxes
            const taskNode = tryConvertToTaskList(listNode);
            nodes.push(taskNode || listNode);
            i = end + 1;
        }
        else if (token.type === 'ordered_list_open') {
            const end = findClosingToken(tokens, i, 'ordered_list');
            const items = parseListItems(tokens.slice(i + 1, end));
            nodes.push({ type: 'orderedList', attrs: { id: generateNodeId() }, content: items });
            i = end + 1;
        }
        else if (token.type === 'blockquote_open') {
            const end = findClosingToken(tokens, i, 'blockquote');
            const inner = tokensToTiptap(tokens.slice(i + 1, end));
            nodes.push({ type: 'blockquote', attrs: { id: generateNodeId() }, content: inner });
            i = end + 1;
        }
        else if (token.type === 'fence') {
            const lang = token.info?.trim() || '';
            const text = token.content.replace(/\n$/, '');
            const content = text ? [{ type: 'text', text }] : [];
            const attrs = { id: generateNodeId() };
            if (lang)
                attrs.language = lang;
            nodes.push({ type: 'codeBlock', attrs, content });
            i += 1;
        }
        else if (token.type === 'hr') {
            nodes.push({ type: 'horizontalRule', attrs: { id: generateNodeId() } });
            i += 1;
        }
        else if (token.type === 'html_block') {
            // <!-- --> is our sentinel for empty paragraphs.
            // <!-- ^abc12345 --> is the same sentinel with a persisted nodeId.
            const trimmed = token.content.trim();
            if (trimmed === '<!-- -->') {
                nodes.push({ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] });
            }
            else {
                const idMatch = trimmed.match(/^<!--\s*\^([a-f0-9]{8})\s*-->$/);
                if (idMatch) {
                    nodes.push({ type: 'paragraph', attrs: { id: idMatch[1] }, content: [] });
                }
            }
            i += 1;
        }
        else if (token.type === 'table_open') {
            const end = findClosingToken(tokens, i, 'table');
            const tableNode = parseTableTokens(tokens.slice(i + 1, end));
            nodes.push(tableNode);
            i = end + 1;
        }
        else if (token.type === 'footnote_block_open') {
            // Footnote definitions section. markdown-it-footnote always emits
            // this block at end-of-doc with all `[^N]: ...` definitions inside,
            // regardless of where in the source the `[^N]:` lines appeared.
            // Parse accepts flexibly; serializer always emits at end-of-doc.
            // adr: adr/footnote-system.md
            const end = findClosingToken(tokens, i, 'footnote_block');
            const definitions = parseFootnoteDefinitions(tokens.slice(i + 1, end));
            if (definitions.length > 0) {
                nodes.push({
                    type: 'footnoteSection',
                    attrs: { id: generateNodeId() },
                    content: definitions,
                });
            }
            i = end + 1;
        }
        else {
            i += 1;
        }
    }
    return nodes;
}
/**
 * Parse the contents of a footnote_block (between footnote_block_open and
 * footnote_block_close). Each footnote is a footnote_open ... footnote_close
 * range containing block-level tokens (paragraphs, lists, etc).
 *
 * Strips the trailing `footnote_anchor` back-link that markdown-it-footnote
 * appends to each footnote's last paragraph — it's a renderer artifact, not
 * source content.
 */
function parseFootnoteDefinitions(tokens) {
    const definitions = [];
    let i = 0;
    while (i < tokens.length) {
        if (tokens[i].type === 'footnote_open') {
            const label = tokens[i].meta?.label || String(tokens[i].meta?.id ?? '');
            const end = findClosingToken(tokens, i, 'footnote');
            const innerTokens = stripFootnoteAnchors(tokens.slice(i + 1, end));
            const content = tokensToTiptap(innerTokens);
            definitions.push({
                type: 'footnoteDefinition',
                attrs: { id: generateNodeId(), label },
                content: content.length > 0
                    ? content
                    : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
            });
            i = end + 1;
        }
        else {
            i += 1;
        }
    }
    return definitions;
}
/**
 * Remove `footnote_anchor` tokens from inline children. markdown-it-footnote
 * appends an anchor (rendered as ↩︎) to the last paragraph of each footnote
 * to back-link to the reference. We strip it on parse because it's a
 * rendering artifact, not source content the author wrote.
 */
function stripFootnoteAnchors(tokens) {
    return tokens.map((token) => {
        if (token.type !== 'inline' || !token.children)
            return token;
        const filtered = token.children.filter((c) => c.type !== 'footnote_anchor');
        if (filtered.length === token.children.length)
            return token;
        // Clone token with filtered children
        const cloned = Object.assign(Object.create(Object.getPrototypeOf(token)), token);
        cloned.children = filtered;
        return cloned;
    });
}
function findClosingToken(tokens, startIndex, type) {
    let depth = 0;
    for (let i = startIndex; i < tokens.length; i++) {
        if (tokens[i].type === `${type}_open`)
            depth++;
        if (tokens[i].type === `${type}_close`) {
            depth--;
            if (depth === 0)
                return i;
        }
    }
    return tokens.length - 1;
}
function parseListItems(tokens) {
    const items = [];
    let i = 0;
    while (i < tokens.length) {
        if (tokens[i].type === 'list_item_open') {
            const end = findClosingToken(tokens, i, 'list_item');
            const inner = tokensToTiptap(tokens.slice(i + 1, end));
            items.push({ type: 'listItem', attrs: { id: generateNodeId() }, content: inner });
            i = end + 1;
        }
        else {
            i++;
        }
    }
    return items;
}
/**
 * Post-process a bulletList: if every listItem starts with [ ] or [x],
 * convert to taskList/taskItem nodes and strip the checkbox prefix.
 */
function tryConvertToTaskList(bulletList) {
    const items = bulletList.content;
    if (!items || items.length === 0)
        return null;
    const checkboxRe = /^\[([ xX])\]\s?/;
    const taskItems = [];
    for (const item of items) {
        const firstChild = item.content?.[0];
        if (!firstChild || firstChild.type !== 'paragraph')
            return null;
        const firstText = firstChild.content?.[0];
        if (!firstText || firstText.type !== 'text')
            return null;
        const match = checkboxRe.exec(firstText.text);
        if (!match)
            return null;
        const checked = match[1] !== ' ';
        // Strip checkbox prefix from text
        const remaining = firstText.text.slice(match[0].length);
        const newContent = [...firstChild.content];
        if (remaining) {
            newContent[0] = { ...firstText, text: remaining };
        }
        else {
            newContent.shift();
        }
        const newParagraph = { ...firstChild, content: newContent };
        const restChildren = item.content.slice(1);
        taskItems.push({
            type: 'taskItem',
            attrs: { id: generateNodeId(), checked },
            content: [newParagraph, ...restChildren],
        });
    }
    return {
        type: 'taskList',
        attrs: { id: generateNodeId() },
        content: taskItems,
    };
}
function parseTableTokens(tokens) {
    const rows = [];
    let i = 0;
    while (i < tokens.length) {
        const token = tokens[i];
        if (token.type === 'thead_open' || token.type === 'tbody_open') {
            i++; // skip section open, process rows inside
            continue;
        }
        if (token.type === 'thead_close' || token.type === 'tbody_close') {
            i++;
            continue;
        }
        if (token.type === 'tr_open') {
            const trEnd = findClosingToken(tokens, i, 'tr');
            const cells = [];
            let j = i + 1;
            while (j < trEnd) {
                const cellToken = tokens[j];
                if (cellToken.type === 'th_open' || cellToken.type === 'td_open') {
                    const cellType = cellToken.type === 'th_open' ? 'tableHeader' : 'tableCell';
                    const cellEnd = findClosingToken(tokens, j, cellToken.type === 'th_open' ? 'th' : 'td');
                    // Inline content is between open and close
                    let content = [];
                    if (j + 1 < cellEnd && tokens[j + 1].type === 'inline') {
                        content = tokens[j + 1].children ? inlineTokensToTiptap(tokens[j + 1].children) : [];
                    }
                    cells.push({
                        type: cellType,
                        attrs: { id: generateNodeId() },
                        content: [{
                                type: 'paragraph',
                                attrs: { id: generateNodeId() },
                                content,
                            }],
                    });
                    j = cellEnd + 1;
                }
                else {
                    j++;
                }
            }
            rows.push({
                type: 'tableRow',
                attrs: { id: generateNodeId() },
                content: cells,
            });
            i = trEnd + 1;
        }
        else {
            i++;
        }
    }
    return {
        type: 'table',
        attrs: { id: generateNodeId() },
        content: rows,
    };
}
function inlineTokensToTiptap(tokens) {
    const nodes = [];
    const markStack = [];
    for (const token of tokens) {
        if (token.type === 'text') {
            if (!token.content)
                continue; // ProseMirror rejects empty text nodes
            const textNode = { type: 'text', text: token.content };
            if (markStack.length > 0) {
                textNode.marks = deduplicateMarks(markStack);
            }
            nodes.push(textNode);
        }
        else if (token.type === 'code_inline') {
            if (!token.content)
                continue;
            const marks = deduplicateMarks([...markStack, { type: 'code' }]);
            nodes.push({ type: 'text', text: token.content, marks });
        }
        else if (token.type === 'strong_open') {
            markStack.push({ type: 'bold' });
        }
        else if (token.type === 'strong_close') {
            popMarkByType(markStack, 'bold');
        }
        else if (token.type === 'em_open') {
            markStack.push({ type: 'italic' });
        }
        else if (token.type === 'em_close') {
            popMarkByType(markStack, 'italic');
        }
        else if (token.type === 's_open') {
            markStack.push({ type: 'strike' });
        }
        else if (token.type === 's_close') {
            popMarkByType(markStack, 'strike');
        }
        else if (token.type === 'ins_open') {
            markStack.push({ type: 'underline' });
        }
        else if (token.type === 'ins_close') {
            popMarkByType(markStack, 'underline');
        }
        else if (token.type === 'mark_open') {
            markStack.push({ type: 'highlight' });
        }
        else if (token.type === 'mark_close') {
            popMarkByType(markStack, 'highlight');
        }
        else if (token.type === 'sub_open') {
            markStack.push({ type: 'subscript' });
        }
        else if (token.type === 'sub_close') {
            popMarkByType(markStack, 'subscript');
        }
        else if (token.type === 'sup_open') {
            markStack.push({ type: 'superscript' });
        }
        else if (token.type === 'sup_close') {
            popMarkByType(markStack, 'superscript');
        }
        else if (token.type === 'link_open') {
            const rawHref = token.attrGet('href') || '';
            const href = decodeURI(rawHref);
            markStack.push({ type: 'link', attrs: { href } });
        }
        else if (token.type === 'link_close') {
            popMarkByType(markStack, 'link');
        }
        else if (token.type === 'image') {
            const src = token.attrGet('src') || '';
            const alt = token.content || token.attrGet('alt') || '';
            nodes.push({
                type: 'image',
                attrs: { id: generateNodeId(), src, alt },
            });
        }
        else if (token.type === 'html_inline') {
            // <br> is our serialized form of hardBreak
            if (/^<br\s*\/?>$/i.test(token.content.trim())) {
                nodes.push({ type: 'hardBreak' });
            }
            else {
                // Preserve raw HTML-looking text so user content isn't silently dropped.
                // On serialize, escapeInlineHtml converts `<` to `&lt;`; on the next
                // parse markdown-it decodes `&lt;` back to a single text token, so the
                // round-trip is stable after the first save.
                const textNode = { type: 'text', text: token.content };
                if (markStack.length > 0) {
                    textNode.marks = deduplicateMarks(markStack);
                }
                nodes.push(textNode);
            }
        }
        else if (token.type === 'hardbreak') {
            nodes.push({ type: 'hardBreak' });
        }
        else if (token.type === 'softbreak') {
            nodes.push({ type: 'text', text: ' ' });
        }
        else if (token.type === 'footnote_ref') {
            // `[^N]` inline reference. Carry the author-written label verbatim so
            // mnemonic labels (`[^sapolsky2017]`) survive round-trip. Display
            // numbering is recomputed by the renderer.
            // adr: adr/footnote-system.md
            const label = token.meta?.label || String(token.meta?.id ?? '');
            nodes.push({
                type: 'footnoteReference',
                attrs: { label },
            });
        }
    }
    return nodes;
}
/** Remove duplicate mark types (e.g. nested **bold** producing two bold marks). */
function deduplicateMarks(marks) {
    const seen = new Set();
    return marks.filter((m) => {
        const key = m.type === 'link' ? `link:${m.attrs?.href}` : m.type;
        if (seen.has(key))
            return false;
        seen.add(key);
        return true;
    });
}
function popMarkByType(stack, type) {
    for (let i = stack.length - 1; i >= 0; i--) {
        if (stack[i].type === type) {
            stack.splice(i, 1);
            return;
        }
    }
}
/**
 * Extract a trailing nodeId anchor from inline content.
 * Format: ` ^abc12345` (space + caret + 8 lowercase hex chars at end of line).
 * Strips the marker from the visible text and returns the captured id.
 * Returns id=null if no anchor found.
 *
 * Known limit: prose ending with the literal pattern ` ^[8 lowercase hex]`
 * will be interpreted as an anchor. Vanishingly rare in real writing.
 */
function extractTrailingNodeId(content) {
    if (!content || content.length === 0)
        return { content, id: null };
    const lastNode = content[content.length - 1];
    if (lastNode.type !== 'text' || !lastNode.text)
        return { content, id: null };
    const match = lastNode.text.match(/ \^([a-f0-9]{8})\s*$/);
    if (!match)
        return { content, id: null };
    const id = match[1];
    const newText = lastNode.text.slice(0, match.index);
    const newContent = [...content];
    if (newText) {
        newContent[newContent.length - 1] = { ...lastNode, text: newText };
    }
    else {
        newContent.pop();
    }
    return { content: newContent, id };
}
/**
 * Split a paragraph's inline content at runs of 2+ consecutive `hardBreak`
 * nodes. Returns one paragraph node per logical chunk. The first paragraph
 * keeps the passed-in id (when present); the rest get fresh ids. Single
 * `hardBreak`s pass through untouched.
 *
 * Empty groups (runs of breaks at the start/end, or back-to-back split points)
 * are dropped, but at least one paragraph is always returned so an
 * all-breaks input still serializes as an empty paragraph rather than
 * vanishing.
 */
function splitParagraphOnDoubleBreaks(content, firstId) {
    if (!content || content.length === 0) {
        return [{ type: 'paragraph', attrs: { id: firstId || generateNodeId() }, content: [] }];
    }
    const groups = [];
    let current = [];
    let i = 0;
    while (i < content.length) {
        if (content[i].type === 'hardBreak') {
            let j = i;
            while (j < content.length && content[j].type === 'hardBreak')
                j++;
            const runLen = j - i;
            if (runLen >= 2) {
                groups.push(current);
                current = [];
            }
            else {
                current.push(content[i]);
            }
            i = j;
        }
        else {
            current.push(content[i]);
            i++;
        }
    }
    groups.push(current);
    const nonEmpty = groups.filter((g) => g.length > 0);
    const final = nonEmpty.length > 0 ? nonEmpty : [[]];
    return final.map((g, idx) => ({
        type: 'paragraph',
        attrs: { id: idx === 0 ? (firstId || generateNodeId()) : generateNodeId() },
        content: g,
    }));
}
/**
 * Heal fused double-`<br>` paragraphs across a node array — the structured/JSON
 * sibling of the per-paragraph `splitParagraphOnDoubleBreaks` heal that runs on
 * the markdown-string parse path. Agent writes and Author's Voice rewrites can
 * arrive as TipTap JSON, which never passes through markdown-it, so a tweet-style
 * paragraph carrying a run of 2+ `hardBreak`s would otherwise enter canonical as
 * a single fused node the parser refuses to reproduce — breaking the serialize→
 * reparse round-trip (sync-check FAIL), destabilizing the node-identity matcher,
 * and corrupting pending decorations.
 *
 * Canonical form for every doc type is separate paragraph nodes; intra-paragraph
 * SINGLE `<br>`s (legitimate soft line breaks) pass through untouched. Idempotent:
 * already-split content (e.g. from the markdown path) is returned unchanged, with
 * the original node and all its attrs preserved when no fusion is present.
 *
 * adr: adr/tweet-paragraph-convention.md
 */
export function splitFusedParagraphs(nodes) {
    if (!Array.isArray(nodes))
        return nodes;
    const out = [];
    for (const node of nodes) {
        if (node?.type === 'paragraph' && Array.isArray(node.content)) {
            const split = splitParagraphOnDoubleBreaks(node.content, node.attrs?.id || null);
            if (split.length <= 1) {
                // No fusion — keep the original node intact (preserves all attrs).
                out.push(node);
            }
            else {
                // Fusion healed: first chunk inherits the original node's attrs/id,
                // the rest become fresh paragraph nodes.
                split.forEach((p, idx) => {
                    out.push(idx === 0 ? { ...node, content: p.content } : p);
                });
            }
        }
        else {
            out.push(node);
        }
    }
    return out;
}
