import { createHash } from "node:crypto";
import {
  chmodSync,
  copyFileSync,
  cpSync,
  existsSync,
  lstatSync,
  mkdirSync,
  readFileSync,
  readdirSync,
  readlinkSync,
  renameSync,
  rmSync,
  statSync,
} from "node:fs";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { z } from "zod";
import type { ControllerStore, WorkspaceRecord } from "./store";

export interface ArtifactManifest {
  files: Array<{
    source: string;
    target: string;
    digest: string;
  }>;
}

export const ArtifactManifestSchema = z.object({
  files: z.array(z.object({
    source: z.string().min(1),
    target: z.string().min(1),
    digest: z.string().regex(/^[a-f0-9]{64}$/),
  }).strict()).min(1),
}).strict();

export interface CreateWorkspaceInput {
  id: string;
  repo: string;
  host: string;
  checkoutPath: string;
  publicationPath: string;
  snapshot: string;
  checkoutGeneration: string;
  maxRemoteJobs: number;
}

export const CreateWorkspaceInputSchema = z.object({
  id: z.string().min(1),
  repo: z.string().min(1),
  host: z.string().min(1),
  checkoutPath: z.string().min(1),
  publicationPath: z.string().min(1),
  snapshot: z.string().regex(/^[a-f0-9]{64}$/),
  checkoutGeneration: z.string().min(1),
  maxRemoteJobs: z.number().int().positive(),
}).strict();

export const WorkspaceGcSchema = z.object({
  ttlMs: z.number().nonnegative(),
}).strict();

export interface WorkspaceManagerOptions {
  rootDir: string;
  now?: () => number;
  readCheckoutGeneration?: (checkoutPath: string) => string;
  crashAfterBackup?: boolean;
  crashAfterSwap?: boolean;
}

export class RemoteJobLimitError extends Error {
  constructor() {
    super("global max_remote_jobs reservation exhausted");
    this.name = "RemoteJobLimitError";
  }
}

export class SimulatedPromotionCrash extends Error {
  constructor() {
    super("simulated crash during publication promotion");
    this.name = "SimulatedPromotionCrash";
  }
}

export class TransportInterruptedError extends Error {
  constructor(message = "transport interrupted") {
    super(message);
    this.name = "TransportInterruptedError";
  }
}

export class TransportReattachError extends Error {
  constructor(message = "transport reattach failed", options?: ErrorOptions) {
    super(message, options);
    this.name = "TransportReattachError";
  }
}

export function digestFile(path: string): string {
  return createHash("sha256").update(readFileSync(path)).digest("hex");
}

export function digestTree(root: string, excludedPaths: string[] = []): string {
  const absoluteRoot = resolve(root);
  const excluded = excludedPaths.map((path) => resolve(path));
  const hash = createHash("sha256");

  const visit = (path: string): void => {
    if (excluded.some((entry) => path === entry || path.startsWith(`${entry}${sep}`))) return;
    const relativePath = relative(absoluteRoot, path);
    if (relativePath.split(sep).includes(".git")) return;
    const stat = lstatSync(path);
    if (stat.isDirectory()) {
      for (const entry of readdirSync(path).sort()) visit(join(path, entry));
      return;
    }
    hash.update(relativePath);
    hash.update("\0");
    if (stat.isSymbolicLink()) {
      hash.update(`link:${readlinkSync(path)}`);
    } else if (stat.isFile()) {
      hash.update(readFileSync(path));
    }
    hash.update("\0");
  };

  visit(absoluteRoot);
  return hash.digest("hex");
}

function safeRelative(root: string, value: string): string {
  if (value === "" || isAbsolute(value)) throw new Error(`invalid relative path: ${value}`);
  const resolved = resolve(root, value);
  const relativePath = relative(resolve(root), resolved);
  if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
    throw new Error(`path escapes root: ${value}`);
  }
  return resolved;
}

function requirePublicationRoot(checkoutPath: string, publicationPath: string): void {
  const checkout = resolve(checkoutPath);
  const publication = resolve(publicationPath);
  const pathFromCheckout = relative(checkout, publication);
  if (
    pathFromCheckout === "" ||
    pathFromCheckout === ".." ||
    pathFromCheckout.startsWith(`..${sep}`)
  ) {
    throw new Error("publicationPath must be a dedicated directory inside checkoutPath");
  }
}

function chmodTreeReadOnly(path: string): void {
  const stat = lstatSync(path);
  if (stat.isSymbolicLink()) return;
  if (stat.isDirectory()) {
    for (const entry of readdirSync(path)) chmodTreeReadOnly(join(path, entry));
    chmodSync(path, 0o555);
    return;
  }
  if (stat.isFile()) chmodSync(path, 0o444);
}

function removeTree(path: string): void {
  if (!existsSync(path)) return;
  const stat = lstatSync(path);
  if (!stat.isSymbolicLink() && stat.isDirectory()) {
    chmodSync(path, 0o755);
    for (const entry of readdirSync(path)) removeTree(join(path, entry));
  }
  rmSync(path, { recursive: true, force: true });
}

