import { createHash } from "node:crypto";
import {
  copyFile,
  mkdir,
  readdir,
  readFile,
  rename,
  rm,
  stat,
  writeFile,
} from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";

type JsonRecord = Record<string, unknown>;

type Options = {
  backupDir: string;
  dataDir: string;
  dryRun: boolean;
  fromActorKey: string;
  fromProjectId?: string;
  fromProjectKey?: string;
  fromUserId?: string;
  metadataDir: string;
  memoryDir: string;
  resultRefsDir: string;
  toActorKey: string;
  toProjectId?: string;
  toProjectKey?: string;
  toUserId?: string;
  transcriptsDir: string;
};

type Change = {
  detail: string;
  file: string;
  kind: "metadata" | "transcript" | "result-ref" | "memory";
};

const usage = `Usage:
  bun scripts/migrate-session-identity.ts --from-user-id <old> --to-user-id <new> [--write]
  bun scripts/migrate-session-identity.ts --from-actor-key <old> --to-actor-key <new> [--write]

Optional:
  --from-project-id <old>       Match old project id
  --to-project-id <new>         Rewrite project id
  --from-project-key <old>      Match old project key
  --to-project-key <new>        Rewrite project key
  --data-dir <dir>              Default: ./data
  --backup-dir <dir>            Default: <data-dir>/backup/session-identity-migration/<timestamp>
  --write                       Apply changes. Without this, dry-run only.

Examples:
  bun scripts/migrate-session-identity.ts \\
    --from-user-id d1acbc3d-cf62-4049-9939-95e4feb37296 \\
    --to-actor-key actor-49a0275a85079f9d

  bun scripts/migrate-session-identity.ts \\
    --from-actor-key actor-da98d99c20c386e7 \\
    --to-actor-key actor-49a0275a85079f9d \\
    --write
`;

const main = async () => {
  const options = parseOptions(process.argv.slice(2));
  const changes: Change[] = [];

  changes.push(...(await migrateMetadata(options)));
  changes.push(...(await migrateTranscripts(options)));
  changes.push(...(await migrateResultRefs(options)));
  changes.push(...(await migrateUserMemory(options)));

  printSummary(options, changes);
};

const parseOptions = (args: string[]): Options => {
  const values = new Map<string, string | boolean>();
  for (let index = 0; index < args.length; index += 1) {
    const arg = args[index];
    if (!arg.startsWith("--")) {
      throw new Error(`unexpected argument: ${arg}\n\n${usage}`);
    }
    const key = arg.slice(2);
    if (key === "help" || key === "h") {
      console.log(usage);
      process.exit(0);
    }
    if (key === "write") {
      values.set(key, true);
      continue;
    }
    const value = args[index + 1];
    if (!value || value.startsWith("--")) {
      throw new Error(`missing value for --${key}\n\n${usage}`);
    }
    values.set(key, value);
    index += 1;
  }

  const dataDir = stringOption(values, "data-dir") ?? "./data";
  const fromUserId = stringOption(values, "from-user-id");
  const toUserId = stringOption(values, "to-user-id");
  const fromActorKey = stringOption(values, "from-actor-key") ?? actorKeyFromUserId(fromUserId);
  const toActorKey = stringOption(values, "to-actor-key") ?? actorKeyFromUserId(toUserId);
  const fromProjectId = stringOption(values, "from-project-id");
  const toProjectId = stringOption(values, "to-project-id");
  const fromProjectKey =
    stringOption(values, "from-project-key") ?? projectKeyFromProjectId(fromProjectId);
  const toProjectKey =
    stringOption(values, "to-project-key") ?? projectKeyFromProjectId(toProjectId);

  if (!fromActorKey || !toActorKey) {
    throw new Error(
      "provide --from-user-id/--to-user-id or --from-actor-key/--to-actor-key\n\n" +
        usage,
    );
  }
  if (fromActorKey === toActorKey && fromProjectKey === toProjectKey) {
    throw new Error("source and target identity are identical");
  }

  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
  const backupDir =
    stringOption(values, "backup-dir") ??
    join(dataDir, "backup", "session-identity-migration", timestamp);

  return {
    backupDir,
    dataDir,
    dryRun: !values.has("write"),
    fromActorKey,
    fromProjectId,
    fromProjectKey,
    fromUserId,
    metadataDir: join(dataDir, "session-metadata"),
    memoryDir: join(dataDir, "memory"),
    resultRefsDir: join(dataDir, "result-refs"),
    toActorKey,
    toProjectId,
    toProjectKey,
    toUserId,
    transcriptsDir: join(dataDir, "session-transcripts"),
  };
};

