#!/usr/bin/env bun
/**
 * migrate-storage.ts — Versioned akm storage migration tool.
 *
 * Usage:
 *   bun scripts/migrate-storage.ts [--dry-run] [--yes] [--from <version>] [--list]
 *
 * Flags:
 *   --dry-run        Print what would happen without making any changes.
 *   --yes            Skip the confirmation prompt and run immediately.
 *   --from <ver>     Only run migrations whose source version is >= <ver>.
 *                    Example: `--from 0.8` skips the 0.7 → 0.8 migration.
 *   --list           Print the available migrations and exit.
 *
 * Architecture:
 *   Each migration version is an object that conforms to MigrationVersion.
 *   The MIGRATIONS array is executed in order; each `run()` is called only
 *   when its `isNeeded()` returns true. Within a version, each step is
 *   independently wrapped so one failure does not block the others.
 *
 * Safety guarantees:
 *   - Never deletes source files. Sources are left in place so rollback is
 *     possible. Some steps may rename a legacy file with a `.migrated`
 *     suffix to mark it consumed, but contents are preserved.
 *   - Verifies each destination before declaring success.
 *   - Wraps every step in a try/catch so one failure cannot block the rest.
 */

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";

import { getCacheDir, getConfigDir, getDefaultStashDir } from "../src/core/paths";
import { MIGRATED_MARKER } from "../src/indexer/usage/unmigrated-vaults-guard";

// ── Argument parsing ─────────────────────────────────────────────────────────

const args = process.argv.slice(2);
const DRY_RUN = args.includes("--dry-run");
const YES = args.includes("--yes");
const LIST_ONLY = args.includes("--list");

function parseFromArg(): string | null {
  const idx = args.indexOf("--from");
  if (idx === -1) return null;
  const val = args[idx + 1];
  if (!val || val.startsWith("--")) {
    console.error("Error: --from requires a version argument (e.g. --from 0.8).");
    process.exit(2);
  }
  return val;
}
const FROM_VERSION = parseFromArg();

// ── Path resolution ──────────────────────────────────────────────────────────

const dataDir =
  process.env.AKM_DATA_DIR?.trim() ??
  (process.env.XDG_DATA_HOME?.trim()
    ? path.join(process.env.XDG_DATA_HOME.trim(), "akm")
    : path.join(os.homedir(), ".local", "share", "akm"));

const stateDir =
  process.env.AKM_STATE_DIR?.trim() ??
  (process.env.XDG_STATE_HOME?.trim()
    ? path.join(process.env.XDG_STATE_HOME.trim(), "akm")
    : path.join(os.homedir(), ".local", "state", "akm"));

const cacheDir = getCacheDir();
const configDir = getConfigDir();
const stateDbPath = path.join(dataDir, "state.db");
const indexDbPath = path.join(dataDir, "index.db");

export interface ResolvedPaths {
  cacheDir: string;
  configDir: string;
  dataDir: string;
  stateDir: string;
  stateDbPath: string;
  indexDbPath: string;
}

const PATHS: ResolvedPaths = {
  cacheDir,
  configDir,
  dataDir,
  stateDir,
  stateDbPath,
  indexDbPath,
};

// ── Result tracking ──────────────────────────────────────────────────────────

type StepStatus = "success" | "skipped" | "failed";

export interface StepResult {
  name: string;
  status: StepStatus;
  detail: string;
}

interface VersionRunReport {
  label: string;
  ran: boolean;
  skipReason?: string;
  results: StepResult[];
}

const versionReports: VersionRunReport[] = [];

// ── Utility helpers ──────────────────────────────────────────────────────────

function ensureDir(dir: string): void {
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
}

function copyAndVerify(src: string, dest: string): boolean {
  fs.copyFileSync(src, dest);
  const srcStat = fs.statSync(src);
  const destStat = fs.statSync(dest);
  return destStat.size === srcStat.size;
}

interface CopyTreeResult {
  copied: number;
  skipped: number;
  failed: number;
}

/**
 * Recursively copy a directory tree, verifying each file (size match) after
 * the copy. This is the single recursive copy primitive for the migration; it
 * unifies the former `copyDirRecursive` (clobber) and `copyDirNoClobber`
 * (no-clobber) helpers, which were byte-for-byte identical apart from the
 * skip-on-exists guard.
 *
 * `clobber: false` NEVER overwrites a file that already exists at the
 * destination — used for migrating opaque secret/env material so any env file
 * the user already authored is preserved. `clobber: true` overwrites (the
 * legacy config-backups behaviour, which only ran when the destination
 * directory did not already exist, so no live file was ever clobbered).
 *
 * Accounting is byte-identical to the originals: a clobber copy never
 * increments `skipped`; both increment `copied`/`failed` via `copyAndVerify`.
 */
function copyTree(src: string, dest: string, opts: { clobber: boolean }): CopyTreeResult {
  let copied = 0;
  let skipped = 0;
  let failed = 0;

  ensureDir(dest);

  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
    const srcEntry = path.join(src, entry.name);
    const destEntry = path.join(dest, entry.name);
    if (entry.isDirectory()) {
      const sub = copyTree(srcEntry, destEntry, opts);
      copied += sub.copied;
      skipped += sub.skipped;
      failed += sub.failed;
      continue;
    }
    if (!opts.clobber && fs.existsSync(destEntry)) {
      skipped++;
      continue;
    }
    try {
      if (copyAndVerify(srcEntry, destEntry)) {
        copied++;
      } else {
        failed++;
      }
    } catch {
      failed++;
    }
  }

  return { copied, skipped, failed };
}

