/**
 * Core operational retention (task-api §6 MVP).
 * Layer: pure selection + FsAdapter purge; no service/RPC.
 */
import assert from "node:assert/strict";
import { test } from "node:test";
import { NodeFs } from "../src/fs/node-fs.js";
import { createTaskResult, writeTaskResult } from "../src/core/task-result.js";
import {
  DEFAULT_KEEP_TERMINAL_DAYS,
  MAX_KEEP_TERMINAL_DAYS,
  normalizeKeepTerminalTasksDays,
  previewOperationalRetention,
  purgeOperationalRetention,
  RetentionError,
} from "../src/core/retention.js";
import { writeTaskRecord, loadTaskRecord, patchTaskRecord } from "../src/core/task.js";
import { makeTent } from "./helpers.js";
import { contentEtag } from "../src/core/etag.js";

const NOW = "2026-07-16T12:00:00.000Z";
const OLD = "2026-06-01T12:00:00.000Z"; // > 30 days before NOW
const RECENT = "2026-07-10T12:00:00.000Z"; // < 30 days before NOW

function clock(iso: string) {
  return { now: () => iso };
}

function nodeSnapshot(id: string, nodePath: string, type = "prompt", body = "") {
  return { id, path: nodePath, type, archived: false, tags: [], body, etag: contentEtag(body) };
}

async function createReviewedResult(
  fs: NodeFs,
  input: Parameters<typeof createTaskResult>[2] & { status: "accepted" | "rejected" },
  at: string
) {
  const result = await createTaskResult(fs, clock(at), { ...input, status: "ready" });
  result.status = input.status;
  result.review = { reviewer: "user", at };
  await writeTaskResult(fs, result);
  return result;
}

class FailTaskRemoveFs extends NodeFs {
  constructor(root: string, private readonly failPath: string) {
    super(root);
  }

  override async remove(path: string): Promise<void> {
    if (path === this.failPath) throw new Error("injected task remove failure");
    await super.remove(path);
  }
}

async function writeTerminalTask(
  fs: NodeFs,
  opts: {
    role?: string;
    state: "accepted" | "rejected" | "interrupted" | "failed";
    id?: string;
    createdAt?: string;
    updatedAt?: string;
    claimId?: string;
  }
) {
  const role = opts.role ?? "executor";
  const sessionId = `ss-${role.replace(/[^a-z0-9]/gi, "").toLowerCase()}`;
  const path = await writeTaskRecord(fs, clock(opts.createdAt ?? OLD), {
    requester: { kind: "user", id: "user" },
    executionSessionId: sessionId,
    nodeIds: [opts.claimId ?? "cx-p1"],
    nodeSnapshots: [nodeSnapshot(opts.claimId ?? "cx-p1", "prompt/表达式任务书")],
    manifestPath: `temp/sessions/${sessionId}/manifests/m.md`,
    prompt: "retention fixture",
    id: opts.id,
  });
  await patchTaskRecord(fs, path, {
    state: opts.state,
    updatedAt: opts.updatedAt ?? opts.createdAt ?? OLD,
  });
  // writeTaskRecord stamps createdAt from clock; ensure both old when needed
  const raw = await fs.readFile(path);
  const patched = raw
    .replace(/createdAt: .*/, `createdAt: ${opts.createdAt ?? OLD}`)
    .replace(/updatedAt: .*/, `updatedAt: ${opts.updatedAt ?? opts.createdAt ?? OLD}`);
  await fs.writeFile(path, patched);
  return loadTaskRecord(fs, path);
}

test("normalizeKeepTerminalTasksDays: default 30, 0 allowed, rejects invalid", () => {
  assert.equal(normalizeKeepTerminalTasksDays(undefined), DEFAULT_KEEP_TERMINAL_DAYS);
  assert.equal(normalizeKeepTerminalTasksDays(0), 0);
  assert.equal(normalizeKeepTerminalTasksDays(7), 7);
  assert.equal(normalizeKeepTerminalTasksDays(MAX_KEEP_TERMINAL_DAYS), MAX_KEEP_TERMINAL_DAYS);
  assert.throws(
    () => normalizeKeepTerminalTasksDays(-1),
    (e: unknown) => e instanceof RetentionError && e.code === "INVALID_KEEP_DAYS"
  );
  assert.throws(
    () => normalizeKeepTerminalTasksDays(1.5),
    (e: unknown) => e instanceof RetentionError
  );
  assert.throws(
    () => normalizeKeepTerminalTasksDays(MAX_KEEP_TERMINAL_DAYS + 1),
    (e: unknown) => e instanceof RetentionError
  );
  assert.throws(
    () => normalizeKeepTerminalTasksDays("30" as unknown),
    (e: unknown) => e instanceof RetentionError
  );
});

