import { createHash, randomBytes } from "node:crypto";
import {
  chmodSync,
  copyFileSync,
  existsSync,
  lstatSync,
  mkdirSync,
  readdirSync,
  readFileSync,
  unlinkSync,
  writeFileSync,
} from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { DatabaseSync } from "./sqlite.ts";
import { KxmConfigError, type KxmConfigIssue } from "./project-config.ts";
import { kxmProjectRunEventsPath, kxmRuntimePaths, projectRuntimeKey, type KxmRuntimePaths } from "./runtime-paths.ts";

export interface DatabaseSchemaSpec {
  schema: string;
  version: number;
  tables?: Readonly<Record<string, readonly string[]>>;
  timeoutMs?: number;
}

export interface BackupStoreRecord {
  storeId: string;
  sourcePath: string;
  backupFile: string;
  schemaVersion: number;
  sha256: string;
  bytes: number;
  integrity: "ok";
}

export interface BackupFileRecord {
  id: string;
  sourcePath: string;
  backupFile: string;
  sha256: string;
  bytes: number;
}

/**
 * What a backup holds from the user state root. `project` (the default): the
 * checkout's own Runtime event store and its prompt sidecar. `all-projects`: the
 * shared Runtime registry and every project's event store, so restoring it rolls
 * back every project on the machine.
 */
export type KxmBackupScope = "project" | "all-projects";

export interface BackupManifest {
  schema: "kxm.backup-manifest.v1";
  backupId: string;
  createdAt: string;
  projectRoot?: string;
  /** Absent on manifests written before backups were scoped. */
  scope?: KxmBackupScope;
  /** The user state root the Runtime stores were copied from; restore rebases them onto the current one. */
  stateRoot?: string;
  /** The Runtime store key of the checkout the backup ran from, derived as the Runtime derives it. */
  runtimeProjectKey?: string;
  stores: BackupStoreRecord[];
  files?: BackupFileRecord[];
  /** False when a discovered store or prompt sidecar was left out. Absent on legacy manifests. */
  complete?: boolean;
  omitted?: string[];
  manifestSha256?: string;
}

export interface RestoreStoreRecord {
  storeId: string;
  sourcePath: string;
  backupFile: string;
  schemaVersion: number;
  integrity: "ok";
}

export interface RestoreResult {
  manifestPath: string;
  backupId: string;
  restoredStores: RestoreStoreRecord[];
}

export function databaseError(code: string, file: string, message: string): KxmConfigError {
  const issue: KxmConfigIssue = { phase: "semantic", code, file, message };
  return new KxmConfigError([issue]);
}

export function checkedParent(path: string, description: string): void {
  const parent = dirname(path);
  if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o700 });
  const stat = lstatSync(parent, { throwIfNoEntry: false });
  if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
    throw databaseError("runtime_path_invalid", description, `${description} parent must be a regular directory, not a link`);
  }
}

export function userTables(database: DatabaseSync): string[] {
  const rows = database.prepare(
    "SELECT name FROM sqlite_master WHERE type = \x27table\x27 AND name NOT LIKE \x27sqlite_%\x27 ORDER BY name",
  ).all() as Array<{ name: string }>;
  return rows.map((row) => row.name);
}

export function tableColumns(database: DatabaseSync, table: string): string[] {
  const rows = database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
  return rows.map((row) => row.name).sort();
}

export function verifyExpectedTables(
  database: DatabaseSync,
  file: string,
  description: string,
  expected: Readonly<Record<string, readonly string[]>>,
): void {
  const present = new Set(userTables(database));
  for (const [table, columns] of Object.entries(expected)) {
    if (!present.has(table)) {
      throw databaseError("runtime_schema_shape_invalid", file, `${description} is missing table ${table}`);
    }
    const actual = tableColumns(database, table);
    const missing = columns.filter((column) => !actual.includes(column));
    if (missing.length > 0) {
      throw databaseError("runtime_schema_shape_invalid", file, `${description} table ${table} is missing columns ${missing.join(", ")}`);
    }
  }
}

export function ensureWalJournalMode(database: DatabaseSync, file: string, description: string, timeoutMs = 5000): void {
  const deadline = Date.now() + timeoutMs;
  const sleeper = new Int32Array(new SharedArrayBuffer(4));
  while (true) {
    try {
      const current = database.prepare("PRAGMA journal_mode").get() as { journal_mode?: string } | undefined;
      if (current?.journal_mode === "wal") {
        return;
      }
      const updated = database.prepare("PRAGMA journal_mode = WAL").get() as { journal_mode?: string } | undefined;
      if (updated?.journal_mode === "wal") {
        return;
      }
    } catch (error) {
      const sqliteError = error as { code?: string; errcode?: number };
      if (sqliteError.code === "ERR_SQLITE_ERROR" && sqliteError.errcode === 5 && Date.now() < deadline) {
        Atomics.wait(sleeper, 0, 0, 10);
        continue;
      }
      throw error;
    }
    if (Date.now() >= deadline) {
      throw databaseError("runtime_timeout", file, `${description} timed out enabling WAL journal mode`);
    }
    Atomics.wait(sleeper, 0, 0, 10);
  }
}

/** Parent, file, and WAL sidecar must be regular filesystem objects. A symlink is refused before the file is opened. */
export function assertDatabaseFile(file: string, description: string): void {
  checkedParent(file, description);
  const stat = lstatSync(file, { throwIfNoEntry: false });
  if (stat) {
    if (stat.isSymbolicLink() || !stat.isFile()) {
      throw databaseError("runtime_path_invalid", description, `${description} must be a regular file, not a link or directory`);
    }
  }
  for (const sidecar of [`${file}-wal`, `${file}-shm`]) {
    const info = lstatSync(sidecar, { throwIfNoEntry: false });
    if (info?.isSymbolicLink()) {
      throw databaseError("runtime_path_invalid", description, `${description} sidecar must not be a link`);
    }
  }
}