function countFilesRecursive(dir: string): number {
  let count = 0;
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    if (entry.isDirectory()) {
      count += countFilesRecursive(path.join(dir, entry.name));
    } else {
      count++;
    }
  }
  return count;
}

// ── Versioned migration types ────────────────────────────────────────────────

export interface MigrationContext {
  dryRun: boolean;
  recordStep: (result: StepResult) => void;
  paths: ResolvedPaths;
}

/**
 * A single migration step declared as data. Each `run` performs its own
 * skip/dry-run/copy/verify logic and RETURNS exactly one `StepResult` (which
 * embeds its own display `name`). The driver (`runSteps`) is the single owner
 * of recording: it calls each step's `run` in array order and records the
 * returned result. This replaces the former ~54 hand-rolled `ctx.recordStep`
 * call sites with ~7 step terminals + one recording driver.
 */
export interface MigrationStep {
  /** Stable identifier for the step (registry key). */
  id: string;
  /** Human-readable title (mirrors the recorded StepResult.name). */
  title: string;
  /** Perform the step and return its single recorded result. */
  run: (ctx: MigrationContext) => StepResult | Promise<StepResult>;
}

export interface MigrationVersion {
  /** Version label, e.g. "0.7 → 0.8" or "0.8 → 0.9". */
  label: string;
  /** Lower-bound version key used for --from filtering, e.g. "0.7" or "0.8". */
  sourceVersion: string;
  /** Detect whether this migration is needed. Return true only when there is work to do. */
  isNeeded: (paths: ResolvedPaths) => boolean | Promise<boolean>;
  /** Ordered list of steps. The driver runs and records each in array order. */
  steps: MigrationStep[];
}

/**
 * The single migration driver: runs each step in declared order and records its
 * returned result through `ctx.recordStep`. This is the ONLY place steps are
 * recorded — the former per-branch `ctx.recordStep` calls are now `return`s.
 */
async function runSteps(steps: MigrationStep[], ctx: MigrationContext): Promise<void> {
  for (const step of steps) {
    ctx.recordStep(await step.run(ctx));
  }
}

// ── 0.7 → 0.8 step implementations ───────────────────────────────────────────

function migrateDb(ctx: MigrationContext, filename: string): StepResult {
  const src = path.join(ctx.paths.cacheDir, filename);
  const dest = path.join(ctx.paths.dataDir, filename);

  if (!fs.existsSync(src)) {
    return { name: filename, status: "skipped", detail: "source not found" };
  }
  if (fs.existsSync(dest)) {
    return { name: filename, status: "skipped", detail: "destination already exists" };
  }

  if (ctx.dryRun) {
    return { name: filename, status: "success", detail: `[dry-run] would copy ${src} → ${dest}` };
  }

  ensureDir(ctx.paths.dataDir);
  const ok = copyAndVerify(src, dest);
  if (ok) {
    return {
      name: filename,
      status: "success",
      detail: `copied to ${dest} — source left at ${src} (delete manually when ready)`,
    };
  }
  return { name: filename, status: "failed", detail: `size mismatch after copy: ${src} → ${dest}` };
}

async function migrateEventsJsonl(ctx: MigrationContext): Promise<StepResult> {
  const src = path.join(ctx.paths.cacheDir, "events.jsonl");

  if (!fs.existsSync(src)) {
    return { name: "events.jsonl → state.db", status: "skipped", detail: "source not found" };
  }

  if (ctx.dryRun) {
    return {
      name: "events.jsonl → state.db",
      status: "success",
      detail: `[dry-run] would import ${src} into ${ctx.paths.stateDbPath}`,
    };
  }

  try {
    ensureDir(ctx.paths.dataDir);
    const { openStateDatabase, importEventsJsonl } = await import("../src/core/state-db");
    const db = openStateDatabase(ctx.paths.stateDbPath);
    try {
      // Wrap the whole-file import in a single transaction so SIGINT or any
      // mid-import failure rolls back atomically — half-imported state.db
      // would otherwise leave the user with no error and silently missing
      // rows the dedup check can't detect later (#475).
      db.exec("BEGIN");
      let imported = 0;
      let maxId = 0;
      let skipped = 0;
      try {
        const result = await importEventsJsonl(db, src);
        imported = result.imported;
        maxId = result.maxId;
        skipped = result.skipped;
        db.exec("COMMIT");
      } catch (err) {
        db.exec("ROLLBACK");
        throw err;
      }
      const dedupNote = skipped > 0 ? ` (${skipped} duplicate(s) skipped — re-run idempotency)` : "";
      return {
        name: "events.jsonl → state.db",
        status: "success",
        detail: `imported ${imported} events (max id: ${maxId})${dedupNote} — source left at ${src} (delete manually when ready)`,
      };
    } finally {
      db.close();
    }
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    return { name: "events.jsonl → state.db", status: "failed", detail: msg };
  }
}

