import { createHash } from "node:crypto";
import {
  closeSync,
  fsyncSync,
  openSync,
  readFileSync,
  renameSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { dirname, isAbsolute } from "node:path";

export interface RepositoryIdentity {
  readonly owner: string;
  readonly name: string;
  readonly remoteUrl: string;
  readonly checkoutPath: string;
  readonly defaultBranch: string;
}

interface JourneyBase {
  readonly schemaVersion: 1;
  readonly journeyKey: string;
  readonly runId: string;
  readonly generation: number;
  readonly priorGenerationHash?: string;
  readonly seedFingerprint: string;
  readonly repository: RepositoryIdentity;
  readonly agentAccountId: string;
  readonly agentModel: string;
  readonly ownerText?: string;
}

export interface SeedJourneyManifest extends JourneyBase {
  readonly stage: "seed";
  readonly generation: 0;
}

export interface OwnerCreatedJourneyManifest extends JourneyBase {
  readonly stage: "owner-created";
  readonly projectId: string;
  readonly planId: string;
  readonly planRevisionId: string;
  readonly taskIds: readonly string[];
  readonly authoringReceipt: string;
}

export interface ApprovedJourneyManifest extends Omit<OwnerCreatedJourneyManifest, "stage"> {
  readonly stage: "approved";
  readonly factoryRunId: string;
  readonly firstTaskId: string;
  readonly approvalReceipt: string;
}

export type JourneyManifest =
  SeedJourneyManifest | OwnerCreatedJourneyManifest | ApprovedJourneyManifest;
export type JourneyStage = JourneyManifest["stage"];

const generatedFields = [
  "projectId",
  "planId",
  "planRevisionId",
  "taskIds",
  "factoryRunId",
  "firstTaskId",
] as const;

function requiredString(value: unknown, field: string): string {
  if (typeof value !== "string" || !value.trim()) {
    throw new Error(`journey manifest requires non-empty ${field}`);
  }
  return value;
}

function asObject(value: unknown): Record<string, unknown> {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error("journey manifest must be a JSON object");
  }
  return value as Record<string, unknown>;
}

function stable(value: unknown): string {
  if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
  if (value && typeof value === "object") {
    return `{${Object.entries(value as Record<string, unknown>)
      .sort(([left], [right]) => left.localeCompare(right))
      .map(([key, entry]) => `${JSON.stringify(key)}:${stable(entry)}`)
      .join(",")}}`;
  }
  return JSON.stringify(value);
}

export function manifestFingerprint(value: unknown): string {
  return createHash("sha256").update(stable(value)).digest("hex");
}

function parseRepository(value: unknown): RepositoryIdentity {
  const identity = asObject(value);
  const remoteUrl = requiredString(identity.remoteUrl, "repository.remoteUrl");
  const checkoutPath = requiredString(identity.checkoutPath, "repository.checkoutPath");
  if (
    checkoutPath === remoteUrl ||
    /^(?:https?|ssh):\/\//u.test(checkoutPath) ||
    /^[^/]+@[^:]+:/u.test(checkoutPath)
  ) {
    throw new Error("journey manifest repository.checkoutPath must not be a repository URL");
  }
  if (!isAbsolute(checkoutPath)) {
    throw new Error("journey manifest repository.checkoutPath must be an absolute local path");
  }
  return {
    owner: requiredString(identity.owner, "repository.owner"),
    name: requiredString(identity.name, "repository.name"),
    remoteUrl,
    checkoutPath,
    defaultBranch: requiredString(identity.defaultBranch, "repository.defaultBranch"),
  };
}

function parseBase(manifest: Record<string, unknown>): JourneyBase {
  if (manifest.schemaVersion !== 1) {
    throw new Error("journey manifest requires schemaVersion 1");
  }
  if (!Number.isSafeInteger(manifest.generation) || (manifest.generation as number) < 0) {
    throw new Error("journey manifest requires non-negative generation");
  }
  return {
    schemaVersion: 1,
    journeyKey: requiredString(manifest.journeyKey, "journeyKey"),
    runId: requiredString(manifest.runId, "runId"),
    generation: manifest.generation as number,
    ...(manifest.priorGenerationHash === undefined
      ? {}
      : {
          priorGenerationHash: requiredString(manifest.priorGenerationHash, "priorGenerationHash"),
        }),
    seedFingerprint: requiredString(manifest.seedFingerprint, "seedFingerprint"),
    repository: parseRepository(manifest.repository),
    agentAccountId: requiredString(manifest.agentAccountId, "agentAccountId"),
    agentModel: requiredString(manifest.agentModel, "agentModel"),
    ...(manifest.ownerText === undefined
      ? {}
      : { ownerText: requiredString(manifest.ownerText, "ownerText") }),
  };
}