export class WorkspaceManager {
  private readonly now: () => number;
  private readonly readCheckoutGeneration?: (checkoutPath: string) => string;
  private crashAfterBackup: boolean;
  private crashAfterSwap: boolean;

  constructor(
    private readonly store: ControllerStore,
    private readonly options: WorkspaceManagerOptions,
  ) {
    this.now = options.now ?? (() => Date.now());
    this.readCheckoutGeneration = options.readCheckoutGeneration;
    this.crashAfterBackup = options.crashAfterBackup ?? false;
    this.crashAfterSwap = options.crashAfterSwap ?? false;
    mkdirSync(options.rootDir, { recursive: true });
    this.recoverPromotions();
  }

  create(input: CreateWorkspaceInput): WorkspaceRecord {
    const checkoutPath = resolve(input.checkoutPath);
    const publicationPath = resolve(input.publicationPath);
    requirePublicationRoot(checkoutPath, publicationPath);
    if (!statSync(checkoutPath).isDirectory()) throw new Error("checkoutPath must be a directory");
    if (digestTree(checkoutPath) !== input.snapshot) {
      throw new Error("submitted snapshot does not match checkout");
    }
    const currentGeneration = this.readCheckoutGeneration?.(checkoutPath) ?? digestTree(checkoutPath);
    if (currentGeneration !== input.checkoutGeneration) {
      throw new Error("submitted checkout generation does not match checkout");
    }

    const jobKey = createHash("sha256").update(input.id).digest("hex");
    const repoKey = createHash("sha256").update(input.repo).digest("hex");
    const workspacePath = join(resolve(this.options.rootDir), "jobs", jobKey);
    const cachePath = join(resolve(this.options.rootDir), "cache", repoKey);
    const snapshotPath = join(workspacePath, "snapshot");
    const overlayPath = join(workspacePath, "overlay");
    const outputPath = join(workspacePath, "output");
    const stagingPath = join(dirname(publicationPath), `.offload-staging-${jobKey}`);
    const backupPath = join(dirname(publicationPath), `.offload-backup-${jobKey}`);
    const record: WorkspaceRecord = {
      jobId: input.id,
      repo: input.repo,
      host: input.host,
      snapshot: input.snapshot,
      checkoutGeneration: input.checkoutGeneration,
      checkoutPath,
      publicationPath,
      workspacePath,
      cachePath,
      snapshotPath,
      overlayPath,
      outputPath,
      stagingPath,
      backupPath,
      manifest: null,
      publicationState: "none",
      publicationReason: null,
      transportReattachCount: 0,
      completedAt: null,
      stage: "building",
    };

    if (!this.store.createWorkspace(record, input.maxRemoteJobs)) {
      throw new RemoteJobLimitError();
    }
    try {
      mkdirSync(cachePath, { recursive: true });
      mkdirSync(dirname(workspacePath), { recursive: true });
      mkdirSync(workspacePath, { recursive: false });
      cpSync(checkoutPath, snapshotPath, { recursive: true, dereference: false });
      cpSync(checkoutPath, overlayPath, { recursive: true, dereference: false });
      mkdirSync(outputPath);
      chmodTreeReadOnly(snapshotPath);
      return this.store.getWorkspace(input.id)!;
    } catch (error) {
      removeTree(workspacePath);
      this.store.abandonWorkspace(input.id);
      throw error;
    }
  }

