/**
 * Connections engine (v0.20.0): doc-to-doc connections are structural data,
 * stored as `references: [docId, ...]` arrays in each source's frontmatter.
 * The inbound list on any target is computed live as the inverse of every
 * doc's references — there is no stored derived field on disk.
 *
 * Design:
 *   - `references:` in frontmatter = source of truth (this doc connects to these).
 *   - Backlinks = computed live (scan all docs' references, return those listing
 *     us). Cached in memory for query-time speed; invalidated on any references
 *     write.
 *   - Legacy `doc:` prose links in body keep rendering (TipTap PadLink) AND
 *     auto-populate `references` on save — backward compat.
 *   - Legacy stored `backlinks:` frontmatter field is dropped on any save
 *     (lazy migration). One-off `rebuildAllReferences()` does the bulk migrate.
 *
 * The pre-v0.20 incremental backlinks pipeline (updateBacklinksForSource) is
 * gone — it had a race that meant on-save updates didn't fire reliably (the
 * test session that motivated this refactor caught it). Computing live
 * removes the entire class of incremental-update bugs.
 */
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import matter from 'gray-matter';
import { getDataDir, atomicWriteFileSync, resolveDocPath, isExternalDoc } from './helpers.js';
import { markdownToTiptap } from './markdown-parse.js';
const HEX8 = /^[a-f0-9]{8}$/;
const ANCHOR_TEXT_MAX = 80; // truncate long anchor text in backlinks frontmatter
/** Parse the post-`doc:` portion of an href into {docId, nodeId}. Mirrors src/editor/link-href.ts. */
function parseDocHref(href) {
    if (!href.startsWith('doc:'))
        return { docId: null, nodeId: null };
    let body = href.slice(4);
    // Strip query string (?q=...) — only needed for client-side scroll
    const qIdx = body.indexOf('?q=');
    if (qIdx >= 0)
        body = body.slice(0, qIdx);
    // Split fragment
    let target = body, nodeId = null;
    const hashIdx = body.indexOf('#');
    if (hashIdx >= 0) {
        const frag = body.slice(hashIdx + 1);
        nodeId = HEX8.test(frag) ? frag : null;
        target = body.slice(0, hashIdx);
    }
    return {
        docId: HEX8.test(target) ? target : null,
        nodeId,
    };
}
function truncate(s, n) {
    if (s.length <= n)
        return s;
    return s.slice(0, n - 1) + '…';
}
/**
 * Walk a TipTap doc, return every `doc:` link found.
 * Each entry includes the enclosing block's nodeId (from_node) and the
 * anchor text (the text wrapped by the link mark).
 */
export function extractForwardLinks(doc, sourceDocId) {
    const links = [];
    function walkBlock(block) {
        if (!block)
            return;
        const blockId = block.attrs?.id;
        if (Array.isArray(block.content)) {
            // Inline pass: collect contiguous text runs with same link mark
            // (a mark may span multiple text nodes if other marks toggle inside)
            let currentHref = null;
            let currentText = [];
            const flush = () => {
                if (currentHref && currentText.length > 0 && blockId) {
                    const parsed = parseDocHref(currentHref);
                    if (parsed.docId) {
                        links.push({
                            text: truncate(currentText.join(''), ANCHOR_TEXT_MAX),
                            from_doc: sourceDocId,
                            from_node: blockId,
                            to_doc: parsed.docId,
                            ...(parsed.nodeId ? { to_node: parsed.nodeId } : {}),
                        });
                    }
                }
                currentText = [];
            };
            for (const child of block.content) {
                if (child.type === 'text') {
                    const linkMark = (child.marks || []).find((m) => m.type === 'link');
                    const href = linkMark?.attrs?.href || null;
                    if (href !== currentHref) {
                        flush();
                        currentHref = href;
                    }
                    if (href && child.text)
                        currentText.push(child.text);
                }
                else {
                    flush();
                    currentHref = null;
                    // Recurse into nested block-level content (e.g., listItem -> paragraph)
                    walkBlock(child);
                }
            }
            flush();
            // Also recurse for blocks whose content is sub-blocks (list, blockquote)
            for (const child of block.content) {
                if (child.type && child.type !== 'text' && Array.isArray(child.content)) {
                    // Already handled above via flush() recursion guard — skip duplicate
                    // (We need to recurse into blocks that contain block-level content,
                    //  but not the text children we already iterated.)
                }
            }
        }
    }
    if (doc?.content) {
        for (const node of doc.content)
            walkBlock(node);
    }
    return links;
}
/**
 * Read a doc's frontmatter from disk and parse it.
 * Returns null if the file doesn't exist or can't be parsed.
 */