export function openDatabase(file: string, description: string, spec: DatabaseSchemaSpec): DatabaseSync {
  const isMemory = file === ":memory:";
  if (!isMemory) {
    assertDatabaseFile(file, description);
  }

  const database = new DatabaseSync(file);
  let transaction = false;
  try {
    database.exec(`PRAGMA busy_timeout = ${spec.timeoutMs ?? 5000}`);
    if (!isMemory) {
      ensureWalJournalMode(database, file, description, spec.timeoutMs);
    }
    database.exec("PRAGMA synchronous = NORMAL");
    database.exec("PRAGMA foreign_keys = ON");

    database.exec("BEGIN IMMEDIATE");
    transaction = true;
    const row = database.prepare("PRAGMA user_version").get() as { user_version: number } | undefined;
    const version = row?.user_version ?? 0;

    if (version > spec.version) {
      throw databaseError("runtime_schema_newer", file, `${description} schema version ${version} is newer than this runtime supports`);
    }

    if (version === 0) {
      const existing = userTables(database);
      if (existing.length > 0) {
        throw databaseError("runtime_schema_shape_invalid", file, `${description} has tables at schema version 0`);
      }
      database.exec(spec.schema);
      database.exec(`PRAGMA user_version = ${spec.version}`);
    } else if (version < spec.version) {
      // No migration lanes, except the Runtime registry copy from schema 1 to 2
      // (migrateRegistryLanes). This is a single-operator tool: every other older
      // database is re-initialised, not upgraded in place, and the code never carries
      // two schema shapes at once. Silently accepting an older file would mean every
      // query has to work against shapes it no longer tests.
      throw databaseError(
        "runtime_schema_outdated",
        file,
        `${description} is schema version ${version}; this build requires ${spec.version}. Delete the state file to start fresh and let its owning process recreate it (\`kxm hub start\` for hub state, the Runtime for event stores). The Runtime registry copy from schema 1 to 2 is the one exception. \`kxm init\` is project-only and rebuilds no database. Upgrading any other old state in place is deliberately unsupported`,
      );
    }

    if (spec.tables) {
      verifyExpectedTables(database, file, description, spec.tables);
    }

    database.exec("COMMIT");
    transaction = false;
    return database;
  } catch (error) {
    if (transaction) {
      try { database.exec("ROLLBACK"); } catch { /* already rolled back */ }
    }
    database.close();
    throw error;
  }
}

const activeTransactions = new WeakSet<DatabaseSync>();

/**
 * How long a connection refuses to retry a `BEGIN` that lost the write race.
 *
 * SQLite's busy timeout is per connection, so a `BEGIN` against a locked
 * database waits the full timeout before failing. Callers that recover from a
 * lost write open several transactions in a row; without this window each of
 * them pays the timeout, and a suite run showed that turning one 4-second test
 * into 17 minutes. The old code got that speed by accident — it left the
 * connection permanently marked as in-transaction after a failed `BEGIN`, which
 * is the defect fixed below — so the backoff replaces the fast-fail without
 * re-introducing the poison.
 *
 * Two limits, both deliberate:
 * - It is a **throttle, not a queue**. A caller whose lock cleared 1 ms later is
 *   still refused for the rest of the window; the refusal is explicit
 *   (`runtime_transaction_busy`, message says `retry deferred`) and bounded by
 *   this constant. A retry after the window may pay the busy timeout again.
 * - It is **per connection object, in this process**. It is not a cross-process
 *   backoff and does not leak to another connection to the same file.
 */
export const TRANSACTION_BUSY_BACKOFF_MS = 1_000;

/**
 * Monotonic elapsed time, deliberately not `Date.now()`.
 *
 * A wall-clock step backwards would otherwise keep a long-gone write lock
 * refusing transactions until real time caught up, and a step forward would end
 * the throttle early. `hrtime.bigint()` is relative to an arbitrary past origin
 * and never moves backwards, so the window is bounded by
 * {@link TRANSACTION_BUSY_BACKOFF_MS} no matter what the system clock does.
 *
 * Injectable: production callers never pass it, and a test drives it directly so
 * the window is stepped rather than raced or slept through.
 */
export type MonotonicClock = () => number;

function monotonicNowMs(): number {
  return Number(process.hrtime.bigint() / 1_000_000n);
}

/**
 * Pending throttle per connection, **keyed by the clock that armed it**.
 *
 * The nesting is the fix, not tidiness. A deadline is a number from *some* clock,
 * and the injectable `clock` exists only so a test can step the window; comparing
 * a deadline armed by one clock against a reading taken from another is how an
 * injected "one hour from now" throttles a production caller that never passed a
 * clock at all — and how that same caller could clear a deadline it never armed.
 * Each clock domain therefore gets its own deadline and can only read, expire or
 * replace its own. The inner map is **weak in the clock**: it keeps no otherwise
 * unreachable clock function alive, so an attempt that builds a fresh closure per
 * call leaves nothing behind once that closure is collected. A clock that *is*
 * retained keeps its entry until it expires or is replaced — collection is neither
 * immediate nor size-bounded, and no committed test measures any of this, because no
 * production caller passes a clock.
 */
const transactionThrottles = new WeakMap<DatabaseSync, WeakMap<MonotonicClock, number>>();