function migrateTaskHistory(ctx: MigrationContext): StepResult {
  const src = path.join(ctx.paths.cacheDir, "tasks", "history");
  const dest = path.join(ctx.paths.stateDir, "tasks", "history");

  if (!fs.existsSync(src)) {
    return { name: "tasks/history/", status: "skipped", detail: "source directory not found" };
  }

  try {
    const files = fs.readdirSync(src).filter((f) => f.endsWith(".jsonl"));

    if (files.length === 0) {
      return { name: "tasks/history/", status: "skipped", detail: "no *.jsonl files found in source directory" };
    }

    if (ctx.dryRun) {
      return {
        name: "tasks/history/",
        status: "success",
        detail: `[dry-run] would copy ${files.length} *.jsonl file(s) from ${src} → ${dest}`,
      };
    }

    ensureDir(dest);

    let copied = 0;
    let failed = 0;

    for (const file of files) {
      const srcFile = path.join(src, file);
      const destFile = path.join(dest, file);
      try {
        if (copyAndVerify(srcFile, destFile)) {
          copied++;
        } else {
          failed++;
        }
      } catch {
        failed++;
      }
    }

    if (failed === 0) {
      return {
        name: "tasks/history/",
        status: "success",
        detail: `copied ${copied} files to ${dest} — sources left in place (delete manually when ready)`,
      };
    }
    return {
      name: "tasks/history/",
      status: "failed",
      detail: `copied ${copied}/${files.length} files; ${failed} failed`,
    };
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    return { name: "tasks/history/", status: "failed", detail: msg };
  }
}

function migrateLockfile(ctx: MigrationContext): StepResult {
  const src = path.join(ctx.paths.configDir, "akm.lock");
  const dest = path.join(ctx.paths.dataDir, "akm.lock");

  if (!fs.existsSync(src)) {
    return { name: "akm.lock", status: "skipped", detail: "source not found" };
  }
  if (fs.existsSync(dest)) {
    return { name: "akm.lock", status: "skipped", detail: "destination already exists" };
  }

  if (ctx.dryRun) {
    return { name: "akm.lock", status: "success", detail: `[dry-run] would copy ${src} → ${dest}` };
  }

  ensureDir(ctx.paths.dataDir);
  const ok = copyAndVerify(src, dest);
  if (ok) {
    return {
      name: "akm.lock",
      status: "success",
      detail:
        `copied to ${dest} — source left at ${src}.\n` +
        `      IMPORTANT: akm now reads ONLY from $DATA/akm.lock. If this step is skipped,\n` +
        `      akm will start with an empty lockfile and 'akm add' will rebuild it from scratch.`,
    };
  }
  return { name: "akm.lock", status: "failed", detail: `size mismatch after copy: ${src} → ${dest}` };
}

function migrateConfigBackups(ctx: MigrationContext): StepResult {
  const src = path.join(ctx.paths.cacheDir, "config-backups");
  const dest = path.join(ctx.paths.dataDir, "config-backups");

  if (!fs.existsSync(src)) {
    return { name: "config-backups/", status: "skipped", detail: "source directory not found" };
  }
  if (fs.existsSync(dest)) {
    return { name: "config-backups/", status: "skipped", detail: "destination already exists" };
  }

  if (ctx.dryRun) {
    let srcCount = 0;
    try {
      srcCount = countFilesRecursive(src);
    } catch {
      // best-effort count for dry-run output
    }
    return {
      name: "config-backups/",
      status: "success",
      detail: `[dry-run] would recursively copy ${srcCount} file(s) from ${src} → ${dest}`,
    };
  }

  try {
    const srcCount = countFilesRecursive(src);
    const { copied, failed } = copyTree(src, dest, { clobber: true });
    const destCount = fs.existsSync(dest) ? countFilesRecursive(dest) : 0;

    if (failed === 0 && destCount === srcCount) {
      return {
        name: "config-backups/",
        status: "success",
        detail: `copied ${copied} files to ${dest} — sources left in place (delete manually when ready)`,
      };
    }
    return {
      name: "config-backups/",
      status: "failed",
      detail: `file count mismatch: source ${srcCount}, destination ${destCount}; ${failed} copy errors`,
    };
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    return { name: "config-backups/", status: "failed", detail: msg };
  }
}

