/**
 * Hashline edit mode — a line-addressable edit format using content-hash anchors.
 *
 * Each line in a file is identified by its 1-indexed line number and a short
 * hash derived from the normalized line text (xxHash32, truncated to 2 chars
 * from a custom nibble alphabet).
 *
 * The combined `LINE#ID` reference acts as both an address and a staleness check:
 * if the file has changed since the caller last read it, hash mismatches are caught
 * before any mutation occurs.
 *
 * Displayed format: `LINENUM#HASH:TEXT`
 * Reference format: `"LINENUM#HASH"` (e.g. `"5#QQ"`)
 *
 * Adapted from Oh My Pi's hashline implementation for Node.js (no Bun dependency).
 */
import { xxHash32 } from "@nerf/native/xxhash";
const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
const DICT = Array.from({ length: 256 }, (_, i) => {
    const h = i >>> 4;
    const l = i & 0x0f;
    return `${NIBBLE_STR[h]}${NIBBLE_STR[l]}`;
});
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
/**
 * Compute a short hash of a single line.
 *
 * Uses xxHash32 on a trailing-whitespace-trimmed, CR-stripped line, truncated to 2 chars
 * from the nibble alphabet. For lines containing no alphanumeric characters (only
 * punctuation/symbols/whitespace), the line number is mixed in to reduce hash collisions.
 */
export function computeLineHash(idx, line) {
    line = line.replace(/\r/g, "").trimEnd();
    let seed = 0;
    if (!RE_SIGNIFICANT.test(line)) {
        seed = idx;
    }
    return DICT[xxHash32(line, seed) & 0xff];
}
/**
 * Formats a tag given the line number and text.
 */
export function formatLineTag(line, text) {
    return `${line}#${computeLineHash(line, text)}`;
}
/**
 * Format file text with hashline prefixes for display.
 *
 * Each line becomes `LINENUM#HASH:TEXT` where LINENUM is 1-indexed.
 */
export function formatHashLines(text, startLine = 1) {
    const lines = text.split("\n");
    return lines
        .map((line, i) => {
        const num = startLine + i;
        return `${formatLineTag(num, line)}:${line}`;
    })
        .join("\n");
}
/**
 * Parse a line reference string like `"5#QQ"` into structured form.
 *
 * @throws Error if the format is invalid
 */
export function parseTag(ref) {
    const match = ref.match(/^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/);
    if (!match) {
        throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#QQ").`);
    }
    const line = Number.parseInt(match[1], 10);
    if (line < 1) {
        throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
    }
    return { line, hash: match[2] };
}
const MISMATCH_CONTEXT = 2;
/**
 * Error thrown when one or more hashline references have stale hashes.
 * Displays grep-style output with `>>>` markers on mismatched lines,
 * showing the correct `LINE#ID` so the caller can fix all refs at once.
 */
export class HashlineMismatchError extends Error {
    constructor(mismatches, fileLines) {
        super(HashlineMismatchError.formatMessage(mismatches, fileLines));
        this.name = "HashlineMismatchError";
        this.mismatches = mismatches;
        this.fileLines = fileLines;
        const remaps = new Map();
        for (const m of mismatches) {
            const actual = computeLineHash(m.line, fileLines[m.line - 1]);
            remaps.set(`${m.line}#${m.expected}`, `${m.line}#${actual}`);
        }
        this.remaps = remaps;
    }
    static formatMessage(mismatches, fileLines) {
        const mismatchSet = new Map();
        for (const m of mismatches) {
            mismatchSet.set(m.line, m);
        }
        const displayLines = new Set();
        for (const m of mismatches) {
            const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
            const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
            for (let i = lo; i <= hi; i++) {
                displayLines.add(i);
            }
        }
        const sorted = [...displayLines].sort((a, b) => a - b);
        const lines = [];
        lines.push(`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE#ID references shown below (>>> marks changed lines).`);
        lines.push("");
        let prevLine = -1;
        for (const lineNum of sorted) {
            if (prevLine !== -1 && lineNum > prevLine + 1) {
                lines.push("    ...");
            }
            prevLine = lineNum;
            const text = fileLines[lineNum - 1];
            const hash = computeLineHash(lineNum, text);
            const prefix = `${lineNum}#${hash}`;
            if (mismatchSet.has(lineNum)) {
                lines.push(`>>> ${prefix}:${text}`);
            }
            else {
                lines.push(`    ${prefix}:${text}`);
            }
        }
        return lines.join("\n");
    }
}
/**
 * Validate that a line reference points to an existing line with a matching hash.
 */
export function validateLineRef(ref, fileLines) {
    if (ref.line < 1 || ref.line > fileLines.length) {
        throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
    }
    const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
    if (actualHash !== ref.hash) {
        throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
    }
}
// ═══════════════════════════════════════════════════════════════════════════
// Prefix Stripping
// ═══════════════════════════════════════════════════════════════════════════
/** Pattern matching hashline display format prefixes: `LINE#ID:CONTENT` and `#ID:CONTENT` */
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\d+\s*#\s*|#\s*)[ZPMQVRWSNKTXJBYH]{2}:/;
/** Pattern matching a unified-diff added-line `+` prefix (but not `++`). */
const DIFF_PLUS_RE = /^[+](?![+])/;
/**
 * Strip hashline display prefixes and diff `+` markers from replacement lines.
 *
 * Models frequently copy the `LINE#ID` prefix from read output into their
 * replacement content. This strips them heuristically before application.
 */