/**
 * A clock reading this helper can reason about.
 *
 * Scope, stated as measured rather than as a slogan. The clock is read at **two call
 * sites**: checking an existing deadline on a non-`DEFERRED` attempt, and arming after a
 * contended `BEGIN` failure. Reads per call, each reproduced by an assertion in
 * `test/core/intake.test.ts`: clean success with no pending entry 0; successful
 * `DEFERRED`, pending entry present or not, 0; fresh contention 1; refusal inside a window
 * 1; an expired deadline followed by renewed contention 2 in that call; a permanent
 * `BEGIN` failure with no pending entry 0; a nested-transaction rejection 0.
 *
 * Which means it is wrong in both directions to say either "every `BEGIN` validates the
 * clock" or "the *only* transaction that skips it is a clean uncontended success" — the
 * second was mine, twice, and the second correction to it was still a slogan. The tests
 * count; the prose only points at them. Production cannot reach the guard at all, because
 * the default is `hrtime`; it exists so the injectable seam cannot become a silent
 * bypass.
 */
function finiteNow(clock: MonotonicClock, label: string): number {
  const now = clock();
  if (typeof now !== "number" || !Number.isFinite(now)) {
    throw databaseError(
      "runtime_transaction_clock_invalid",
      "transaction",
      `${label} must return a finite monotonic number; got ${String(now)}`,
    );
  }
  return now;
}

/**
 * Is this `BEGIN` failure contention for the write lock, as opposed to a
 * programming or environment error?
 *
 * Only contention may be retried, and only contention earns the throttle window.
 * Anything else — "cannot start a transaction within a transaction", a closed
 * connection, a miscompiled statement — must surface unchanged, or a permanent
 * bug looks like a transient one and the caller retries forever.
 *
 * SQLite's **numeric result code decides** where one exists, because it is stable
 * across versions while message text is not: the primary code is `code & 0xff`, so
 * extended forms land on their primaries — `SQLITE_BUSY_RECOVERY` (261) and
 * `SQLITE_BUSY_SNAPSHOT` (517) on `SQLITE_BUSY` (5), `SQLITE_LOCKED_SHAREDCACHE`
 * (262) on `SQLITE_LOCKED` (6). `SQLITE_PROTOCOL` (15) is included deliberately:
 * SQLite raises it when repeated attempts to start a transaction under WAL exhaust
 * the retry count, which is a lock-acquisition retry condition, not a broken
 * database.
 *
 * Where both runtimes expose one, **the number wins over the text**: Node spells it
 * `errcode`, `bun:sqlite` spells it `errno` (verified on Bun 1.3.14, where a shared-
 * cache `BEGIN` arrives as `errno: 262`, `code: "SQLITE_LOCKED_SHAREDCACHE"`,
 * message "database schema is locked: shared"). A symbolic `code`/`name` of the form
 * `SQLITE_BUSY*`/`SQLITE_LOCKED*`/`SQLITE_PROTOCOL*` is accepted next, and bare
 * message matching is the last resort — applied only when neither exists, so a
 * wrapper that merely quotes "database is locked" alongside a permanent code is not
 * mistaken for contention.
 *
 * Deliberately **not** contention: `SQLITE_FULL` / "database or disk is full",
 * "unable to open database file", and WAL shared-memory I/O failures. Those stay
 * broken until something outside this connection changes.
 */
/** Node: `errcode`. Bun: `errno`. Both spell the extended code as a number. */
const CONTENTION_PRIMARY_CODES: readonly number[] = [5, 6, 15];
/** `bun:sqlite` puts the symbolic name in `code`; Node puts its own kind there. */
const CONTENTION_SYMBOLIC_NAMES = /^SQLITE_(?:BUSY|LOCKED|PROTOCOL)(?:_[A-Z0-9]+)?$/;
/** Any SQLite result name. If one is present it decides, so text cannot argue. */
const SQLITE_RESULT_NAMES = /^SQLITE_[A-Z][A-Z0-9_]*$/;
const CONTENTION_MESSAGES =
  /^(?:database is locked|database table is locked|locking protocol|SQLITE_BUSY|SQLITE_LOCKED|SQLITE_PROTOCOL)(?:$|[\s.:])/i;

export function isTransactionContention(error: unknown): boolean {
  const carrier = error as { errcode?: unknown; errCode?: unknown; errno?: unknown; code?: unknown; name?: unknown }
    | undefined;
  // A numeric code wins first, then a symbolic result name, and both win **over the
  // message**: `errno` is what `bun:sqlite` exposes for the extended result code while
  // its `code` field holds the symbolic name, and Node spells the number `errcode`.
  // All three properties are read; what the list orders is which **integer value
  // decides** — a present `errcode` outranks `errCode`, which outranks `errno`, and a
  // later number is never consulted. Text is consulted only when the error carries
  // neither a number nor a SQLite result name, so no wrapper quoting an older
  // "database is locked" can outvote a code on either runtime.
  for (const value of [carrier?.errcode, carrier?.errCode, carrier?.errno]) {
    if (typeof value === "number" && Number.isInteger(value)) return CONTENTION_PRIMARY_CODES.includes(value & 0xff);
  }
  for (const value of [carrier?.code, carrier?.name]) {
    // A result **name** is as authoritative as a number, and in both directions:
    // `SQLITE_FULL` wearing a "database is locked" message is not contention. Node
    // spells its own error kind `ERR_SQLITE_ERROR`, which is deliberately not a
    // SQLite result name and so never reaches a verdict here.
    if (typeof value === "string" && SQLITE_RESULT_NAMES.test(value)) {
      return CONTENTION_SYMBOLIC_NAMES.test(value);
    }
  }
  return CONTENTION_MESSAGES.test(error instanceof Error ? error.message : String(error));
}

