import { createHash } from "node:crypto";

import type { ExecutionReport, Milestone, TaskGroup, TaskItem } from "./contracts.ts";

export const TASKTREE_BACKUP_SCHEMA_VERSION = 1;

export type TaskTreeSnapshot = {
  schemaVersion: typeof TASKTREE_BACKUP_SCHEMA_VERSION;
  groups: TaskGroup[];
  tasks: TaskItem[];
  milestones: Milestone[];
  reports: ExecutionReport[];
};

export type TaskTreeBackupManifest = {
  schemaVersion: typeof TASKTREE_BACKUP_SCHEMA_VERSION;
  target: string;
  generatedAt: string;
  contentSha256: string;
  writer: string;
  counts: {
    groups: number;
    tasks: number;
    milestones: number;
    reports: number;
  };
};

export type TaskTreeBackupState = {
  target: string;
  pending: boolean;
  requestedAt: string | null;
  requestReason: string;
  lastAttemptAt: string | null;
  lastSuccessAt: string | null;
  contentSha256: string;
  gitCommit: string;
  status: "never" | "pending" | "succeeded" | "failed";
  errorCode: string;
  errorMessage: string;
  updatedAt: string;
};

export type TaskTreeRestoreDiff = Record<
  "groups" | "tasks" | "milestones" | "reports",
  { create: number; update: number; delete: number }
>;

export function canonicalSnapshot(value: TaskTreeSnapshot): string {
  return `${JSON.stringify(normalizeSnapshot(value), null, 2)}\n`;
}

export function snapshotSha256(value: TaskTreeSnapshot): string {
  return createHash("sha256").update(canonicalSnapshot(value)).digest("hex");
}

export function snapshotManifest(
  snapshot: TaskTreeSnapshot,
  options: { target: string; generatedAt?: string; writer?: string },
): TaskTreeBackupManifest {
  return {
    schemaVersion: TASKTREE_BACKUP_SCHEMA_VERSION,
    target: requiredText(options.target, "target"),
    generatedAt: new Date(options.generatedAt ?? Date.now()).toISOString(),
    contentSha256: snapshotSha256(snapshot),
    writer: options.writer ?? "hwlab-tasktree-worker",
    counts: snapshotCounts(snapshot),
  };
}

export function parseSnapshot(text: string): TaskTreeSnapshot {
  let value: unknown;
  try {
    value = JSON.parse(text);
  } catch {
    throw codedError("backup_snapshot_invalid_json", "backup snapshot is not valid JSON");
  }
  return validateSnapshot(value);
}

export function parseManifest(text: string): TaskTreeBackupManifest {
  let value: unknown;
  try {
    value = JSON.parse(text);
  } catch {
    throw codedError("backup_manifest_invalid_json", "backup manifest is not valid JSON");
  }
  if (!isRecord(value) || value.schemaVersion !== TASKTREE_BACKUP_SCHEMA_VERSION) {
    throw codedError("backup_manifest_schema_unsupported", "backup manifest schema version is unsupported");
  }
  const counts = value.counts;
  if (!isRecord(counts)) throw codedError("backup_manifest_invalid", "backup manifest counts are required");
  return {
    schemaVersion: TASKTREE_BACKUP_SCHEMA_VERSION,
    target: requiredText(value.target, "manifest.target"),
    generatedAt: validIso(value.generatedAt, "manifest.generatedAt"),
    contentSha256: validSha(value.contentSha256, "manifest.contentSha256"),
    writer: requiredText(value.writer, "manifest.writer"),
    counts: {
      groups: validCount(counts.groups, "manifest.counts.groups"),
      tasks: validCount(counts.tasks, "manifest.counts.tasks"),
      milestones: validCount(counts.milestones, "manifest.counts.milestones"),
      reports: validCount(counts.reports, "manifest.counts.reports"),
    },
  };
}

export function verifySnapshotManifest(snapshot: TaskTreeSnapshot, manifest: TaskTreeBackupManifest): void {
  if (snapshotSha256(snapshot) !== manifest.contentSha256) {
    throw codedError("backup_content_sha_mismatch", "backup snapshot does not match the manifest SHA");
  }
  if (JSON.stringify(snapshotCounts(snapshot)) !== JSON.stringify(manifest.counts)) {
    throw codedError("backup_count_mismatch", "backup snapshot counts do not match the manifest");
  }
}

export function restoreDiff(current: TaskTreeSnapshot, incoming: TaskTreeSnapshot): TaskTreeRestoreDiff {
  return {
    groups: entityDiff(current.groups, incoming.groups),
    tasks: entityDiff(current.tasks, incoming.tasks),
    milestones: entityDiff(current.milestones, incoming.milestones),
    reports: entityDiff(current.reports, incoming.reports),
  };
}

