import { describe, expect, it } from "vitest";
import {
  renderFileToolApprovalPayload,
  renderPartialToolCall,
  shortenPath,
} from "../src/tool-render.js";

describe("renderPartialToolCall — exec_command", () => {
  it("streams `exec_command <- $ {cmd}` when the schema has no description argument", () => {
    // The default path: the switch is off (or the tool is unknown), so nothing can supersede
    // the plain form and it streams character by character.
    expect(renderPartialToolCall("exec_command", '{"cmd":')).toBeNull();
    expect(renderPartialToolCall("exec_command", '{"cmd":"l')).toBe("exec_command <- $ l");
    expect(renderPartialToolCall("exec_command", '{"cmd":"ls"}')).toBe("exec_command <- $ ls");
    expect(renderPartialToolCall("exec_command", '{"cmd":"echo \\"hi\\"')).toBe(
      'exec_command <- $ echo "hi"',
    );
  });

  it("waits for the description when the schema carries the argument", () => {
    const described = { expectDescription: true };
    // Nothing renders while the description could still be the first thing shown...
    expect(renderPartialToolCall("exec_command", '{"cmd":"ls -la', described)).toBeNull();
    // ...until the arguments settle without one, or the fragment is final.
    expect(renderPartialToolCall("exec_command", '{"cmd":"ls -la"}', described)).toBe(
      "exec_command <- $ ls -la",
    );
    expect(
      renderPartialToolCall("exec_command", '{"cmd":"ls -la', { ...described, final: true }),
    ).toBe("exec_command <- $ ls -la");
  });

  it("renders `exec_command <- {description} ($ {cmd})` when a description is present", () => {
    expect(
      renderPartialToolCall("exec_command", '{"description":"List files","cmd":"ls -la"}'),
    ).toBe("exec_command <- List files ($ ls -la)");
    // Same final form regardless of the model's property order.
    expect(
      renderPartialToolCall("exec_command", '{"cmd":"ls -la","description":"List files"}'),
    ).toBe("exec_command <- List files ($ ls -la)");
    // Multi-line descriptions fold to one line; an empty description falls back to the plain form.
    expect(renderPartialToolCall("exec_command", '{"cmd":"ls","description":"a\\nb"}')).toBe(
      "exec_command <- a b ($ ls)",
    );
    expect(renderPartialToolCall("exec_command", '{"cmd":"ls","description":""}')).toBe(
      "exec_command <- $ ls",
    );
  });

  it("streams the description form append-only when the description arrives first", () => {
    const stages = [
      '{"description":"List fi', // description streams live
      '{"description":"List files"', // description complete
      '{"description":"List files","cmd":"ls', // cmd streaming inside the open parenthesis
      '{"description":"List files","cmd":"ls -la"}', // cmd complete: parenthesis closes
    ];
    const previews = stages.map((s) =>
      renderPartialToolCall("exec_command", s, { expectDescription: true }),
    );
    expect(previews[0]).toBe("exec_command <- List fi");
    expect(previews[1]).toBe("exec_command <- List files");
    expect(previews[2]).toBe("exec_command <- List files ($ ls");
    expect(previews[3]).toBe("exec_command <- List files ($ ls -la)");
    for (let i = 1; i < previews.length; i++) {
      expect(previews[i]!.startsWith(previews[i - 1]!)).toBe(true);
    }
  });

  it("never shows the plain form first when the model emits the payload before the description", () => {
    // The regression this guards: a plain line followed by a described one for the same call.
    const stages = [
      '{"cmd":"ls -la', // withheld: the schema says a description is coming
      '{"cmd":"ls -la","description":"List fi', // description streams; payload waits for it
      '{"cmd":"ls -la","description":"List files"}', // settled: payload appended
    ];
    const previews = stages.map((s) =>
      renderPartialToolCall("exec_command", s, { expectDescription: true }),
    );
    expect(previews[0]).toBeNull();
    expect(previews[1]).toBe("exec_command <- List fi");
    expect(previews[2]).toBe("exec_command <- List files ($ ls -la)");
    expect(previews[2]!.startsWith(previews[1]!)).toBe(true);
    expect(previews.some((p) => p === "exec_command <- $ ls -la")).toBe(false);
  });
});