export function withDatabaseTransaction<T>(
  database: DatabaseSync,
  work: () => T,
  mode: "IMMEDIATE" | "DEFERRED" | "EXCLUSIVE" = "IMMEDIATE",
  clock: MonotonicClock = monotonicNowMs,
): T {
  if (activeTransactions.has(database)) {
    throw databaseError("runtime_transaction_nested", "transaction", "nested transactions are not allowed");
  }
  // A DEFERRED BEGIN takes no write lock, so it is exempt from the *check*: refusing
  // it would deny legitimate work over a contention it did not ask for. Exempt from
  // the check only — shared-cache schema locks can still make a deferred `BEGIN`
  // fail, and that failure arms a deadline like any other, because the next attempt
  // would stall the same way.
  if (mode !== "DEFERRED") {
    const deadlines = transactionThrottles.get(database);
    const until = deadlines?.get(clock);
    if (until !== undefined) {
      const remaining = until - finiteNow(clock, "the transaction clock");
      if (remaining > 0) {
        throw databaseError(
          "runtime_transaction_busy",
          "transaction",
          `a previous BEGIN was blocked on this database; retry deferred ${String(remaining)}ms`,
        );
      }
      deadlines?.delete(clock);
    }
  }
  // Claim the marker only once BEGIN has succeeded. If BEGIN throws — another
  // writer held the database past the busy timeout — a connection that never
  // entered a transaction must not be left permanently marked as inside one,
  // which would fail every later transaction on it with a misleading
  // "nested" error. `finally` cannot cover this: it only runs after the try.
  try {
    database.exec(`BEGIN ${mode}`);
  } catch (error) {
    if (!isTransactionContention(error)) throw error;
    const now = finiteNow(clock, "the transaction clock");
    let deadlines = transactionThrottles.get(database);
    if (deadlines === undefined) {
      deadlines = new WeakMap<MonotonicClock, number>();
      transactionThrottles.set(database, deadlines);
    }
    deadlines.set(clock, now + TRANSACTION_BUSY_BACKOFF_MS);
    throw databaseError(
      "runtime_transaction_busy",
      "transaction",
      `BEGIN ${mode} blocked by another transaction: ${error instanceof Error ? error.message : String(error)}`,
    );
  }
  activeTransactions.add(database);
  // A successful write-mode BEGIN means this connection is holding the slot, so any
  // earlier contention is over. A DEFERRED success proves nothing about the write
  // lock and must not clear a throttle that another caller's contention armed.
  if (mode !== "DEFERRED") transactionThrottles.get(database)?.delete(clock);
  try {
    const result = work();
    database.exec("COMMIT");
    return result;
  } catch (error) {
    // A failed ROLLBACK usually means the connection is gone. Clearing the marker
    // is bookkeeping, not proof: it does **not** show SQLite exited the
    // transaction, and nothing here invalidates a handle whose rollback failed.
    // Pre-existing, named rather than papered over — the caller receives the
    // original error.
    try { database.exec("ROLLBACK"); } catch { /* ignore rollback error if connection dead */ }
    throw error;
  } finally {
    activeTransactions.delete(database);
  }
}

export function checkpointWal(
  database: DatabaseSync,
  mode: "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE" = "TRUNCATE",
): { busy: number; log: number; checkpointed: number } {
  const row = database.prepare(`PRAGMA wal_checkpoint(${mode})`).get() as {
    busy?: number;
    log?: number;
    checkpointed?: number;
  } | undefined;
  return {
    busy: row?.busy ?? 0,
    log: row?.log ?? 0,
    checkpointed: row?.checkpointed ?? 0,
  };
}

export function checkIntegrity(database: DatabaseSync): boolean {
  const rows = database.prepare("PRAGMA integrity_check").all() as Array<{ integrity_check?: string }>;
  return rows.length === 1 && rows[0]?.integrity_check === "ok";
}

export function fileSha256(filePath: string): string {
  const bytes = readFileSync(filePath);
  return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
}

export function backupDatabaseFile(sourcePath: string, targetPath: string, storeId: string): BackupStoreRecord {
  const resolvedSource = resolve(sourcePath);
  const resolvedTarget = resolve(targetPath);

  const sourceStat = lstatSync(resolvedSource, { throwIfNoEntry: false });
  if (!sourceStat || !sourceStat.isFile() || sourceStat.isSymbolicLink()) {
    throw databaseError("runtime_path_invalid", resolvedSource, `source database ${resolvedSource} must be a regular file, not a link or directory`);
  }

  checkedParent(resolvedTarget, "backup target");

  if (existsSync(resolvedTarget)) {
    unlinkSync(resolvedTarget);
  }

  const sourceDb = new DatabaseSync(resolvedSource);
  let schemaVersion = 0;
  try {
    sourceDb.exec("PRAGMA busy_timeout = 5000");
    checkpointWal(sourceDb, "TRUNCATE");
    if (!checkIntegrity(sourceDb)) {
      throw databaseError("database_corrupted", resolvedSource, `database ${resolvedSource} failed integrity check`);
    }
    const versionRow = sourceDb.prepare("PRAGMA user_version").get() as { user_version: number } | undefined;
    schemaVersion = versionRow?.user_version ?? 0;

    const escapedTarget = resolvedTarget.replace(/\x27/g, "\x27\x27");
    sourceDb.exec(`VACUUM INTO \x27${escapedTarget}\x27`);
  } finally {
    sourceDb.close();
  }

  const targetDb = new DatabaseSync(resolvedTarget);
  try {
    targetDb.exec("PRAGMA busy_timeout = 5000");
    if (!checkIntegrity(targetDb)) {
      throw databaseError("database_corrupted", resolvedTarget, `backup database ${resolvedTarget} failed integrity check`);
    }
  } finally {
    targetDb.close();
  }

  try { chmodSync(resolvedTarget, 0o600); } catch { /* Windows */ }

  const sha256 = fileSha256(resolvedTarget);
  const bytes = lstatSync(resolvedTarget).size;

  return {
    storeId,
    sourcePath: resolvedSource,
    backupFile: basename(resolvedTarget),
    schemaVersion,
    sha256,
    bytes,
    integrity: "ok",
  };
}