const stringOption = (values: Map<string, string | boolean>, key: string) => {
  const value = values.get(key);
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
};

const actorKeyFromUserId = (userId?: string) =>
  userId ? scopedKey("actor", userId) : undefined;

const projectKeyFromProjectId = (projectId?: string) =>
  projectId ? scopedKey("project", projectId) : undefined;

const scopedKey = (prefix: string, value: string) =>
  `${prefix}-${createHash("sha256").update(value.trim()).digest("hex").slice(0, 16)}`;

const migrateMetadata = async (options: Options): Promise<Change[]> => {
  const changes: Change[] = [];
  for (const file of await listJsonFiles(options.metadataDir)) {
    const record = await readJson(file);
    if (!record || record.actorKey !== options.fromActorKey) {
      continue;
    }
    if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
      continue;
    }
    if (options.fromProjectId && record.projectId !== options.fromProjectId) {
      continue;
    }

    const next: JsonRecord = {
      ...record,
      actorKey: options.toActorKey,
      ...(options.toUserId ? { ownerUserId: options.toUserId } : {}),
      ...(options.toProjectId ? { projectId: options.toProjectId } : {}),
      ...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
    };

    await writeJsonWithBackup(options, file, next);
    changes.push({
      detail: `${record.actorKey} -> ${next.actorKey}`,
      file,
      kind: "metadata",
    });
  }
  return changes;
};

const migrateTranscripts = async (options: Options): Promise<Change[]> => {
  const changes: Change[] = [];
  for (const file of await listJsonFiles(options.transcriptsDir)) {
    const transcript = await readJson(file);
    if (!transcript || transcript.actorKey !== options.fromActorKey) {
      continue;
    }
    if (options.fromProjectKey && transcript.projectKey !== options.fromProjectKey) {
      continue;
    }

    const next: JsonRecord = {
      ...transcript,
      actorKey: options.toActorKey,
      ...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
    };
    const nextPath = join(
      dirname(file),
      `${next.actorKey}__${next.projectKey}__${next.sessionId}.json`,
    );
    if (nextPath !== file && (await exists(nextPath))) {
      throw new Error(`refusing to overwrite existing transcript: ${nextPath}`);
    }

    await writeJsonWithBackup(options, file, next);
    if (nextPath !== file) {
      await renameWithBackup(options, file, nextPath);
    }
    changes.push({
      detail: `${basename(file)} -> ${basename(nextPath)}`,
      file,
      kind: "transcript",
    });
  }
  return changes;
};

const migrateResultRefs = async (options: Options): Promise<Change[]> => {
  const changes: Change[] = [];
  for (const file of await listJsonFiles(options.resultRefsDir)) {
    const record = await readJson(file);
    if (!record || record.actorKey !== options.fromActorKey) {
      continue;
    }
    if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
      continue;
    }
    if (options.fromProjectId && record.projectId !== options.fromProjectId) {
      continue;
    }

    const next: JsonRecord = {
      ...record,
      actorKey: options.toActorKey,
      ...(options.toProjectId ? { projectId: options.toProjectId } : {}),
      ...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
    };
    await writeJsonWithBackup(options, file, next);
    changes.push({
      detail: `${record.actorKey} -> ${next.actorKey}`,
      file,
      kind: "result-ref",
    });
  }
  return changes;
};

const migrateUserMemory = async (options: Options): Promise<Change[]> => {
  const fromFile = join(options.memoryDir, "users", `${options.fromActorKey}.md`);
  if (!(await exists(fromFile))) {
    return [];
  }
  const toFile = join(options.memoryDir, "users", `${options.toActorKey}.md`);
  if (fromFile === toFile) {
    return [];
  }

  if (await exists(toFile)) {
    const [fromContent, toContent] = await Promise.all([
      readFile(fromFile, "utf8"),
      readFile(toFile, "utf8"),
    ]);
    const merged = mergeMemoryMarkdown(toContent, fromContent);
    await writeTextWithBackup(options, toFile, merged);
    await removeWithBackup(options, fromFile);
  } else {
    await renameWithBackup(options, fromFile, toFile);
  }

  return [
    {
      detail: `${basename(fromFile)} -> ${basename(toFile)}`,
      file: fromFile,
      kind: "memory",
    },
  ];
};

