import { expect } from "e2e";
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, readlinkSync, utimesSync, watch } from "node:fs";
import { join } from "node:path";
import { test, WEB, makeState, runPromise } from "./t3.ts";
import { APP_DIR, T3_HOME } from "../e2e.config.ts";

const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" });

test("Cleanup preserves tracked edits hidden from Git status", WEB, async ({ t3, app, screen, browser }) => {
  const project = await t3.project("cleanup-demo", { "config.txt": "original configuration\n" });
  const fixture = makeState(T3_HOME);
  for (const [title, branch] of [["Assume unchanged edit", "assume-unchanged"], ["Skip worktree edit", "skip-worktree"], ["Clean control", "clean-control"]]) {
    await runPromise(fixture.cli(["thread", "start", project, "Finish the fixture turn", "--title", title!, "--provider", "devReplay", "--model", "multi-turn", "--worktree", "main", "--branch", branch!, "--wait", "--json"]));
  }
  const worktrees = git(project, "worktree", "list", "--porcelain").split("\n\n").slice(1).filter(Boolean).map(block => {
    const lines = block.split("\n");
    return { path: lines[0]!.slice(9), branch: lines.find(line => line.startsWith("branch "))!.split("/").at(-1)! };
  });
  for (const tree of worktrees) {
    if (tree.branch !== "clean-control") {
      git(tree.path, "update-index", `--${tree.branch}`, "config.txt");
      writeFileSync(join(tree.path, "config.txt"), `valuable ${tree.branch} edit\n`);
    }
    if (git(tree.path, "status", "--porcelain").trim()) throw new Error("Fixture Git status must be empty");
  }
  await t3.open();
  await screen.getByText("cleanup-demo", { exact: true }).first().waitFor({ state: "visible", timeout: 120_000 });
  await app.open("/settings/worktrees");
  await screen.getByRole("switch", "Delete inactive worktrees", { exact: true }).tap();
  const days = screen.getByRole("textbox", "Delete inactive worktrees in days");
  await days.fill("1");
  await days.press("Enter");
  await screen.getByRole("button", "Advanced", { exact: true }).last().tap();
  await screen.getByRole("combobox", "Keep worktrees with local changes").tap();
  await screen.getByRole("option", "Any local files", { exact: true }).tap();
  // Hide machine-local fixture paths from public captures; reasons and thread titles stay visible.
  await browser.evaluate(() => {
    const style = document.createElement("style");
    style.textContent = 'code { font-size: 0 !important } code::after { content: "[disposable fixture folder]"; font-size: 12px }';
    document.head.append(style);
  });
  await app.open("/settings/maintenance");
  await screen.getByRole("button", "Delete now", { exact: true }).waitFor({ state: "visible" });
  await browser.evaluate(() => {
    const style = document.createElement("style");
    style.textContent = 'code { font-size: 0 !important } code::after { content: "[disposable fixture folder]"; font-size: 12px }';
    document.head.append(style);
  });
  const runtime = JSON.parse(readFileSync(join(T3_HOME, "userdata/server-runtime.json"), "utf8"));
  const listeners = execFileSync("ss", ["-H", "-ltnp", `sport = :${runtime.port}`], { encoding: "utf8" });
  if (!listeners.includes(`pid=${runtime.pid},`) || readlinkSync(`/proc/${runtime.pid}/cwd`) !== join(APP_DIR, "apps/server")) throw new Error("Not the isolated fixture server");
  // Stop the isolated server to release idle provider sessions before aging fixture timestamps.
  process.kill(runtime.pid, "SIGTERM");
  execFileSync("tail", ["--pid=" + runtime.pid, "-f", "/dev/null"]);
  const restarted = new Promise<void>((resolve) => {
    const watcher = watch(join(T3_HOME, "userdata"), () => {
      const state = join(T3_HOME, "userdata/server-runtime.json");
      if (!existsSync(state)) return;
      try {
        if (JSON.parse(readFileSync(state, "utf8")).pid !== runtime.pid) {
          watcher.close();
          resolve();
        }
      } catch {}
    });
  });
  // The dev server's own Node watcher starts the same app again, with the same home and ports.
  utimesSync(join(APP_DIR, "apps/server/src/bin.ts"), new Date(), new Date());
  await restarted;
  await app.open("/settings/maintenance");
  await screen.getByRole("button", "Delete now", { exact: true }).waitFor({ state: "visible" });
  await browser.evaluate(() => {
    const style = document.createElement("style");
    style.textContent = 'code { font-size: 0 !important } code::after { content: "[disposable fixture folder]"; font-size: 12px } * { transition-duration: 0s !important; animation-duration: 0s !important }';
    document.head.append(style);
  });
  await screen.getByRole("button", /Last run/).waitFor({ state: "visible" });
  const restartedRuntime = JSON.parse(readFileSync(join(T3_HOME, "userdata/server-runtime.json"), "utf8"));
  const restartedListeners = execFileSync("ss", ["-H", "-ltnp", `sport = :${restartedRuntime.port}`], { encoding: "utf8" });
  if (!restartedListeners.includes(`pid=${restartedRuntime.pid},`) || readlinkSync(`/proc/${restartedRuntime.pid}/cwd`) !== join(APP_DIR, "apps/server")) throw new Error("Not the restarted fixture server");
  process.kill(restartedRuntime.pid, "SIGSTOP");
  try {
    execFileSync("python3", ["-c", `
import sqlite3, json, sys
from datetime import datetime, timezone, timedelta
con = sqlite3.connect(sys.argv[1])
old = (datetime.now(timezone.utc)-timedelta(days=2)).isoformat(timespec='milliseconds').replace('+00:00','Z')
def age(v):
    if isinstance(v,dict): return {k:age(x) for k,x in v.items()}
    if isinstance(v,list): return [age(x) for x in v]
    if isinstance(v,str) and len(v)>20 and v[4:5]=='-' and v[10:11]=='T' and v.endswith('Z'): return old
    return v
for (table,) in con.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'orchestration_v2_projection_%'").fetchall():
    cols = [r[1] for r in con.execute('PRAGMA table_info("'+table+'")')]
    for col in cols:
        if col.endswith('_at'):
            con.execute('UPDATE "'+table+'" SET "'+col+'"=? WHERE "'+col+'" IS NOT NULL', (old,))
    if 'payload_json' in cols:
        for rowid,payload in con.execute('SELECT rowid,payload_json FROM "'+table+'"').fetchall():
            con.execute('UPDATE "'+table+'" SET payload_json=? WHERE rowid=?',(json.dumps(age(json.loads(payload))),rowid))
con.commit()
con.close()
`, join(T3_HOME, "userdata/statev2.sqlite")]);
  } finally { process.kill(restartedRuntime.pid, "SIGCONT"); }
  await screen.getByRole("button", "Delete now", { exact: true }).tap();
  await screen.getByRole("button", /Last run [1-9][0-9]* removed/).waitFor({ state: "visible" });
  await screen.getByRole("button", /Last run/).tap();
  await screen.getByText("Clean control", { exact: true }).last().waitFor({ state: "visible" });
  await browser.evaluate(() => {
    document.getElementById("storage-cleanup-last-run")?.scrollIntoView({ block: "start" });
  });
  await app.screenshot("cleanup-result");
  const survival = Object.fromEntries(worktrees.map(tree => [tree.branch, existsSync(join(tree.path, "config.txt")) ? readFileSync(join(tree.path, "config.txt"), "utf8") : null]));
  console.log("Observed fixture file contents:", survival);
  expect(survival).toEqual({ "assume-unchanged": "valuable assume-unchanged edit\n", "skip-worktree": "valuable skip-worktree edit\n", "clean-control": null });
  await expect(screen.getByText("has uncommitted changes hidden from git status (config.txt)", { exact: false }).first()).toBeVisible();
});
