import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { archiveAccountThread, deleteArchivedAccountThreadLocalData, listAccountThreads, listAllUnifiedThreads, listUnifiedThreads, publicThread, renameAccountThread, threadEvents } from "./codex-history.js";

test("public thread removes local paths and resolves project label from workspace root", () => {
  const result = publicThread({
    id: "thread-1",
    name: "Fix login",
    preview: "Please fix login",
    status: { type: "idle" },
    cwd: "C:\\Project\\Codex-remote\\apps\\backend",
    path: "C:\\secret\\rollout.jsonl",
    createdAt: 100,
    updatedAt: 120
  }, [{ id: "remote", label: "Code Remote Manager", path: "C:\\Project\\Codex-remote" }]);

  assert.deepEqual(result, {
    id: "thread-1",
    name: "Fix login",
    preview: "Please fix login",
    status: "idle",
    workspaceId: "remote",
    workspaceLabel: "Codex-remote",
    createdAt: "1970-01-01T00:01:40.000Z",
    updatedAt: "1970-01-01T00:02:00.000Z"
  });
  assert.equal("cwd" in result, false);
  assert.equal("path" in result, false);
});

test("public thread redacts secrets and private paths from name and preview", () => {
  const result = publicThread({
    id: "thread-secret",
    name: "Fix CODEX_HOME C:\\Users\\operator\\.codex-main\\auth.json",
    preview: "Use Bearer preview-token in C:\\Project\\Codex-remote\\.env",
    updatedAt: 120
  }, [{ id: "remote", label: "Code Remote Manager", path: "C:\\Project\\Codex-remote" }], {}, {
    accounts: [{ codexHome: "C:\\Users\\operator\\.codex-main" }],
    workspaces: [{ path: "C:\\Project\\Codex-remote" }]
  });

  const serialized = JSON.stringify(result);
  assert.equal(serialized.includes("CODEX_HOME"), false);
  assert.equal(serialized.includes("auth.json"), false);
  assert.equal(serialized.includes("preview-token"), false);
  assert.equal(serialized.includes("operator"), false);
  assert.equal(serialized.includes("C:\\\\Project\\\\Codex-remote"), false);
  assert.match(result.name, /\[private-path\]/);
  assert.match(result.preview, /Bearer \[redacted\]/);
});

test("public thread may expose only cwd final folder as project label", () => {
  const result = publicThread({
    id: "thread-1",
    preview: "Work",
    cwd: "C:\\Project\\DashBoard\\apps",
    updatedAt: 120
  }, [], { projectRoots: ["C:\\Project\\DashBoard"] });

  assert.equal(result.workspaceLabel, "DashBoard");
  assert.equal("workspaceId" in result, false);
  assert.equal("cwd" in result, false);
});

test("public thread resolves project label from projectsRoot direct child", () => {
  const result = publicThread({
    id: "thread-1",
    preview: "Old project work",
    cwd: "C:\\Project\\DashBoard\\apps",
    updatedAt: 120
  }, [], {}, { projectsRoot: "C:\\Project" });

  assert.equal(result.workspaceLabel, "DashBoard");
  assert.equal("workspaceId" in result, false);
  assert.equal(JSON.stringify(result).includes("C:\\\\Project"), false);
});

test("public thread does not label projectsRoot itself as a project", () => {
  const result = publicThread({
    id: "thread-1",
    preview: "Root work",
    cwd: "C:\\Project",
    updatedAt: 120
  }, [], {}, { projectsRoot: "C:\\Project" });

  assert.equal("workspaceLabel" in result, false);
  assert.equal("cwd" in result, false);
});

test("public thread sends projectless desktop threads to Chats", () => {
  const result = publicThread({
    id: "thread-1",
    preview: "Work",
    cwd: "C:\\Users\\ansrb\\Documents\\Codex\\2026-06-24\\https-www-youtube-com-watch-v",
    updatedAt: 120
  }, [], {
    projectRoots: ["C:\\Project\\DashBoard", "C:\\Project\\Codex-remote"],
    projectlessThreadIds: new Set(["thread-1"])
  });

  assert.equal("workspaceLabel" in result, false);
  assert.equal("cwd" in result, false);
});

test("workspace root basename overrides workspace display label", () => {
  const result = publicThread({
    id: "thread-1",
    preview: "Work",
    cwd: "C:\\Project\\Codex-remote",
    updatedAt: 120
  }, [{ id: "code-remote-manager", label: "Code Remote Manager", path: "C:\\Project\\Codex-remote" }]);

  assert.equal(result.workspaceId, "code-remote-manager");
  assert.equal(result.workspaceLabel, "Codex-remote");
});