export function stripNewLinePrefixes(lines) {
    let hashPrefixCount = 0;
    let diffPlusCount = 0;
    let nonEmpty = 0;
    for (const l of lines) {
        if (l.length === 0)
            continue;
        nonEmpty++;
        if (HASHLINE_PREFIX_RE.test(l))
            hashPrefixCount++;
        if (DIFF_PLUS_RE.test(l))
            diffPlusCount++;
    }
    if (nonEmpty === 0)
        return lines;
    const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty;
    const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
    if (!stripHash && !stripPlus)
        return lines;
    return lines.map(l => {
        if (stripHash)
            return l.replace(HASHLINE_PREFIX_RE, "");
        if (stripPlus)
            return l.replace(DIFF_PLUS_RE, "");
        return l;
    });
}
/**
 * Parse edit content — handles string, array, or null input.
 * Strips hashline prefixes and diff markers from model output.
 */
export function parseHashlineText(edit) {
    if (edit === null)
        return [];
    if (typeof edit === "string") {
        const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
        edit = normalizedEdit.replaceAll("\r", "").split("\n");
    }
    return stripNewLinePrefixes(edit);
}
// ═══════════════════════════════════════════════════════════════════════════
// Auto-correction Heuristics
// ═══════════════════════════════════════════════════════════════════════════
function maybeAutocorrectEscapedTabIndentation(edits, warnings) {
    for (const edit of edits) {
        if (edit.lines.length === 0)
            continue;
        const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
        if (!hasEscapedTabs)
            continue;
        const hasRealTabs = edit.lines.some(line => line.includes("\t"));
        if (hasRealTabs)
            continue;
        let correctedCount = 0;
        const corrected = edit.lines.map(line => line.replace(/^((?:\\t)+)/, escaped => {
            correctedCount += escaped.length / 2;
            return "\t".repeat(escaped.length / 2);
        }));
        if (correctedCount === 0)
            continue;
        edit.lines = corrected;
        warnings.push(`Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`);
    }
}
const MIN_AUTOCORRECT_LENGTH = 2;
function shouldAutocorrect(line, otherLine) {
    if (!line || line !== otherLine)
        return false;
    line = line.trim();
    if (line.length < MIN_AUTOCORRECT_LENGTH) {
        return line.endsWith("}") || line.endsWith(")");
    }
    return true;
}
// ═══════════════════════════════════════════════════════════════════════════
// Edit Application
// ═══════════════════════════════════════════════════════════════════════════
/**
 * Apply an array of hashline edits to file content.
 *
 * Each edit operation identifies target lines directly (`replace`,
 * `append`, `prepend`). Line references are resolved via parseTag
 * and hashes validated before any mutation.
 *
 * Edits are sorted bottom-up (highest effective line first) so earlier
 * splices don't invalidate later line numbers.
 *
 * @returns The modified content and the 1-indexed first changed line number
 */
