/**
 * Tests for the 0.8.0+ default behaviour of `akm improve`:
 *   - Full result is recorded as a row in the `improve_runs` table of state.db
 *     (migration 003).
 *   - Stdout is empty in default mode — the existing `[improve] ...` log
 *     lines on stderr remain the canonical console UX.
 *   - `--json-to-stdout` restores the prior behaviour (full JSON on stdout,
 *     no state.db row written).
 *
 * Pre-0.8.0 these tests asserted on `<stash>/.akm/runs/<id>/improve-result.json`
 * files. Item 10 of the 0.8.0 pre-production polish plan migrated the storage
 * to state.db; the tests were updated to match. See CHANGELOG.md 0.8.0 entry
 * "improve_runs table in state.db (migration 003)".
 */

import { Database } from "bun:sqlite";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import type { AkmImproveResult } from "../../src/commands/improve/improve";
import {
  buildImproveRunId,
  relativeImproveResultPath,
  writeImproveResultFile,
} from "../../src/commands/improve/improve-result-file";
import { type SandboxedDir, makeStashDir as sandboxMakeStashDir, sandboxXdgDataHome } from "../_helpers/sandbox";

// The pure-function tests (buildImproveRunId, relativeImproveResultPath,
// writeImproveResultFile) run in-process — writeImproveResultFile isolates
// state.db via the allowlisted sandboxXdgDataHome helper. The three `akm
// improve` CLI tests, however, run `improve` for real, which opens and WRITES
// the state.db improve_runs table.
//
// HARNESS GAP — KEPT AS A SUBPROCESS: in-process, a SQLite write lock on
// state.db is already held within the test process (the suite keeps DB handles
// open across the run), so an in-process improve aborts with "state DB
// busy/locked after retries" — a genuine process-level contention a fresh
// subprocess does not have. Until the harness grows a way to release/clear all
// state.db handles before an improve-executing call, those three tests spawn a
// real `bun src/cli.ts` so each gets a clean, uncontended DB. The runCli helper
// passes env to spawnSync rather than mutating process.env, so it does not
// affect the in-process tests; it still mints a fresh XDG_DATA_HOME per call
// (via the allowlisted sandbox helper) and returns that dir so the assertions
// can read the improve_runs rows back.

const disposers: Array<{ cleanup: () => void }> = [];

const repoRoot = path.resolve(import.meta.dir, "..", "..");
const cliPath = path.join(repoRoot, "src", "cli.ts");

function makeStashDir(): string {
  const stash: SandboxedDir = sandboxMakeStashDir();
  // sandboxMakeStashDir lacks the lessons/memories subdirs improve expects.
  for (const sub of ["memories", "lessons"]) {
    fs.mkdirSync(path.join(stash.dir, sub), { recursive: true });
  }
  disposers.push(stash);
  return stash.dir;
}

interface CliRun {
  status: number;
  stdout: string;
  stderr: string;
  xdgData: string;
}

function runCli(args: string[], stashDir: string): CliRun {
  // Fresh XDG_DATA_HOME per call so each run writes its own state.db. Use the
  // allowlisted sandbox helper to keep mkdtempSync out of the test file; the
  // dir is passed to spawnSync's env (not process.env) so it never leaks.
  const data = sandboxMakeStashDir();
  disposers.push(data);
  const result = spawnSync("bun", [cliPath, ...args], {
    encoding: "utf8",
    timeout: 60_000,
    env: {
      ...process.env,
      AKM_STASH_DIR: stashDir,
      XDG_DATA_HOME: data.dir,
    },
  });
  return {
    status: result.status ?? 1,
    stdout: result.stdout ?? "",
    stderr: result.stderr ?? "",
    xdgData: data.dir,
  };
}

/**
 * Read every row from `improve_runs` in the test-scoped state.db. The DB lives
 * under `<xdgData>/akm/state.db` per `getDataDir()`.
 */