async function migrateTaskHistoryToDb(ctx: MigrationContext): Promise<StepResult> {
  const src = path.join(ctx.paths.cacheDir, "tasks", "history");

  if (!fs.existsSync(src)) {
    return { name: "tasks/history/ → state.db", status: "skipped", detail: "source directory not found" };
  }

  const files = fs.readdirSync(src).filter((f) => f.endsWith(".jsonl"));

  if (files.length === 0) {
    return {
      name: "tasks/history/ → state.db",
      status: "skipped",
      detail: "no *.jsonl files found in source directory",
    };
  }

  if (ctx.dryRun) {
    return {
      name: "tasks/history/ → state.db",
      status: "success",
      detail: `[dry-run] would parse ${files.length} *.jsonl file(s) and import rows into ${ctx.paths.stateDbPath}`,
    };
  }

  try {
    ensureDir(ctx.paths.dataDir);

    const { openStateDatabase, upsertTaskHistory } = await import("../src/core/state-db");
    const db = openStateDatabase(ctx.paths.stateDbPath);

    let imported = 0;
    let failed = 0;

    try {
      // Each file's rows commit atomically. If the process dies between
      // files, prior files are durably imported; the in-flight file is
      // rolled back; re-running the migration picks up where it left off
      // (upsertTaskHistory dedupes on task_id PK).
      // Previously the loop was unwrapped, so SIGINT mid-import left
      // task_history half-populated with no error and no way to detect
      // which rows were missing (#475).
      for (const file of files) {
        const filePath = path.join(src, file);
        const text = fs.readFileSync(filePath, "utf8");
        const lines = text.split("\n").filter((l) => l.trim().length > 0);

        db.exec("BEGIN");
        let fileImported = 0;
        try {
          for (const line of lines) {
            try {
              const row = JSON.parse(line) as {
                id: string;
                status: string;
                startedAt: string;
                finishedAt: string;
                durationMs: number;
                log: string;
                target: { kind: string; ref?: string; profile?: string };
                detail?: Record<string, unknown>;
              };

              const meta: Record<string, unknown> = { durationMs: row.durationMs };
              if (row.detail !== undefined) meta.detail = row.detail;
              if (row.target?.kind === "prompt" && row.target.profile !== undefined) {
                meta.profile = row.target.profile;
              }

              upsertTaskHistory(db, {
                task_id: row.id,
                status: row.status,
                started_at: row.startedAt,
                completed_at: row.status !== "failed" ? row.finishedAt : null,
                failed_at: row.status === "failed" ? row.finishedAt : null,
                log_path: row.log || null,
                target_kind: row.target?.kind ?? null,
                target_ref: row.target?.kind === "workflow" ? (row.target.ref ?? null) : null,
                metadata_json: JSON.stringify(meta),
              });
              fileImported++;
            } catch {
              failed++;
            }
          }
          db.exec("COMMIT");
          imported += fileImported;
        } catch (err) {
          db.exec("ROLLBACK");
          throw err;
        }
      }
    } finally {
      db.close();
    }

    if (failed === 0) {
      return {
        name: "tasks/history/ → state.db",
        status: "success",
        detail: `imported ${imported} task history row(s) from ${files.length} JSONL file(s) into state.db — sources left in place (delete manually when ready)`,
      };
    }
    return {
      name: "tasks/history/ → state.db",
      status: "failed",
      detail: `imported ${imported} row(s); ${failed} line(s) could not be parsed`,
    };
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    return { name: "tasks/history/ → state.db", status: "failed", detail: msg };
  }
}

function noteRegistryIndexCache(ctx: MigrationContext): StepResult {
  const src = path.join(ctx.paths.cacheDir, "registry-index");

  if (!fs.existsSync(src)) {
    return { name: "registry-index/ (note)", status: "skipped", detail: "no old $CACHE/registry-index/ directory found" };
  }

  const legacyFiles = fs.readdirSync(src).filter((f) => f.endsWith(".json") && !f.startsWith("website-"));

  if (legacyFiles.length === 0) {
    return { name: "registry-index/ (note)", status: "skipped", detail: "no old *.json cache files found" };
  }

  if (!ctx.dryRun) {
    console.log(
      `\n  Note: found ${legacyFiles.length} old registry-index JSON file(s) in ${src}.` +
        `\n        These are ignored in v0.9 — data is now stored in the registry_index_cache` +
        `\n        table in $DATA/index.db and will be rebuilt on next 'akm registry search'.` +
        `\n        You may safely delete these files after migration:\n` +
        legacyFiles.map((f) => `          ${path.join(src, f)}`).join("\n"),
    );
  }

  return {
    name: "registry-index/ (note)",
    status: "success",
    detail:
      `${legacyFiles.length} old file(s) noted at ${src} — registry index cache will be rebuilt on next ` +
      `'akm registry search'. Safe to delete: ${src}/*.json`,
  };
}

// ── 0.7 → 0.8 migration ──────────────────────────────────────────────────────

const v07To08Migration: MigrationVersion = {
  label: "0.7 → 0.8",
  sourceVersion: "0.7",
  isNeeded: (paths) => {
    // Needed if any legacy source exists in the old locations.
    const candidates = [
      path.join(paths.cacheDir, "index.db"),
      path.join(paths.cacheDir, "workflow.db"),
      path.join(paths.cacheDir, "events.jsonl"),
      path.join(paths.cacheDir, "tasks", "history"),
      path.join(paths.configDir, "akm.lock"),
      path.join(paths.cacheDir, "config-backups"),
      path.join(paths.cacheDir, "registry-index"),
    ];
    return candidates.some((p) => fs.existsSync(p));
  },
  steps: [
    { id: "index-db", title: "index.db", run: (ctx) => migrateDb(ctx, "index.db") },
    { id: "workflow-db", title: "workflow.db", run: (ctx) => migrateDb(ctx, "workflow.db") },
    { id: "events-jsonl", title: "events.jsonl → state.db", run: migrateEventsJsonl },
    { id: "tasks-history-files", title: "tasks/history/", run: migrateTaskHistory },
    { id: "lockfile", title: "akm.lock", run: migrateLockfile },
    { id: "config-backups", title: "config-backups/", run: migrateConfigBackups },
    { id: "tasks-history-db", title: "tasks/history/ → state.db", run: migrateTaskHistoryToDb },
    { id: "registry-index-note", title: "registry-index/ (note)", run: noteRegistryIndexCache },
  ],
};

// ── 0.8 → 0.9 step: graph file → DB ──────────────────────────────────────────

