import { materializeNode } from "./mdast-materializer.js";
import { MdastReader } from "./mdast-reader.js";
import { CommandBuffer, classifyReturn } from "../command-buffer.js";
import { ru32, rstr, readPosition } from "../wire-read.js";
import { decodeMdastTypeData } from "./generated/layout.js";
import { TYPE_NAMES, NAME_TO_TYPE, VISITOR_KEYS, MDAST_OPSTREAM_TYPES, } from "./generated/node-types.js";
import { OpWriter, OF_VALUE, OF_URL, OF_TITLE, OF_ALT, OF_LANG, OF_META, OF_IDENTIFIER, OF_LABEL, OF_NAME, OF_REFERENCE_TYPE, OF_DEPTH, OF_CHECKED, OF_START, OF_ORDERED, OF_SPREAD, OF_EXPLICIT, PROP_STRING, emitMdxAttr, } from "../op-stream.js";
import { walkMdastHandle, mdastTextContentHandle } from "#binding";
import { asArray, makeRequireNid, mergeAndReset, unencodableContentError, } from "../visitor-shared.js";
import { LazyChildResolver } from "../lazy-child-resolver.js";
import { MdastChildStub } from "./child-stub.js";
/** Maps MdastNode objects to their arena node IDs without Object.defineProperty overhead. */
const mdastNodeIdMap = new WeakMap();
function nid(node) {
    // Genuine stubs carry their id as a plain field; a spread copy is not
    // `instanceof` and has no `_nodeId`, so it correctly reads as new content.
    if (node instanceof MdastChildStub)
        return node._id;
    const id = mdastNodeIdMap.get(node);
    if (id !== undefined)
        return id;
    // Plain objects are trusted only via the WeakMap or a NON-enumerable
    // `_nodeId` (the materializer's convention, which spread cannot copy) — an
    // enumerable one rode in on a copy and must read as new content.
    const d = Object.getOwnPropertyDescriptor(node, "_nodeId");
    return d !== undefined && !d.enumerable ? d.value : undefined;
}
const requireNid = makeRequireNid(nid);
export class MdastVisitorContext {
    #commandBuffer = new CommandBuffer();
    #diagnostics = [];
    #handle;
    #getSource;
    #resolver;
    /** One canonical object per parent id, so visitors can dedupe by identity.
     *  Null until the first `parent()` call; most passes never make one. */
    #parentsById = null;
    /**
     * The URL of the document being processed (the compile `fileURL` option),
     * or `undefined` when none was given. Use `fileURLToPath(ctx.fileURL)` for a
     * decoded filesystem path.
     */
    fileURL;
    /**
     * Document-level data bag, shared across every plugin in the compile and
     * across the mdast→hast phase boundary. Mutate keys directly
     * (`ctx.data.foo = x`); the bag itself isn't reassignable. Values are kept
     * on the JS side, so any value is allowed, including functions and class
     * instances. Returned to the caller as `result.data`.
     */
    data;
    constructor(handle, getSource, fileURL, resolver, data) {
        this.#handle = handle;
        this.#getSource = getSource;
        this.fileURL = fileURL;
        this.#resolver = resolver;
        this.data = data;
    }
    get source() {
        const value = this.#getSource();
        Object.defineProperty(this, "source", { value, writable: false, enumerable: true });
        return value;
    }
    removeNode(node) {
        this.#commandBuffer.removeNode(requireNid(node, "removeNode"));
    }
    insertBefore(node, newNode) {
        const id = requireNid(node, "insertBefore");
        for (const n of asArray(newNode))
            emitMdastTree(this.#commandBuffer, "insertBefore", id, n);
    }
    insertAfter(node, newNode) {
        const id = requireNid(node, "insertAfter");
        for (const n of asArray(newNode))
            emitMdastTree(this.#commandBuffer, "insertAfter", id, n);
    }
    /**
     * Wrap `node` in `parentNode`, making it `parentNode`'s first child. Any
     * children `parentNode` declares are kept after it.
     */
    wrapNode(node, parentNode) {
        const id = requireNid(node, "wrapNode");
        emitMdastTree(this.#commandBuffer, "wrapNode", id, parentNode);
    }
    prependChild(node, childNode) {
        const id = requireNid(node, "prependChild");
        for (const n of asArray(childNode))
            emitMdastTree(this.#commandBuffer, "prependChild", id, n);
    }
    appendChild(node, childNode) {
        const id = requireNid(node, "appendChild");
        for (const n of asArray(childNode))
            emitMdastTree(this.#commandBuffer, "appendChild", id, n);
    }
    /** Insert one node or an array at `index`; clamps (`0` or less prepends, past the end appends). */
    insertChildAt(node, index, childNode) {
        const children = "children" in node ? node.children : [];
        if (index <= 0 || children.length === 0) {
            this.prependChild(node, childNode);
        }
        else if (index >= children.length) {
            this.appendChild(node, childNode);
        }
        else {
            this.insertBefore(children[index], childNode);
        }
    }
    /** Remove the `index`-th child of `node`; a no-op when there is no such child. */
    removeChildAt(node, index) {
        const child = "children" in node ? node.children[index] : undefined;
        if (child)
            this.removeNode(child);
    }
    replaceNode(node, newNode) {
        const id = requireNid(node, "replaceNode");
        emitMdastTree(this.#commandBuffer, "replace", id, newNode, true);
    }
    setProperty(node, key, value) {
        if (key === "children") {
            // children is structural: set-children keeps the node and swaps only its
            // child list (reused children keep their id).
            const id = requireNid(node, "setProperty");
            const ops = compileMdastChildrenToOpstream(value);
            if (!ops)
                throw unencodableContentError(value);
            this.#commandBuffer.setChildrenOpstream(id, ops);
            return;
        }
        if (key === "data") {
            // data is stored as JSON in the arena, serialize it for the command buffer
            this.#commandBuffer.setProperty(requireNid(node, "setProperty"), key, value != null ? JSON.stringify(value) : null);
            return;
        }
        this.#commandBuffer.setProperty(requireNid(node, "setProperty"), key, value);
    }
    /** Collect the concatenated text of all descendant text nodes (like mdast-util-to-string). */
    textContent(node, options) {
        return mdastTextContentHandle(this.#handle, requireNid(node, "textContent"), options);
    }
    parent(node) {
        const parentId = this.#resolver.parentIdOf(requireNid(node, "parent"));
        if (parentId === undefined)
            return undefined;
        const byId = (this.#parentsById ??= new Map());
        let parent = byId.get(parentId);
        if (parent === undefined) {
            parent = this.#resolver.materializeOne(parentId);
            byId.set(parentId, parent);
        }
        return parent;
    }
    /**
     * Index of `node` within its parent's children, or `undefined` at the root.
     * Use this rather than `parent.children.indexOf(node)`, which won't find it.
     */
    indexOf(node) {
        return this.#resolver.indexInParent(requireNid(node, "indexOf"));
    }
    report({ message, node, severity = "error", }) {
        this.#diagnostics.push({
            message,
            nodeId: node ? nid(node) : undefined,
            position: node?.position,
            severity,
        });
    }
    /** Get the binary command buffer for all mutations recorded via context methods. */
    getCommandBuffer() {
        return this.#commandBuffer;
    }
    getDiagnostics() {
        return this.#diagnostics;
    }
}
export function resolveMdastSubscriptions(plugin) {
    const subs = [];
    for (const [name, fn] of Object.entries(plugin)) {
        if (VISITOR_KEYS.has(name) && typeof fn === "function") {
            const nodeType = NAME_TO_TYPE[name];
            if (nodeType !== undefined) {
                subs.push({
                    nodeType,
                    visitFn: fn,
                });
            }
        }
    }
    return subs;
}
class MdastLazyChildResolver extends LazyChildResolver {
    createReader(wire) {
        return new MdastReader(wire);
    }
    materializeNode(reader, nodeId) {
        return materializeNode(reader, nodeId);
    }
    readParentId(reader, nodeId) {
        return reader.getParentId(nodeId);
    }
    readChildIds(reader, nodeId) {
        return reader.getChildIds(nodeId);
    }
}
/** Build the child-stub list for a matched node from the wire's `[child_ids]
 *  [child_types]` blocks — no arena snapshot. The seal check still applies:
 *  post-pass ids are stale, and a stub built from them could later splice the
 *  wrong node as a ref. */
function readMdastChildStubs(view, buf, idsPos, typesPos, count, resolver) {
    resolver.assertUnsealed();
    const stubs = new Array(count);
    for (let i = 0; i < count; i++) {
        stubs[i] = new MdastChildStub(resolver, ru32(view, idsPos + i * 4), buf[typesPos + i]);
    }
    return stubs;
}
/** Install `children` as an own enumerable getter (spread must carry it),
 *  self-replacing with the one stable stub array on first read. One closure
 *  and one define per node — installing the wire locals as hidden slots
 *  instead measurably regressed every matching pipeline. */
function makeLazyChildren(node, view, buf, childIdsPos, childTypesPos, childCount, resolver) {
    Object.defineProperty(node, "children", {
        get() {
            const val = readMdastChildStubs(view, buf, childIdsPos, childTypesPos, childCount, resolver);
            Object.defineProperty(this, "children", {
                value: val,
                writable: true,
                enumerable: true,
                configurable: true,
            });
            return val;
        },
        enumerable: true,
        configurable: true,
    });
}
/**
 * Read an MDAST node from the inline data in a match buffer entry.
 *
 * Inline format (from Rust serialize_mdast_node_inline):
 *   [node_data: u32+bytes][position: 6×u32 = 24B][child_count: u32][child_ids: N×u32]
 *   [child_types: N×u8][type-specific data]
 */
function readMdastMatchedNode(view, buf, dataOffset, nodeId, nodeType, resolver) {
    let pos = dataOffset;
    const dataJsonLen = ru32(view, pos);
    pos += 4;
    let initialData = null;
    if (dataJsonLen > 0) {
        const jsonStr = rstr(buf, pos, dataJsonLen);
        try {
            initialData = JSON.parse(jsonStr);
        }
        catch (err) {
            if (process.env.NODE_ENV !== "production") {
                console.warn(`readMdastMatchedNode: malformed node_data for nodeId=${nodeId}`, err);
            }
        }
        pos += dataJsonLen;
    }
    const position = readPosition(view, pos);
    pos += 24;
    const childCount = ru32(view, pos);
    pos += 4;
    // Ids/types decode lazily with `.children` — most matched nodes never read them.
    const childIdsPos = pos;
    pos += childCount * 4;
    const childTypesPos = pos;
    pos += childCount;
    const typeName = TYPE_NAMES[nodeType] ?? `unknown(${nodeType})`;
    const node = { type: typeName };
    if (position !== undefined)
        node.position = position;
    if (childCount > 0) {
        makeLazyChildren(node, view, buf, childIdsPos, childTypesPos, childCount, resolver);
    }
    // Fixed-field types decode from the generated layout table; the rest
    // (variable-length / cross-field) stay in the hand-written switch.
    if (!decodeMdastTypeData(view, buf, pos, nodeType, node)) {
        switch (nodeType) {
            case 5: {
                // list
                node.start = ru32(view, pos);
                node.ordered = buf[pos + 4] !== 0;
                node.spread = buf[pos + 5] !== 0;
                if (!node.ordered)
                    node.start = null;
                break;
            }
            case 6: {
                // listItem
                const checked = buf[pos];
                node.checked = checked === 2 ? null : checked === 1;
                node.spread = buf[pos + 1] !== 0;
                break;
            }
            // table (21), directives (30/31/32) and mdxJsx elements (100/101) are
            // decoded by the generated `decodeMdastTypeData` from their tails.
            // root(0), paragraph(1), thematicBreak(3), blockquote(4), emphasis(11),
            // strong(12), break(14), tableRow(22), tableCell(23), delete(24): no extra data
        }
    }
    mdastNodeIdMap.set(node, nodeId);
    if (initialData) {
        node.data = initialData;
    }
    return node;
}
/** The arena id of a node if it is an existing (materialized) node, else
 *  undefined for a freshly-built one. */
function reusedId(node) {
    if (node === null || typeof node !== "object")
        return undefined;
    const id = nid(node);
    return typeof id === "number" ? id : undefined;
}
// Reused across every replacement in a pass: compile is synchronous and its
// result is copied into the command buffer before the next call, so a single
// writer is safe and avoids a 512-byte allocation per built node.
const mdastWriter = new OpWriter();
/**
 * Compile a declarative MDAST replacement tree to the op-stream — the only
 * structural encoding. Reused nodes (those still carrying an arena id) become
 * `ref`s so the rebuild splices the original back in place. Returns null when
 * the replay can't reproduce the tree identically (unsupported node type or
 * out-of-range numeric field); the caller turns that into a hard error.
 */
function compileMdastToOpstream(root, forReplace = false) {
    mdastWriter.begin();
    try {
        if (!emitMdastOp(mdastWriter, root, true, forReplace))
            return null;
        return mdastWriter.take();
    }
    finally {
        mdastWriter.end();
    }
}
/** Compile a set-children payload: a root-wrapped child list, the shape
 *  `Patch::SetChildren` splices in. Reused children become refs. */
function compileMdastChildrenToOpstream(children) {
    if (!Array.isArray(children))
        return null;
    mdastWriter.begin();
    try {
        mdastWriter.open(NAME_TO_TYPE.root);
        for (const c of children) {
            if (!emitMdastOp(mdastWriter, c, false, false))
                return null;
        }
        mdastWriter.close();
        return mdastWriter.take();
    }
    finally {
        mdastWriter.end();
    }
}
function emitMdastOp(w, node, isRoot, forReplace) {
    if (node === null || typeof node !== "object")
        return false;
    if (!isRoot) {
        const id = reusedId(node);
        if (id !== undefined) {
            w.ref(id);
            return true;
        }
    }
    const n = node;
    const type = MDAST_OPSTREAM_TYPES[n.type];
    if (type === undefined)
        return false;
    w.open(type);
    if (typeof n.value === "string")
        w.str(OF_VALUE, n.value);
    if (typeof n.url === "string")
        w.str(OF_URL, n.url);
    if (typeof n.title === "string")
        w.str(OF_TITLE, n.title);
    if (typeof n.alt === "string")
        w.str(OF_ALT, n.alt);
    if (typeof n.lang === "string")
        w.str(OF_LANG, n.lang);
    if (typeof n.meta === "string")
        w.str(OF_META, n.meta);
    if (typeof n.identifier === "string")
        w.str(OF_IDENTIFIER, n.identifier);
    if (typeof n.label === "string")
        w.str(OF_LABEL, n.label);
    if (typeof n.referenceType === "string")
        w.str(OF_REFERENCE_TYPE, n.referenceType);
    // Out-of-range numbers compile to null and the caller throws — a visible
    // error instead of silently masking the bits.
    if (typeof n.depth === "number") {
        if (!Number.isInteger(n.depth) || n.depth < 0 || n.depth > 255)
            return false;
        w.u8(OF_DEPTH, n.depth);
    }
    if (typeof n.checked === "boolean")
        w.u8(OF_CHECKED, n.checked ? 1 : 0);
    if (typeof n.start === "number") {
        if (!Number.isInteger(n.start) || n.start < 0 || n.start > 4294967295)
            return false;
        w.u32(OF_START, n.start);
    }
    if (typeof n.ordered === "boolean")
        w.bool(OF_ORDERED, n.ordered);
    if (typeof n.spread === "boolean")
        w.bool(OF_SPREAD, n.spread);
    if (typeof n.name === "string")
        w.str(OF_NAME, n.name);
    const attrs = n.attributes;
    if (Array.isArray(attrs)) {
        for (const a of attrs)
            emitMdxAttr(w, a);
    }
    else if (attrs !== null && typeof attrs === "object") {
        // Directive attributes: a string→string map; non-string values are
        // dropped, since the stored form holds only strings.
        for (const key in attrs) {
            const v = attrs[key];
            if (typeof v === "string")
                w.prop(key, PROP_STRING, v);
        }
    }
    if (Array.isArray(n.align))
        w.align(n.align.map(alignCode));
    if (n.data?._mdxExplicitJsx === true) {
        w.bool(OF_EXPLICIT, true);
    }
    if (n.data != null)
        w.data(n.data);
    if (isRoot && forReplace && n._keepChildren === true) {
        // Replace splices the target's original children, discarding any the
        // replacement declares.
        w.keepChildren();
    }
    else {
        // `_keepChildren` only applies to replace; other ops ignore the marker
        // and emit the declared children.
        const children = n.children;
        if (Array.isArray(children)) {
            for (const c of children)
                if (!emitMdastOp(w, c, false, forReplace))
                    return false;
        }
    }
    w.close();
    return true;
}
/** Map a table `align` entry to its arena code (none=0). */
function alignCode(a) {
    return a === "left" ? 1 : a === "right" ? 2 : a === "center" ? 3 : 0;
}
/** True for the `{raw}` / `{rawHtml}` escape hatches — re-parsed by Rust rather
 *  than compiled to an op-stream, so they ride the RAW_MARKDOWN / RAW_HTML
 *  payloads instead of the declarative encoder. */
function isRawMdastContent(content) {
    const c = content;
    return typeof c.raw === "string" || typeof c.rawHtml === "string";
}
/** Encode `content` as the `op` structural command. Declarative nodes compile
 *  to the op-stream; the `{raw}`/`{rawHtml}` escape hatches ride the raw
 *  re-parse payloads. Anything that compiles to neither is a hard error — the
 *  op-stream is the only declarative encoding. The switches stay inline so the
 *  buffer calls are monomorphic (computed method names defeat inline caches on
 *  this warm path). */
function emitMdastTree(buffer, op, id, content, forReplace = false) {
    if (isRawMdastContent(content)) {
        switch (op) {
            case "replace":
                return buffer.replace(id, content);
            case "insertBefore":
                return buffer.insertBefore(id, content);
            case "insertAfter":
                return buffer.insertAfter(id, content);
            case "prependChild":
                return buffer.prependChild(id, content);
            case "appendChild":
                return buffer.appendChild(id, content);
            case "wrapNode":
                return buffer.wrapNode(id, content);
        }
    }
    const ops = compileMdastToOpstream(content, forReplace);
    if (ops === null)
        throw unencodableContentError(content);
    switch (op) {
        case "replace":
            return buffer.replaceOpstream(id, ops);
        case "insertBefore":
            return buffer.insertBeforeOpstream(id, ops);
        case "insertAfter":
            return buffer.insertAfterOpstream(id, ops);
        case "prependChild":
            return buffer.prependChildOpstream(id, ops);
        case "appendChild":
            return buffer.appendChildOpstream(id, ops);
        case "wrapNode":
            return buffer.wrapNodeOpstream(id, ops);
    }
}
/** A result that is the same object as the input node is a no-op, so context
 *  mutations (e.g. setProperty) are not clobbered. */
function applyMdastVisitResult(result, nodeId, returnBuffer, originalNode) {
    if (result === undefined || result === null)
        return;
    if (result === originalNode)
        return;
    const cls = classifyReturn(result);
    switch (cls) {
        case "raw_markdown":
            returnBuffer.replace(nodeId, result);
            break;
        case "raw_html":
            returnBuffer.replace(nodeId, result);
            break;
        case "structured_node":
            emitMdastTree(returnBuffer, "replace", nodeId, result, true);
            break;
    }
}
/**
 * Walk an MDAST handle in Rust, dispatch matched nodes to JS visitor functions,
 * and apply mutations back to the handle. No arena buffers cross NAPI.
 *
 * Returns MdastVisitResult synchronously if all visitors are sync,
 * or Promise<MdastVisitResult> if any visitor is async.
 */
export function visitMdastHandle(handle, plugin, subs, source, fileURL, data = {}) {
    const getSource = typeof source === "function" ? source : () => source;
    const resolver = new MdastLazyChildResolver(handle);
    const context = new MdastVisitorContext(handle, getSource, fileURL, resolver, data);
    const returnBuffer = new CommandBuffer();
    const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: [] }));
    const matchBuf = walkMdastHandle(handle, rustSubs);
    const matchView = new DataView(matchBuf.buffer, matchBuf.byteOffset, matchBuf.byteLength);
    const matchCount = ru32(matchView, 0);
    let deferred = null;
    for (let i = 0; i < matchCount; i++) {
        const indexBase = 4 + i * 10;
        const nodeId = ru32(matchView, indexBase);
        const subIndex = matchBuf[indexBase + 4];
        const dataOffset = ru32(matchView, indexBase + 6);
        const sub = subs[subIndex];
        const node = readMdastMatchedNode(matchView, matchBuf, dataOffset, nodeId, sub.nodeType, resolver);
        const result = sub.visitFn.call(plugin, node, context);
        if (result instanceof Promise) {
            deferred ??= [];
            deferred.push({ nodeId, promise: result, originalNode: node });
        }
        else {
            applyMdastVisitResult(result, nodeId, returnBuffer, node);
        }
    }
    if (deferred) {
        return Promise.all(deferred.map((d) => d.promise.then((r) => ({ nodeId: d.nodeId, result: r, originalNode: d.originalNode })))).then((results) => {
            for (const { nodeId, result, originalNode } of results) {
                applyMdastVisitResult(result, nodeId, returnBuffer, originalNode);
            }
            // End of the pass — the caller applies the returned command buffer next,
            // renumbering the arena, so later snapshots would resolve match-time
            // child ids against wrong nodes. This is the last point we control.
            resolver.seal();
            return finalizeMdastVisit(handle, context, returnBuffer);
        });
    }
    resolver.seal();
    return finalizeMdastVisit(handle, context, returnBuffer);
}
function finalizeMdastVisit(handle, context, returnBuffer) {
    const { merged, hasMutations } = mergeAndReset(returnBuffer, context);
    return { commandBuffer: merged, diagnostics: context.getDiagnostics(), hasMutations };
}