export function applyHashlineEdits(text, edits) {
    if (edits.length === 0) {
        return { lines: text, firstChangedLine: undefined };
    }
    const fileLines = text.split("\n");
    const originalFileLines = [...fileLines];
    let firstChangedLine;
    const noopEdits = [];
    const warnings = [];
    // Pre-validate: collect all hash mismatches before mutating
    const mismatches = [];
    function validateRef(ref) {
        if (ref.line < 1 || ref.line > fileLines.length) {
            throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
        }
        const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
        if (actualHash === ref.hash) {
            return true;
        }
        mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
        return false;
    }
    for (const edit of edits) {
        switch (edit.op) {
            case "replace": {
                if (edit.end) {
                    const startValid = validateRef(edit.pos);
                    const endValid = validateRef(edit.end);
                    if (!startValid || !endValid)
                        continue;
                    if (edit.pos.line > edit.end.line) {
                        throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
                    }
                }
                else {
                    if (!validateRef(edit.pos))
                        continue;
                }
                break;
            }
            case "append": {
                if (edit.pos && !validateRef(edit.pos))
                    continue;
                if (edit.lines.length === 0) {
                    edit.lines = [""];
                }
                break;
            }
            case "prepend": {
                if (edit.pos && !validateRef(edit.pos))
                    continue;
                if (edit.lines.length === 0) {
                    edit.lines = [""];
                }
                break;
            }
        }
    }
    if (mismatches.length > 0) {
        throw new HashlineMismatchError(mismatches, fileLines);
    }
    maybeAutocorrectEscapedTabIndentation(edits, warnings);
    // Deduplicate identical edits targeting the same line(s)
    const seenEditKeys = new Map();
    const dedupIndices = new Set();
    for (let i = 0; i < edits.length; i++) {
        const edit = edits[i];
        let lineKey;
        switch (edit.op) {
            case "replace":
                lineKey = edit.end ? `r:${edit.pos.line}:${edit.end.line}` : `s:${edit.pos.line}`;
                break;
            case "append":
                lineKey = edit.pos ? `i:${edit.pos.line}` : "ieof";
                break;
            case "prepend":
                lineKey = edit.pos ? `ib:${edit.pos.line}` : "ibef";
                break;
        }
        const dstKey = `${lineKey}:${edit.lines.join("\n")}`;
        if (seenEditKeys.has(dstKey)) {
            dedupIndices.add(i);
        }
        else {
            seenEditKeys.set(dstKey, i);
        }
    }
    if (dedupIndices.size > 0) {
        for (let i = edits.length - 1; i >= 0; i--) {
            if (dedupIndices.has(i))
                edits.splice(i, 1);
        }
    }
    // Compute sort key (descending) — bottom-up application
    const annotated = edits.map((edit, idx) => {
        let sortLine;
        let precedence;
        switch (edit.op) {
            case "replace":
                sortLine = edit.end ? edit.end.line : edit.pos.line;
                precedence = 0;
                break;
            case "append":
                sortLine = edit.pos ? edit.pos.line : fileLines.length + 1;
                precedence = 1;
                break;
            case "prepend":
                sortLine = edit.pos ? edit.pos.line : 0;
                precedence = 2;
                break;
        }
        return { edit, idx, sortLine, precedence };
    });
    annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
    function trackFirstChanged(line) {
        if (firstChangedLine === undefined || line < firstChangedLine) {
            firstChangedLine = line;
        }
    }
    // Apply edits bottom-up
    for (const { edit, idx } of annotated) {
        switch (edit.op) {
            case "replace": {
                if (!edit.end) {
                    const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
                    const newLines = edit.lines;
                    if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
                        noopEdits.push({
                            editIndex: idx,
                            loc: `${edit.pos.line}#${edit.pos.hash}`,
                            current: origLines.join("\n"),
                        });
                        break;
                    }
                    fileLines.splice(edit.pos.line - 1, 1, ...newLines);
                    trackFirstChanged(edit.pos.line);
                }
                else {
                    const count = edit.end.line - edit.pos.line + 1;
                    const newLines = [...edit.lines];
                    const trailingReplacementLine = newLines[newLines.length - 1]?.trimEnd();
                    const nextSurvivingLine = fileLines[edit.end.line]?.trimEnd();
                    if (shouldAutocorrect(trailingReplacementLine, nextSurvivingLine) &&
                        fileLines[edit.end.line - 1]?.trimEnd() !== trailingReplacementLine) {
                        newLines.pop();
                        warnings.push(`Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine}" that duplicated next surviving line`);
                    }
                    const leadingReplacementLine = newLines[0]?.trimEnd();
                    const prevSurvivingLine = fileLines[edit.pos.line - 2]?.trimEnd();
                    if (shouldAutocorrect(leadingReplacementLine, prevSurvivingLine) &&
                        fileLines[edit.pos.line - 1]?.trimEnd() !== leadingReplacementLine) {
                        newLines.shift();
                        warnings.push(`Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed leading replacement line "${leadingReplacementLine}" that duplicated preceding surviving line`);
                    }
                    fileLines.splice(edit.pos.line - 1, count, ...newLines);
                    trackFirstChanged(edit.pos.line);
                }
                break;
            }
            case "append": {
                const inserted = edit.lines;
                if (inserted.length === 0) {
                    noopEdits.push({
                        editIndex: idx,
                        loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "EOF",
                        current: edit.pos ? originalFileLines[edit.pos.line - 1] : "",
                    });
                    break;
                }
                if (edit.pos) {
                    fileLines.splice(edit.pos.line, 0, ...inserted);
                    trackFirstChanged(edit.pos.line + 1);
                }
                else {
                    if (fileLines.length === 1 && fileLines[0] === "") {
                        fileLines.splice(0, 1, ...inserted);
                        trackFirstChanged(1);
                    }
                    else {
                        fileLines.splice(fileLines.length, 0, ...inserted);
                        trackFirstChanged(fileLines.length - inserted.length + 1);
                    }
                }
                break;
            }
            case "prepend": {
                const inserted = edit.lines;
                if (inserted.length === 0) {
                    noopEdits.push({
                        editIndex: idx,
                        loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "BOF",
                        current: edit.pos ? originalFileLines[edit.pos.line - 1] : "",
                    });
                    break;
                }
                if (edit.pos) {
                    fileLines.splice(edit.pos.line - 1, 0, ...inserted);
                    trackFirstChanged(edit.pos.line);
                }
                else {
                    if (fileLines.length === 1 && fileLines[0] === "") {
                        fileLines.splice(0, 1, ...inserted);
                    }
                    else {
                        fileLines.splice(0, 0, ...inserted);
                    }
                    trackFirstChanged(1);
                }
                break;
            }
        }
    }
    return {
        lines: fileLines.join("\n"),
        firstChangedLine,
        ...(warnings.length > 0 ? { warnings } : {}),
        ...(noopEdits.length > 0 ? { noopEdits } : {}),
    };
}
//# sourceMappingURL=hashline.js.map