/**
 * Unit tests for the Workspace files service: directory-listing order, read/write,
 * path confinement (`..` traversal and symlink escape), size-limit protection,
 * batch existence checks (files/stat); and the Agent delete route (default_agent
 * cannot be deleted, owner-only, directory and index cleanup).
 */
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { WorkspaceFilesService } from "../src/services/workspace-files-service.js";
import type {
  AgentCreateResponse,
  ProjectCreateResponse,
  SessionCreateResponse,
} from "../src/api/types.js";
import { apiClient, createTestApp, makeTempRoot, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js";

describe("workspace-files-service", () => {
  let ws: string;
  let outside: string;
  const svc = new WorkspaceFilesService();

  beforeEach(async () => {
    ws = await makeTempRoot();
    outside = await makeTempRoot();
    await fs.mkdir(path.join(ws, "sub"));
    await fs.writeFile(path.join(ws, "b.txt"), "hello");
    await fs.writeFile(path.join(ws, "sub", "c.md"), "# md");
    await fs.writeFile(path.join(outside, "secret.txt"), "secret");
  });
  afterEach(async () => {
    await fs.rm(ws, { recursive: true, force: true });
    await fs.rm(outside, { recursive: true, force: true });
  });

  it("directory listing: dirs first, sorted by name; subdirectory paths work", async () => {
    const root = await svc.list(ws, "");
    expect(root.entries.map((e) => `${e.kind}:${e.name}`)).toEqual(["dir:sub", "file:b.txt"]);
    const sub = await svc.list(ws, "sub");
    expect(sub.entries.map((e) => e.name)).toEqual(["c.md"]);
  });

  it("file read: content and content-type; directories / missing files error", async () => {
    const file = await svc.read(ws, "sub/c.md");
    expect(file.data.toString()).toBe("# md");
    expect(file.contentType).toContain("markdown");
    const preview = await svc.read(ws, "sub/c.md", { maxBytes: 2 });
    expect(preview.data.toString()).toBe("# ");
    expect(preview.truncated).toBe(true);
    await expect(svc.read(ws, "sub")).rejects.toMatchObject({ status: 400 });
    await expect(svc.read(ws, "nope.txt")).rejects.toMatchObject({ status: 404 });
  });

  it("file write: overwrites; missing parent directories are auto-created (folder uploads keep their structure)", async () => {
    await svc.write(ws, "sub/new.txt", Buffer.from("data"));
    expect(await fs.readFile(path.join(ws, "sub", "new.txt"), "utf8")).toBe("data");
    await svc.write(ws, "missing/deep/x.txt", Buffer.from("d"));
    expect(await fs.readFile(path.join(ws, "missing", "deep", "x.txt"), "utf8")).toBe("d");
  });

  it("path confinement: `..` traversal and absolute paths are both rejected", async () => {
    await expect(svc.list(ws, "../")).rejects.toMatchObject({ status: 400 });
    await expect(svc.read(ws, `../${path.basename(outside)}/secret.txt`)).rejects.toMatchObject({
      status: 400,
    });
    await expect(svc.write(ws, "../escape.txt", Buffer.from("x"))).rejects.toMatchObject({
      status: 400,
    });
  });

  it("Workspace at the filesystem root: subdirectories still drill down (prefix-joining '//' regression)", async () => {
    const root = path.parse(ws).root;
    const sub = await svc.list(root, path.relative(root, path.join(ws, "sub")));
    expect(sub.entries.map((e) => e.name)).toEqual(["c.md"]);
  });

  it("a symlink to a directory inside the Workspace: kind is dir and it can be drilled into", async () => {
    await fs.symlink(path.join(ws, "sub"), path.join(ws, "link-sub"));
    const root = await svc.list(ws, "");
    expect(root.entries.map((e) => `${e.kind}:${e.name}`)).toEqual([
      "dir:link-sub",
      "dir:sub",
      "file:b.txt",
    ]);
    const viaLink = await svc.list(ws, "link-sub");
    expect(viaLink.entries.map((e) => e.name)).toEqual(["c.md"]);
  });

  it("symlink escape: reads and writes are both rejected when the link points outside the Workspace", async () => {
    await fs.symlink(outside, path.join(ws, "link-out"));
    const root = await svc.list(ws, "");
    expect(root.entries.map((entry) => entry.name)).not.toContain("link-out");
    await expect(svc.list(ws, "link-out")).rejects.toMatchObject({ status: 400 });
    await expect(svc.read(ws, "link-out/secret.txt")).rejects.toMatchObject({ status: 400 });
    // Writing outside via a directory symlink: caught by the parent-directory realpath check.
    await expect(svc.write(ws, "link-out/evil.txt", Buffer.from("x"))).rejects.toMatchObject({
      status: 400,
    });
    // Auto-creation under a missing path is equally restricted: if the nearest
    // existing ancestor is a symlink pointing outside, mkdir must not be used to escape.
    await expect(svc.write(ws, "link-out/new/evil.txt", Buffer.from("x"))).rejects.toMatchObject({
      status: 400,
    });
    expect(
      await fs
        .stat(path.join(outside, "new"))
        .then(() => true)
        .catch(() => false),
    ).toBe(false);
  });

  it("writing through a last-segment symlink: O_NOFOLLOW refuses to overwrite files outside the Workspace by proxy", async () => {
    // The Agent has a symlink inside the Workspace pointing to an outside file; an upload attempts to overwrite it.
    const victim = path.join(outside, "secret.txt");
    await fs.symlink(victim, path.join(ws, "report.pdf"));
    await expect(svc.write(ws, "report.pdf", Buffer.from("PWNED"))).rejects.toMatchObject({
      status: 400,
    });
    // The outside file's content is unchanged.
    expect(await fs.readFile(victim, "utf8")).toBe("secret");
  });
});

describe("files/stat route (batch existence check)", () => {
  let t: TestApp;
  let owner: ReturnType<typeof apiClient>;
  let outsider: ReturnType<typeof apiClient>;
  let sessionId: string;
  let workspace: string;

  beforeEach(async () => {
    t = await createTestApp();
    const a = await provisionUser(t.app, "owner");
    const b = await provisionUser(t.app, "outsider");
    owner = apiClient(t.app, a.cookie);
    outsider = apiClient(t.app, b.cookie);
    const created = (await (
      await owner.post("/api/projects", { projectId: "owner-stat", name: "project" })
    ).json()) as ProjectCreateResponse;
    const projectId = created.project.projectId;
    await owner.put(`/api/projects/${projectId}/models`, {
      defaultModel: { provider: "anthropic", modelId: "claude-sonnet-4-6" },
      models: [{ provider: "anthropic", modelId: "claude-sonnet-4-6", contextWindow: 128000 }],
    });
    const sess = (await (
      await owner.post(`/api/projects/${projectId}/agents/default_agent/sessions`, {})
    ).json()) as SessionCreateResponse;
    sessionId = sess.session.sessionId;
    workspace = sess.session.workspace;
    await fs.mkdir(path.join(sess.session.workspace, "sub"));
    await fs.writeFile(path.join(sess.session.workspace, "a.txt"), "A");
    await fs.writeFile(path.join(sess.session.workspace, "sub", "b.md"), "B");
  });
  afterEach(async () => {
    await t.cleanup();
  });

  it("files/content on html: inline stays text/plain; preview=1 keeps text/html under a CSP sandbox; download keeps the real type with no CSP", async () => {
    await fs.writeFile(path.join(workspace, "page.html"), "<!doctype html><script>1</script>");
    const url = `/api/sessions/${sessionId}/files/content?path=page.html`;

    const inline = await owner.get(url);
    expect(inline.status).toBe(200);
    expect(inline.headers.get("content-type")).toContain("text/plain");
    expect(inline.headers.get("content-security-policy")).toBeNull();

    const preview = await owner.get(`${url}&preview=1`);
    expect(preview.status).toBe(200);
    expect(preview.headers.get("content-type")).toContain("text/html");
    const csp = preview.headers.get("content-security-policy") ?? "";
    expect(csp).toContain("sandbox");
    expect(csp).not.toContain("allow-same-origin");
    expect(preview.headers.get("content-disposition")).toContain("inline");

    // download wins over preview: attachment + real type, no CSP needed.
    const download = await owner.get(`${url}&download=1&preview=1`);
    expect(download.headers.get("content-type")).toContain("text/html");
    expect(download.headers.get("content-disposition")).toContain("attachment");
    expect(download.headers.get("content-security-policy")).toBeNull();

    // Non-scriptable files are unaffected by preview.
    const txt = await owner.get(`/api/sessions/${sessionId}/files/content?path=a.txt&preview=1`);
    expect(txt.headers.get("content-type")).toContain("text/plain");
    expect(txt.headers.get("content-security-policy")).toBeNull();
  });

  it("existing files return in order, deduplicated; missing / directory / out-of-bounds all count as nonexistent, always 200", async () => {
    const res = await owner.post(`/api/sessions/${sessionId}/files/stat`, {
      paths: ["sub/b.md", "a.txt", "sub/b.md", "nope.txt", "sub", "../escape.txt", "/etc/passwd"],
    });
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ existing: ["sub/b.md", "a.txt"] });

    const empty = await owner.post(`/api/sessions/${sessionId}/files/stat`, { paths: [] });
    expect(empty.status).toBe(200);
    expect(await empty.json()).toEqual({ existing: [] });
  });

  it("invalid body → 400: non-array / non-string entries / too many / too long", async () => {
    const url = `/api/sessions/${sessionId}/files/stat`;
    expect((await owner.post(url, { paths: "a.txt" })).status).toBe(400);
    expect((await owner.post(url, { paths: [1] })).status).toBe(400);
    const tooMany = Array.from({ length: 101 }, () => "a.txt");
    expect((await owner.post(url, { paths: tooMany })).status).toBe(400);
    expect((await owner.post(url, { paths: ["x".repeat(513)] })).status).toBe(400);
  });

  it("outsider access → 404 (no existence leak)", async () => {
    const res = await outsider.post(`/api/sessions/${sessionId}/files/stat`, {
      paths: ["a.txt"],
    });
    expect(res.status).toBe(404);
  });
});