test("preview: never selects active tasks or ready results", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);

  const activePath = await writeTaskRecord(fs, clock(OLD), {
    requester: { kind: "user", id: "user" },
    executionSessionId: "ss-executor",
    nodeIds: ["cx-p1"],
    nodeSnapshots: [nodeSnapshot("cx-p1", "prompt/表达式任务书")],
    manifestPath: "temp/sessions/ss-executor/manifests/m.md",
    prompt: "still running work",
    id: "tk-active01",
  });
  // Force old timestamps but keep queued (active)
  await fs.writeFile(
    activePath,
    (await fs.readFile(activePath))
      .replace(/createdAt: .*/, `createdAt: ${OLD}`)
      .replace(/updatedAt: .*/, `updatedAt: ${OLD}`)
  );

  await writeTerminalTask(fs, { state: "accepted", id: "tk-oldacc1" });

  const ready = await createTaskResult(fs, clock(OLD), {
    taskId: "tk-missing",
    resultsDir: "temp/sessions/ss-executor/results",
    report: "awaiting review",
    status: "ready",
  });
  await fs.writeFile(
    ready.path,
    (await fs.readFile(ready.path))
      .replace(/createdAt: .*/, `createdAt: ${OLD}`)
      .replace(/updatedAt: .*/, `updatedAt: ${OLD}`)
  );

  const preview = await previewOperationalRetention(fs, {
    keepTerminalTasksDays: 30,
    now: NOW,
  });

  assert.ok(preview.candidates.every((c) => c.taskPath !== activePath));
  assert.ok(preview.candidates.every((c) => !c.resultPaths.includes(ready.path)));
  assert.ok(preview.candidates.some((c) => c.taskId === "tk-oldacc1"));
  assert.equal(await fs.exists(activePath), true);
  assert.equal(await fs.exists(ready.path), true);
});

test("preview: terminal task past retention is a task-group candidate", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);
  const task = await writeTerminalTask(fs, { state: "failed", id: "tk-failold" });
  const result = await createReviewedResult(fs, {
    taskId: "tk-failold",
    resultsDir: "temp/sessions/ss-executor/results",
    report: "historical result",
    status: "rejected",
  }, OLD);
  await fs.writeFile(
    result.path,
    (await fs.readFile(result.path))
      .replace(/createdAt: .*/, `createdAt: ${OLD}`)
      .replace(/updatedAt: .*/, `updatedAt: ${OLD}`)
  );

  const preview = await previewOperationalRetention(fs, {
    keepTerminalTasksDays: 30,
    now: NOW,
  });
  const group = preview.candidates.find((c) => c.kind === "task-group" && c.taskId === "tk-failold");
  assert.ok(group);
  assert.equal(group!.taskPath, task.path);
  assert.ok(group!.resultPaths.includes(result.path));
  assert.equal(preview.candidateTaskCount, 1);
  assert.equal(preview.candidateTaskResultCount, 1);
});

test("preview: recent terminal task within 30 days is not a candidate", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);
  await writeTerminalTask(fs, {
    state: "accepted",
    id: "tk-recent1",
    createdAt: RECENT,
    updatedAt: RECENT,
  });

  const preview = await previewOperationalRetention(fs, {
    keepTerminalTasksDays: 30,
    now: NOW,
  });
  assert.ok(!preview.candidates.some((c) => c.taskId === "tk-recent1"));
});

test("preview: recent related result keeps the whole terminal task group hot", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);
  const task = await writeTerminalTask(fs, { state: "accepted", id: "tk-hotgroup" });
  await createReviewedResult(fs, {
    taskId: "tk-hotgroup",
    resultsDir: "temp/sessions/ss-executor/results",
    report: "recent accepted result",
    status: "accepted",
  }, RECENT);

  const preview = await previewOperationalRetention(fs, {
    keepTerminalTasksDays: 30,
    now: NOW,
  });
  assert.ok(!preview.candidates.some((candidate) => candidate.taskPath === task.path));
});