function parseTaskIds(value: unknown): readonly string[] {
  if (!Array.isArray(value) || value.length === 0) {
    throw new Error("journey manifest requires ordered non-empty taskIds");
  }
  const ids = value.map((id, index) => requiredString(id, `taskIds[${index}]`));
  if (new Set(ids).size !== ids.length) {
    throw new Error("journey manifest taskIds must be unique");
  }
  return Object.freeze(ids);
}

export function parseJourneyManifest(value: unknown): JourneyManifest {
  const manifest = asObject(value);
  const base = parseBase(manifest);
  const stage = requiredString(manifest.stage, "stage");

  if (stage === "seed") {
    for (const field of generatedFields) {
      if (field in manifest) {
        throw new Error(`seed journey manifest rejects product-generated ${field}`);
      }
    }
    if (base.generation !== 0 || base.priorGenerationHash !== undefined) {
      throw new Error("seed generation must be 0 and have no prior generation hash");
    }
    return { ...base, stage, generation: 0 };
  }
  if (stage !== "owner-created" && stage !== "approved") {
    throw new Error(`unknown journey stage ${stage}`);
  }

  const taskIds = parseTaskIds(manifest.taskIds);
  const ownerCreated: OwnerCreatedJourneyManifest = {
    ...base,
    stage: "owner-created",
    projectId: requiredString(manifest.projectId, "projectId"),
    planId: requiredString(manifest.planId, "planId"),
    planRevisionId: requiredString(manifest.planRevisionId, "planRevisionId"),
    taskIds,
    authoringReceipt: requiredString(manifest.authoringReceipt, "authoringReceipt"),
  };
  if (stage === "owner-created") return ownerCreated;

  const firstTaskId = requiredString(manifest.firstTaskId, "firstTaskId");
  if (firstTaskId !== taskIds[0]) {
    throw new Error("firstTaskId must equal the first ordered taskId");
  }
  return {
    ...ownerCreated,
    stage,
    factoryRunId: requiredString(manifest.factoryRunId, "factoryRunId"),
    firstTaskId,
    approvalReceipt: requiredString(manifest.approvalReceipt, "approvalReceipt"),
  };
}

export function parseSeedJourneyManifest(value: unknown): SeedJourneyManifest {
  const manifest = parseJourneyManifest(value);
  if (manifest.stage !== "seed") throw new Error("seed stage required");
  return manifest;
}

export function parseOwnerCreatedJourneyManifest(value: unknown): OwnerCreatedJourneyManifest {
  const manifest = parseJourneyManifest(value);
  if (manifest.stage !== "owner-created") throw new Error("owner-created stage required");
  return manifest;
}

export function parseApprovedJourneyManifest(value: unknown): ApprovedJourneyManifest {
  const manifest = parseJourneyManifest(value);
  if (manifest.stage !== "approved") throw new Error("approved stage required");
  return manifest;
}

export function loadJourneyManifest(path: string): JourneyManifest {
  try {
    return parseJourneyManifest(JSON.parse(readFileSync(path, "utf8")));
  } catch (error) {
    throw new Error(
      `cannot read journey manifest ${path}: ${error instanceof Error ? error.message : String(error)}`,
      { cause: error },
    );
  }
}

export function loadJourneyManifestAtLeast(path: string, minimum: JourneyStage): JourneyManifest {
  const manifest = loadJourneyManifest(path);
  const rank: Record<JourneyStage, number> = { seed: 0, "owner-created": 1, approved: 2 };
  if (rank[manifest.stage] < rank[minimum]) {
    throw new Error(`${minimum} identity required; manifest is ${manifest.stage}`);
  }
  return manifest;
}

function assertSameIdentity(prior: JourneyManifest, next: JourneyManifest): void {
  const immutable = (manifest: JourneyManifest) => ({
    schemaVersion: manifest.schemaVersion,
    journeyKey: manifest.journeyKey,
    runId: manifest.runId,
    seedFingerprint: manifest.seedFingerprint,
    repository: manifest.repository,
    agentAccountId: manifest.agentAccountId,
    agentModel: manifest.agentModel,
    ownerText: manifest.ownerText,
  });
  if (stable(immutable(prior)) !== stable(immutable(next))) {
    throw new Error("journey transition replaces immutable seed identity");
  }
}