describe("agent delete route", () => {
  let t: TestApp;
  let owner: ReturnType<typeof apiClient>;
  let outsider: ReturnType<typeof apiClient>;
  let projectId: string;

  beforeEach(async () => {
    t = await createTestApp();
    const a = await provisionUser(t.app, "owner");
    const b = await provisionUser(t.app, "outsider");
    owner = apiClient(t.app, a.cookie);
    outsider = apiClient(t.app, b.cookie);
    const created = (await (
      await owner.post("/api/projects", { projectId: "owner-ws", name: "project" })
    ).json()) as ProjectCreateResponse;
    projectId = created.project.projectId;
  });
  afterEach(async () => {
    await t.cleanup();
  });

  it("owner deletes an Agent: 204, the directory and list entry disappear; default_agent 409; outsiders 404", async () => {
    const created = (await (
      await owner.post(`/api/projects/${projectId}/agents`, { agentId: "temp_agent", name: "temp" })
    ).json()) as AgentCreateResponse;
    const agentId = created.agent.agentId;
    const dir = path.join(t.root, projectId, "agents", agentId);
    await fs.access(dir); // directory exists after creation

    const outsiderRes = await outsider.delete(`/api/projects/${projectId}/agents/${agentId}`);
    expect(outsiderRes.status).toBe(404); // no access → don't leak existence

    const res = await owner.delete(`/api/projects/${projectId}/agents/${agentId}`);
    expect(res.status).toBe(204);
    await expect(fs.access(dir)).rejects.toThrow();
    const list = (await (await owner.get(`/api/projects/${projectId}/agents`)).json()) as {
      agents: Array<{ agentId: string }>;
    };
    expect(list.agents.some((x) => x.agentId === agentId)).toBe(false);

    const def = await owner.delete(`/api/projects/${projectId}/agents/default_agent`);
    expect(def.status).toBe(409);
  });
});