export function restoreDatabaseFile(
  backupPath: string,
  targetPath: string,
  storeId: string,
  expectedSchemaVersion?: number,
  maxSupportedVersion?: number,
): RestoreStoreRecord {
  const resolvedBackup = resolve(backupPath);
  const resolvedTarget = resolve(targetPath);

  const backupStat = lstatSync(resolvedBackup, { throwIfNoEntry: false });
  if (!backupStat || !backupStat.isFile() || backupStat.isSymbolicLink()) {
    throw databaseError("runtime_path_invalid", resolvedBackup, `backup database ${resolvedBackup} must be a regular file, not a link or directory`);
  }

  const backupDb = new DatabaseSync(resolvedBackup);
  let schemaVersion = 0;
  try {
    backupDb.exec("PRAGMA busy_timeout = 5000");
    if (!checkIntegrity(backupDb)) {
      throw databaseError("database_corrupted", resolvedBackup, `backup database ${resolvedBackup} failed integrity check`);
    }
    const versionRow = backupDb.prepare("PRAGMA user_version").get() as { user_version: number } | undefined;
    schemaVersion = versionRow?.user_version ?? 0;

    if (maxSupportedVersion !== undefined && schemaVersion > maxSupportedVersion) {
      throw databaseError(
        "runtime_schema_newer",
        resolvedBackup,
        `backup store ${storeId} schema version ${schemaVersion} is newer than supported maximum ${maxSupportedVersion}`,
      );
    }
    if (expectedSchemaVersion !== undefined && schemaVersion !== expectedSchemaVersion) {
      throw databaseError(
        "runtime_schema_mismatch",
        resolvedBackup,
        `backup store ${storeId} schema version ${schemaVersion} does not match manifest version ${expectedSchemaVersion}`,
      );
    }
  } finally {
    backupDb.close();
  }

  checkedParent(resolvedTarget, "restore target");

  for (const file of [resolvedTarget, `${resolvedTarget}-wal`, `${resolvedTarget}-shm`]) {
    if (existsSync(file)) {
      try { unlinkSync(file); } catch { /* ignore */ }
    }
  }

  copyFileSync(resolvedBackup, resolvedTarget);
  try { chmodSync(resolvedTarget, 0o600); } catch { /* Windows */ }

  const targetDb = new DatabaseSync(resolvedTarget);
  try {
    targetDb.exec("PRAGMA busy_timeout = 5000");
    if (!checkIntegrity(targetDb)) {
      throw databaseError("database_corrupted", resolvedTarget, `restored database ${resolvedTarget} failed integrity check`);
    }
  } finally {
    targetDb.close();
  }

  return {
    storeId,
    sourcePath: resolvedTarget,
    backupFile: basename(resolvedBackup),
    schemaVersion,
    integrity: "ok",
  };
}

/**
 * Per-store restore ceilings, held in **one** table read by both backup discovery
 * and restore. Two copies of these numbers drifted once already: a schema bump
 * raised the restore ceiling and left the discovery ceiling behind, and nothing
 * caught it because nothing read the stale copy.
 *
 * Each value MUST track the version its store declares — `HUB_STORE_SCHEMA_VERSION`
 * in `store.ts`, `KXM_REGISTRY_SCHEMA_VERSION` and `KXM_EVENT_STORE_SCHEMA_VERSION`
 * in `runtime-store.ts`. Those modules import this one, so the constants cannot be
 * named here; the gate is the named test
 * `restore ceilings track every store's own schema version` in
 * `test/core/e6-backup-restore-migrations.test.ts`, which is what the deleted
 * "must track" comments were only pretending to be.
 */
export const KXM_BACKUP_CEILINGS = {
  "hub-store": 5,
  registry: 2,
  "binding-store": 1,
  events: 7,
} as const;

export function kxmBackupCeiling(storeId: string): number {
  if (storeId === "runtime-registry") return KXM_BACKUP_CEILINGS.registry;
  if (storeId.startsWith("events:")) return KXM_BACKUP_CEILINGS.events;
  // An id this build does not know keeps the ceiling restore has always defaulted to.
  return KXM_BACKUP_CEILINGS[storeId as keyof typeof KXM_BACKUP_CEILINGS] ?? KXM_BACKUP_CEILINGS["hub-store"];
}

interface DiscoveredStore {
  storeId: string;
  sourcePath: string;
  maxSupportedVersion: number;
}

interface DiscoveredFile {
  id: string;
  sourcePath: string;
}

function pushStore(stores: DiscoveredStore[], storeId: string, sourcePath: string): void {
  if (stores.some((store) => store.storeId === storeId || store.sourcePath === sourcePath)) return;
  stores.push({ storeId, sourcePath, maxSupportedVersion: kxmBackupCeiling(storeId) });
}

interface DiscoveredSources {
  stores: DiscoveredStore[];
  files: DiscoveredFile[];
  stateRoot?: string;
  runtimeProjectKey: string;
}

interface BackupDiscoverOptions {
  hubDataPath?: string;
  env?: NodeJS.ProcessEnv;
  allProjects?: boolean;
}

function pushEventStore(stores: DiscoveredStore[], files: DiscoveredFile[], paths: KxmRuntimePaths, key: string): void {
  const dbPath = join(paths.projectsDir, key, "run-events.db");
  if (!existsSync(dbPath)) return;
  const safe = key.replace(/[^a-zA-Z0-9_.-]/g, "_");
  let storeId = `events:${safe}`;
  if (stores.some((store) => store.storeId === storeId)) storeId = `events:runtime:${safe}`;
  pushStore(stores, storeId, dbPath);
  const sidecar = `${dbPath}.run-prompts.json`;
  if (existsSync(sidecar)) {
    const id = `${storeId}:run-prompts`;
    if (!files.some((file) => file.id === id || file.sourcePath === sidecar)) {
      files.push({ id, sourcePath: sidecar });
    }
  }
}

/**
 * The Runtime stores under the user state root. By default only this checkout's
 * event store: the registry and the other projects' event stores are shared by every
 * project on the machine, and a restore of them rolls all of those projects back, so
 * only an explicit `allProjects` backup holds them.
 */