/**
 * Candidate locations for legacy file-based graph snapshots. The akm codebase
 * never wrote graph data to files (since 0.8 graph data lives entirely in
 * index.db), so this is mostly a forward-looking placeholder. We still scan
 * a small set of conventional paths so that any external tool that exported
 * a snapshot can be re-imported.
 */
function legacyGraphCandidatePaths(ctx: MigrationContext): string[] {
  return [
    path.join(ctx.paths.cacheDir, "graph-snapshot.json"),
    path.join(ctx.paths.dataDir, "graph-snapshot.json"),
    path.join(ctx.paths.dataDir, "graph-export.json"),
  ];
}

function findLegacyGraphFile(ctx: MigrationContext): string | null {
  for (const candidate of legacyGraphCandidatePaths(ctx)) {
    if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
  }
  // Also accept any *.json under $CACHE/graph/ if present.
  const graphDir = path.join(ctx.paths.cacheDir, "graph");
  if (fs.existsSync(graphDir) && fs.statSync(graphDir).isDirectory()) {
    try {
      const json = fs
        .readdirSync(graphDir)
        .filter((f) => f.endsWith(".json"))
        .map((f) => path.join(graphDir, f));
      if (json.length > 0) return json[0]!;
    } catch {
      // ignored
    }
  }
  return null;
}

interface LegacyGraphSnapshotShape {
  schemaVersion?: number;
  generatedAt?: string;
  stashRoot?: string;
  files?: Array<{
    path: string;
    type: string;
    bodyHash?: string;
    entities?: string[];
    relations?: Array<{ from: string; to: string; type?: string; confidence?: number }>;
    confidence?: number;
  }>;
  entities?: string[];
  relations?: Array<{ from: string; to: string; type?: string; confidence?: number }>;
  quality?: {
    consideredFiles?: number;
    extractedFiles?: number;
    entityCount?: number;
    relationCount?: number;
    extractionCoverage?: number;
    density?: number;
  };
}

function validateGraphSnapshot(parsed: unknown): { ok: true; data: LegacyGraphSnapshotShape } | { ok: false; reason: string } {
  if (parsed == null || typeof parsed !== "object") return { ok: false, reason: "root is not an object" };
  const obj = parsed as LegacyGraphSnapshotShape;
  if (typeof obj.stashRoot !== "string" || obj.stashRoot.length === 0) {
    return { ok: false, reason: "missing string field 'stashRoot'" };
  }
  if (!Array.isArray(obj.files)) return { ok: false, reason: "missing array field 'files'" };
  for (const [i, f] of obj.files.entries()) {
    if (!f || typeof f !== "object") return { ok: false, reason: `files[${i}] is not an object` };
    if (typeof f.path !== "string") return { ok: false, reason: `files[${i}].path must be a string` };
    if (typeof f.type !== "string") return { ok: false, reason: `files[${i}].type must be a string` };
    if (!Array.isArray(f.entities)) return { ok: false, reason: `files[${i}].entities must be an array` };
    if (!Array.isArray(f.relations)) return { ok: false, reason: `files[${i}].relations must be an array` };
  }
  return { ok: true, data: obj };
}