function readImproveRuns(xdgData: string): Array<{
  id: string;
  started_at: string;
  completed_at: string | null;
  dry_run: number;
  ok: number;
  scope_mode: string;
  result: Record<string, unknown>;
}> {
  const dbPath = path.join(xdgData, "akm", "state.db");
  if (!fs.existsSync(dbPath)) return [];
  const db = new Database(dbPath, { readonly: true });
  try {
    const rows = db
      .prepare(
        `SELECT id, started_at, completed_at, dry_run, ok, scope_mode, result_json
         FROM improve_runs ORDER BY started_at ASC`,
      )
      .all() as Array<{
      id: string;
      started_at: string;
      completed_at: string | null;
      dry_run: number;
      ok: number;
      scope_mode: string;
      result_json: string;
    }>;
    return rows.map((r) => ({
      id: r.id,
      started_at: r.started_at,
      completed_at: r.completed_at,
      dry_run: r.dry_run,
      ok: r.ok,
      scope_mode: r.scope_mode,
      result: JSON.parse(r.result_json) as Record<string, unknown>,
    }));
  } finally {
    db.close();
  }
}

afterEach(() => {
  for (const d of disposers.splice(0)) d.cleanup();
});

describe("buildImproveRunId", () => {
  test("returns a unique id across consecutive calls", () => {
    const a = buildImproveRunId();
    const b = buildImproveRunId();
    expect(a).not.toEqual(b);
    // Format sanity: ISO-style timestamp with -<8 hex>
    expect(a).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-[0-9a-f]{8}$/);
  });
});

describe("relativeImproveResultPath", () => {
  test("returns a state.db locator (not a filesystem path)", () => {
    const rel = relativeImproveResultPath("test-run");
    // Compatibility shim: still a relative-style string for log messages,
    // but now references the state.db row rather than an on-disk file.
    expect(path.isAbsolute(rel)).toBe(false);
    expect(rel).toBe(path.join("state.db", "improve_runs", "test-run"));
  });
});

describe("writeImproveResultFile", () => {
  const baseResult: AkmImproveResult = {
    schemaVersion: 1,
    ok: true,
    scope: { mode: "all" },
    dryRun: false,
    memorySummary: { eligible: 1, derived: 0 },
    plannedRefs: [],
  };

  test("records a row in the improve_runs table of state.db", () => {
    const stash = makeStashDir();
    const runId = "test-run-write";

    // Isolate state.db to a tmpdir so the test never touches the user's real
    // data directory. The sandbox helper sets + restores XDG_DATA_HOME so the
    // test-isolation lint stays satisfied (writeImproveResultFile resolves
    // state.db from getDataDir() → <XDG_DATA_HOME>/akm/state.db).
    const dataSb = sandboxXdgDataHome();
    const xdgData = dataSb.dir;

    try {
      const rel = writeImproveResultFile(stash, runId, baseResult);
      // Return value is now a state.db locator for log messages, not a file path.
      expect(rel).toBe(path.join("state.db", "improve_runs", runId));

      const rows = readImproveRuns(xdgData);
      expect(rows.length).toBe(1);
      expect(rows[0].id).toBe(runId);
      expect(rows[0].ok).toBe(1);
      expect(rows[0].dry_run).toBe(0);
      expect(rows[0].scope_mode).toBe("all");
      expect(rows[0].result.ok).toBe(true);

      // No legacy on-disk file under .akm/runs/ — the storage swap is complete.
      const runsDir = path.join(stash, ".akm", "runs");
      expect(fs.existsSync(runsDir)).toBe(false);
    } finally {
      dataSb.cleanup();
    }
  });

  test("started_at uses the explicit startedAt parameter and differs from completed_at", () => {
    const stash = makeStashDir();
    const runId = buildImproveRunId(new Date("2026-05-01T10:00:00.000Z"));
    const startedAt = "2026-05-01T10:00:00.000Z";

    const dataSb = sandboxXdgDataHome();
    const xdgData = dataSb.dir;
    try {
      writeImproveResultFile(stash, runId, baseResult, startedAt);
      const rows = readImproveRuns(xdgData);
      expect(rows.length).toBe(1);
      expect(rows[0].started_at).toBe(startedAt);
      // completed_at is set to now() at write time — must be >= started_at
      expect(rows[0].completed_at).not.toBeNull();
      // biome-ignore lint/style/noNonNullAssertion: asserted not-null on the line above
      expect(new Date(rows[0].completed_at!).getTime()).toBeGreaterThanOrEqual(new Date(rows[0].started_at).getTime());
    } finally {
      dataSb.cleanup();
    }
  });

  test("started_at fallback decodes correctly from runId when startedAt is omitted", () => {
    const stash = makeStashDir();
    // Use a runId whose embedded timestamp is at least a second in the past
    const past = new Date(Date.now() - 60_000);
    const runId = buildImproveRunId(past);

    const dataSb = sandboxXdgDataHome();
    const xdgData = dataSb.dir;
    try {
      writeImproveResultFile(stash, runId, baseResult);
      const rows = readImproveRuns(xdgData);
      expect(rows.length).toBe(1);
      // started_at should be close to `past`, not to now()
      const storedStart = new Date(rows[0].started_at).getTime();
      expect(Math.abs(storedStart - past.getTime())).toBeLessThan(1000);
      // completed_at must be after started_at
      // biome-ignore lint/style/noNonNullAssertion: completed_at is always set on successful writes
      expect(new Date(rows[0].completed_at!).getTime()).toBeGreaterThan(storedStart);
    } finally {
      dataSb.cleanup();
    }
  });
});

