import { Container, Text } from "@earendil-works/pi-tui";
import { chmod as fsChmod, mkdir as fsMkdir, readFile as fsReadFile, readdir as fsReaddir, stat as fsStat, writeFile as fsWriteFile } from "fs/promises";
import { dirname, join } from "path";
import { Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
import { getRegisteredConflictBlocks, parseConflictBlocks } from "./conflict-registry.js";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { createHashlineSnapshotStore, formatHashlineContent, recordHashlineSnapshot, stripKnownHashlineCopiedContent, stripKnownHashlineCopiedContentWithMeta } from "./hashline.js";
import { resolveToCwd } from "./path-utils.js";
import { parseArchiveSelector, parseSqliteSelector, resolveArchiveSelector, resolveInternalSelector, sqliteSelectorForPath, writeArchiveSelector, writeInternalSelector, writeSqliteSelector } from "./resource-selectors.js";
import { invalidateNativeSearchCache } from "./search-native.js";
import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
const writeSchema = Type.Object({
    path: Type.String({ description: "File path, writable internal resource, archive entry, SQLite row selector, or conflict resolution target." }),
    content: Type.String({ description: "Full replacement content. SQLite non-delete writes parse as a JSON5 object; empty content deletes a SQLite row when a row key exists." }),
}, { additionalProperties: false });
async function findConflictBlocks(root, limit = 100) {
    const out = [];
    async function walk(dir) { for (const entry of await fsReaddir(dir, { withFileTypes: true }).catch(() => [])) {
        if (out.length >= limit || entry.name === ".git" || entry.name === "node_modules")
            continue;
        const full = join(dir, entry.name);
        if (entry.isDirectory())
            await walk(full);
        else if (entry.isFile()) {
            const text = await fsReadFile(full, "utf8").catch(() => "");
            if (text.includes("<<<<<<<") && text.includes("=======") && text.includes(">>>>>>>"))
                out.push(...parseConflictBlocks(full, text));
        }
    } }
    await walk(root);
    return out;
}
/**
 * Reconcile the read-time conflict view (registry, populated by `read
 * …:conflicts`) against the live tree-walk snapshot. A registered block is
 * only trustworthy when the markers still sit at its recorded file+offset —
 * otherwise the file drifted and the id→block mapping would be wrong. When
 * any registered block is still live, resolve ids against the registered
 * ordering (what the agent actually saw); otherwise fall back to the fresh
 * tree walk. This closes the divergent id-space where `conflict://2` could
 * resolve a different block than the read displayed.
 */
function reconcileConflictBlocks(live, cwd) {
    const registered = getRegisteredConflictBlocks(cwd);
    if (registered.length === 0)
        return live;
    const liveKeys = new Set(live.map((block) => `${block.file}\0${block.start}\0${block.end}`));
    const registeredLive = registered.filter((block) => liveKeys.has(`${block.file}\0${block.start}\0${block.end}`));
    // If the registered view no longer matches the live tree at all, the file
    // changed under us — trust the live walk rather than a stale id mapping.
    return registeredLive.length > 0 ? registeredLive : live;
}
function conflictReplacement(content, block) {
    const token = content.trim();
    if (token === "@ours")
        return block.ours;
    if (token === "@theirs")
        return block.theirs;
    if (token === "@base")
        return block.base;
    const normalized = content.endsWith("\n") ? content.slice(0, -1) : content;
    return normalized === "" ? [] : normalized.split("\n");
}
async function resolveConflictBlocks(blocks, content) {
    const byFile = new Map();
    for (const block of blocks)
        byFile.set(block.file, [...(byFile.get(block.file) ?? []), block]);
    const written = [];
    for (const [file, fileBlocks] of byFile) {
        const text = await fsReadFile(file, "utf8"), lines = text.split("\n");
        for (const block of fileBlocks.sort((a, b) => b.start - a.start))
            lines.splice(block.start, block.end - block.start + 1, ...conflictReplacement(content, block));
        await fsWriteFile(file, lines.join("\n"), "utf8");
        written.push(file);
    }
    return written;
}
async function conflictSnapshotHeaders(files, cwd, store) {
    const headers = [];
    for (const file of [...new Set(files)]) {
        invalidateNativeSearchCache(file);
        const text = await fsReadFile(file, "utf8").catch(() => undefined);
        if (text !== undefined)
            headers.push(formatHashlineContent(recordHashlineSnapshot(file, cwd, text, store)).split("\n")[0]);
    }
    return headers;
}
const GENERATED_MARKER_BYTES = 1024;
const GENERATED_MARKER_LINES = 40;
function hasGeneratedMarker(text) {
    const header = text.slice(0, GENERATED_MARKER_BYTES).split("\n").slice(0, GENERATED_MARKER_LINES);
    return header.some((line) => /@generated|auto-generated|DO NOT EDIT|GENERATED -- do not edit/i.test(line));
}
function strippedHashlineNote(stripped) { return stripped ? "\n\nNote: stripped copied hashline headers and line prefixes before writing." : ""; }
function hashlineHeaderForWrite(path, cwd, content, store) { const snapshot = recordHashlineSnapshot(path, cwd, content, store); return `[${snapshot.displayPath}#${snapshot.tag}]`; }
const defaultWriteOperations = {
    writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
    mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => { }),
};
class WriteCallRenderComponent extends Text {
    constructor() {
        super("", 0, 0);
    }
}
const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50;
function highlightSingleLine(line, lang) {
    const highlighted = highlightCode(line, lang);
    return highlighted[0] ?? "";
}
function refreshWriteHighlightPrefix(cache) {
    const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length);
    if (prefixCount === 0)
        return;
    const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
    const prefixHighlighted = highlightCode(prefixSource, cache.lang);
    for (let i = 0; i < prefixCount; i++) {
        cache.highlightedLines[i] =
            prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
    }
}
function rebuildWriteHighlightCacheFull(rawPath, fileContent) {
    const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
    if (!lang)
        return undefined;
    const displayContent = normalizeDisplayText(fileContent);
    const normalized = replaceTabs(displayContent);
    return {
        rawPath,
        lang,
        rawContent: fileContent,
        normalizedLines: normalized.split("\n"),
        highlightedLines: highlightCode(normalized, lang),
    };
}
function updateWriteHighlightCacheIncremental(cache, rawPath, fileContent) {
    const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
    if (!lang)
        return undefined;
    if (!cache)
        return rebuildWriteHighlightCacheFull(rawPath, fileContent);
    if (cache.lang !== lang || cache.rawPath !== rawPath)
        return rebuildWriteHighlightCacheFull(rawPath, fileContent);
    if (!fileContent.startsWith(cache.rawContent))
        return rebuildWriteHighlightCacheFull(rawPath, fileContent);
    if (fileContent.length === cache.rawContent.length)
        return cache;
    const deltaRaw = fileContent.slice(cache.rawContent.length);
    const deltaDisplay = normalizeDisplayText(deltaRaw);
    const deltaNormalized = replaceTabs(deltaDisplay);
    cache.rawContent = fileContent;
    if (cache.normalizedLines.length === 0) {
        cache.normalizedLines.push("");
        cache.highlightedLines.push("");
    }
    const segments = deltaNormalized.split("\n");
    const lastIndex = cache.normalizedLines.length - 1;
    cache.normalizedLines[lastIndex] += segments[0];
    cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang);
    for (let i = 1; i < segments.length; i++) {
        cache.normalizedLines.push(segments[i]);
        cache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang));
    }
    refreshWriteHighlightPrefix(cache);
    return cache;
}
function trimTrailingEmptyLines(lines) {
    let end = lines.length;
    while (end > 0 && lines[end - 1] === "") {
        end--;
    }
    return lines.slice(0, end);
}
function formatWriteCall(args, options, theme, cache) {
    const rawPath = str(args?.path);
    const fileContent = str(args?.content);
    const path = rawPath !== null ? shortenPath(rawPath) : null;
    const invalidArg = invalidArgText(theme);
    let text = `${theme.fg("toolTitle", theme.bold("write"))} ${path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")}`;
    if (fileContent === null) {
        text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
    }
    else if (fileContent) {
        const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
        const renderedLines = lang
            ? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang))
            : normalizeDisplayText(fileContent).split("\n");
        const lines = trimTrailingEmptyLines(renderedLines);
        const totalLines = lines.length;
        const maxLines = options.expanded ? lines.length : 10;
        const displayLines = lines.slice(0, maxLines);
        const remaining = lines.length - maxLines;
        text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
        if (remaining > 0) {
            text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "Expand")}${theme.fg("muted", ")")}`;
        }
    }
    return text;
}
function formatWriteResult(result, theme) {
    if (!result.isError) {
        return undefined;
    }
    const output = result.content
        .filter((c) => c.type === "text")
        .map((c) => c.text || "")
        .join("\n");
    if (!output) {
        return undefined;
    }
    return `\n${theme.fg("error", output)}`;
}
export function createWriteToolDefinition(cwd, options) {
    const ops = options?.operations ?? defaultWriteOperations;
    const hashlineStore = options?.hashlineStore ?? createHashlineSnapshotStore();
    return {
        name: "write",
        label: "write",
        description: "Create or overwrite a file, writable internal resource, archive entry, SQLite row, or merge-conflict resolution.",
        promptSnippet: "Create or overwrite a writable path selector.",
        promptGuidelines: ["Use write when replacing the full content of a target; use edit for source edits anchored to existing hashline snapshots."],
        parameters: writeSchema,
        async execute(_toolCallId, { path, content }, signal, _onUpdate, _ctx) {
            const resourceCtx = _ctx;
            const archive = parseArchiveSelector(path);
            const parsedSqlite = parseSqliteSelector(path);
            if (parsedSqlite?.table && path.includes("?"))
                throw new Error("SQLite write selectors must not include query parameters.");
            if (parsedSqlite?.table && (parsedSqlite.limit !== undefined || parsedSqlite.offset !== undefined || parsedSqlite.where || parsedSqlite.order || parsedSqlite.schema || parsedSqlite.sampleRows !== undefined || parsedSqlite.query))
                throw new Error("SQLite write selectors must not include query parameters.");
            const sqlite = sqliteSelectorForPath(path, cwd);
            if (!sqlite && parsedSqlite?.table)
                throw new Error(`SQLite database not found or is not SQLite: ${path}`);
            if (sqlite) {
                const stripped = stripKnownHashlineCopiedContentWithMeta(content, "", cwd, hashlineStore);
                const message = writeSqliteSelector(sqlite, stripped.content);
                return { content: [{ type: "text", text: `${message}${strippedHashlineNote(stripped.stripped)}` }], details: { meta: { sourcePath: sqlite.databasePath } } };
            }
            if (archive) {
                const stripped = stripKnownHashlineCopiedContentWithMeta(content, "", cwd, hashlineStore);
                const resolvedArchive = resolveArchiveSelector(archive, cwd);
                writeArchiveSelector(resolvedArchive, stripped.content);
                return { content: [{ type: "text", text: `Successfully wrote ${stripped.content.length} bytes to ${path}${strippedHashlineNote(stripped.stripped)}` }], details: { resolvedPath: resolvedArchive.archivePath } };
            }
            if (path.startsWith("conflict://")) {
                const writeContent = stripKnownHashlineCopiedContent(content, "", cwd, hashlineStore);
                const target = decodeURIComponent(path.slice("conflict://".length));
                if (!target)
                    throw new Error("conflict:// target is required.");
                if (target.includes("/"))
                    throw new Error("Scoped conflict resources are read-only; write conflict://<id> or conflict://*.");
                const liveBlocks = await findConflictBlocks(cwd);
                if (liveBlocks.length === 0)
                    throw new Error("No conflict markers found.");
                const blocks = reconcileConflictBlocks(liveBlocks, cwd);
                if (target === "*") {
                    const headers = await conflictSnapshotHeaders(await resolveConflictBlocks(blocks, writeContent), cwd, hashlineStore);
                    return { content: [{ type: "text", text: `Resolved ${blocks.length} conflict${blocks.length === 1 ? "" : "s"}${headers.length > 0 ? `\n\nSnapshots:\n${headers.join("\n")}` : ""}` }], details: undefined };
                }
                if (!/^\d+$/.test(target))
                    throw new Error("Conflict writes must target conflict://<id> or conflict://*.");
                const id = Number.parseInt(target, 10);
                const block = blocks[id - 1];
                if (!block)
                    throw new Error(`Conflict id not found: ${target}`);
                const headers = await conflictSnapshotHeaders(await resolveConflictBlocks([block], writeContent), cwd, hashlineStore);
                return { content: [{ type: "text", text: `Resolved conflict ${target}${headers[0] ? `\n\n${headers[0]}` : ""}` }], details: undefined };
            }
            if (/^[a-z]+:\/\//i.test(path)) {
                const sourcePath = path.startsWith("local://") ? resolveInternalSelector(path, cwd) : undefined;
                if (sourcePath)
                    return createWriteToolDefinition(cwd, options).execute(_toolCallId, { path: sourcePath, content }, signal, _onUpdate, _ctx);
                const stripped = stripKnownHashlineCopiedContentWithMeta(content, "", cwd, hashlineStore);
                await writeInternalSelector(path, cwd, stripped.content, resourceCtx);
                return { content: [{ type: "text", text: `Successfully wrote ${stripped.content.length} bytes to ${path}${strippedHashlineNote(stripped.stripped)}` }], details: {} };
            }
            const absolutePath = resolveToCwd(path, cwd);
            const dir = dirname(absolutePath);
            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();
                // Create parent directories if needed.
                await ops.mkdir(dir);
                throwIfAborted();
                const existing = await fsReadFile(absolutePath, "utf8").catch(() => undefined);
                if (existing !== undefined && hasGeneratedMarker(existing))
                    throw new Error(`Refusing to overwrite generated file: ${path}`);
                const stripped = stripKnownHashlineCopiedContentWithMeta(content, absolutePath, cwd, hashlineStore);
                const writeContent = stripped.content;
                await ops.writeFile(absolutePath, writeContent);
                invalidateNativeSearchCache(absolutePath);
                let madeExecutable = false;
                if (writeContent.startsWith("#!")) {
                    const mode = await fsStat(absolutePath).then((stat) => stat.mode).catch(() => undefined);
                    if (mode !== undefined) {
                        await fsChmod(absolutePath, mode | 0o111);
                        madeExecutable = true;
                    }
                }
                throwIfAborted();
                const header = hashlineHeaderForWrite(absolutePath, cwd, writeContent, hashlineStore);
                return {
                    content: [{ type: "text", text: `${header}\nSuccessfully wrote ${writeContent.length} bytes to ${path}${strippedHashlineNote(stripped.stripped)}` }],
                    details: { resolvedPath: absolutePath, ...(madeExecutable ? { madeExecutable } : {}) },
                };
            });
        },
        renderCall(args, theme, context) {
            const renderArgs = args;
            const rawPath = str(renderArgs?.path);
            const fileContent = str(renderArgs?.content);
            const component = context.lastComponent ?? new WriteCallRenderComponent();
            if (fileContent !== null) {
                component.cache = context.argsComplete
                    ? rebuildWriteHighlightCacheFull(rawPath, fileContent)
                    : updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent);
            }
            else {
                component.cache = undefined;
            }
            component.setText(formatWriteCall(renderArgs, { expanded: context.expanded, isPartial: context.isPartial }, theme, component.cache));
            return component;
        },
        renderResult(result, _options, theme, context) {
            const output = formatWriteResult({ ...result, isError: context.isError }, theme);
            if (!output) {
                const component = context.lastComponent ?? new Container();
                component.clear();
                return component;
            }
            const text = context.lastComponent ?? new Text("", 0, 0);
            text.setText(output);
            return text;
        },
    };
}
export function createWriteTool(cwd, options) {
    return wrapToolDefinition(createWriteToolDefinition(cwd, options));
}
//# sourceMappingURL=write.js.map