export function validateSnapshot(value: unknown): TaskTreeSnapshot {
  if (!isRecord(value) || value.schemaVersion !== TASKTREE_BACKUP_SCHEMA_VERSION) {
    throw codedError("backup_snapshot_schema_unsupported", "backup snapshot schema version is unsupported");
  }
  if (!Array.isArray(value.groups) || !Array.isArray(value.tasks) || !Array.isArray(value.milestones) || !Array.isArray(value.reports)) {
    throw codedError("backup_snapshot_invalid", "backup snapshot entity arrays are required");
  }
  const snapshot = normalizeSnapshot(value as TaskTreeSnapshot);
  uniqueIds(snapshot.groups, "group");
  uniqueIds(snapshot.tasks, "task");
  uniqueIds(snapshot.milestones, "milestone");
  uniqueIds(snapshot.reports, "report");
  const groupIds = new Set(snapshot.groups.map((item) => item.id));
  const taskIds = new Set(snapshot.tasks.map((item) => item.id));
  for (const task of snapshot.tasks) {
    if (!groupIds.has(task.groupId)) throw codedError("backup_reference_invalid", `task ${task.id} references a missing taskgroup`);
    if (task.parentId !== null && !taskIds.has(task.parentId)) throw codedError("backup_reference_invalid", `task ${task.id} references a missing parent`);
  }
  for (const milestone of snapshot.milestones) {
    if (!groupIds.has(milestone.groupId)) throw codedError("backup_reference_invalid", `milestone ${milestone.id} references a missing taskgroup`);
    if (milestone.taskId !== null && !taskIds.has(milestone.taskId)) throw codedError("backup_reference_invalid", `milestone ${milestone.id} references a missing task`);
  }
  for (const report of snapshot.reports) {
    if (!taskIds.has(report.taskId)) throw codedError("backup_reference_invalid", `report ${report.id} references a missing task`);
  }
  assertAcyclic(snapshot.tasks);
  return snapshot;
}

function normalizeSnapshot(value: TaskTreeSnapshot): TaskTreeSnapshot {
  return {
    schemaVersion: TASKTREE_BACKUP_SCHEMA_VERSION,
    groups: [...value.groups].sort(byId),
    tasks: [...value.tasks].sort(byId),
    milestones: [...value.milestones].sort(byId),
    reports: [...value.reports].sort(byId),
  };
}

function snapshotCounts(snapshot: TaskTreeSnapshot) {
  return {
    groups: snapshot.groups.length,
    tasks: snapshot.tasks.length,
    milestones: snapshot.milestones.length,
    reports: snapshot.reports.length,
  };
}

function entityDiff<T extends { id: string }>(current: T[], incoming: T[]) {
  const before = new Map(current.map((item) => [item.id, JSON.stringify(item)]));
  const after = new Map(incoming.map((item) => [item.id, JSON.stringify(item)]));
  let create = 0;
  let update = 0;
  let remove = 0;
  for (const [id, value] of after) {
    if (!before.has(id)) create += 1;
    else if (before.get(id) !== value) update += 1;
  }
  for (const id of before.keys()) if (!after.has(id)) remove += 1;
  return { create, update, delete: remove };
}

function uniqueIds(items: Array<{ id: string }>, kind: string): void {
  const ids = new Set<string>();
  for (const item of items) {
    if (!item.id || ids.has(item.id)) throw codedError("backup_snapshot_invalid", `${kind} IDs must be non-empty and unique`);
    ids.add(item.id);
  }
}

function assertAcyclic(tasks: TaskItem[]): void {
  const parents = new Map(tasks.map((item) => [item.id, item.parentId]));
  for (const task of tasks) {
    const seen = new Set<string>();
    let cursor: string | null = task.id;
    while (cursor !== null) {
      if (seen.has(cursor)) throw codedError("backup_reference_cycle", `task ${task.id} has a cyclic parent chain`);
      seen.add(cursor);
      cursor = parents.get(cursor) ?? null;
    }
  }
}

function byId(left: { id: string }, right: { id: string }): number {
  return left.id.localeCompare(right.id);
}

function isRecord(value: unknown): value is Record<string, any> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function requiredText(value: unknown, field: string): string {
  if (typeof value !== "string" || value.trim().length === 0) throw codedError("backup_contract_invalid", `${field} is required`);
  return value;
}

function validIso(value: unknown, field: string): string {
  const date = new Date(String(value ?? ""));
  if (Number.isNaN(date.valueOf())) throw codedError("backup_contract_invalid", `${field} must be an ISO timestamp`);
  return date.toISOString();
}

function validSha(value: unknown, field: string): string {
  const text = requiredText(value, field);
  if (!/^[0-9a-f]{64}$/u.test(text)) throw codedError("backup_contract_invalid", `${field} must be a SHA-256 hex digest`);
  return text;
}

function validCount(value: unknown, field: string): number {
  if (!Number.isInteger(value) || Number(value) < 0) throw codedError("backup_contract_invalid", `${field} must be a non-negative integer`);
  return Number(value);
}

function codedError(code: string, message: string): Error {
  return Object.assign(new Error(message), { code });
}