const mergeMemoryMarkdown = (target: string, source: string) => {
  const lines = target.split("\n");
  const existing = new Set(lines.map((line) => line.trim()).filter(Boolean));
  for (const line of source.split("\n")) {
    const normalized = line.trim();
    if (!normalized || existing.has(normalized)) {
      continue;
    }
    lines.push(line);
    existing.add(normalized);
  }
  return `${lines.join("\n").replace(/\n+$/u, "")}\n`;
};

const listJsonFiles = async (dir: string) => {
  try {
    const names = await readdir(dir);
    return names
      .filter((name) => name.endsWith(".json"))
      .map((name) => join(dir, name));
  } catch (error) {
    if (isErrno(error, "ENOENT")) {
      return [];
    }
    throw error;
  }
};

const readJson = async (file: string): Promise<JsonRecord | null> => {
  try {
    const value = JSON.parse(await readFile(file, "utf8")) as unknown;
    return isRecord(value) ? value : null;
  } catch (error) {
    if (isErrno(error, "ENOENT")) {
      return null;
    }
    throw error;
  }
};

const writeJsonWithBackup = async (
  options: Options,
  file: string,
  value: JsonRecord,
) => {
  await writeTextWithBackup(options, file, `${JSON.stringify(value, null, 2)}\n`);
};

const writeTextWithBackup = async (
  options: Options,
  file: string,
  content: string,
) => {
  if (options.dryRun) {
    return;
  }
  await backupFile(options, file);
  await writeFile(file, content, "utf8");
};

const renameWithBackup = async (options: Options, from: string, to: string) => {
  if (options.dryRun) {
    return;
  }
  await backupFile(options, from);
  await mkdir(dirname(to), { recursive: true });
  await rename(from, to);
};

const removeWithBackup = async (options: Options, file: string) => {
  if (options.dryRun) {
    return;
  }
  await backupFile(options, file);
  await rm(file, { force: true });
};

const backupFile = async (options: Options, file: string) => {
  const relativePath = relative(process.cwd(), file);
  const target = join(options.backupDir, relativePath);
  await mkdir(dirname(target), { recursive: true });
  await copyFile(file, target);
};

const exists = async (file: string) => {
  try {
    await stat(file);
    return true;
  } catch (error) {
    if (isErrno(error, "ENOENT")) {
      return false;
    }
    throw error;
  }
};

const isRecord = (value: unknown): value is JsonRecord =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const isErrno = (error: unknown, code: string) =>
  error instanceof Error &&
  "code" in error &&
  (error as NodeJS.ErrnoException).code === code;

const printSummary = (options: Options, changes: Change[]) => {
  const counts = changes.reduce<Record<string, number>>((acc, change) => {
    acc[change.kind] = (acc[change.kind] ?? 0) + 1;
    return acc;
  }, {});

  console.log(options.dryRun ? "DRY RUN: no files changed" : "Migration applied");
  console.log(`from actor: ${options.fromActorKey}`);
  console.log(`to actor:   ${options.toActorKey}`);
  if (options.fromProjectKey || options.toProjectKey) {
    console.log(`project:    ${options.fromProjectKey ?? "*"} -> ${options.toProjectKey ?? "(unchanged)"}`);
  }
  if (!options.dryRun) {
    console.log(`backup dir: ${options.backupDir}`);
  }
  console.log(`changes:    ${changes.length}`);
  for (const [kind, count] of Object.entries(counts)) {
    console.log(`  ${kind}: ${count}`);
  }
  for (const change of changes.slice(0, 40)) {
    console.log(`- [${change.kind}] ${change.detail}`);
  }
  if (changes.length > 40) {
    console.log(`... ${changes.length - 40} more`);
  }
};

main().catch((error) => {
  console.error(error instanceof Error ? error.message : String(error));
  process.exit(1);
});