describe("renderPartialToolCall — run_subagent", () => {
  it("renders `run_subagent <- {prompt}` and the description form", () => {
    expect(renderPartialToolCall("run_subagent", '{"prompt":')).toBeNull();
    expect(renderPartialToolCall("run_subagent", '{"prompt":"analy')).toBe("run_subagent <- analy");
    expect(renderPartialToolCall("run_subagent", '{"prompt":"line1\\nline2"}')).toBe(
      "run_subagent <- line1 line2",
    );
    expect(
      renderPartialToolCall(
        "run_subagent",
        '{"description":"Delegating research","prompt":"do the thing"}',
      ),
    ).toBe("run_subagent <- Delegating research (do the thing)");
  });
});

describe("renderPartialToolCall — input_command / input_subagent", () => {
  it("renders polls (empty chars) without a payload", () => {
    expect(renderPartialToolCall("input_command", '{"process_id":')).toBeNull();
    expect(renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d"}')).toBe(
      "input_command <- proc-1a2b3c4d",
    );
    expect(
      renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":""}'),
    ).toBe("input_command <- proc-1a2b3c4d");
  });

  it("renders non-empty chars with visible control characters", () => {
    expect(
      renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"y\\n"}'),
    ).toBe("input_command <- proc-1a2b3c4d << y\\n");
    // U+0003 (Ctrl-C) is rendered in caret notation.
    expect(
      renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"\\u0003"}'),
    ).toBe("input_command <- proc-1a2b3c4d << ^C");
    // Disambiguates literal backslash escapes: chars "a", "\", "n" render as a\\n, distinct from a real newline \n.
    expect(
      renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"a\\\\n"}'),
    ).toBe("input_command <- proc-1a2b3c4d << a\\\\n");
  });

  it("wraps the payload in parentheses after the description", () => {
    expect(
      renderPartialToolCall(
        "input_command",
        '{"description":"Confirm the prompt","process_id":"proc-1a2b3c4d","chars":"y\\n"}',
      ),
    ).toBe("input_command <- Confirm the prompt (proc-1a2b3c4d << y\\n)");
    expect(
      renderPartialToolCall(
        "input_subagent",
        '{"description":"Poll for progress","subagent_id":"subagent-9f8e7d6c"}',
      ),
    ).toBe("input_subagent <- Poll for progress (subagent-9f8e7d6c)");
  });

  it("keeps input_command previews append-only across \\uXXXX delta boundaries", () => {
    // Schema order (description first) keeps the whole call streaming live.
    const stages = [
      '{"description":"Confirm","process_id":"proc-1a2b3c4d","chars":"y',
      '{"description":"Confirm","process_id":"proc-1a2b3c4d","chars":"y\\u0',
      '{"description":"Confirm","process_id":"proc-1a2b3c4d","chars":"y\\u0003',
    ];
    const previews = stages.map((s) =>
      renderPartialToolCall("input_command", s, { expectDescription: true })!,
    );
    expect(previews[0]).toBe("input_command <- Confirm (proc-1a2b3c4d << y");
    // An incomplete \u escape is treated as "stop here" rather than emitting the raw hex as literal text.
    expect(previews[1]).toBe("input_command <- Confirm (proc-1a2b3c4d << y");
    expect(previews[2]).toBe("input_command <- Confirm (proc-1a2b3c4d << y^C");
    for (let i = 1; i < previews.length; i++) {
      expect(previews[i]!.startsWith(previews[i - 1]!)).toBe(true);
    }
  });

  it("renders input_subagent polls and follow-up prompts", () => {
    expect(
      renderPartialToolCall("input_subagent", '{"subagent_id":"subagent-9f8e7d6c","prompt":""}'),
    ).toBe("input_subagent <- subagent-9f8e7d6c");
    expect(
      renderPartialToolCall(
        "input_subagent",
        '{"subagent_id":"subagent-9f8e7d6c","prompt":"continue with the tests"}',
      ),
    ).toBe("input_subagent <- subagent-9f8e7d6c << continue with the tests");
  });

  it("truncates long payload previews and stops growing afterwards", () => {
    const long = "x".repeat(130);
    const capped = renderPartialToolCall(
      "input_subagent",
      `{"subagent_id":"subagent-9f8e7d6c","prompt":"${long}"}`,
    );
    expect(capped).toBe(`input_subagent <- subagent-9f8e7d6c << ${"x".repeat(120)}…`);
    const longer = renderPartialToolCall(
      "input_subagent",
      `{"subagent_id":"subagent-9f8e7d6c","prompt":"${long}yyy"}`,
    );
    expect(longer).toBe(capped);
  });
});