async function migrateGraphFileToDb(ctx: MigrationContext): Promise<StepResult> {
  const name = "Graph snapshot import";

  const legacyFile = findLegacyGraphFile(ctx);
  if (!legacyFile) {
    return { name, status: "skipped", detail: "no legacy graph file found" };
  }

  if (!fs.existsSync(ctx.paths.indexDbPath)) {
    return {
      name,
      status: "skipped",
      detail: `index.db not found at ${ctx.paths.indexDbPath}; run akm to initialize, then re-run this migration`,
    };
  }

  if (ctx.dryRun) {
    return {
      name,
      status: "success",
      detail: `[dry-run] would parse ${legacyFile} and import into ${ctx.paths.indexDbPath} graph tables`,
    };
  }

  try {
    const raw = fs.readFileSync(legacyFile, "utf8");
    let parsed: unknown;
    try {
      parsed = JSON.parse(raw);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      return { name, status: "failed", detail: `${legacyFile} is not valid JSON: ${msg}` };
    }

    const validation = validateGraphSnapshot(parsed);
    if (!validation.ok) {
      return {
        name,
        status: "failed",
        detail: `${legacyFile} does not match expected GraphSnapshot shape: ${validation.reason}`,
      };
    }
    const snapshot = validation.data;

    const { openExistingDatabase, closeDatabase } = await import("../src/indexer/db/db");
    const { replaceStoredGraph, loadStoredGraphMeta } = await import("../src/indexer/db/graph-db");

    const db = openExistingDatabase(ctx.paths.indexDbPath);
    try {
      const graph = {
        schemaVersion: snapshot.schemaVersion ?? 2,
        generatedAt: snapshot.generatedAt ?? new Date().toISOString(),
        stashRoot: snapshot.stashRoot!,
        files: (snapshot.files ?? []).map((f) => ({
          path: f.path,
          type: f.type,
          ...(f.bodyHash ? { bodyHash: f.bodyHash } : {}),
          entities: f.entities ?? [],
          relations: (f.relations ?? []).map((r) => ({
            from: r.from,
            to: r.to,
            ...(r.type ? { type: r.type } : {}),
            ...(typeof r.confidence === "number" ? { confidence: r.confidence } : {}),
          })),
          ...(typeof f.confidence === "number" ? { confidence: f.confidence } : {}),
        })),
        ...(snapshot.entities ? { entities: snapshot.entities } : {}),
        ...(snapshot.relations
          ? {
              relations: snapshot.relations.map((r) => ({
                from: r.from,
                to: r.to,
                ...(r.type ? { type: r.type } : {}),
                ...(typeof r.confidence === "number" ? { confidence: r.confidence } : {}),
              })),
            }
          : {}),
        ...(snapshot.quality
          ? {
              quality: {
                consideredFiles: snapshot.quality.consideredFiles ?? 0,
                extractedFiles: snapshot.quality.extractedFiles ?? 0,
                entityCount: snapshot.quality.entityCount ?? 0,
                relationCount: snapshot.quality.relationCount ?? 0,
                extractionCoverage: snapshot.quality.extractionCoverage ?? 0,
                density: snapshot.quality.density ?? 0,
              },
            }
          : {}),
      };

      // GraphFile type from src/indexer/graph-extraction.ts. The cast is safe
      // because validateGraphSnapshot enforced the required fields above.
      replaceStoredGraph(db, graph as unknown as Parameters<typeof replaceStoredGraph>[1]);

      // Verify that at least one file was actually imported. If all files were
      // orphans (no matching entries row), the import is effectively a no-op
      // and we must not consume the source file.
      const importedCount = (
        db.prepare("SELECT COUNT(*) AS cnt FROM graph_files WHERE stash_root = ?").get(snapshot.stashRoot!) as {
          cnt: number;
        }
      ).cnt;

      const meta = loadStoredGraphMeta(snapshot.stashRoot!, db);
      if (!meta) {
        return {
          name,
          status: "failed",
          detail: `import did not produce a graph_meta row for stash ${snapshot.stashRoot}`,
        };
      }

      if (importedCount === 0) {
        return {
          name,
          status: "failed",
          detail:
            `import produced zero graph_files rows for stash ${snapshot.stashRoot} — ` +
            `the entries table has no matching paths. Run "akm index" first, then retry the migration. ` +
            `Source file ${legacyFile} was NOT renamed.`,
        };
      }

      // Rename source so a re-run does not double-import. Contents preserved.
      const renamed = `${legacyFile}.migrated`;
      try {
        fs.renameSync(legacyFile, renamed);
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return {
          name,
          status: "success",
          detail: `imported graph for ${snapshot.stashRoot} into ${ctx.paths.indexDbPath} but could not rename ${legacyFile}: ${msg}`,
        };
      }

      return {
        name,
        status: "success",
        detail:
          `imported graph snapshot from ${legacyFile} into ${ctx.paths.indexDbPath} ` +
          `(stash ${snapshot.stashRoot}; ${importedCount} file(s) imported of ${graph.files.length} in source). ` +
          `Source renamed to ${renamed} — delete manually when ready.`,
      };
    } finally {
      closeDatabase(db);
    }
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    return { name, status: "failed", detail: msg };
  }
}

// ── vaults/ → env/ migration (0.8.0) ─────────────────────────────────────────

/** Resolve the primary writable stash dir from config.json (else the default). */
function resolvePrimaryStashDir(configDirPath: string): string {
  try {
    const cfgPath = path.join(configDirPath, "config.json");
    if (fs.existsSync(cfgPath)) {
      const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")) as { stashDir?: unknown };
      if (typeof cfg.stashDir === "string" && cfg.stashDir.length > 0) return cfg.stashDir;
    }
  } catch {
    // Fall through to the default stash dir.
  }
  return getDefaultStashDir();
}

/** Count `.env` files (the only relevant env assets) under a directory tree. */
function countEnvFilesRecursive(dir: string): number {
  let count = 0;
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      count += countEnvFilesRecursive(full);
    } else if (entry.name === ".env" || entry.name.endsWith(".env")) {
      count++;
    }
  }
  return count;
}

/**
 * Tighten permissions on the migrated tree: 0700 dirs, 0600 files, then verify.
 * `copyAndVerify` checks size only and does not preserve mode, so without this
 * migrated secret material could land at the umask default. Best-effort (and
 * unverified) on Windows, where POSIX modes are not meaningful.
 */
function chmodTreeSecure(dir: string): { ok: boolean; detail?: string } {
  const isWin = process.platform === "win32";
  try {
    fs.chmodSync(dir, 0o700);
  } catch (err) {
    if (!isWin) return { ok: false, detail: `chmod 0700 ${dir} failed: ${err instanceof Error ? err.message : err}` };
  }
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      const sub = chmodTreeSecure(full);
      if (!sub.ok) return sub;
      continue;
    }
    try {
      fs.chmodSync(full, 0o600);
      if (!isWin && (fs.statSync(full).mode & 0o777) !== 0o600) {
        return { ok: false, detail: `mode verification failed for ${full}` };
      }
    } catch (err) {
      if (!isWin) return { ok: false, detail: `chmod 0600 ${full} failed: ${err instanceof Error ? err.message : err}` };
    }
  }
  return { ok: true };
}

/**
 * Migrate `<stash>/vaults/` → `<stash>/env/` (copy, never move). The legacy
 * vaults/ tree is left intact as a frozen copy and a `vaults/.migrated` marker
 * is written so the step is idempotent. Deletion of vaults/ is deferred to the
 * 0.9.0 removal (behind explicit user approval).
 */