export function readFrontmatter(filename) {
    try {
        const filePath = resolveDocPath(filename);
        if (!existsSync(filePath))
            return null;
        const raw = readFileSync(filePath, 'utf-8');
        const parsed = matter(raw);
        return { data: parsed.data, content: parsed.content, rawMatter: parsed.matter };
    }
    catch {
        return null;
    }
}
/**
 * Write a doc's file with updated frontmatter (preserves body verbatim).
 * Only touches the frontmatter — does NOT re-serialize the body, which would
 * lose nodeIds and reformat. This is safe to call on non-active docs.
 */
export function writeFrontmatter(filename, newData) {
    const filePath = resolveDocPath(filename);
    const raw = readFileSync(filePath, 'utf-8');
    const parsed = matter(raw);
    // Clean up: drop null/undefined fields
    for (const key of Object.keys(newData)) {
        if (newData[key] === undefined || newData[key] === null)
            delete newData[key];
        else if (Array.isArray(newData[key]) && newData[key].length === 0)
            delete newData[key];
    }
    // Match the project convention: JSON-encoded YAML frontmatter
    const newFrontmatter = `---\n${JSON.stringify(newData)}\n---\n\n${parsed.content.trimStart()}`;
    // Avoid no-op writes
    if (newFrontmatter === raw)
        return;
    atomicWriteFileSync(filePath, newFrontmatter);
}
// ============================================================================
// COMPUTE-LIVE BACKLINKS — the v0.20 surface, extended in v0.21
// ============================================================================
//
// `computeBacklinksFor(targetDocId)` returns every inbound edge pointing at
// targetDocId. Two sources contribute:
//
//   1. Doc-level edges from `references:` frontmatter arrays (v0.20 model —
//      structural, no node granularity). Entry: `{ from_doc }`.
//   2. Paragraph-anchored edges from prose `[text](doc:DOCID#NODEID)` link
//      marks in the body (v0.21 — restores per-paragraph backlinks for the
//      dotted-underline + "See connections" UI). Entry: `{ from_doc, from_node,
//      to_node, text }`.
//
// Cached in memory; any write that touches references or body invalidates
// (state.ts:writeToDisk after every save). Cache rebuilds lazily on next read.
/** Inverse index: target docId → list of inbound edges. */
let backlinksCache = null;
/** Build (or rebuild) the entire inverse index by scanning every .md in the
 *  data dir. Two passes per file: frontmatter references (cheap) + body
 *  paragraph-anchored prose links (parse + walk). For personal corpora of a
 *  few hundred docs this lands in ~1-2 seconds; the cache holds across many
 *  reads, so amortized cost is negligible. */