function assertLegalTransition(prior: JourneyManifest, next: JourneyManifest): void {
  const expectedStage = prior.stage === "seed" ? "owner-created" : "approved";
  if (prior.stage === "approved" || next.stage !== expectedStage) {
    throw new Error(`illegal journey stage transition ${prior.stage} -> ${next.stage}`);
  }
  if (
    next.generation !== prior.generation + 1 ||
    next.priorGenerationHash !== manifestFingerprint(prior)
  ) {
    throw new Error("journey transition requires the immediately prior generation hash");
  }
  assertSameIdentity(prior, next);
  if (prior.stage === "owner-created" && next.stage === "approved") {
    for (const field of [
      "projectId",
      "planId",
      "planRevisionId",
      "taskIds",
      "authoringReceipt",
    ] as const) {
      if (stable(prior[field]) !== stable(next[field])) {
        throw new Error(`journey transition replaces cumulative generated identity ${field}`);
      }
    }
  }
}

export function atomicTransitionJourney(
  path: string,
  prior: JourneyManifest,
  nextValue: unknown,
): JourneyManifest {
  const lockPath = `${path}.lock`;
  const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
  let lockFd: number | undefined;
  let tempCreated = false;
  try {
    lockFd = openSync(lockPath, "wx", 0o600);
    const disk = loadJourneyManifest(path);
    if (manifestFingerprint(disk) !== manifestFingerprint(prior)) {
      throw new Error("prior generation/fingerprint mismatch");
    }
    const next = parseJourneyManifest(nextValue);
    assertLegalTransition(prior, next);

    const tempFd = openSync(tempPath, "wx", 0o600);
    tempCreated = true;
    try {
      writeFileSync(tempFd, `${JSON.stringify(next, null, 2)}\n`);
      fsyncSync(tempFd);
    } finally {
      closeSync(tempFd);
    }
    renameSync(tempPath, path);
    tempCreated = false;
    const directoryFd = openSync(dirname(path), "r");
    try {
      fsyncSync(directoryFd);
    } finally {
      closeSync(directoryFd);
    }
    return next;
  } finally {
    if (tempCreated) rmSync(tempPath, { force: true });
    if (lockFd !== undefined) {
      closeSync(lockFd);
      rmSync(lockPath, { force: true });
    }
  }
}

export function journeyScopedOwnerLabels(journeyKey: string): Readonly<{
  projectName: string;
  planTitle: string;
}> {
  const key = requiredString(journeyKey, "journeyKey");
  return Object.freeze({
    projectName: `AWP owner journey ${key}`,
    planTitle: `Owner journey plan ${key}`,
  });
}

export type PersistedClosureState<T> =
  | { readonly status: "absent" }
  | { readonly status: "complete"; readonly value: T }
  | { readonly status: "partial"; readonly reason: string }
  | { readonly status: "mismatched"; readonly reason: string };

/**
 * Resolves a journey-key-scoped persisted closure for crash recovery. Only an
 * absent closure may be created and only one complete closure may be reused.
 */
export function reconcilePersistedClosure<T>(
  description: string,
  matches: readonly PersistedClosureState<T>[],
): T | undefined {
  if (matches.length === 0) return undefined;
  if (matches.length !== 1) {
    throw new Error(`${description} recovery is ambiguous: expected at most one closure`);
  }
  const match = matches[0]!;
  if (match.status === "absent") {
    throw new Error(`${description} recovery returned an explicit absent match`);
  }
  if (match.status !== "complete") {
    throw new Error(`${description} recovery rejected ${match.status} state: ${match.reason}`);
  }
  return match.value;
}

export interface OwnerCreatedIdentity {
  readonly projectId: string;
  readonly planId: string;
  readonly planRevisionId: string;
  readonly taskIds: readonly string[];
  readonly authoringReceipt: string;
}

export interface ApprovalIdentity {
  readonly factoryRunId: string;
  readonly firstTaskId: string;
  readonly approvalReceipt: string;
}

export function transitionToOwnerCreated(
  path: string,
  seed: SeedJourneyManifest,
  identity: OwnerCreatedIdentity,
): OwnerCreatedJourneyManifest {
  return atomicTransitionJourney(path, seed, {
    ...seed,
    ...identity,
    stage: "owner-created",
    generation: 1,
    priorGenerationHash: manifestFingerprint(seed),
  }) as OwnerCreatedJourneyManifest;
}

export function transitionToApproved(
  path: string,
  ownerCreated: OwnerCreatedJourneyManifest,
  identity: ApprovalIdentity,
): ApprovedJourneyManifest {
  return atomicTransitionJourney(path, ownerCreated, {
    ...ownerCreated,
    ...identity,
    stage: "approved",
    generation: ownerCreated.generation + 1,
    priorGenerationHash: manifestFingerprint(ownerCreated),
  }) as ApprovedJourneyManifest;
}