function discoverUserRuntime(
  runtimeProjectKey: string,
  options: BackupDiscoverOptions,
  stores: DiscoveredStore[],
  files: DiscoveredFile[],
): string | undefined {
  let paths: KxmRuntimePaths;
  try {
    paths = kxmRuntimePaths(options.env ? { env: options.env } : {});
  } catch {
    return undefined;
  }
  if (!options.allProjects) {
    pushEventStore(stores, files, paths, runtimeProjectKey);
    return paths.stateRoot;
  }
  if (existsSync(paths.registryDb)) {
    const storeId = stores.some((store) => store.storeId === "registry") ? "runtime-registry" : "registry";
    pushStore(stores, storeId, paths.registryDb);
  }
  if (!existsSync(paths.projectsDir)) return paths.stateRoot;
  let entries;
  try {
    entries = readdirSync(paths.projectsDir, { withFileTypes: true });
  } catch {
    return paths.stateRoot;
  }
  for (const entry of entries) {
    if (entry.isDirectory()) pushEventStore(stores, files, paths, entry.name);
  }
  return paths.stateRoot;
}

function discoverBackupSources(projectRoot: string, options: BackupDiscoverOptions = {}): DiscoveredSources {
  const root = resolve(projectRoot);
  const stores: DiscoveredStore[] = [];
  const files: DiscoveredFile[] = [];

  const hubPath = options.hubDataPath ? resolve(options.hubDataPath) : join(root, ".kxm", "state", "kxm.db");
  if (existsSync(hubPath)) pushStore(stores, "hub-store", hubPath);

  const registryPath = join(root, ".kxm", "runtime", "registry.db");
  if (existsSync(registryPath)) pushStore(stores, "registry", registryPath);

  const bindingsPath = join(root, ".kxm", "runtime", "bindings.db");
  if (existsSync(bindingsPath)) pushStore(stores, "binding-store", bindingsPath);

  const eventsDir = join(root, ".kxm", "runtime", "events");
  if (existsSync(eventsDir)) {
    const entries = readdirSync(eventsDir, { withFileTypes: true });
    for (const entry of entries) {
      if (entry.isFile() && entry.name.endsWith(".db")) {
        const key = entry.name.replace(/\.db$/, "").replace(/[^a-zA-Z0-9_.-]/g, "_");
        pushStore(stores, `events:${key}`, join(eventsDir, entry.name));
      }
    }
  }

  const runtimeProjectKey = projectRuntimeKey(root);
  const stateRoot = discoverUserRuntime(runtimeProjectKey, options, stores, files);
  return { stores, files, runtimeProjectKey, ...(stateRoot !== undefined ? { stateRoot } : {}) };
}

function backupPlainFile(sourcePath: string, targetPath: string, id: string): BackupFileRecord {
  const resolvedSource = resolve(sourcePath);
  const sourceStat = lstatSync(resolvedSource, { throwIfNoEntry: false });
  if (!sourceStat || !sourceStat.isFile() || sourceStat.isSymbolicLink()) {
    throw databaseError("runtime_path_invalid", resolvedSource, `backup file ${resolvedSource} must be a regular file, not a link or directory`);
  }
  checkedParent(targetPath, "backup target");
  if (existsSync(targetPath)) unlinkSync(targetPath);
  copyFileSync(resolvedSource, targetPath);
  try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
  return {
    id,
    sourcePath: resolvedSource,
    backupFile: basename(targetPath),
    sha256: fileSha256(targetPath),
    bytes: sourceStat.size,
  };
}

function backupFilename(sourcePath: string, id: string, used: Set<string>): string {
  let filename = basename(sourcePath);
  if (used.has(filename)) {
    filename = `${id.replace(/[^a-zA-Z0-9_.-]/g, "_")}-${filename}`;
  }
  used.add(filename);
  return filename;
}

function restorePlainFile(backupFilePath: string, targetPath: string): void {
  checkedParent(targetPath, "restore target");
  if (existsSync(targetPath)) unlinkSync(targetPath);
  copyFileSync(backupFilePath, targetPath);
  try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
}

export function discoverProjectStores(projectRoot: string, options: BackupDiscoverOptions = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
  return discoverBackupSources(projectRoot, options).stores;
}

export interface BackupPlan {
  projectRoot: string;
  outDir: string;
  createdAt: string;
  scope: KxmBackupScope;
  stateRoot?: string;
  runtimeProjectKey: string;
  stores: Array<{ storeId: string; sourcePath: string; backupFile: string }>;
  files: Array<{ id: string; sourcePath: string; backupFile: string }>;
}

function backupDiscoverOptions(options: BackupDiscoverOptions): BackupDiscoverOptions {
  return {
    ...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
    ...(options.env !== undefined ? { env: options.env } : {}),
    ...(options.allProjects === true ? { allProjects: true } : {}),
  };
}

/** Which stores and files a backup would copy and where, without opening any of them.
 * Opening a source for backup checkpoints its WAL, so the plan stays at paths. */
export function planBackup(options: BackupDiscoverOptions & {
  projectRoot?: string;
  outDir?: string;
} = {}): BackupPlan {
  const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
  const discovered = discoverBackupSources(projectRoot, backupDiscoverOptions(options));

  if (discovered.stores.length === 0) {
    throw databaseError("backup_no_stores", projectRoot, "no existing SQLite stores found to backup");
  }

  const createdAt = new Date().toISOString();
  const timestamp = createdAt.replace(/[:.]/g, "-");
  const outDir = options.outDir ? resolve(options.outDir) : join(projectRoot, ".kxm", "backups", `backup-${timestamp}`);
  const usedFilenames = new Set<string>();
  const stores = discovered.stores.map((store) => ({
    storeId: store.storeId,
    sourcePath: store.sourcePath,
    backupFile: backupFilename(store.sourcePath, store.storeId, usedFilenames),
  }));
  const files = discovered.files.map((file) => ({
    id: file.id,
    sourcePath: file.sourcePath,
    backupFile: backupFilename(file.sourcePath, file.id, usedFilenames),
  }));
  return {
    projectRoot,
    outDir,
    createdAt,
    scope: options.allProjects === true ? "all-projects" : "project",
    ...(discovered.stateRoot !== undefined ? { stateRoot: discovered.stateRoot } : {}),
    runtimeProjectKey: discovered.runtimeProjectKey,
    stores,
    files,
  };
}