function buildBacklinksCache() {
    const cache = new Map();
    let files = [];
    try {
        files = readdirSync(getDataDir()).filter((f) => f.endsWith('.md'));
    }
    catch {
        return cache;
    }
    /** Dedup keys per target: source docs with no `to_node` collapse to one
     *  doc-level entry; paragraph-anchored entries dedup per (from_doc, to_node)
     *  pair so multi-link-same-anchor in a single source counts once. */
    const seen = new Map();
    function push(targetDocId, entry) {
        const key = entry.to_node ? `${entry.from_doc}#${entry.to_node}` : entry.from_doc;
        let seenForTarget = seen.get(targetDocId);
        if (!seenForTarget) {
            seenForTarget = new Set();
            seen.set(targetDocId, seenForTarget);
        }
        if (seenForTarget.has(key))
            return;
        seenForTarget.add(key);
        if (!cache.has(targetDocId))
            cache.set(targetDocId, []);
        cache.get(targetDocId).push(entry);
    }
    for (const f of files) {
        try {
            const raw = readFileSync(join(getDataDir(), f), 'utf-8');
            const parsed = matter(raw);
            const sourceDocId = parsed.data?.docId;
            if (!sourceDocId || typeof sourceDocId !== 'string')
                continue;
            // Pass 1: structural references (frontmatter). Doc-level only.
            const refs = parsed.data?.references;
            if (Array.isArray(refs)) {
                for (const targetDocId of refs) {
                    if (typeof targetDocId !== 'string')
                        continue;
                    push(targetDocId, { from_doc: sourceDocId });
                }
            }
            // Pass 2: paragraph-anchored prose links. Only entries with a #NODEID
            // anchor in the href contribute — doc-level prose links are already
            // captured by Pass 1 via the references-auto-sync at save time.
            try {
                const tipDoc = markdownToTiptap(raw).document;
                const proseLinks = extractForwardLinks(tipDoc, sourceDocId);
                for (const link of proseLinks) {
                    if (!link.to_node)
                        continue; // doc-level — Pass 1 handles it
                    push(link.to_doc, {
                        from_doc: link.from_doc,
                        from_node: link.from_node,
                        to_node: link.to_node,
                        text: link.text,
                    });
                }
            }
            catch {
                // markdownToTiptap can throw on malformed bodies — best-effort skip
            }
        }
        catch {
            // skip unreadable
        }
    }
    return cache;
}
/** Drop the in-memory cache. Next read rebuilds from disk. Called from
 *  state.ts:writeToDisk after a save that may have changed references OR the
 *  body's prose link set. */
export function invalidateBacklinksCache() {
    backlinksCache = null;
}
/**
 * Return every inbound edge pointing at targetDocId — both doc-level (from
 * `references:` frontmatter) and paragraph-anchored (from prose
 * `[text](doc:DOCID#NODEID)` links). Cached in memory.
 *
 * Entries with `to_node` populated are paragraph-anchored: the backlinks
 * decoration plugin paints a dotted underline on the matching target
 * paragraph, and the context menu surfaces "See connections" listing the
 * sources. Entries without `to_node` are doc-level and intended for
 * doc-scope UI (e.g. "N sources link to this doc").
 */
export function computeBacklinksFor(targetDocId) {
    if (!backlinksCache)
        backlinksCache = buildBacklinksCache();
    const entries = backlinksCache.get(targetDocId);
    if (!entries)
        return [];
    // Stable sort: paragraph-anchored entries first (so per-paragraph UI gets
    // them ordered consistently), then doc-level, both by from_doc.
    return [...entries].sort((a, b) => {
        const aAnchored = a.to_node ? 0 : 1;
        const bAnchored = b.to_node ? 0 : 1;
        if (aAnchored !== bAnchored)
            return aAnchored - bAnchored;
        if (a.from_doc !== b.from_doc)
            return a.from_doc < b.from_doc ? -1 : 1;
        if ((a.to_node ?? '') !== (b.to_node ?? ''))
            return (a.to_node ?? '') < (b.to_node ?? '') ? -1 : 1;
        return 0;
    });
}
// ============================================================================
// PROSE-LINK AUTO-SYNC — backward compat for legacy [text](doc:id) prose links
// ============================================================================
/**
 * Scan a TipTap doc for prose `doc:` links and merge their target docIds
 * into the source's `references:` frontmatter. Idempotent — only writes
 * when there are new docIds to add.
 *
 * Called from state.ts:writeToDisk after the markdown body is persisted, so
 * existing prose links (which still render as click-through internal links
 * via the PadLink TipTap extension) automatically appear in `references:`
 * for graph/crawl/backlinks-panel consumption.
 */