test("list account threads reads dynamic desktop project state", async () => {
  const dir = await mkdtemp(join(tmpdir(), "codex-history-"));
  try {
    await writeFile(join(dir, ".codex-global-state.json"), JSON.stringify({
      "project-order": ["C:\\Project\\DashBoard", "C:\\Project\\Job"],
      "projectless-thread-ids": ["projectless-1"]
    }));
    const config = {
      codexCliPath: "codex",
      accounts: [{ id: "desktop", codexHome: dir, historyOnly: true }],
      workspaces: []
    };
    const requester = async () => ({
      data: [
        { id: "project-1", preview: "Project", cwd: "C:\\Project\\DashBoard\\apps", updatedAt: 200 },
        { id: "projectless-1", preview: "Loose", cwd: "C:\\Users\\ansrb\\Documents\\Codex\\new-chat", updatedAt: 100 }
      ],
      nextCursor: null
    });

    const result = await listAccountThreads(config, "desktop", {}, requester);

    assert.deepEqual(result.threads.map((thread) => thread.workspaceLabel), ["DashBoard", undefined]);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("list account threads ignores non-absolute desktop project roots", async () => {
  const dir = await mkdtemp(join(tmpdir(), "codex-history-"));
  try {
    await writeFile(join(dir, ".codex-global-state.json"), JSON.stringify({
      "project-order": ["..\\Codex-remote", "C:\\Project\\Allowed"]
    }));
    const config = {
      codexCliPath: "codex",
      accounts: [{ id: "desktop", codexHome: dir, historyOnly: true }],
      workspaces: []
    };
    const requester = async () => ({
      data: [
        { id: "relative-root", preview: "Relative", cwd: join(process.cwd(), "..", "Codex-remote"), updatedAt: 200 },
        { id: "absolute-root", preview: "Absolute", cwd: "C:\\Project\\Allowed\\src", updatedAt: 100 }
      ]
    });

    const result = await listAccountThreads(config, "desktop", {}, requester);

    assert.deepEqual(result.threads.map((thread) => thread.workspaceLabel), [undefined, "Allowed"]);
    assert.equal(JSON.stringify(result).includes("Codex-remote"), false);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("list account threads uses selected account and newest-first pagination", async () => {
  const calls = [];
  const config = {
    codexCliPath: "codex",
    accounts: [{ id: "account-b", codexHome: "B_HOME" }],
    workspaces: []
  };
  const requester = async (input) => {
    calls.push(input);
    return { data: [], nextCursor: "next-1" };
  };

  const result = await listAccountThreads(config, "account-b", {
    cursor: "cursor-1",
    search: "login"
  }, requester);

  assert.deepEqual(calls, [{
    accountId: "account-b",
    codexCliPath: "codex",
    codexHome: "B_HOME",
    method: "thread/list",
    params: {
      cursor: "cursor-1",
      limit: 50,
      sortKey: "updated_at",
      sortDirection: "desc",
      searchTerm: "login",
      archived: false
    }
  }]);
  assert.deepEqual(result, { threads: [], nextCursor: "next-1" });
});

test("list account threads can read archived threads and hides local deleted tombstones", async () => {
  const dir = await mkdtemp(join(tmpdir(), "codex-history-"));
  try {
    const config = {
      dataDir: dir,
      codexCliPath: "codex",
      accounts: [{ id: "account-b", codexHome: "B_HOME" }],
      workspaces: []
    };
    const calls = [];
    const requester = async (input) => {
      calls.push(input);
      return { data: [{ id: "deleted", preview: "Gone" }, { id: "kept", preview: "Keep" }] };
    };

    await deleteArchivedAccountThreadLocalData(config, "account-b", "deleted", requester);
    const result = await listAccountThreads(config, "account-b", { archived: true }, requester);

    assert.equal(calls.at(-1).params.archived, true);
    assert.deepEqual(result.threads.map((thread) => thread.id), ["kept"]);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("delete archived local data rejects non-archived threads before tombstoning", async () => {
  const dir = await mkdtemp(join(tmpdir(), "codex-history-"));
  try {
    const config = {
      dataDir: dir,
      codexCliPath: "codex",
      accounts: [{ id: "account-b", codexHome: "B_HOME" }],
      workspaces: []
    };

    await assert.rejects(
      deleteArchivedAccountThreadLocalData(config, "account-b", "live", async () => ({ data: [{ id: "other" }] })),
      /thread_not_archived/
    );
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test("archive account thread uses the owning account app-server", async () => {
  const calls = [];
  const config = {
    codexCliPath: "codex",
    accounts: [{ id: "account-b", codexHome: "B_HOME" }]
  };

  const result = await archiveAccountThread(config, "account-b", "thread-1", async (input) => {
    calls.push(input);
    return {};
  });

  assert.deepEqual(calls, [{
    codexCliPath: "codex",
    codexHome: "B_HOME",
    method: "thread/archive",
    params: { threadId: "thread-1" }
  }]);
  assert.deepEqual(result, { archived: true, ownerId: "account-b", threadId: "thread-1" });
});

test("rename account thread trims the name and uses the owning account app-server", async () => {
  const calls = [];
  const config = { codexCliPath: "codex", accounts: [{ id: "account-b", codexHome: "B_HOME" }] };
  const result = await renameAccountThread(config, "account-b", "thread-1", "  New title  ", async (input) => {
    calls.push(input);
    return {};
  });

  assert.deepEqual(calls, [{
    codexCliPath: "codex",
    codexHome: "B_HOME",
    method: "thread/name/set",
    params: { threadId: "thread-1", name: "New title" }
  }]);
  assert.deepEqual(result, { renamed: true, ownerId: "account-b", threadId: "thread-1", name: "New title" });
});

test("rename account thread rejects an empty name before contacting app-server", async () => {
  await assert.rejects(
    renameAccountThread({ accounts: [{ id: "a", codexHome: "A" }] }, "a", "t", "  ", async () => assert.fail()),
    /thread_name_required/
  );
});

test("unified threads merge owners newest-first", async () => {
  const config = {
    codexCliPath: "codex",
    accounts: [
      { id: "a", label: "Account A", codexHome: "A" },
      { id: "desktop", label: "Desktop Codex", codexHome: "D", historyOnly: true }
    ],
    workspaces: []
  };
  const requester = async ({ codexHome }) => ({ data: codexHome === "A"
    ? [{ id: "a1", preview: "A", updatedAt: 100 }]
    : [{ id: "d1", preview: "Desktop", updatedAt: 200 }], nextCursor: null });

  const result = await listUnifiedThreads(config, {}, requester);

  assert.deepEqual(result.threads.map(({ id, ownerId, ownerLabel }) => ({ id, ownerId, ownerLabel })), [
    { id: "d1", ownerId: "desktop", ownerLabel: "Desktop Codex" },
    { id: "a1", ownerId: "a", ownerLabel: "Account A" }
  ]);
  assert.deepEqual(result.errors, []);
});

test("unified threads preserve successful sources on partial failure", async () => {
  const config = { codexCliPath: "codex", accounts: [
    { id: "a", label: "A", codexHome: "A" }, { id: "b", label: "B", codexHome: "B" }
  ], workspaces: [] };
  const requester = async ({ codexHome }) => {
    if (codexHome === "B") throw new Error("offline");
    return { data: [{ id: "same", preview: "A", updatedAt: 100 }, { id: "same", preview: "A duplicate", updatedAt: 90 }] };
  };

  const result = await listUnifiedThreads(config, {}, requester);
  assert.equal(result.threads.length, 1);
  assert.deepEqual(result.errors, [{ ownerId: "b", ownerLabel: "B", error: "source_unavailable" }]);
});

test("unified thread pagination does not restart an exhausted source", async () => {
  const config = { codexCliPath: "codex", accounts: [
    { id: "a", label: "A", codexHome: "A" }, { id: "b", label: "B", codexHome: "B" }
  ], workspaces: [] };
  const calls = [];
  const requester = async ({ codexHome, params }) => {
    calls.push({ codexHome, cursor: params.cursor });
    if (codexHome === "A" && !params.cursor) return { data: [{ id: "a-new", updatedAt: 500 }], nextCursor: "a-next" };
    if (codexHome === "A") return { data: [{ id: "a-old", updatedAt: 200 }], nextCursor: null };
    if (!params.cursor) return { data: [{ id: "b-new", updatedAt: 400 }], nextCursor: "b-next" };
    if (params.cursor === "b-next") return { data: [{ id: "b-middle", updatedAt: 300 }], nextCursor: "b-last" };
    return { data: [{ id: "b-old", updatedAt: 100 }], nextCursor: null };
  };

  const first = await listUnifiedThreads(config, {}, requester);
  const second = await listUnifiedThreads(config, { cursor: first.nextCursor }, requester);
  const third = await listUnifiedThreads(config, { cursor: second.nextCursor }, requester);

  assert.deepEqual(third.threads.map((thread) => thread.id), ["b-old"]);
  assert.equal(third.nextCursor, null);
  assert.deepEqual(calls, [
    { codexHome: "A", cursor: undefined },
    { codexHome: "B", cursor: undefined },
    { codexHome: "A", cursor: "a-next" },
    { codexHome: "B", cursor: "b-next" },
    { codexHome: "B", cursor: "b-last" }
  ]);
});

test("archived unified threads request a smaller first page from every source", async () => {
  const config = {
    codexCliPath: "codex",
    accounts: [{ id: "a", label: "A", codexHome: "A" }],
    workspaces: []
  };
  const limits = [];

  await listUnifiedThreads(config, { archived: true }, async ({ params }) => {
    limits.push(params.limit);
    return { data: [], nextCursor: null };
  });

  assert.deepEqual(limits, [20]);
});

test("all unified threads exhausts account cursors for project grouping", async () => {
  const config = { codexCliPath: "codex", accounts: [
    { id: "a", label: "A", codexHome: "A" },
    { id: "b", label: "B", codexHome: "B" }
  ], workspaces: [] };
  const calls = [];
  const requester = async ({ codexHome, params }) => {
    calls.push({ codexHome, cursor: params.cursor });
    if (codexHome === "A" && !params.cursor) return { data: [{ id: "a-new", updatedAt: 300 }], nextCursor: "a-next" };
    if (codexHome === "A" && params.cursor === "a-next") return { data: [{ id: "a-old", updatedAt: 100 }], nextCursor: null };
    return { data: [{ id: "b-only", updatedAt: 200 }], nextCursor: null };
  };

  const result = await listAllUnifiedThreads(config, {}, requester);

  assert.deepEqual(result.threads.map((thread) => thread.id), ["a-new", "b-only", "a-old"]);
  assert.deepEqual(calls, [
    { codexHome: "A", cursor: undefined },
    { codexHome: "B", cursor: undefined },
    { codexHome: "A", cursor: "a-next" }
  ]);
});

test("projects thread history into safe conversation events", () => {
  const events = threadEvents({
    id: "thread-1",
    turns: [{
      id: "turn-1",
      status: "completed",
      startedAt: 100,
      completedAt: 105,
      durationMs: 5000,
      items: [
        { id: "u1", type: "userMessage", content: [{ type: "text", text: "Hello" }] },
        { id: "c1", type: "agentMessage", phase: "commentary", text: "Checking files" },
        { id: "r1", type: "reasoning", summary: ["Need inspect repo"], content: ["hidden"] },
        { id: "p1", type: "plan", text: "Inspect files" },
        { id: "cmd1", type: "commandExecution", command: "npm test", status: "completed", durationMs: 250 },
        { id: "f1", type: "fileChange", changes: [{ path: "secret.txt" }], status: "completed" },
        { id: "a1", type: "agentMessage", phase: "final_answer", text: "Done" }
      ]
    }]
  });

  assert.deepEqual(events.map((event) => event.event), [
    "message.accepted",
    "turn.started",
    "activity.started",
    "session.output",
    "reasoning.delta",
    "plan.updated",
    "activity.completed",
    "activity.completed",
    "activity.started",
    "session.output",
    "turn.completed"
  ]);
  assert.equal(events.find((event) => event.event === "message.accepted").data.text, "Hello");
  assert.equal(events.find((event) => event.event === "reasoning.delta").data.text,
    "Need inspect repo");
  assert.equal(events.some((event) => JSON.stringify(event).includes("hidden")), false);
  assert.equal(events.some((event) => JSON.stringify(event).includes("secret.txt")), false);
  assert.equal(events.findLast((event) => event.event === "session.output").data.text, "Done");
});

test("projects an interrupted historical turn as completed interruption", () => {
  const events = threadEvents({
    turns: [{
      id: "turn-interrupted",
      status: "interrupted",
      startedAt: 100,
      completedAt: 105,
      items: [{ id: "u1", type: "userMessage", content: [{ type: "text", text: "Stop" }] }]
    }]
  });

  assert.deepEqual(events.map((event) => event.event), ["message.accepted", "turn.started", "turn.completed"]);
  assert.equal(events.at(-1).data.status, "interrupted");
});

test("projects historical image-only user messages without an empty bubble", () => {
  const events = threadEvents({
    turns: [{
      id: "turn-image",
      status: "completed",
      items: [{
        id: "user-image",
        type: "userMessage",
        content: [
          { type: "image", url: "data:image/png;base64,private" },
          { type: "localImage", path: "C:\\Users\\person\\private.png" }
        ]
      }]
    }]
  });

  const accepted = events.find((event) => event.event === "message.accepted");
  assert.equal(accepted.data.text, undefined);
  assert.equal(accepted.data.attachmentCount, 2);
  assert.equal(JSON.stringify(events).includes("private"), false);
  assert.equal(JSON.stringify(events).includes("C:\\\\Users"), false);
});

test("redacts historical user messages before they become public events", () => {
  const events = threadEvents({
    turns: [{
      id: "turn-secret",
      status: "completed",
      items: [{
        id: "user-secret",
        type: "userMessage",
        content: [{ type: "text", text: "Use Basic dXNlcjpwYXNz at C:\\Users\\Jane Doe\\secret file.txt" }]
      }]
    }]
  });
  const output = JSON.stringify(events);

  assert.equal(output.includes("dXNlcjpwYXNz"), false);
  assert.equal(output.includes("Jane Doe"), false);
  assert.match(output, /\[redacted\]/);
  assert.match(output, /\[private-path\]/);
});

test("does not synthesize completion for an externally active turn", () => {
  const events = threadEvents({
    turns: [{
      id: "turn-active",
      status: "active",
      startedAt: 100,
      items: [{ id: "u1", type: "userMessage", content: [{ type: "text", text: "Live" }] }]
    }]
  });

  assert.deepEqual(events.map((event) => event.event), ["message.accepted", "turn.started"]);
});

test("projects only allowlisted workspace paths for historical file changes", () => {
  const events = threadEvents({
    cwd: "C:\\Project\\Codex-remote",
    turns: [{ id: "turn-1", status: "completed", items: [{
      id: "files-1",
      type: "fileChange",
      changes: [
        { path: "apps/pwa/src/App.jsx" },
        { path: "C:\\Private\\secret.txt" }
      ],
      status: "completed"
    }] }]
  }, { workspaces: [{ id: "workspace-1", path: "C:\\Project\\Codex-remote" }] });

  const activity = events.find((event) => event.data?.itemId === "files-1");
  assert.deepEqual(activity.data.paths, ["apps/pwa/src/App.jsx"]);
  assert.equal(JSON.stringify(events).includes("Private"), false);
});

test("projects historical js shell calls as safe command activity", () => {
  const events = threadEvents({
    turns: [{
      id: "turn-1",
      status: "completed",
      items: [{
        id: "tool-1",
        type: "dynamicToolCall",
        tool: "js",
        arguments: { code: 'await tools.shell_command({"command":"type C:\\\\private\\\\secret.txt"})' },
        status: "completed"
      }]
    }]
  });
  const activity = events.find((event) => event.data?.itemId === "tool-1");

  assert.equal(activity.data.kind, "commandExecution");
  assert.equal(activity.data.summary, "PowerShell command");
  assert.equal(JSON.stringify(activity).includes("secret.txt"), false);
});

test("projects historical command and tool result details like live events", () => {
  const events = threadEvents({
    turns: [{
      id: "turn-results",
      status: "completed",
      items: [
        {
          id: "command-result",
          type: "commandExecution",
          command: "npm test",
          aggregatedOutput: "tests failed",
          exitCode: 2,
          status: "failed"
        },
        {
          id: "tool-result",
          type: "mcpToolCall",
          server: "example",
          tool: "lookup",
          result: { content: [{ type: "text", text: "Public result" }] },
          status: "completed"
        },
        {
          id: "tool-error",
          type: "dynamicToolCall",
          tool: "lookup",
          error: { message: "lookup unavailable" },
          status: "failed"
        }
      ]
    }]
  });

  const byId = new Map(events.filter((event) => event.data?.itemId).map((event) => [event.data.itemId, event.data]));
  assert.deepEqual(byId.get("command-result"), {
    turnId: "turn-results",
    itemId: "command-result",
    kind: "commandExecution",
    summary: "npm test",
    status: "failed",
    text: "tests failed",
    exitCode: 2
  });
  assert.equal(byId.get("tool-result").text, "Public result");
  assert.equal(byId.get("tool-error").error, "lookup unavailable");
});

test("redacts configured paths from projected history activity", () => {
  const events = threadEvents({
    turns: [{
      id: "turn-1", status: "completed",
      items: [{ id: "cmd-1", type: "commandExecution", command: "type C:\\Project\\Codex-remote\\auth.json" }]
    }]
  }, { workspaces: [{ path: "C:\\Project\\Codex-remote" }] });

  assert.equal(JSON.stringify(events).includes("C:\\\\Project\\\\Codex-remote"), false);
});