export function createBackup(options: BackupDiscoverOptions & {
  projectRoot?: string;
  outDir?: string;
} = {}): { manifest: BackupManifest; outDir: string } {
  const { projectRoot, outDir, createdAt, scope, stateRoot, runtimeProjectKey, stores, files } = planBackup(options);
  const backupId = `bk_${randomBytes(8).toString("hex")}`;

  if (!existsSync(outDir)) {
    mkdirSync(outDir, { recursive: true, mode: 0o700 });
  }

  const backedUpStores: BackupStoreRecord[] = [];
  const backedUpFiles: BackupFileRecord[] = [];
  const omitted: string[] = [];

  for (const store of stores) {
    try {
      backedUpStores.push(backupDatabaseFile(store.sourcePath, join(outDir, store.backupFile), store.storeId));
    } catch {
      omitted.push(store.storeId);
    }
  }
  for (const file of files) {
    try {
      backedUpFiles.push(backupPlainFile(file.sourcePath, join(outDir, file.backupFile), file.id));
    } catch {
      omitted.push(file.id);
    }
  }

  if (backedUpStores.length === 0) {
    throw databaseError("backup_no_stores", projectRoot, "no SQLite store could be copied");
  }

  const again = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
  for (const store of again.stores) {
    if (!backedUpStores.some((copied) => copied.storeId === store.storeId) && !omitted.includes(store.storeId)) {
      omitted.push(store.storeId);
    }
  }
  for (const file of again.files) {
    if (!backedUpFiles.some((copied) => copied.id === file.id) && !omitted.includes(file.id)) {
      omitted.push(file.id);
    }
  }

  const manifest: BackupManifest = {
    schema: "kxm.backup-manifest.v1",
    backupId,
    createdAt,
    projectRoot,
    scope,
    ...(stateRoot !== undefined ? { stateRoot } : {}),
    runtimeProjectKey,
    stores: backedUpStores,
    ...(backedUpFiles.length > 0 ? { files: backedUpFiles } : {}),
    complete: omitted.length === 0,
    ...(omitted.length > 0 ? { omitted } : {}),
  };

  const manifestJson = JSON.stringify(manifest, null, 2) + "\n";
  const manifestSha256 = `sha256:${createHash("sha256").update(manifestJson, "utf8").digest("hex")}`;
  manifest.manifestSha256 = manifestSha256;

  const finalJson = JSON.stringify(manifest, null, 2) + "\n";
  const manifestPath = join(outDir, "manifest.json");
  writeFileSync(manifestPath, finalJson, "utf8");

  return { manifest, outDir };
}

export interface RestorePlan {
  manifestPath: string;
  backupId: string;
  /** The manifest's scope; absent on manifests written before backups were scoped. */
  scope?: KxmBackupScope;
  stores: Array<{ storeId: string; backupFilePath: string; targetPath: string; schemaVersion: number; maxSupportedVersion: number }>;
  files: Array<{ id: string; backupFilePath: string; targetPath: string }>;
}

export interface RestoreOptions {
  /** The checkout to restore into. Its stores are rebased onto it, and its own Runtime
   * event store is the one the Runtime derives for it. */
  projectRoot?: string;
  env?: NodeJS.ProcessEnv;
  /** Also restore the shared Runtime registry and other projects' event stores. */
  allProjects?: boolean;
}

function pathUnder(root: string | undefined, path: string): string | undefined {
  if (!root) return undefined;
  const rel = relative(root, path);
  if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
  return rel;
}

/**
 * Where one backed-up store or file goes, and whether it belongs to this checkout.
 *
 * A path under the manifest's project root is rebased onto the checkout being restored.
 * The checkout's own Runtime event store (and its prompt sidecar) goes to the store the
 * Runtime derives for that checkout, under the current user state root. Anything else
 * under the user state root is machine-wide — the registry, another project's event
 * store — and so is anything outside both recorded roots except a relocated hub store,
 * which is how a manifest written before scopes records Runtime stores. Those restore
 * only with `allProjects`, rebased onto the current user state root when the manifest
 * recorded one.
 */
function restoreTarget(
  manifest: BackupManifest,
  sourcePath: string,
  id: string,
  options: RestoreOptions,
  currentStateRoot: () => string,
): { targetPath: string; machineWide: boolean } {
  const stateRel = pathUnder(manifest.stateRoot, sourcePath);
  const projectRel = pathUnder(manifest.projectRoot, sourcePath);
  // KXM_STATE_HOME may sit inside the checkout; the deeper root owns the path.
  if (stateRel !== undefined && (projectRel === undefined || stateRel.length <= projectRel.length)) {
    const ownEvents = manifest.runtimeProjectKey !== undefined
      ? join("runtime", "projects", manifest.runtimeProjectKey, "run-events.db")
      : undefined;
    const own = ownEvents !== undefined && (stateRel === ownEvents || stateRel === `${ownEvents}.run-prompts.json`);
    if (own && !options.allProjects && options.projectRoot !== undefined) {
      const eventsPath = kxmProjectRunEventsPath(resolve(options.projectRoot), options.env ?? process.env);
      return { targetPath: stateRel === ownEvents ? eventsPath : `${eventsPath}.run-prompts.json`, machineWide: false };
    }
    return { targetPath: join(currentStateRoot(), stateRel), machineWide: !own };
  }
  if (projectRel !== undefined) {
    return { targetPath: options.projectRoot ? join(resolve(options.projectRoot), projectRel) : sourcePath, machineWide: false };
  }
  return { targetPath: sourcePath, machineWide: id !== "hub-store" };
}