export function syncReferencesFromProse(sourceDocId, sourceDoc, currentMetadata) {
    const links = extractForwardLinks(sourceDoc, sourceDocId);
    if (links.length === 0)
        return null;
    const proseTargets = new Set();
    for (const l of links)
        proseTargets.add(l.to_doc);
    const existing = Array.isArray(currentMetadata.references) ? currentMetadata.references : [];
    const merged = new Set(existing);
    const added = [];
    for (const t of proseTargets) {
        if (!merged.has(t)) {
            merged.add(t);
            added.push(t);
        }
    }
    if (added.length === 0)
        return null;
    return { added, newReferences: Array.from(merged) };
}
// ============================================================================
// MIGRATION — bulk backfill from prose links + strip stored backlinks
// ============================================================================
/**
 * Read all docs in the data dir, return their parsed frontmatter + tiptap doc.
 * Used by the migration rebuild.
 */
function loadAllDocsForRebuild() {
    const out = [];
    let files = [];
    try {
        files = readdirSync(getDataDir()).filter((f) => f.endsWith('.md'));
    }
    catch {
        return out;
    }
    for (const f of files) {
        try {
            const raw = readFileSync(join(getDataDir(), f), 'utf-8');
            const parsed = markdownToTiptap(raw);
            const docId = parsed.metadata?.docId;
            if (!docId)
                continue;
            out.push({ docId, filename: f, doc: parsed.document, metadata: parsed.metadata });
        }
        catch {
            // skip unreadable
        }
    }
    return out;
}
/**
 * Full rescan: for every doc, extract prose `doc:` links from body and merge
 * their targets into `references:` frontmatter. Also strip any legacy
 * `backlinks:` field. Idempotent — re-running produces no changes if the
 * corpus is already migrated.
 *
 * Replaces the v0.19 `rebuildAllBacklinks` which built the (now-removed)
 * derived backlinks projection. The new rescue path is `/api/rebuild-references`
 * (with `/api/rebuild-backlinks` kept as a 308 redirect for one release cycle).
 */
export function rebuildAllReferences() {
    const allDocs = loadAllDocsForRebuild();
    let updated = 0;
    for (const d of allDocs) {
        const fm = readFrontmatter(d.filename);
        if (!fm)
            continue;
        // Extract prose links → docIds
        const proseLinks = extractForwardLinks(d.doc, d.docId);
        const proseTargets = new Set(proseLinks.map((l) => l.to_doc));
        // Merge with existing references (dedup)
        const existing = Array.isArray(fm.data.references) ? fm.data.references : [];
        const merged = Array.from(new Set([...existing, ...proseTargets])).sort();
        // Decide whether anything changed
        const referencesChanged = JSON.stringify(existing.slice().sort()) !== JSON.stringify(merged);
        const hadLegacyBacklinks = 'backlinks' in fm.data;
        if (!referencesChanged && !hadLegacyBacklinks)
            continue;
        const newData = { ...fm.data };
        if (merged.length > 0)
            newData.references = merged;
        else
            delete newData.references;
        delete newData.backlinks; // lazy migration
        try {
            writeFrontmatter(d.filename, newData);
            updated++;
        }
        catch {
            // skip
        }
    }
    invalidateBacklinksCache();
    return { scanned: allDocs.length, updated };
}
/**
 * @deprecated v0.20 — kept as a no-op shim so any caller imports still work.
 * The incremental backlinks pipeline is gone; backlinks compute live. State's
 * writeToDisk no longer calls this.
 */
export function updateBacklinksForSource() {
    return { touched: [] };
}
/**
 * Read previously-saved markdown from disk for a given source filename
 * and extract its forward links. Used by the save hook to compute the
 * "old" link set before the new write lands.
 */
export function extractForwardLinksFromDisk(filename, sourceDocId) {
    try {
        const filePath = resolveDocPath(filename);
        if (isExternalDoc(filename) || !existsSync(filePath))
            return [];
        const raw = readFileSync(filePath, 'utf-8');
        const parsed = markdownToTiptap(raw);
        return extractForwardLinks(parsed.document, sourceDocId);
    }
    catch {
        return [];
    }
}
