/**
 * Behavior tests for the file tools (read_file / edit_file / write_file): happy paths,
 * every failure case, workspace-relative path resolution, offset/limit windows,
 * replace_all semantics, and parent-directory creation. Directly drives
 * BuiltinTool.execute and captures the generator's return value (same approach as
 * read-image.test.ts); Environment-side framing is covered by environment.test.ts.
 */
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
  chmod,
  mkdir,
  mkdtemp,
  readdir,
  readFile,
  rm,
  stat,
  symlink,
  writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
  DEFAULT_READ_FILE_LIMIT,
  MAX_LINE_LENGTH,
  READ_FILE_NAME,
  READ_FILE_SCAN_CAP_BYTES,
  createReadFileTool,
} from "../src/environment/tools/read-file.js";
import { EDIT_FILE_NAME, createEditFileTool } from "../src/environment/tools/edit-file.js";
import { WRITE_FILE_NAME, createWriteFileTool } from "../src/environment/tools/write-file.js";
import type { BuiltinTool, ToolResult } from "../src/environment/tools/types.js";
import type { OmniMessage } from "../src/omnimessage/index.js";
import type { ToolDefinitionConfig } from "../src/interfaces.js";
import { modelVisiblePath } from "../src/internal/model-visible-path.js";

function def(name: string, permission: "r" | "rw"): ToolDefinitionConfig {
  return { name, description: "test", permission };
}

/** Runs one tool execution: concatenates text deltas and captures the generator's return value. */
async function run(tool: BuiltinTool, args: Record<string, unknown>, workspaceDir: string) {
  const gen = tool.execute(args, { workspaceDir, toolCallId: "c1" });
  const messages: OmniMessage[] = [];
  let result: ToolResult | void;
  for (;;) {
    const res = await gen.next();
    if (res.done) {
      result = res.value;
      break;
    }
    messages.push(res.value);
  }
  const text = messages.map((m) => (m.payload as { output?: string }).output ?? "").join("");
  return { result, text };
}

let tmp: string;

beforeEach(async () => {
  tmp = await mkdtemp(path.join(tmpdir(), "penguin-filetools-"));
});

afterEach(async () => {
  await rm(tmp, { recursive: true, force: true });
});