async function migrateVaultsToEnv(ctx: MigrationContext): Promise<StepResult> {
  const name = "vaults/ → env/";
  const stashDir = resolvePrimaryStashDir(ctx.paths.configDir);
  const vaultsDir = path.join(stashDir, "vaults");
  const envDir = path.join(stashDir, "env");
  const marker = path.join(vaultsDir, MIGRATED_MARKER);

  if (!fs.existsSync(vaultsDir)) {
    return { name, status: "skipped", detail: `no vaults/ directory under ${stashDir}` };
  }
  if (fs.existsSync(marker)) {
    return { name, status: "skipped", detail: "already migrated (.migrated marker present)" };
  }
  const vaultEnvCount = countEnvFilesRecursive(vaultsDir);
  if (vaultEnvCount === 0) {
    return { name, status: "skipped", detail: "vaults/ has no .env files to migrate" };
  }

  if (ctx.dryRun) {
    return {
      name,
      status: "success",
      detail: `[dry-run] would copy ${vaultEnvCount} .env file(s) ${vaultsDir} → ${envDir}, chmod 0600/0700, and write ${marker}`,
    };
  }

  try {
    const { copied, skipped, failed } = copyTree(vaultsDir, envDir, { clobber: false });
    if (failed > 0) {
      return { name, status: "failed", detail: `${failed} file(s) failed to copy; env/ left as-is, no marker written` };
    }
    const chmodRes = chmodTreeSecure(envDir);
    if (!chmodRes.ok) {
      return { name, status: "failed", detail: chmodRes.detail ?? "permission tightening failed" };
    }
    const envEnvCount = countEnvFilesRecursive(envDir);
    if (envEnvCount < vaultEnvCount) {
      return {
        name,
        status: "failed",
        detail: `post-copy count ${envEnvCount} < source ${vaultEnvCount}; no marker written`,
      };
    }
    fs.writeFileSync(marker, `migrated vaults/ -> env/ at ${new Date().toISOString()}\n`, { mode: 0o600 });
    return {
      name,
      status: "success",
      detail:
        `copied ${copied} file(s) (${skipped} already present in env/, preserved) ${vaultsDir} → ${envDir}; ` +
        `vaults/ left intact as a frozen copy. Run \`akm index\` to refresh search.`,
    };
  } catch (err) {
    return { name, status: "failed", detail: err instanceof Error ? err.message : String(err) };
  }
}

// ── 0.8 → 0.9 migration ──────────────────────────────────────────────────────

const v08To09Migration: MigrationVersion = {
  label: "0.8 → 0.9",
  sourceVersion: "0.8",
  isNeeded: (_paths) => {
    // The graph import step is a no-op when no legacy file exists, but we
    // still want this migration listed so the user sees the step it would
    // perform. Returning true ensures the run() executes and records a
    // "skipped" step rather than silently omitting the whole section.
    return true;
  },
  steps: [
    { id: "graph-snapshot-import", title: "Graph snapshot import", run: migrateGraphFileToDb },
    { id: "vaults-to-env", title: "vaults/ → env/", run: migrateVaultsToEnv },
  ],
};

// ── Registry ─────────────────────────────────────────────────────────────────

export const MIGRATIONS: MigrationVersion[] = [v07To08Migration, v08To09Migration];

// ── Confirmation prompt ──────────────────────────────────────────────────────

async function confirm(): Promise<boolean> {
  if (YES) return true;

  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  return new Promise((resolve) => {
    rl.question("Proceed with migration? [y/N] ", (answer) => {
      rl.close();
      resolve(answer.trim().toLowerCase() === "y");
    });
  });
}

// ── --list output ────────────────────────────────────────────────────────────

function printList(): void {
  console.log("akm storage migrations (in execution order):");
  for (const m of MIGRATIONS) {
    console.log(`  • ${m.label}    [sourceVersion=${m.sourceVersion}]`);
  }
  console.log();
  console.log("Resolved paths:");
  console.log(`  $CACHE  = ${cacheDir}`);
  console.log(`  $CONFIG = ${configDir}`);
  console.log(`  $DATA   = ${dataDir}`);
  console.log(`  $STATE  = ${stateDir}`);
}

// ── Filtering ────────────────────────────────────────────────────────────────

/**
 * Compare two dotted version strings ("0.7", "0.8.0", etc.) numerically.
 * Returns negative if a < b, positive if a > b, 0 if equal.
 */
function compareVersion(a: string, b: string): number {
  const parse = (v: string) =>
    v
      .replace(/^v/, "")
      .split(".")
      .map((p) => Number.parseInt(p, 10))
      .map((n) => (Number.isFinite(n) ? n : 0));
  const aa = parse(a);
  const bb = parse(b);
  const len = Math.max(aa.length, bb.length);
  for (let i = 0; i < len; i++) {
    const diff = (aa[i] ?? 0) - (bb[i] ?? 0);
    if (diff !== 0) return diff;
  }
  return 0;
}

function filteredMigrations(): MigrationVersion[] {
  if (!FROM_VERSION) return MIGRATIONS;
  return MIGRATIONS.filter((m) => compareVersion(m.sourceVersion, FROM_VERSION) >= 0);
}

// ── Summary printing ─────────────────────────────────────────────────────────