  stage(jobId: string, manifest: ArtifactManifest): void {
    const workspace = this.requireWorkspace(jobId);
    if (manifest.files.length === 0) throw new Error("artifact manifest must not be empty");
    const temporaryPath = `${workspace.stagingPath}.tmp`;
    rmSync(temporaryPath, { recursive: true, force: true });
    mkdirSync(temporaryPath, { recursive: true });
    const targets = new Set<string>();
    try {
      for (const artifact of manifest.files) {
        const source = safeRelative(workspace.outputPath, artifact.source);
        const target = safeRelative(temporaryPath, artifact.target);
        if (targets.has(target)) {
          throw new Error(`duplicate artifact target: ${artifact.target}`);
        }
        targets.add(target);
        const sourceStat = lstatSync(source);
        if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
          throw new Error(`artifact source must be a regular file: ${artifact.source}`);
        }
        if (digestFile(source) !== artifact.digest) {
          throw new Error(`artifact digest mismatch: ${artifact.source}`);
        }
        mkdirSync(dirname(target), { recursive: true });
        copyFileSync(source, target);
      }
      rmSync(workspace.stagingPath, { recursive: true, force: true });
      renameSync(temporaryPath, workspace.stagingPath);
      this.store.setWorkspaceManifest(jobId, manifest);
    } catch (error) {
      rmSync(temporaryPath, { recursive: true, force: true });
      throw error;
    }
  }

  promote(jobId: string): { state: "promoted" } | { state: "blocked"; reason: string } {
    const workspace = this.requireWorkspace(jobId);
    if (workspace.publicationState !== "staged") {
      throw new Error(`workspace is not staged: ${jobId}`);
    }
    const currentSnapshot = digestTree(workspace.checkoutPath, [
      workspace.stagingPath,
      workspace.backupPath,
    ]);
    const snapshotMatches = currentSnapshot === workspace.snapshot;
    const currentGeneration = this.readCheckoutGeneration?.(workspace.checkoutPath) ?? currentSnapshot;
    const generationMatches = currentGeneration === workspace.checkoutGeneration;
    if (!snapshotMatches || !generationMatches) {
      const reason = !snapshotMatches && !generationMatches
        ? "snapshot-and-generation-changed"
        : !snapshotMatches
          ? "snapshot-changed"
          : "checkout-generation-changed";
      this.store.recordPublicationBlocked(jobId, reason);
      return { state: "blocked", reason };
    }

    if (!existsSync(workspace.stagingPath)) throw new Error(`staging missing: ${jobId}`);
    this.store.setWorkspacePublication(jobId, "promoting");
    rmSync(workspace.backupPath, { recursive: true, force: true });
    if (existsSync(workspace.publicationPath)) {
      renameSync(workspace.publicationPath, workspace.backupPath);
    }
    if (this.crashAfterBackup) {
      this.crashAfterBackup = false;
      throw new SimulatedPromotionCrash();
    }
    renameSync(workspace.stagingPath, workspace.publicationPath);
    if (this.crashAfterSwap) {
      this.crashAfterSwap = false;
      throw new SimulatedPromotionCrash();
    }
    rmSync(workspace.backupPath, { recursive: true, force: true });
    this.store.setWorkspacePublication(jobId, "promoted", null, this.now());
    return { state: "promoted" };
  }

  async withTransport<T>(
    jobId: string,
    operation: (mode: "attach" | "reattach") => Promise<T>,
  ): Promise<T> {
    const workspace = this.requireWorkspace(jobId);
    if (workspace.transportReattachCount > 0 || workspace.stage === "failed") {
      throw new TransportReattachError("transport reattach already consumed");
    }
    try {
      return await operation("attach");
    } catch (error) {
      if (!(error instanceof TransportInterruptedError)) throw error;
    }
    if (!this.store.claimTransportReattach(jobId)) {
      throw new TransportReattachError("transport reattach already consumed");
    }
    try {
      return await operation("reattach");
    } catch (error) {
      this.store.markTransportFailed(jobId);
      throw new TransportReattachError("transport interrupted after reattach", { cause: error });
    }
  }

  gc(ttlMs: number): string[] {
    if (!Number.isFinite(ttlMs) || ttlMs < 0) throw new Error("ttlMs must be non-negative");
    const expired = this.store.listExpiredWorkspaces(this.now() - ttlMs);
    for (const workspace of expired) {
      removeTree(workspace.workspacePath);
      rmSync(workspace.stagingPath, { recursive: true, force: true });
      rmSync(workspace.backupPath, { recursive: true, force: true });
      this.store.deleteWorkspace(workspace.jobId);
    }
    return expired.map((workspace) => workspace.jobId);
  }

  private recoverPromotions(): void {
    for (const workspace of this.store.listPromotingWorkspaces()) {
      if (this.publicationMatches(workspace)) {
        rmSync(workspace.stagingPath, { recursive: true, force: true });
        rmSync(workspace.backupPath, { recursive: true, force: true });
        this.store.setWorkspacePublication(workspace.jobId, "promoted", null, this.now());
        continue;
      }
      if (!existsSync(workspace.publicationPath) && existsSync(workspace.backupPath)) {
        renameSync(workspace.backupPath, workspace.publicationPath);
      }
      if (!existsSync(workspace.stagingPath)) {
        throw new Error(`cannot recover partial publication: ${workspace.jobId}`);
      }
      this.store.setWorkspacePublication(workspace.jobId, "staged");
    }
  }

  private requireWorkspace(jobId: string): WorkspaceRecord {
    const workspace = this.store.getWorkspace(jobId);
    if (!workspace) throw new Error(`unknown workspace: ${jobId}`);
    return workspace;
  }

  private publicationMatches(workspace: WorkspaceRecord): boolean {
    if (!existsSync(workspace.publicationPath)) return false;
    const parsed = ArtifactManifestSchema.safeParse(workspace.manifest);
    if (!parsed.success) return false;
    try {
      const expected = parsed.data.files
        .map((artifact) => relative(
          workspace.publicationPath,
          safeRelative(workspace.publicationPath, artifact.target),
        ))
        .sort();
      const actual: string[] = [];
      const collect = (path: string): void => {
        const stat = lstatSync(path);
        if (stat.isDirectory() && !stat.isSymbolicLink()) {
          for (const entry of readdirSync(path)) collect(join(path, entry));
          return;
        }
        if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("invalid publication entry");
        actual.push(relative(workspace.publicationPath, path));
      };
      collect(workspace.publicationPath);
      actual.sort();
      if (actual.length !== expected.length || actual.some((path, index) => path !== expected[index])) {
        return false;
      }
      return parsed.data.files.every((artifact) => {
        const target = safeRelative(workspace.publicationPath, artifact.target);
        const stat = lstatSync(target);
        return stat.isFile() && !stat.isSymbolicLink() && digestFile(target) === artifact.digest;
      });
    } catch {
      return false;
    }
  }
}
