import { applyHashlineEdits, applyHashlineSectionToText, applyHashlineSectionWithRecovery, normalizeSnapshotText, parseHashlinePatch, validateHashlineSnapshot, } from "@pie-lab/hashline";
import { Box, Container, Spacer, Text } from "@pie-lab/tui";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import { Type } from "typebox";
import { renderDiff } from "../../modes/interactive/components/diff.js";
import { applyEditsToNormalizedContent, computeEditsDiff, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, } from "./edit-diff.js";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { resolveToCwd } from "./path-utils.js";
import { renderToolPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
const replaceEditSchema = Type.Object({
    oldText: Type.String({
        description: "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",
    }),
    newText: Type.String({ description: "Replacement text for this targeted edit." }),
    hash: Type.Optional(Type.String({
        description: "Optional SHA256 hash of the target line for anchor recovery when file has changed.",
    })),
    before: Type.Optional(Type.Array(Type.String(), {
        description: "Optional context lines before the target (for fuzzy recovery).",
    })),
    after: Type.Optional(Type.Array(Type.String(), {
        description: "Optional context lines after the target (for fuzzy recovery).",
    })),
}, { additionalProperties: false });
const editSchema = Type.Object({
    path: Type.Optional(Type.String({ description: "Path to the file to edit (relative or absolute)" })),
    edits: Type.Optional(Type.Array(replaceEditSchema, {
        description: "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
    })),
    input: Type.Optional(Type.String({
        description: "Hashline patch input using ¶PATH#TAG headers from read output plus replace/delete/insert operations.",
    })),
}, { additionalProperties: false });
const defaultEditOperations = {
    readFile: (path) => fsReadFile(path),
    writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
    access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
};
function prepareEditArguments(input) {
    if (!input || typeof input !== "object") {
        return input;
    }
    const args = input;
    // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array
    if (typeof args.edits === "string") {
        try {
            const parsed = JSON.parse(args.edits);
            if (Array.isArray(parsed))
                args.edits = parsed;
        }
        catch { }
    }
    const legacy = args;
    if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") {
        return args;
    }
    const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : [];
    edits.push({ oldText: legacy.oldText, newText: legacy.newText });
    const { oldText: _oldText, newText: _newText, ...rest } = legacy;
    return { ...rest, edits };
}
function validateEditInput(input) {
    if (typeof input.input === "string") {
        throw new Error("Hashline input must be handled by the hashline edit path.");
    }
    if (typeof input.path !== "string" || input.path.length === 0) {
        throw new Error("Edit tool input is invalid. path is required for replacement edits.");
    }
    if (!Array.isArray(input.edits) || input.edits.length === 0) {
        throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
    }
    return { path: input.path, edits: input.edits };
}
function createEditCallRenderComponent() {
    return Object.assign(new Box(1, 1, (text) => text), {
        preview: undefined,
        previewArgsKey: undefined,
        previewPending: false,
        settledError: false,
    });
}
function getEditCallRenderComponent(state, lastComponent) {
    if (lastComponent instanceof Box) {
        const component = lastComponent;
        state.callComponent = component;
        return component;
    }
    if (state.callComponent) {
        return state.callComponent;
    }
    const component = createEditCallRenderComponent();
    state.callComponent = component;
    return component;
}
/**
 * Try to apply edits using Hashline if hash/context info is available.
 * Falls back to traditional string matching if Hashline fails or lacks context.
 */