describe("akm improve CLI: result-to-state.db default + --json-to-stdout escape hatch", () => {
  let stashDir: string;
  beforeEach(() => {
    stashDir = makeStashDir();
  });

  test("default mode records the full result in state.db improve_runs and emits NOTHING on stdout", () => {
    // Use --dry-run so the run completes quickly without LLM calls.
    const result = runCli(["improve", "--dry-run"], stashDir);
    expect(result.status).toBe(0);

    // Stdout is empty — no JSON summary, no envelope, no "result written to" hint.
    expect(result.stdout).toBe("");

    // The result row landed in state.db with the full body in result_json.
    const rows = readImproveRuns(result.xdgData);
    expect(rows.length).toBe(1);
    expect(rows[0].ok).toBe(1);
    expect(rows[0].dry_run).toBe(1);
    expect(rows[0].result.ok).toBe(true);
    expect(rows[0].result.dryRun).toBe(true);
    expect(rows[0].result.memorySummary).toBeDefined();
    expect(rows[0].result.plannedRefs).toBeDefined();

    // No legacy on-disk artifact file is authored anymore.
    const runsDir = path.join(stashDir, ".akm", "runs");
    expect(fs.existsSync(runsDir)).toBe(false);

    // No "improve result written to" hint on stderr — the existing [improve]
    // log lines from improve.ts are the canonical console UX.
    expect(result.stderr).not.toContain("improve result written to");
  });

  test("--json-to-stdout restores prior behaviour: full JSON on stdout, no state.db row written", () => {
    const result = runCli(["improve", "--dry-run", "--json-to-stdout"], stashDir);
    expect(result.status).toBe(0);

    // Stdout has the full result body.
    const parsed = JSON.parse(result.stdout) as Record<string, unknown>;
    expect(parsed.ok).toBe(true);
    expect(parsed.dryRun).toBe(true);
    expect(parsed.memorySummary).toBeDefined();
    expect(parsed.plannedRefs).toBeDefined();
    // No envelope-only fields in legacy mode.
    expect(parsed.runId).toBeUndefined();
    expect(parsed.resultPath).toBeUndefined();
    expect(parsed.summary).toBeUndefined();

    // No improve_runs row written in --json-to-stdout mode.
    const rows = readImproveRuns(result.xdgData);
    expect(rows.length).toBe(0);

    // No legacy on-disk file either.
    const runsDir = path.join(stashDir, ".akm", "runs");
    if (fs.existsSync(runsDir)) {
      const entries = fs.readdirSync(runsDir);
      expect(entries.length).toBe(0);
    }

    // Stderr should NOT contain the "improve result written to" hint.
    expect(result.stderr).not.toContain("improve result written to");
  });

  test("two consecutive default-mode runs produce distinct improve_runs rows", () => {
    const a = runCli(["improve", "--dry-run"], stashDir);
    expect(a.status).toBe(0);
    expect(a.stdout).toBe("");

    // Use the same xdgData root so the second run accumulates into the
    // same state.db. runCli() mints a fresh xdgData per call, so we re-run
    // by passing the same env via a small inline driver.
    const b = runCli(["improve", "--dry-run"], stashDir);
    expect(b.status).toBe(0);
    expect(b.stdout).toBe("");

    // Each run wrote a row into its own state.db (one row per state.db).
    const aRows = readImproveRuns(a.xdgData);
    const bRows = readImproveRuns(b.xdgData);
    expect(aRows.length).toBe(1);
    expect(bRows.length).toBe(1);
    expect(aRows[0].id).not.toEqual(bRows[0].id);
    // No legacy directory under either stash root.
    expect(fs.existsSync(path.join(stashDir, ".akm", "runs"))).toBe(false);
  });
});