test("preview: duplicate task ids are reported and never selected", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);
  const first = await writeTerminalTask(fs, {
    state: "accepted",
    id: "tk-duplicate",
    claimId: "cx-p1",
  });
  const second = await writeTerminalTask(fs, {
    state: "failed",
    id: "tk-duplicate",
    claimId: "cx-p2",
  });

  const preview = await previewOperationalRetention(fs, {
    keepTerminalTasksDays: 0,
    now: NOW,
  });
  assert.ok(!preview.candidates.some((candidate) => candidate.taskId === "tk-duplicate"));
  assert.ok(preview.skipped.some((item) => item.path === first.path));
  assert.ok(preview.skipped.some((item) => item.path === second.path));
  assert.ok(preview.warnings.some((warning) => warning.includes("duplicate task id")));
});

test("preview/purge: keepTerminalTasksDays=0 makes terminal immediately eligible", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);
  const task = await writeTerminalTask(fs, {
    state: "interrupted",
    id: "tk-imm0",
    createdAt: RECENT,
    updatedAt: RECENT,
  });

  const preview = await previewOperationalRetention(fs, {
    keepTerminalTasksDays: 0,
    now: NOW,
  });
  assert.ok(preview.candidates.some((c) => c.taskPath === task.path));

  const purged = await purgeOperationalRetention(fs, {
    keepTerminalTasksDays: 0,
    now: NOW,
  });
  assert.ok(purged.purged.taskPaths.includes(task.path));
  assert.equal(await fs.exists(task.path), false);
  assert.ok(purged.deletedCount >= 1);
});

test("purge: deletes task + results as a group; leaves active work", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);

  const oldTask = await writeTerminalTask(fs, { state: "accepted", id: "tk-group1" });
  const oldTaskResult = await createReviewedResult(fs, {
    taskId: "tk-group1",
    resultsDir: "temp/sessions/ss-executor/results",
    report: "old accepted summary",
    status: "accepted",
  }, OLD);
  await fs.writeFile(
    oldTaskResult.path,
    (await fs.readFile(oldTaskResult.path))
      .replace(/createdAt: .*/, `createdAt: ${OLD}`)
      .replace(/updatedAt: .*/, `updatedAt: ${OLD}`)
  );

  const activePath = await writeTaskRecord(fs, clock(NOW), {
    requester: { kind: "user", id: "user" },
    executionSessionId: "ss-executor",
    nodeIds: ["cx-p2"],
    nodeSnapshots: [nodeSnapshot("cx-p2", "prompt/表达式任务书/草稿")],
    manifestPath: "temp/sessions/ss-executor/manifests/m2.md",
    prompt: "do not purge me",
    id: "tk-live01",
  });
  await patchTaskRecord(fs, activePath, { state: "running", updatedAt: NOW });

  const result = await purgeOperationalRetention(fs, {
    keepTerminalTasksDays: 30,
    now: NOW,
  });

  assert.ok(result.purged.taskPaths.includes(oldTask.path));
  assert.ok(result.purged.resultPaths.includes(oldTaskResult.path));
  assert.equal(await fs.exists(oldTask.path), false);
  assert.equal(await fs.exists(oldTaskResult.path), false);
  assert.equal(await fs.exists(activePath), true);
  assert.equal((await loadTaskRecord(fs, activePath)).state, "running");
});

test("purge: task removal failure preserves all related results", async () => {
  const dir = await makeTent();
  const seedFs = new NodeFs(dir);
  const task = await writeTerminalTask(seedFs, { state: "accepted", id: "tk-failrm1" });
  const oldResult = await createReviewedResult(seedFs, {
    taskId: "tk-failrm1",
    resultsDir: "temp/sessions/ss-executor/results",
    report: "must survive parent delete failure",
    status: "accepted",
  }, OLD);
  const failingFs = new FailTaskRemoveFs(dir, task.path);

  const purged = await purgeOperationalRetention(failingFs, {
    keepTerminalTasksDays: 0,
    now: NOW,
  });

  assert.equal(await seedFs.exists(task.path), true);
  assert.equal(await seedFs.exists(oldResult.path), true);
  assert.ok(!purged.purged.taskPaths.includes(task.path));
  assert.ok(!purged.purged.resultPaths.includes(oldResult.path));
  assert.ok(purged.warnings.some((warning) => warning.includes("injected task remove failure")));
});