function printGroupedSummary(): void {
  console.log("\nakm storage migration — RESULTS");
  console.log("================================");

  let totalFailures = 0;

  for (const report of versionReports) {
    console.log(`\n=== Migration: ${report.label} ===`);
    if (!report.ran) {
      console.log(`  (not run — ${report.skipReason ?? "isNeeded returned false"})`);
      continue;
    }
    if (report.results.length === 0) {
      console.log("  (no steps recorded)");
      continue;
    }
    for (const r of report.results) {
      const glyph = r.status === "success" ? "✓" : r.status === "skipped" ? "⊘" : "✗";
      console.log(`  ${glyph} ${r.name}${r.status === "skipped" ? ` — ${r.detail}` : ""}`);
      if (r.status !== "skipped") {
        console.log(`      ${r.detail}`);
      }
      if (r.status === "failed") totalFailures += 1;
    }
  }

  if (totalFailures > 0) {
    console.log(`\n${totalFailures} step(s) failed.`);
    process.exitCode = 1;
    // Suppress the "you can delete legacy files" footer when any step
    // failed — otherwise we tell users it's safe to `rm` files whose
    // migration didn't actually complete (#475).
    console.log(`
Migration did NOT fully complete. DO NOT delete any legacy files yet.
Re-run \`akm-migrate-storage --yes\` after addressing the failure(s) above.
The migration is idempotent — successful steps are skipped on re-run via
their dedup keys (events: (event_type, ts, ref, metadata_json);
task_history: task_id PK).
`);
    return;
  }
  console.log("\nMigration complete. No errors.");

  console.log(`
Old files at the original locations are safe to delete manually after verifying akm works:
  ${path.join(cacheDir, "index.db")}
  ${path.join(cacheDir, "workflow.db")}
  ${path.join(cacheDir, "events.jsonl")}
  ${path.join(cacheDir, "tasks", "history")}
  ${path.join(cacheDir, "config-backups")}
  ${path.join(configDir, "akm.lock")}

Next step — repopulate graph data (if migrating from 0.7):
  The 0.8.0 graph schema redesign (DB_VERSION 12 → 13) rebuilds the graph
  tables in index.db. Non-graph tables regenerate automatically on first
  open, but graph extraction requires LLM calls. Run:
    akm improve
  once after migration so graph-backed features (akm graph related/entity,
  graph-boosted search ranking) have data to work with. See
  docs/migration/v0.7-to-v0.8.md#graph-extraction-will-re-run-after-upgrade.
`);
}

// ── Runner ───────────────────────────────────────────────────────────────────

export async function runMigrations(opts: { dryRun: boolean; paths?: ResolvedPaths } = { dryRun: DRY_RUN }): Promise<VersionRunReport[]> {
  const paths = opts.paths ?? PATHS;
  const reports: VersionRunReport[] = [];

  const migrations = filteredMigrations();

  for (const migration of migrations) {
    const stepResults: StepResult[] = [];
    const ctx: MigrationContext = {
      dryRun: opts.dryRun,
      recordStep: (r) => stepResults.push(r),
      paths,
    };

    let needed = false;
    try {
      needed = await migration.isNeeded(paths);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      reports.push({
        label: migration.label,
        ran: false,
        skipReason: `isNeeded() threw: ${msg}`,
        results: [],
      });
      continue;
    }

    if (!needed) {
      reports.push({ label: migration.label, ran: false, skipReason: "isNeeded() returned false", results: [] });
      continue;
    }

    try {
      await runSteps(migration.steps, ctx);
      reports.push({ label: migration.label, ran: true, results: stepResults });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      stepResults.push({ name: "(migration aborted)", status: "failed", detail: msg });
      reports.push({ label: migration.label, ran: true, results: stepResults });
    }
  }

  return reports;
}

// ── Main ─────────────────────────────────────────────────────────────────────

async function main(): Promise<void> {
  if (LIST_ONLY) {
    printList();
    return;
  }

  console.log("akm storage migration");
  console.log("=====================");
  console.log(`  $CACHE  = ${cacheDir}`);
  console.log(`  $CONFIG = ${configDir}`);
  console.log(`  $DATA   = ${dataDir}`);
  console.log(`  $STATE  = ${stateDir}`);
  console.log();
  console.log("Planned migrations (after --from filter):");
  for (const m of filteredMigrations()) {
    console.log(`  • ${m.label}`);
  }
  if (FROM_VERSION) console.log(`  (filtered: --from ${FROM_VERSION})`);
  console.log();

  if (!DRY_RUN) {
    const proceed = await confirm();
    if (!proceed) {
      console.log("Aborted. No changes made.");
      return;
    }
  } else {
    console.log("[dry-run] No changes will be written.\n");
  }

  const reports = await runMigrations({ dryRun: DRY_RUN, paths: PATHS });
  versionReports.push(...reports);

  printGroupedSummary();

  if (DRY_RUN) {
    console.log("Dry run complete. No changes made.");
  }
}

// Run main only when this file is executed directly (not when imported by tests).
const invokedDirectly = (() => {
  try {
    const entry = process.argv[1];
    if (!entry) return false;
    const here = new URL(import.meta.url).pathname;
    return path.resolve(entry) === here;
  } catch {
    return false;
  }
})();

if (invokedDirectly) {
  await main();
}