function tryApplyWithHashline(content, edits) {
    // Only use Hashline if at least one edit has hash/context
    const hasHashlineInfo = edits.some((e) => e.hash || e.before || e.after);
    if (!hasHashlineInfo)
        return null;
    if (edits.some((edit) => edit.oldText.includes("\n")))
        return null;
    const result = applyHashlineEdits(content, edits.map((edit) => ({
        anchor: edit.oldText,
        hash: edit.hash,
        before: edit.before,
        after: edit.after,
        newText: edit.newText,
    })));
    if (result.error)
        return null;
    return { newContent: result.text, usedHashline: true };
}
function getRenderablePreviewInput(args) {
    if (!args) {
        return null;
    }
    const path = typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : null;
    if (!path) {
        return null;
    }
    if (Array.isArray(args.edits) &&
        args.edits.length > 0 &&
        args.edits.every((edit) => typeof edit?.oldText === "string" && typeof edit?.newText === "string")) {
        return { path, edits: args.edits };
    }
    if (typeof args.oldText === "string" && typeof args.newText === "string") {
        return { path, edits: [{ oldText: args.oldText, newText: args.newText }] };
    }
    return null;
}
function formatEditCall(args, theme, cwd) {
    const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd);
    return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
}
function formatEditResult(args, preview, result, theme, isError) {
    const rawPath = str(args?.file_path ?? args?.path);
    const previewDiff = preview && !("error" in preview) ? preview.diff : undefined;
    const previewError = preview && "error" in preview ? preview.error : undefined;
    if (isError) {
        const errorText = result.content
            .filter((c) => c.type === "text")
            .map((c) => c.text || "")
            .join("\n");
        if (!errorText || errorText === previewError) {
            return undefined;
        }
        return theme.fg("error", errorText);
    }
    const resultDiff = result.details?.diff;
    if (resultDiff && resultDiff !== previewDiff) {
        return renderDiff(resultDiff, { filePath: rawPath ?? undefined });
    }
    return undefined;
}
function getEditHeaderBg(preview, settledError, theme) {
    if (preview) {
        if ("error" in preview) {
            return (text) => theme.bg("toolErrorBg", text);
        }
        return (text) => theme.bg("toolSuccessBg", text);
    }
    if (settledError) {
        return (text) => theme.bg("toolErrorBg", text);
    }
    return (text) => theme.bg("toolPendingBg", text);
}
function buildEditCallComponent(component, args, theme, cwd) {
    component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme));
    component.clear();
    component.addChild(new Text(formatEditCall(args, theme, cwd), 0, 0));
    if (!component.preview) {
        return component;
    }
    const body = "error" in component.preview ? theme.fg("error", component.preview.error) : renderDiff(component.preview.diff);
    component.addChild(new Spacer(1));
    component.addChild(new Text(body, 0, 0));
    return component;
}
function setEditPreview(component, preview, argsKey) {
    const current = component.preview;
    const changed = current === undefined ||
        ("error" in current && "error" in preview
            ? current.error !== preview.error
            : "error" in current !== "error" in preview) ||
        (!("error" in current) &&
            !("error" in preview) &&
            (current.diff !== preview.diff || current.firstChangedLine !== preview.firstChangedLine));
    component.preview = preview;
    component.previewArgsKey = argsKey;
    component.previewPending = false;
    return changed;
}
export function createEditToolDefinition(cwd, options) {
    const ops = options?.operations ?? defaultEditOperations;
    const snapshotStore = options?.snapshotStore;
    const conflictHistory = options?.conflictHistory;
    return {
        name: "edit",
        label: "edit",
        description: "Edit files using either hashline patches from read output or exact text replacement. Prefer hashline input when read returned ¶PATH#TAG numbered content.",
        promptSnippet: "Make precise file edits with hashline patches or exact text replacement",
        promptGuidelines: [
            "When read output includes a ¶PATH#TAG header, prefer edit input with hashline replace/delete/insert operations.",
            "Hashline examples: replace 2..3: followed by +new lines; delete 5; insert after 8: followed by +new lines.",
            "Use edit for precise changes (edits[].oldText must match exactly)",
            "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
            "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
            "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
        ],
        parameters: editSchema,
        renderShell: "self",
        prepareArguments: prepareEditArguments,
        async execute(_toolCallId, input, signal, _onUpdate, _ctx) {
            if (typeof input.input === "string") {
                if (!snapshotStore) {
                    throw new Error("Hashline edit input is not available in this session.");
                }
                const patch = parseHashlinePatch(input.input);
                const seenFiles = new Set();
                const updates = [];
                for (const section of patch.sections) {
                    const absolutePath = resolveToCwd(section.path, cwd);
                    if (seenFiles.has(absolutePath)) {
                        throw new Error(`Multiple hashline sections target the same file: ${section.path}`);
                    }
                    seenFiles.add(absolutePath);
                    await ops.access(absolutePath);
                    if (signal?.aborted)
                        throw new Error("Operation aborted");
                    const buffer = await ops.readFile(absolutePath);
                    if (signal?.aborted)
                        throw new Error("Operation aborted");
                    const rawContent = buffer.toString("utf-8");
                    const { bom, text: content } = stripBom(rawContent);
                    const originalEnding = detectLineEnding(content);
                    const normalizedContent = normalizeToLF(content);
                    const snapshot = snapshotStore.byHash(absolutePath, section.hash);
                    let newContent;
                    let recovered = false;
                    if (snapshot) {
                        const result = applyHashlineSectionWithRecovery(snapshot.text, normalizedContent, section);
                        newContent = result.text;
                        recovered = result.recovered;
                    }
                    else {
                        validateHashlineSnapshot(normalizedContent, section.hash, section.path);
                        newContent = applyHashlineSectionToText(normalizeSnapshotText(normalizedContent), section);
                    }
                    updates.push({
                        path: section.path,
                        absolutePath,
                        baseContent: normalizeSnapshotText(normalizedContent),
                        newContent,
                        originalEnding,
                        bom,
                        recovered,
                    });
                }
                for (const update of updates) {
                    await withFileMutationQueue(update.absolutePath, async () => {
                        if (signal?.aborted)
                            throw new Error("Operation aborted");
                        await ops.writeFile(update.absolutePath, update.bom + restoreLineEndings(update.newContent, update.originalEnding));
                        snapshotStore.record(update.absolutePath, update.newContent);
                        conflictHistory?.clearPath(update.absolutePath);
                    });
                }
                const baseContent = updates.map((update) => `--- ${update.path}\n${update.baseContent}`).join("\n");
                const newContent = updates.map((update) => `--- ${update.path}\n${update.newContent}`).join("\n");
                const diffResult = generateDiffString(baseContent, newContent);
                const recoveredCount = updates.filter((update) => update.recovered).length;
                return {
                    content: [
                        {
                            type: "text",
                            text: `Successfully applied hashline edit to ${updates.length} file(s).` +
                                (recoveredCount > 0 ? ` Recovered stale snapshot for ${recoveredCount} file(s).` : ""),
                        },
                    ],
                    details: {
                        diff: diffResult.diff,
                        patch: updates
                            .map((update) => generateUnifiedPatch(update.path, update.baseContent, update.newContent))
                            .join("\n"),
                        firstChangedLine: diffResult.firstChangedLine,
                    },
                };
            }
            const { path, edits } = validateEditInput(input);
            const absolutePath = resolveToCwd(path, cwd);
            return withFileMutationQueue(absolutePath, async () => {
                // Do not reject from an abort event listener here: that would release the
                // mutation queue while an in-flight filesystem operation may still finish.
                // Checking signal.aborted after each await observes the same aborts while
                // keeping the queue locked until the current operation has settled.
                const throwIfAborted = () => {
                    if (signal?.aborted)
                        throw new Error("Operation aborted");
                };
                throwIfAborted();
                // Check if file exists.
                try {
                    await ops.access(absolutePath);
                }
                catch (error) {
                    throwIfAborted();
                    const errorMessage = error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
                    throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);
                }
                throwIfAborted();
                // Read the file.
                const buffer = await ops.readFile(absolutePath);
                const rawContent = buffer.toString("utf-8");
                throwIfAborted();
                // Strip BOM before matching. The model will not include an invisible BOM in oldText.
                const { bom, text: content } = stripBom(rawContent);
                const originalEnding = detectLineEnding(content);
                const normalizedContent = normalizeToLF(content);
                // Try Hashline first if hash/context info is available
                let baseContent = normalizedContent;
                let newContent;
                const hashlineResult = tryApplyWithHashline(normalizedContent, edits);
                if (hashlineResult) {
                    // Hashline succeeded
                    newContent = hashlineResult.newContent;
                }
                else {
                    // Fall back to traditional string matching
                    const result = applyEditsToNormalizedContent(normalizedContent, edits, path);
                    baseContent = result.baseContent;
                    newContent = result.newContent;
                }
                throwIfAborted();
                const finalContent = bom + restoreLineEndings(newContent, originalEnding);
                await ops.writeFile(absolutePath, finalContent);
                snapshotStore?.record(absolutePath, newContent);
                conflictHistory?.clearPath(absolutePath);
                throwIfAborted();
                const diffResult = generateDiffString(baseContent, newContent);
                const patch = generateUnifiedPatch(path, baseContent, newContent);
                return {
                    content: [
                        {
                            type: "text",
                            text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
                        },
                    ],
                    details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
                };
            });
        },
        renderCall(args, theme, context) {
            const component = getEditCallRenderComponent(context.state, context.lastComponent);
            const previewInput = getRenderablePreviewInput(args);
            const argsKey = previewInput
                ? JSON.stringify({ path: previewInput.path, edits: previewInput.edits })
                : undefined;
            if (component.previewArgsKey !== argsKey) {
                component.preview = undefined;
                component.previewArgsKey = argsKey;
                component.previewPending = false;
                component.settledError = false;
            }
            if (context.argsComplete && previewInput && !component.preview && !component.previewPending) {
                component.previewPending = true;
                const requestKey = argsKey;
                void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd).then((preview) => {
                    if (component.previewArgsKey === requestKey) {
                        setEditPreview(component, preview, requestKey);
                        context.invalidate();
                    }
                });
            }
            return buildEditCallComponent(component, args, theme, context.cwd);
        },
        renderResult(result, _options, theme, context) {
            const callComponent = context.state.callComponent;
            const previewInput = getRenderablePreviewInput(context.args);
            const argsKey = previewInput
                ? JSON.stringify({ path: previewInput.path, edits: previewInput.edits })
                : undefined;
            const typedResult = result;
            const resultDiff = !context.isError ? typedResult.details?.diff : undefined;
            let changed = false;
            if (callComponent) {
                if (typeof resultDiff === "string") {
                    changed =
                        setEditPreview(callComponent, { diff: resultDiff, firstChangedLine: typedResult.details?.firstChangedLine }, argsKey) || changed;
                }
                if (callComponent.settledError !== context.isError) {
                    callComponent.settledError = context.isError;
                    changed = true;
                }
                if (changed) {
                    buildEditCallComponent(callComponent, context.args, theme, context.cwd);
                }
            }
            const output = formatEditResult(context.args, callComponent?.preview, typedResult, theme, context.isError);
            const component = context.lastComponent ?? new Container();
            component.clear();
            if (!output) {
                return component;
            }
            component.addChild(new Spacer(1));
            component.addChild(new Text(output, 1, 0));
            return component;
        },
    };
}
export function createEditTool(cwd, options) {
    return wrapToolDefinition(createEditToolDefinition(cwd, options));
}
//# sourceMappingURL=edit.js.map