test("purge: orphan terminal result is cleaned independently", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);
  const orphan = await createReviewedResult(fs, {
    taskId: "tk-gonegone",
    resultsDir: "temp/sessions/ss-executor/results",
    report: "orphan rejected",
    status: "rejected",
  }, OLD);
  await fs.writeFile(
    orphan.path,
    (await fs.readFile(orphan.path))
      .replace(/createdAt: .*/, `createdAt: ${OLD}`)
      .replace(/updatedAt: .*/, `updatedAt: ${OLD}`)
  );

  const result = await purgeOperationalRetention(fs, {
    keepTerminalTasksDays: 30,
    now: NOW,
  });
  assert.ok(result.candidates.some((c) => c.kind === "orphan-result"));
  assert.ok(result.purged.resultPaths.includes(orphan.path));
  assert.equal(await fs.exists(orphan.path), false);
});

test("preview: bad operational files stay on disk and appear in skipped/warnings", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);
  const badTask = "temp/sessions/ss-executor/tasks/broken-task.md";
  const badTaskResult = "temp/sessions/ss-executor/results/broken-rs.md";
  await fs.mkdir("temp/sessions/ss-executor/tasks");
  await fs.mkdir("temp/sessions/ss-executor/results");
  await fs.writeFile(badTask, "---\ntype: task\n---\nnot a valid envelope\n");
  await fs.writeFile(badTaskResult, "---\ntype: note\n---\nnot a result\n");

  const preview = await previewOperationalRetention(fs, {
    keepTerminalTasksDays: 0,
    now: NOW,
  });
  assert.ok(preview.skipped.some((s) => s.path === badTask));
  assert.ok(preview.skipped.some((s) => s.path === badTaskResult));
  assert.ok(preview.warnings.some((w) => w.includes(badTask)));
  assert.ok(preview.warnings.some((w) => w.includes(badTaskResult)));
  assert.equal(await fs.exists(badTask), true);
  assert.equal(await fs.exists(badTaskResult), true);

  const purged = await purgeOperationalRetention(fs, {
    keepTerminalTasksDays: 0,
    now: NOW,
  });
  assert.equal(await fs.exists(badTask), true);
  assert.equal(await fs.exists(badTaskResult), true);
  assert.ok(purged.skipped.some((s) => s.path === badTask));
});

test("preview: refuses task-group when a related result is draft or ready", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);
  const task = await writeTerminalTask(fs, { state: "rejected", id: "tk-readyblk" });
  // Terminal rejected task but still has a ready result (inconsistent / review pending)
  const ready = await createTaskResult(fs, clock(OLD), {
    taskId: "tk-readyblk",
    resultsDir: "temp/sessions/ss-executor/results",
    report: "still ready",
    status: "ready",
  });
  await fs.writeFile(
    ready.path,
    (await fs.readFile(ready.path))
      .replace(/createdAt: .*/, `createdAt: ${OLD}`)
      .replace(/updatedAt: .*/, `updatedAt: ${OLD}`)
  );

  const preview = await previewOperationalRetention(fs, {
    keepTerminalTasksDays: 0,
    now: NOW,
  });
  assert.ok(!preview.candidates.some((c) => c.taskPath === task.path));
  assert.ok(preview.warnings.some((w) => w.includes("non-terminal result")));
  assert.equal(await fs.exists(task.path), true);
  assert.equal(await fs.exists(ready.path), true);

});

test("preview is read-only (purge is the only mutator)", async () => {
  const dir = await makeTent();
  const fs = new NodeFs(dir);
  const task = await writeTerminalTask(fs, { state: "accepted", id: "tk-roonly" });
  await previewOperationalRetention(fs, { keepTerminalTasksDays: 0, now: NOW });
  assert.equal(await fs.exists(task.path), true);
});