describe("renderPartialToolCall — file tools", () => {
  it("renders `<name> <shortened path>` only once the path is complete", () => {
    expect(renderPartialToolCall("read_file", '{"file_path":')).toBeNull();
    // A still-streaming path is withheld: shortening a growing path would rewrite the line.
    expect(renderPartialToolCall("read_file", '{"file_path":"src/ap')).toBeNull();
    expect(renderPartialToolCall("read_file", '{"file_path":"src/app.py","offset":10}')).toBe(
      "read_file src/app.py",
    );
    expect(
      renderPartialToolCall("edit_file", '{"file_path":"a.txt","old_string":"x","new_string":"y"}'),
    ).toBe("edit_file a.txt");
    expect(
      renderPartialToolCall("write_file", '{"file_path":"packages/core/src/state/out.ts"}'),
    ).toBe("write_file …/state/out.ts");
  });
});

describe("renderPartialToolCall — fallback", () => {
  it("falls back to name(args-prefix) for unknown tools", () => {
    expect(renderPartialToolCall("search", '{"q":"hi')).toBe('search({"q":"hi');
  });
});

describe("shortenPath", () => {
  it("keeps at most one parent directory plus the filename", () => {
    expect(shortenPath("file.ts")).toBe("file.ts");
    expect(shortenPath("src/file.ts")).toBe("src/file.ts");
    expect(shortenPath("/etc/hosts")).toBe("/etc/hosts");
    expect(shortenPath("packages/core/src/state/default-config.ts")).toBe(
      "…/state/default-config.ts",
    );
    expect(shortenPath("/home/user/project/src/app.py")).toBe("…/src/app.py");
  });
});

describe("renderFileToolApprovalPayload", () => {
  it("prints the decoded edit_file payload with gutters for multi-line fields", () => {
    const payload = renderFileToolApprovalPayload(
      "edit_file",
      JSON.stringify({
        file_path: "src/app.py",
        old_string: "a\nb",
        new_string: "a\nc",
      }),
    );
    expect(payload).toBe(
      [
        "file_path: src/app.py",
        "old_string:",
        "  | a",
        "  | b",
        "new_string:",
        "  | a",
        "  | c",
      ].join("\n"),
    );
  });

  it("prints write_file content and read_file window arguments", () => {
    expect(
      renderFileToolApprovalPayload("write_file", '{"file_path":"out.md","content":"hello"}'),
    ).toBe(["file_path: out.md", "content: hello"].join("\n"));
    expect(
      renderFileToolApprovalPayload("read_file", '{"file_path":"a.txt","offset":3,"limit":5}'),
    ).toBe(["file_path: a.txt", "offset: 3", "limit: 5"].join("\n"));
  });

  it("bounds the payload to a line count with an explicit elision note", () => {
    const content = Array.from({ length: 60 }, (_, i) => `line-${i + 1}`).join("\n");
    const payload = renderFileToolApprovalPayload(
      "write_file",
      JSON.stringify({ file_path: "big.txt", content }),
    )!;
    const lines = payload.split("\n");
    // 24 shown lines + the elision note.
    expect(lines).toHaveLength(25);
    expect(lines[lines.length - 1]).toMatch(/\[… \d+ more lines not shown\]/);
  });

  it("returns null for non-file tools", () => {
    expect(renderFileToolApprovalPayload("exec_command", '{"cmd":"ls"}')).toBeNull();
  });
});