/** Everything restore checks before it overwrites anything: the manifest, each
 * backup file's digest, each store's schema against this build's ceiling (from
 * the manifest), and that nothing outside this checkout would be overwritten
 * without `allProjects`. Reads files; opens no database. */
export function planRestore(
  manifestPathOrDir: string,
  options: RestoreOptions = {},
): RestorePlan {
  let manifestPath = resolve(manifestPathOrDir);
  const stat = lstatSync(manifestPath, { throwIfNoEntry: false });
  if (!stat) {
    throw databaseError("runtime_path_invalid", manifestPath, `manifest path ${manifestPath} does not exist`);
  }
  if (stat.isDirectory()) {
    manifestPath = join(manifestPath, "manifest.json");
  }

  if (!existsSync(manifestPath)) {
    throw databaseError("runtime_path_invalid", manifestPath, `backup manifest ${manifestPath} not found`);
  }

  const manifestDir = dirname(manifestPath);
  const rawText = readFileSync(manifestPath, "utf8");
  let manifest: BackupManifest;
  try {
    manifest = JSON.parse(rawText) as BackupManifest;
  } catch (error) {
    throw databaseError("restore_manifest_invalid", manifestPath, `failed to parse backup manifest: ${error instanceof Error ? error.message : String(error)}`);
  }

  if (manifest.schema !== "kxm.backup-manifest.v1" || !Array.isArray(manifest.stores) || manifest.stores.length === 0) {
    throw databaseError("restore_manifest_invalid", manifestPath, "manifest is not a valid kxm.backup-manifest.v1 document");
  }
  if (manifest.complete === false) {
    throw databaseError("restore_incomplete", manifestPath, "backup manifest is incomplete; refusing to restore a partial copy");
  }

  let stateRoot: string | undefined;
  const currentStateRoot = (): string => (stateRoot ??= kxmRuntimePaths({ env: options.env ?? process.env }).stateRoot);
  const machineWide: string[] = [];

  const stores: RestorePlan["stores"] = [];
  for (const store of manifest.stores) {
    const backupFilePath = join(manifestDir, store.backupFile);
    if (!existsSync(backupFilePath)) {
      throw databaseError("restore_file_missing", backupFilePath, `backup file ${store.backupFile} missing from ${manifestDir}`);
    }

    const actualSha256 = fileSha256(backupFilePath);
    if (actualSha256 !== store.sha256) {
      throw databaseError(
        "restore_manifest_digest_mismatch",
        backupFilePath,
        `backup file ${store.backupFile} sha256 ${actualSha256} does not match manifest hash ${store.sha256}`,
      );
    }

    const maxSupportedVersion = kxmBackupCeiling(store.storeId);
    if (store.schemaVersion > maxSupportedVersion) {
      throw databaseError(
        "runtime_schema_newer",
        backupFilePath,
        `backup store ${store.storeId} schema version ${store.schemaVersion} is newer than supported maximum ${maxSupportedVersion}`,
      );
    }

    const target = restoreTarget(manifest, store.sourcePath, store.storeId, options, currentStateRoot);
    if (target.machineWide) machineWide.push(store.storeId);
    stores.push({ storeId: store.storeId, backupFilePath, targetPath: target.targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
  }

  const files: RestorePlan["files"] = [];
  for (const file of manifest.files ?? []) {
    const backupFilePath = join(manifestDir, file.backupFile);
    if (!existsSync(backupFilePath)) {
      throw databaseError("restore_file_missing", backupFilePath, `backup file ${file.backupFile} missing from ${manifestDir}`);
    }
    const actualSha256 = fileSha256(backupFilePath);
    if (actualSha256 !== file.sha256) {
      throw databaseError(
        "restore_manifest_digest_mismatch",
        backupFilePath,
        `backup file ${file.backupFile} sha256 ${actualSha256} does not match manifest hash ${file.sha256}`,
      );
    }
    const target = restoreTarget(manifest, file.sourcePath, file.id, options, currentStateRoot);
    if (target.machineWide) machineWide.push(file.id);
    files.push({ id: file.id, backupFilePath, targetPath: target.targetPath });
  }

  if (machineWide.length > 0 && !options.allProjects) {
    throw databaseError(
      "restore_requires_all_projects",
      manifestPath,
      `backup ${manifest.backupId} holds Runtime state shared by every project on this machine (${machineWide.join(", ")}); `
        + "restoring it rolls back the registry or other projects' runs. Pass --all-projects to restore all of it, or restore a backup taken without --all-projects",
    );
  }

  return {
    manifestPath,
    backupId: manifest.backupId,
    ...(manifest.scope !== undefined ? { scope: manifest.scope } : {}),
    stores,
    files,
  };
}

/** Overwrite each target in a checked plan: the stores, then their prompt sidecars. */
export function applyRestorePlan(plan: RestorePlan): RestoreResult {
  const restoredStores = plan.stores.map((store) => restoreDatabaseFile(
    store.backupFilePath,
    store.targetPath,
    store.storeId,
    store.schemaVersion,
    store.maxSupportedVersion,
  ));
  for (const file of plan.files) {
    restorePlainFile(file.backupFilePath, file.targetPath);
  }
  return {
    manifestPath: plan.manifestPath,
    backupId: plan.backupId,
    restoredStores,
  };
}

export function restoreBackup(
  manifestPathOrDir: string,
  options: RestoreOptions = {},
): RestoreResult {
  return applyRestorePlan(planRestore(manifestPathOrDir, options));
}
