/**
 * Yjs-native anchor serialization for span-anchored comments.
 *
 * Anchors are pairs of `Y.RelativePosition` objects pointing at the start
 * and end of the comment’s target span. RelativePosition is symbolic
 * (“between these two Yjs items”) rather than offset-based, so it survives
 * concurrent edits automatically — that’s the whole reason we built on Yjs
 * for the editor. Comments survive other users typing in front of the
 * highlighted span, surrounding deletes, or remote rewrites, with no
 * server-side migration logic.
 *
 * Wire format:
 *   2 bytes (big-endian)  — length of start blob
 *   start blob            — Y.encodeRelativePosition output
 *   end blob              — Y.encodeRelativePosition output
 *   ↓ base64-encoded for SQLite blob storage / JSON transport
 *
 * Resolution falls back to fuzzy-quote matching when the primary anchor
 * resolves to null AND the original highlighted text still exists exactly
 * once in the document — Google Docs / Hypothesis pattern. If both fail,
 * the comment renders as orphaned with the preserved quote as context.
 */
import * as Y from 'yjs';
/**
 * Encode a Uint8Array as base64. Uses Node's Buffer when available so
 * server-side callers (MCP virtual-entity adapter, agent context builder)
 * round-trip byte-identical to the browser path; falls back to btoa for
 * pure-browser builds. Both halves must agree because anchors travel
 * through the JSON wire format and back.
 */
function bytesToBase64(bytes) {
    if (typeof Buffer !== 'undefined') {
        return Buffer.from(bytes).toString('base64');
    }
    let s = '';
    for (let i = 0; i < bytes.length; i++)
        s += String.fromCharCode(bytes[i]);
    return btoa(s);
}
function base64ToBytes(b64) {
    if (typeof Buffer !== 'undefined') {
        const buf = Buffer.from(b64, 'base64');
        return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
    }
    const s = atob(b64);
    const out = new Uint8Array(s.length);
    for (let i = 0; i < s.length; i++)
        out[i] = s.charCodeAt(i);
    return out;
}
export function serializeAnchor(ytext, from, to) {
    const fromRel = Y.createRelativePositionFromTypeIndex(ytext, from);
    const toRel = Y.createRelativePositionFromTypeIndex(ytext, to);
    const startBytes = Y.encodeRelativePosition(fromRel);
    const endBytes = Y.encodeRelativePosition(toRel);
    const wire = new Uint8Array(2 + startBytes.length + endBytes.length);
    const len = startBytes.length;
    wire[0] = (len >> 8) & 0xff;
    wire[1] = len & 0xff;
    wire.set(startBytes, 2);
    wire.set(endBytes, 2 + len);
    const quote = ytext.toString().slice(from, to).slice(0, 256);
    return { relpos: bytesToBase64(wire), quote };
}
export function resolveAnchor(ydoc, ytext, relposBase64, quote) {
    try {
        const wire = base64ToBytes(relposBase64);
        const startLen = (wire[0] << 8) | wire[1];
        const startBytes = wire.slice(2, 2 + startLen);
        const endBytes = wire.slice(2 + startLen);
        const fromRel = Y.decodeRelativePosition(startBytes);
        const toRel = Y.decodeRelativePosition(endBytes);
        const fromAbs = Y.createAbsolutePositionFromRelativePosition(fromRel, ydoc);
        const toAbs = Y.createAbsolutePositionFromRelativePosition(toRel, ydoc);
        if (fromAbs && toAbs && fromAbs.type === ytext && toAbs.type === ytext && fromAbs.index <= toAbs.index) {
            // Yjs RelativePositions don’t return null when the items they
            // reference are gone — they snap to a surviving item (often
            // position 0 or the doc end), producing a degenerate
            // from===to resolution. When the original anchor had
            // non-empty content, a zero-width result means the anchor
            // is stale, not literally degenerate; fall through to the
            // quote-fallback. We accept from===to only when the original
            // quote was also empty (entry-level anchors aren’t issued
            // with an empty selection by the rail UI but the serializer
            // permits it structurally).
            const degenerate = fromAbs.index === toAbs.index && quote.length > 0;
            if (!degenerate) {
                // Even when both endpoints resolve to a non-degenerate range,
                // Yjs may snap to surviving items that flank a deleted middle —
                // the range exists but no longer covers the quoted text.
                // Heuristic: when the resolved range is materially shorter than
                // the quote, treat as drift and fall through to quote-fallback.
                // (Pure insertion inside the anchor EXPANDS the range, so a
                // longer-than-quote resolved range is the expected case and
                // must still be accepted.) Threshold: resolved must be at
                // least 50% of the quote length (min 1) — catches
                // most-of-content-deleted while leaving normal edits alone.
                if (quote.length > 0) {
                    const resolvedLen = toAbs.index - fromAbs.index;
                    const minLen = Math.max(1, Math.floor(quote.length * 0.5));
                    if (resolvedLen < minLen) {
                        // Drift — but if the surviving range is a clean prefix or
                        // suffix of the original quote, the edit just lopped off
                        // one end and the resolved range still points exactly
                        // where the comment belongs (just shorter). Accept it as
                        // a partial resolution. Middle-collapse cases produce
                        // resolved text that's neither a prefix nor suffix of the
                        // quote (e.g. anchor "quick brown fox" with the middle
                        // deleted resolves to "qx" — not a prefix of the quote)
                        // and still fall through to quote-fallback → orphan.
                        const resolvedText = ytext.toString().slice(fromAbs.index, toAbs.index);
                        if (quote.startsWith(resolvedText) || quote.endsWith(resolvedText)) {
                            return { from: fromAbs.index, to: toAbs.index, orphaned: false, recovered: true };
                        }
                        // else: real drift — fall through to quote-fallback below.
                    }
                    else {
                        return { from: fromAbs.index, to: toAbs.index, orphaned: false, recovered: false };
                    }
                }
                else {
                    return { from: fromAbs.index, to: toAbs.index, orphaned: false, recovered: false };
                }
            }
        }
    }
    catch {
        // fall through to quote-fallback
    }
    // Primary anchor failed. If the quote is still findable exactly once,
    // re-anchor onto that occurrence. Multiple occurrences are ambiguous —
    // we treat that as orphan to avoid silently picking the wrong instance.
    if (quote.length > 0) {
        const doc = ytext.toString();
        const first = doc.indexOf(quote);
        if (first >= 0 && doc.indexOf(quote, first + 1) < 0) {
            return {
                from: first,
                to: first + quote.length,
                orphaned: false,
                recovered: true,
            };
        }
    }
    return { from: -1, to: -1, orphaned: true, recovered: false };
}
//# sourceMappingURL=comment-anchor.js.map