import { describe, it, expect, beforeEach } from "vitest";
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js";
import { readFileTool, writeFileTool, listFilesTool, PathEscape } from "../../src/agent/workspace.js";

describe("workspace path confinement", () => {
  let ws: string;
  let tools: ToolRegistry;

  beforeEach(async () => {
    ws = await mkdtemp(join(tmpdir(), "hub-unit-"));
    tools = new ToolRegistry();
    tools.register("read_file", readFileTool);
    tools.register("write_file", writeFileTool);
    tools.register("list_files", listFilesTool);
    await mkdir(join(ws, "sub"));
    await writeFile(join(ws, "a.txt"), "hello");
    await writeFile(join(ws, "sub", "b.txt"), "world");
  });

  const ctx = () => ({ runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws });

  it("reads a file inside the workspace", async () => {
    expect(await executeTool(tools, "read_file", { path: "a.txt" }, ctx())).toBe("hello");
  });

  it("reads nested files", async () => {
    expect(await executeTool(tools, "read_file", { path: "sub/b.txt" }, ctx())).toBe("world");
  });

  it("rejects .. escapes as error JSON, not a throw", async () => {
    const out = await executeTool(tools, "read_file", { path: "../../etc/passwd" }, ctx());
    expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
  });

  it("rejects absolute paths outside the workspace", async () => {
    const out = await executeTool(tools, "read_file", { path: "/etc/passwd" }, ctx());
    expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
  });

  it("writes a file and reads it back", async () => {
    await executeTool(tools, "write_file", { path: "sub/c.txt", content: "new" }, ctx());
    expect(await executeTool(tools, "read_file", { path: "sub/c.txt" }, ctx())).toBe("new");
  });

  it("creates parent dirs on write", async () => {
    await executeTool(tools, "write_file", { path: "deep/nested/d.txt", content: "x" }, ctx());
    expect(await executeTool(tools, "read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x");
  });

  it("lists the workspace root", async () => {
    const out = await executeTool(tools, "list_files", {}, ctx());
    const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
    expect(entries).toContainEqual({ name: "a.txt", kind: "file" });
    expect(entries).toContainEqual({ name: "sub", kind: "dir" });
  });

  it("lists a subdirectory", async () => {
    const out = await executeTool(tools, "list_files", { path: "sub" }, ctx());
    const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
    expect(entries).toContainEqual({ name: "b.txt", kind: "file" });
  });

  it("PathEscape is an Error subclass", () => {
    expect(new PathEscape("../x", "/ws")).toBeInstanceOf(Error);
    expect(new PathEscape("../x", "/ws").name).toBe("PathEscape");
  });

  it("read of nonexistent file returns error JSON", async () => {
    const out = await executeTool(tools, "read_file", { path: "nope.txt" }, ctx());
    expect((JSON.parse(out) as { error: string }).error).toBeTruthy();
  });

  it("write with .. escape returns error JSON", async () => {
    const out = await executeTool(tools, "write_file", { path: "../escape.txt", content: "x" }, ctx());
    expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
  });
});

async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
  const def = tools.build(ctx)[name];
  if (def?.execute === undefined) {
    throw new Error(`missing executable tool: ${name}`);
  }
  return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise<string>;
}