describe("read_file", () => {
  const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));

  it("reads a file by workspace-relative path in cat -n style", async () => {
    await writeFile(path.join(tmp, "a.txt"), "alpha\nbeta\ngamma\n");
    const { result, text } = await run(tool(), { file_path: "a.txt" }, tmp);
    expect(result?.stopReason).toBeUndefined(); // Defaults to completed
    expect(text.split("\n")).toEqual(["     1\talpha", "     2\tbeta", "     3\tgamma"]);
  });

  it("reads an absolute path outside the workspace", async () => {
    const outside = await mkdtemp(path.join(tmpdir(), "penguin-filetools-out-"));
    try {
      const abs = path.join(outside, "abs.txt");
      await writeFile(abs, "outside\n");
      const { result, text } = await run(tool(), { file_path: abs }, tmp);
      expect(result?.stopReason).toBeUndefined();
      expect(text).toContain("\toutside");
    } finally {
      await rm(outside, { recursive: true, force: true });
    }
  });

  it("windows the output with offset/limit and points at the continuation", async () => {
    const lines = Array.from({ length: 10 }, (_, i) => `line-${i + 1}`).join("\n");
    await writeFile(path.join(tmp, "many.txt"), `${lines}\n`);
    const { result, text } = await run(tool(), { file_path: "many.txt", offset: 3, limit: 4 }, tmp);
    expect(result?.stopReason).toBeUndefined();
    const rows = text.split("\n");
    expect(rows[0]).toBe("     3\tline-3");
    expect(rows[3]).toBe("     6\tline-6");
    expect(rows[4]).toBe(
      "[file has 10 lines total; showing 3-6 — call again with offset to continue]",
    );
  });

  it("caps a missing limit at the default window and reports the total", async () => {
    const total = DEFAULT_READ_FILE_LIMIT + 5;
    const lines = Array.from({ length: total }, (_, i) => `l${i + 1}`).join("\n");
    await writeFile(path.join(tmp, "long.txt"), lines);
    const { text } = await run(tool(), { file_path: "long.txt" }, tmp);
    expect(text).toContain(`[file has ${total} lines total; showing 1-${DEFAULT_READ_FILE_LIMIT}`);
  });

  it("truncates a single overlong line with a marker", async () => {
    await writeFile(path.join(tmp, "wide.txt"), `${"x".repeat(MAX_LINE_LENGTH + 50)}\nshort\n`);
    const { text } = await run(tool(), { file_path: "wide.txt" }, tmp);
    expect(text).toContain("… [line truncated]");
    expect(text).toContain("\tshort");
    expect(text).not.toContain("x".repeat(MAX_LINE_LENGTH + 1));
  });

  it("reports an empty file as a note, not a failure", async () => {
    await writeFile(path.join(tmp, "empty.txt"), "");
    const { result, text } = await run(tool(), { file_path: "empty.txt" }, tmp);
    expect(result?.stopReason).toBeUndefined();
    expect(text).toContain("empty file");
  });

  it("fails with a path hint when the file does not exist", async () => {
    const { result, text } = await run(tool(), { file_path: "missing.txt" }, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("File not found");
    expect(text).toContain("missing.txt");
    expect(text).toContain("workspace");
    expect(text).toContain("Absolute paths are supported");
    expect(text).toContain(
      `The directory "${modelVisiblePath(tmp)}" exists but has no entry "missing.txt".`,
    );
    expect(text).toContain("It is empty.");
  });

  it("points at the nearest existing directory and its closest-named entries", async () => {
    await mkdir(path.join(tmp, "agent_state"));
    await writeFile(path.join(tmp, "agent_state", "AGENTS.md"), "persona\n");
    await writeFile(path.join(tmp, "notes.txt"), "n\n");
    const { result, text } = await run(tool(), { file_path: path.join(tmp, "AGENTS.md") }, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain(
      `The directory "${modelVisiblePath(tmp)}" exists but has no entry "AGENTS.md".`,
    );
    // Ranked by shared prefix with the missing name: agent_state/ first, dirs marked with "/".
    expect(text).toContain("It contains: agent_state/, notes.txt");
  });

  it("reports the first missing segment of a deeper path", async () => {
    const missing = path.join(tmp, "nope", "deep", "file.txt");
    const { result, text } = await run(tool(), { file_path: missing }, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain(
      `The directory "${modelVisiblePath(tmp)}" exists but has no entry "nope".`,
    );
  });

  it("says so when a path segment is a file, not a directory", async () => {
    await writeFile(path.join(tmp, "a.txt"), "x\n");
    const inner = path.join(tmp, "a.txt", "inner.txt");
    const { result, text } = await run(tool(), { file_path: inner }, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("File not found");
    expect(text).toContain(
      `Note: "${modelVisiblePath(path.join(tmp, "a.txt"))}" exists but is a file, not a directory.`,
    );
  });

  it("caps the entry listing and counts the rest", async () => {
    for (let i = 0; i < 10; i += 1) await writeFile(path.join(tmp, `e${i}.txt`), "x\n");
    const { text } = await run(tool(), { file_path: "zz.txt" }, tmp);
    expect(text).toContain("(+2 more)");
    expect(text).not.toContain("e8.txt");
  });

  it("fails when the path is a directory", async () => {
    await mkdir(path.join(tmp, "subdir"));
    const { result, text } = await run(tool(), { file_path: "subdir" }, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("directory");
  });

  it("fails on binary content (NUL bytes) and advises other tools", async () => {
    await writeFile(path.join(tmp, "bin.dat"), Buffer.from([0x89, 0x50, 0x00, 0x0a, 0x42]));
    const { result, text } = await run(tool(), { file_path: "bin.dat" }, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("binary");
    expect(text).toContain("read_image");
  });

  it("fails when offset is past the end of the file", async () => {
    await writeFile(path.join(tmp, "two.txt"), "a\nb\n");
    const { result, text } = await run(tool(), { file_path: "two.txt", offset: 5 }, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("past the end");
    expect(text).toContain("2 lines");
  });

  it("fails when file_path is missing", async () => {
    const { result, text } = await run(tool(), {}, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain('"file_path"');
  });
});

describe("edit_file", () => {
  const tool = () => createEditFileTool(def(EDIT_FILE_NAME, "rw"));

  it("replaces a unique occurrence and echoes a git-style unified diff", async () => {
    const file = path.join(tmp, "src.ts");
    await writeFile(file, "const a = 1;\nconst b = 2;\nconst c = 3;\n");
    const { result, text } = await run(
      tool(),
      { file_path: "src.ts", old_string: "const b = 2;", new_string: "const b = 20;" },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text).toBe(
      [
        'Replaced 1 occurrence in "src.ts".',
        "@@ -1,3 +1,3 @@",
        " const a = 1;",
        "-const b = 2;",
        "+const b = 20;",
        " const c = 3;",
      ].join("\n"),
    );
    expect(await readFile(file, "utf8")).toBe("const a = 1;\nconst b = 20;\nconst c = 3;\n");
  });

  it("replaces every occurrence with replace_all and diffs same-line hits in one hunk", async () => {
    const file = path.join(tmp, "multi.txt");
    await writeFile(file, "foo bar foo baz foo\n");
    const { result, text } = await run(
      tool(),
      { file_path: "multi.txt", old_string: "foo", new_string: "qux", replace_all: true },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text).toBe(
      [
        'Replaced 3 occurrences in "multi.txt".',
        "@@ -1,1 +1,1 @@",
        "-foo bar foo baz foo",
        "+qux bar qux baz qux",
      ].join("\n"),
    );
    expect(await readFile(file, "utf8")).toBe("qux bar qux baz qux\n");
  });

  it("fails when old_string is not found", async () => {
    await writeFile(path.join(tmp, "f.txt"), "hello\n");
    const { result, text } = await run(
      tool(),
      { file_path: "f.txt", old_string: "goodbye", new_string: "farewell" },
      tmp,
    );
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain('old_string not found in "f.txt"');
    expect(await readFile(path.join(tmp, "f.txt"), "utf8")).toBe("hello\n"); // Untouched
  });

  it("fails with the count when old_string is ambiguous and replace_all is unset", async () => {
    await writeFile(path.join(tmp, "dup.txt"), "x\nx\nx\n");
    const { result, text } = await run(
      tool(),
      { file_path: "dup.txt", old_string: "x", new_string: "y" },
      tmp,
    );
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("3 times");
    expect(text).toContain("replace_all");
    expect(await readFile(path.join(tmp, "dup.txt"), "utf8")).toBe("x\nx\nx\n");
  });

  it("fails when old_string equals new_string", async () => {
    await writeFile(path.join(tmp, "same.txt"), "abc\n");
    const { result, text } = await run(
      tool(),
      { file_path: "same.txt", old_string: "abc", new_string: "abc" },
      tmp,
    );
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("identical");
  });

  it("fails and suggests write_file when the file does not exist", async () => {
    const { result, text } = await run(
      tool(),
      { file_path: "nope.txt", old_string: "a", new_string: "b" },
      tmp,
    );
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("File not found");
    expect(text).toContain("write_file");
    expect(text).toContain(
      `The directory "${modelVisiblePath(tmp)}" exists but has no entry "nope.txt".`,
    );
  });

  it("fails when the path is a directory", async () => {
    await mkdir(path.join(tmp, "d"));
    const { result, text } = await run(
      tool(),
      { file_path: "d", old_string: "a", new_string: "b" },
      tmp,
    );
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("directory");
  });

  it("fails when required arguments are missing", async () => {
    const missingPath = await run(tool(), { old_string: "a", new_string: "b" }, tmp);
    expect(missingPath.result?.stopReason).toBe("failed");
    expect(missingPath.text).toContain('"file_path"');
    const missingOld = await run(tool(), { file_path: "f", new_string: "b" }, tmp);
    expect(missingOld.result?.stopReason).toBe("failed");
    expect(missingOld.text).toContain('"old_string"');
    const missingNew = await run(tool(), { file_path: "f", old_string: "a" }, tmp);
    expect(missingNew.result?.stopReason).toBe("failed");
    expect(missingNew.text).toContain('"new_string"');
  });
});

describe("write_file", () => {
  const tool = () => createWriteFileTool(def(WRITE_FILE_NAME, "rw"));

  it("creates a new file (workspace-relative) and reports lines/bytes", async () => {
    const { result, text } = await run(
      tool(),
      { file_path: "out.txt", content: "one\ntwo\n" },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text).toContain('Created "out.txt"');
    expect(text).toContain("2 lines");
    expect(text).toContain("8 bytes");
    expect(await readFile(path.join(tmp, "out.txt"), "utf8")).toBe("one\ntwo\n");
  });

  it("creates missing parent directories", async () => {
    const { result, text } = await run(
      tool(),
      { file_path: "a/b/c/deep.txt", content: "deep" },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text).toContain("Created");
    expect(await readFile(path.join(tmp, "a/b/c/deep.txt"), "utf8")).toBe("deep");
    expect((await stat(path.join(tmp, "a/b"))).isDirectory()).toBe(true);
  });

  it("reports Overwrote when the file already exists", async () => {
    await writeFile(path.join(tmp, "exists.txt"), "old");
    const { result, text } = await run(
      tool(),
      { file_path: "exists.txt", content: "new content" },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text).toContain('Overwrote "exists.txt"');
    expect(await readFile(path.join(tmp, "exists.txt"), "utf8")).toBe("new content");
  });

  it("accepts an empty string as content", async () => {
    const { result, text } = await run(tool(), { file_path: "blank.txt", content: "" }, tmp);
    expect(result?.stopReason).toBeUndefined();
    expect(text).toContain("0 lines, 0 bytes");
    expect(await readFile(path.join(tmp, "blank.txt"), "utf8")).toBe("");
  });

  it("fails when the path is a directory", async () => {
    await mkdir(path.join(tmp, "adir"));
    const { result, text } = await run(tool(), { file_path: "adir", content: "x" }, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("directory");
  });

  it("fails when a parent path component is an existing file", async () => {
    await writeFile(path.join(tmp, "plain.txt"), "x");
    const { result, text } = await run(
      tool(),
      { file_path: "plain.txt/child.txt", content: "y" },
      tmp,
    );
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("Failed to write");
  });

  it("fails when content is missing (but not when it is empty)", async () => {
    const { result, text } = await run(tool(), { file_path: "nocontent.txt" }, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain('"content"');
  });
});

describe("read_file — argument coercion, CRLF, secret guard", () => {
  const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));

  it("accepts numeric strings for offset/limit", async () => {
    const lines = Array.from({ length: 10 }, (_, i) => `line-${i + 1}`).join("\n");
    await writeFile(path.join(tmp, "many.txt"), `${lines}\n`);
    const { result, text } = await run(
      tool(),
      { file_path: "many.txt", offset: "3", limit: "4" },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text).toContain("     3\tline-3");
    expect(text).toContain("showing 3-6");
  });

  it("fails on non-numeric offset/limit instead of silently defaulting", async () => {
    await writeFile(path.join(tmp, "a.txt"), "x\n");
    const badOffset = await run(tool(), { file_path: "a.txt", offset: "abc" }, tmp);
    expect(badOffset.result?.stopReason).toBe("failed");
    expect(badOffset.text).toContain('Invalid "offset"');
    const badLimit = await run(tool(), { file_path: "a.txt", limit: 0 }, tmp);
    expect(badLimit.result?.stopReason).toBe("failed");
    expect(badLimit.text).toContain('Invalid "limit"');
  });

  it("strips trailing \\r from displayed lines and notes CRLF line endings", async () => {
    await writeFile(path.join(tmp, "crlf.txt"), "alpha\r\nbeta\r\n");
    const { result, text } = await run(tool(), { file_path: "crlf.txt" }, tmp);
    expect(result?.stopReason).toBeUndefined();
    expect(text).toContain("     1\talpha\n");
    expect(text).not.toContain("\r");
    expect(text).toContain("(file uses CRLF line endings)");
  });

  it("refuses the secret stores by basename regardless of directory", async () => {
    await writeFile(path.join(tmp, ".vault.toml"), "SECRET=1\n");
    await mkdir(path.join(tmp, "sub"));
    await writeFile(path.join(tmp, "sub", ".project_config.toml"), "key=1\n");
    const vault = await run(tool(), { file_path: ".vault.toml" }, tmp);
    expect(vault.result?.stopReason).toBe("failed");
    expect(vault.text).toContain("Refusing to read");
    const cfg = await run(tool(), { file_path: "sub/.project_config.toml" }, tmp);
    expect(cfg.result?.stopReason).toBe("failed");
    expect(cfg.text).toContain("Refusing to read");
  });

  it("refuses case-variant names and symlinks that dereference to a secret store", async () => {
    await writeFile(path.join(tmp, ".vault.toml"), "SECRET=1\n");
    // Case variant: a case-insensitive filesystem (macOS/Windows) opens ".VAULT.TOML" as
    // the store itself; on a case-sensitive one refusing the name is a harmless false positive.
    const upper = await run(tool(), { file_path: ".VAULT.TOML" }, tmp);
    expect(upper.result?.stopReason).toBe("failed");
    expect(upper.text).toContain("Refusing to read");
    // Symlink: the link's own basename says nothing about what it dereferences to.
    await symlink(path.join(tmp, ".vault.toml"), path.join(tmp, "notes.txt"));
    const linked = await run(tool(), { file_path: "notes.txt" }, tmp);
    expect(linked.result?.stopReason).toBe("failed");
    expect(linked.text).toContain("Refusing to read");
    expect(linked.text).not.toContain("SECRET=1");
    // A symlink NAMED like a store but pointing elsewhere is refused on the lexical name —
    // an acceptable false positive for a guard.
    await writeFile(path.join(tmp, "plain.txt"), "hello\n");
    await symlink(path.join(tmp, "plain.txt"), path.join(tmp, ".project_config.toml"));
    const named = await run(tool(), { file_path: ".project_config.toml" }, tmp);
    expect(named.result?.stopReason).toBe("failed");
    // An ordinary symlink to an ordinary file still reads.
    await symlink(path.join(tmp, "plain.txt"), path.join(tmp, "alias.txt"));
    const ok = await run(tool(), { file_path: "alias.txt" }, tmp);
    expect(ok.result?.stopReason).toBeUndefined();
    expect(ok.text).toContain("hello");
  });
});

describe("read_file — bounded scan and output budget", () => {
  it("self-budgets the window so the continuation note survives a small maxOutputLength", async () => {
    const lines = Array.from({ length: 10 }, (_, i) => `line-${i + 1}`).join("\n");
    await writeFile(path.join(tmp, "many.txt"), `${lines}\n`);
    const tool = createReadFileTool({
      name: READ_FILE_NAME,
      description: "test",
      permission: "r",
      maxOutputLength: 300,
    });
    const { result, text } = await run(tool, { file_path: "many.txt" }, tmp);
    expect(result?.stopReason).toBeUndefined();
    // The whole output fits under the tool's own cap, so Environment cannot cut the note.
    expect(text.length).toBeLessThanOrEqual(300);
    expect(text).toMatch(
      /\[file has 10 lines total; showing 1-\d+ \(output limit reached\) — call again with offset to continue\]/,
    );
    expect(text).not.toContain("line-10");
  });

  it("fails with guidance when the scan cap is hit before the window (one huge line)", async () => {
    await writeFile(path.join(tmp, "huge-line.txt"), "x".repeat(READ_FILE_SCAN_CAP_BYTES + 1024));
    const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
    const { result, text } = await run(tool(), { file_path: "huge-line.txt" }, tmp);
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("Stopped after scanning 8 MB");
    expect(text).toContain("sed -n");
  });

  it.skipIf(process.platform !== "win32")(
    "accepts the same absolute path in Windows and POSIX spellings",
    async () => {
      await writeFile(path.join(tmp, "spellings.txt"), "both spellings work\n");
      const windowsSpelling = path.join(tmp, "spellings.txt");
      const posixSpelling = windowsSpelling.replaceAll("\\", "/");
      const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
      for (const spelling of [windowsSpelling, posixSpelling]) {
        const { text } = await run(tool(), { file_path: spelling }, tmp);
        expect(text).toContain("both spellings work");
      }
    },
  );

  it.skipIf(process.platform !== "win32")(
    "names the workspace with forward slashes when a file is missing",
    async () => {
      const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
      const { text } = await run(tool(), { file_path: "no-such-file.txt" }, tmp);
      expect(text).toContain("File not found");
      expect(text).not.toContain("\\");
    },
  );

  it("confirms EOF for an unterminated line one byte below the scan cap", async () => {
    await writeFile(
      path.join(tmp, "scan-cap-minus-one.txt"),
      "x".repeat(READ_FILE_SCAN_CAP_BYTES - 1),
    );
    const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
    const { result, text } = await run(tool(), { file_path: "scan-cap-minus-one.txt" }, tmp);
    expect(result?.stopReason).toBeUndefined();
    expect(text).toContain("[line truncated]");
    expect(text).not.toContain("file has more than");
    expect(text).not.toContain("Stopped after scanning");
  });

  it("reports a lower-bound total when the file outruns the scan cap after the window", async () => {
    // > 8MB of two-byte lines: the window (1-2000) completes early, counting stops at the cap.
    await writeFile(path.join(tmp, "long.txt"), "x\n".repeat(READ_FILE_SCAN_CAP_BYTES / 2 + 4096));
    const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
    const { result, text } = await run(tool(), { file_path: "long.txt" }, tmp);
    expect(result?.stopReason).toBeUndefined();
    expect(text).toMatch(
      /\[file has more than \d+ lines; showing 1-2000 — call again with offset to continue\]/,
    );
  });
});

describe("edit_file — review follow-ups", () => {
  const tool = () => createEditFileTool(def(EDIT_FILE_NAME, "rw"));

  it("gives an explicitly-empty old_string its own message", async () => {
    await writeFile(path.join(tmp, "f.txt"), "hello\n");
    const { result, text } = await run(
      tool(),
      { file_path: "f.txt", old_string: "", new_string: "x" },
      tmp,
    );
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("old_string must not be empty");
    expect(text).toContain("write_file");
  });

  it("mentions CRLF in the not-found error when the file uses \\r\\n", async () => {
    await writeFile(path.join(tmp, "crlf.txt"), "alpha\r\nbeta\r\n");
    const { result, text } = await run(
      tool(),
      { file_path: "crlf.txt", old_string: "alpha\nbeta", new_string: "gamma" },
      tmp,
    );
    expect(result?.stopReason).toBe("failed");
    expect(text).toContain("old_string not found");
    expect(text).toContain("CRLF");
  });

  it("erasing the whole content diffs to a pure removal hunk", async () => {
    const file = path.join(tmp, "erase.txt");
    await writeFile(file, "abc");
    const { result, text } = await run(
      tool(),
      { file_path: "erase.txt", old_string: "abc", new_string: "" },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text).toBe('Replaced 1 occurrence in "erase.txt".\n@@ -1,1 +0,0 @@\n-abc');
    expect(await readFile(file, "utf8")).toBe("");
  });

  it("writes atomically: no temp files are left behind", async () => {
    await writeFile(path.join(tmp, "at.txt"), "a b a\n");
    await run(tool(), { file_path: "at.txt", old_string: "b", new_string: "c" }, tmp);
    const entries = await readdir(tmp);
    expect(entries.filter((e) => e.includes(".tmp-"))).toEqual([]);
  });
});

describe("write_file — review follow-ups", () => {
  const tool = () => createWriteFileTool(def(WRITE_FILE_NAME, "rw"));

  // POSIX-only: Windows has no owner-only mode bits to preserve (chmod maps to the read-only attribute).
  it.skipIf(process.platform === "win32")(
    "preserves the permission bits of an overwritten file (atomic rename)",
    async () => {
      const file = path.join(tmp, "mode.txt");
      await writeFile(file, "old");
      await chmod(file, 0o600);
      const { result } = await run(tool(), { file_path: "mode.txt", content: "new" }, tmp);
      expect(result?.stopReason).toBeUndefined();
      expect((await stat(file)).mode & 0o777).toBe(0o600);
      expect(await readFile(file, "utf8")).toBe("new");
    },
  );

  it("writes atomically: no temp files are left behind", async () => {
    await run(tool(), { file_path: "fresh.txt", content: "x" }, tmp);
    const entries = await readdir(tmp);
    expect(entries.filter((e) => e.includes(".tmp-"))).toEqual([]);
  });
});

describe("edit_file — diff output caps", () => {
  const tool = () => createEditFileTool(def(EDIT_FILE_NAME, "rw"));

  it("caps replace_all storms at a handful of hunks plus an elision note", async () => {
    // 8 far-apart sites (gaps wider than twice the context) -> 8 hunks, capped at 5.
    const block = ["target", ...Array.from({ length: 9 }, (_, i) => `filler-${i}`)].join("\n");
    await writeFile(
      path.join(tmp, "cap.txt"),
      `${Array.from({ length: 8 }, () => block).join("\n")}\n`,
    );
    const { result, text } = await run(
      tool(),
      { file_path: "cap.txt", old_string: "target", new_string: "changed", replace_all: true },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text).toContain('Replaced 8 occurrences in "cap.txt".');
    expect(text.match(/@@ /g)).toHaveLength(5);
    expect(text).toContain("…and 3 more replacements");
  });

  it("self-budgets under a tiny maxOutputLength: the summary and note survive, hunks are dropped", async () => {
    await writeFile(path.join(tmp, "tiny.txt"), "a\nb\na\n");
    const budgetTool = createEditFileTool({
      name: EDIT_FILE_NAME,
      description: "test",
      permission: "rw",
      maxOutputLength: 150,
    });
    const { result, text } = await run(
      budgetTool,
      { file_path: "tiny.txt", old_string: "a", new_string: "z", replace_all: true },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text.length).toBeLessThanOrEqual(150);
    expect(text).toContain('Replaced 2 occurrences in "tiny.txt".');
    expect(text).not.toContain("@@");
    expect(text).toContain("…and 2 more replacements");
  });

  it("strips \\r from diff lines on CRLF files", async () => {
    await writeFile(path.join(tmp, "crlfdiff.txt"), "alpha\r\nbeta\r\n");
    const { result, text } = await run(
      tool(),
      { file_path: "crlfdiff.txt", old_string: "alpha", new_string: "gamma" },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text).toContain("-alpha");
    expect(text).toContain("+gamma");
    expect(text).toContain(" beta");
    expect(text).not.toContain("\r");
  });
});

describe("write_file — overwrite diffs", () => {
  const tool = () => createWriteFileTool(def(WRITE_FILE_NAME, "rw"));

  it("appends a small unified diff when overwriting", async () => {
    await writeFile(path.join(tmp, "d.txt"), "one\ntwo\nthree\n");
    const { result, text } = await run(
      tool(),
      { file_path: "d.txt", content: "one\nTWO\nthree\n" },
      tmp,
    );
    expect(result?.stopReason).toBeUndefined();
    expect(text).toBe(
      [
        'Overwrote "d.txt" (3 lines, 14 bytes).',
        "@@ -1,3 +1,3 @@",
        " one",
        "-two",
        "+TWO",
        " three",
      ].join("\n"),
    );
  });

  it("notes an unchanged overwrite", async () => {
    await writeFile(path.join(tmp, "same.txt"), "keep\n");
    const { text } = await run(tool(), { file_path: "same.txt", content: "keep\n" }, tmp);
    expect(text).toContain('Overwrote "same.txt"');
    expect(text).toContain("(content unchanged)");
    expect(text).not.toContain("@@");
  });

  it("collapses a large rewrite to a one-line +X/−Y summary", async () => {
    const oldContent = Array.from({ length: 200 }, (_, i) => `a${i}`).join("\n");
    const newContent = Array.from({ length: 200 }, (_, i) => `b${i}`).join("\n");
    await writeFile(path.join(tmp, "big.txt"), oldContent);
    const { text } = await run(tool(), { file_path: "big.txt", content: newContent }, tmp);
    expect(text).toContain('Overwrote "big.txt"');
    expect(text).toContain("+200/−200 lines vs the previous content (diff too large to show)");
    expect(text).not.toContain("@@");
  });

  it("created files carry no diff", async () => {
    const { text } = await run(tool(), { file_path: "fresh2.txt", content: "x\ny\n" }, tmp);
    expect(text).toContain('Created "fresh2.txt"');
    expect(text).not.toContain("@@");
  });
});
