import { constants } from "node:fs";
import {
  access,
  copyFile,
  lstat,
  mkdir,
  mkdtemp,
  readFile,
  realpath,
  rename,
  rm,
  writeFile,
} from "node:fs/promises";
import { dirname, join, parse, resolve } from "node:path";
import { assertCompatible, type ClientEnvironment } from "./compatibility.ts";
import { hashFile, verifyManifest } from "./manifest.ts";
import {
  readInstalledReleaseState,
  type InstalledArtifactState,
  type InstalledReleaseState,
} from "./installed-state.ts";
import { isPathWithinRoot } from "./path-security.ts";
import type { ReleaseArtifact, ReleaseManifest } from "./types.ts";
import { ValidationError } from "./validation.ts";

interface PreparedArtifact {
  artifact: ReleaseArtifact;
  target: string;
  staged: string;
  backup: string;
  movedPrevious: boolean;
  installed: boolean;
}

interface PreparedRetirement {
  artifact: InstalledArtifactState;
  target: string;
  backup: string;
  moved: boolean;
}

export interface InstallReleaseOptions {
  manifest: unknown;
  publicKeyPath?: string;
  publicKeyPem?: string;
  artifactCache: string;
  instanceRoot: string;
  environment: ClientEnvironment;
}

export interface InstallResult {
  manifest: ReleaseManifest;
  changed: boolean;
  installed: string[];
  removed: string[];
}

async function exists(path: string): Promise<boolean> {
  try {
    await access(path, constants.F_OK);
    return true;
  } catch {
    return false;
  }
}

async function checkedRoot(path: string, name: string): Promise<string> {
  const resolved = resolve(path);
  if (resolved === parse(resolved).root) throw new ValidationError(`${name} ne peut pas être la racine du système`);
  const actual = await realpath(resolved);
  const metadata = await lstat(actual);
  if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
    throw new ValidationError(`${name} doit être un dossier réel`);
  }
  return actual;
}

async function ensureSafeParent(root: string, file: string): Promise<void> {
  const parent = dirname(file);
  await mkdir(parent, { recursive: true });
  const actualParent = await realpath(parent);
  if (!isPathWithinRoot(root, actualParent)) {
    throw new ValidationError(`Un lien symbolique fait sortir le chemin de l'instance: ${file}`);
  }
}

async function rejectSymlink(path: string): Promise<void> {
  try {
    const metadata = await lstat(path);
    if (metadata.isSymbolicLink() || !metadata.isFile()) {
      throw new ValidationError(`La cible existante n'est pas un fichier régulier: ${path}`);
    }
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
  }
}

async function verifyCachedArtifact(cacheRoot: string, artifact: ReleaseArtifact): Promise<string> {
  const candidate = resolve(cacheRoot, artifact.fileName);
  const actual = await realpath(candidate);
  if (!isPathWithinRoot(cacheRoot, actual)) {
    throw new ValidationError(`Artefact cache hors racine ou lien symbolique: ${artifact.fileName}`);
  }
  const integrity = await hashFile(actual);
  if (integrity.size !== artifact.integrity.size || integrity.sha256 !== artifact.integrity.sha256) {
    throw new ValidationError(`SHA-256 ou taille incorrecte dans le cache: ${artifact.fileName}`);
  }
  return actual;
}

async function instanceAlreadyCurrent(root: string, manifest: ReleaseManifest): Promise<boolean> {
  for (const artifact of manifest.payload.artifacts) {
    const target = resolve(root, artifact.installPath);
    if (!isPathWithinRoot(root, target) || !await exists(target)) return false;
    await rejectSymlink(target);
    const integrity = await hashFile(target);
    if (integrity.size !== artifact.integrity.size || integrity.sha256 !== artifact.integrity.sha256) return false;
  }
  return true;
}

function stateMatchesManifest(state: InstalledReleaseState | null, manifest: ReleaseManifest): boolean {
  if (
    state === null
    || state.releaseId !== manifest.payload.releaseId
    || state.version !== manifest.payload.version
    || state.payloadSha256 !== manifest.integrity.payloadSha256
    || state.artifacts.length !== manifest.payload.artifacts.length
  ) return false;
  const expectedByPath = new Map(manifest.payload.artifacts.map((artifact) => [
    artifact.installPath.toLowerCase(),
    artifact,
  ]));
  return state.artifacts.every((artifact) => {
    const expected = expectedByPath.get(artifact.installPath.toLowerCase());
    return expected !== undefined
      && expected.id === artifact.id
      && expected.integrity.algorithm === artifact.integrity.algorithm
      && expected.integrity.sha256 === artifact.integrity.sha256
      && expected.integrity.size === artifact.integrity.size;
  });
}

async function rollback(
  prepared: PreparedArtifact[],
  retirements: PreparedRetirement[],
): Promise<void> {
  for (const retirement of [...retirements].reverse()) {
    try {
      if (retirement.moved && await exists(retirement.backup) && !await exists(retirement.target)) {
        await rename(retirement.backup, retirement.target);
      }
    } catch {
      // Preserve the original installation error. The backup remains recoverable by the operator.
    }
  }
  for (const entry of [...prepared].reverse()) {
    try {
      if (entry.installed && await exists(entry.target)) {
        await rm(entry.target, { force: true });
      }
      if (entry.movedPrevious && await exists(entry.backup)) {
        await rename(entry.backup, entry.target);
      }
      if (await exists(entry.staged)) await rm(entry.staged, { force: true });
    } catch {
      // Preserve the original installation error. The operator can recover from .bglands/backups.
    }
  }
}

async function writeState(root: string, manifest: ReleaseManifest): Promise<void> {
  const stateDirectory = resolve(root, ".bglands");
  await mkdir(stateDirectory, { recursive: true });
  const actualStateDirectory = await realpath(stateDirectory);
  if (!isPathWithinRoot(root, actualStateDirectory)) throw new ValidationError("Dossier d'état hors instance");
  const statePath = resolve(stateDirectory, "installed-release.json");
  const temporary = `${statePath}.tmp-${process.pid}`;
  const state = {
    schemaVersion: 1,
    releaseId: manifest.payload.releaseId,
    version: manifest.payload.version,
    payloadSha256: manifest.integrity.payloadSha256,
    installedAt: new Date().toISOString(),
    artifacts: manifest.payload.artifacts.map(({ id, installPath, integrity }) => ({ id, installPath, integrity })),
  };
  await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: "wx" });
  await rename(temporary, statePath);
}

export async function installRelease(options: InstallReleaseOptions): Promise<InstallResult> {
  const manifest = await verifyManifest(options.manifest, {
    ...(options.publicKeyPath ? { publicKeyPath: options.publicKeyPath } : {}),
    ...(options.publicKeyPem ? { publicKeyPem: options.publicKeyPem } : {}),
    requireSignature: true,
  });
  assertCompatible(manifest, options.environment);
  const instanceRoot = await checkedRoot(options.instanceRoot, "instanceRoot");
  const cacheRoot = await checkedRoot(options.artifactCache, "artifactCache");
  const previousState = await readInstalledReleaseState(instanceRoot);

  if (
    previousState !== null
    && previousState.releaseId === manifest.payload.releaseId
    && previousState.payloadSha256 !== manifest.integrity.payloadSha256
  ) {
    throw new ValidationError("L'état local réutilise le releaseId courant avec un payload différent");
  }
  if (
    previousState !== null
    && previousState.payloadSha256 === manifest.integrity.payloadSha256
    && !stateMatchesManifest(previousState, manifest)
  ) {
    throw new ValidationError("L'état local ne correspond pas au manifeste signé qu'il référence");
  }

  if (stateMatchesManifest(previousState, manifest) && await instanceAlreadyCurrent(instanceRoot, manifest)) {
    return { manifest, changed: false, installed: [], removed: [] };
  }

  // Every input is checked before the first mutation of the instance.
  const verifiedSources = new Map<string, string>();
  for (const artifact of manifest.payload.artifacts) {
    verifiedSources.set(artifact.id, await verifyCachedArtifact(cacheRoot, artifact));
  }

  const nextPaths = new Set(manifest.payload.artifacts.map((artifact) => artifact.installPath.toLowerCase()));
  const retiredArtifacts = previousState?.artifacts.filter(
    (artifact) => !nextPaths.has(artifact.installPath.toLowerCase()),
  ) ?? [];
  const verifiedRetirements: Array<{ artifact: InstalledArtifactState; target: string }> = [];
  for (const artifact of retiredArtifacts) {
    const target = resolve(instanceRoot, artifact.installPath);
    if (!isPathWithinRoot(instanceRoot, target)) {
      throw new ValidationError(`Ancienne cible hors instance: ${artifact.installPath}`);
    }
    if (!await exists(target)) continue;
    await rejectSymlink(target);
    const integrity = await hashFile(target);
    if (integrity.size !== artifact.integrity.size || integrity.sha256 !== artifact.integrity.sha256) {
      throw new ValidationError(
        `Ancien artefact modifié; retrait automatique refusé: ${artifact.installPath}`,
      );
    }
    verifiedRetirements.push({ artifact, target });
  }

  const releaseBackupRoot = resolve(instanceRoot, ".bglands", "backups", manifest.payload.releaseId);
  await mkdir(releaseBackupRoot, { recursive: true });
  const actualReleaseBackupRoot = await realpath(releaseBackupRoot);
  if (!isPathWithinRoot(instanceRoot, actualReleaseBackupRoot)) {
    throw new ValidationError("Dossier de sauvegarde hors instance");
  }
  const transactionBackupRoot = await mkdtemp(join(actualReleaseBackupRoot, "transaction-"));

  const prepared: PreparedArtifact[] = [];
  const retirements: PreparedRetirement[] = [];
  try {
    for (const [index, artifact] of manifest.payload.artifacts.entries()) {
      const target = resolve(instanceRoot, artifact.installPath);
      if (!isPathWithinRoot(instanceRoot, target)) throw new ValidationError(`Cible hors instance: ${artifact.installPath}`);
      await ensureSafeParent(instanceRoot, target);
      await rejectSymlink(target);
      const staged = `${target}.bglands-new-${process.pid}-${index}`;
      if (await exists(staged)) throw new ValidationError(`Fichier temporaire déjà présent: ${staged}`);
      const backup = resolve(transactionBackupRoot, artifact.installPath);
      if (!isPathWithinRoot(transactionBackupRoot, backup)) {
        throw new ValidationError(`Sauvegarde hors transaction: ${artifact.installPath}`);
      }
      await ensureSafeParent(transactionBackupRoot, backup);
      if (await exists(backup)) throw new ValidationError(`Sauvegarde de transaction déjà présente: ${backup}`);
      await copyFile(verifiedSources.get(artifact.id)!, staged, constants.COPYFILE_EXCL);
      const stagedIntegrity = await hashFile(staged);
      if (stagedIntegrity.size !== artifact.integrity.size || stagedIntegrity.sha256 !== artifact.integrity.sha256) {
        throw new ValidationError(`Copie intermédiaire invalide: ${artifact.fileName}`);
      }
      prepared.push({
        artifact,
        target,
        staged,
        backup,
        movedPrevious: false,
        installed: false,
      });
    }

    for (const { artifact, target } of verifiedRetirements) {
      const backup = resolve(transactionBackupRoot, artifact.installPath);
      if (!isPathWithinRoot(transactionBackupRoot, backup)) {
        throw new ValidationError(`Sauvegarde de retrait hors transaction: ${artifact.installPath}`);
      }
      await ensureSafeParent(transactionBackupRoot, backup);
      if (await exists(backup)) throw new ValidationError(`Sauvegarde de transaction déjà présente: ${backup}`);
      retirements.push({ artifact, target, backup, moved: false });
    }

    for (const entry of prepared) {
      if (await exists(entry.target)) {
        await rename(entry.target, entry.backup);
        entry.movedPrevious = true;
      }
      await rename(entry.staged, entry.target);
      entry.installed = true;
    }
    for (const retirement of retirements) {
      await rename(retirement.target, retirement.backup);
      retirement.moved = true;
    }
    if (!prepared.some((entry) => entry.movedPrevious) && !retirements.some((entry) => entry.moved)) {
      await rm(transactionBackupRoot, { recursive: true, force: true });
    }
    await writeState(instanceRoot, manifest);
  } catch (error) {
    await rollback(prepared, retirements);
    throw error;
  }
  return {
    manifest,
    changed: true,
    installed: prepared.map((entry) => entry.artifact.installPath),
    removed: retirements.filter((entry) => entry.moved).map((entry) => entry.artifact.installPath),
  };
}

export async function readManifest(path: string): Promise<unknown> {
  return JSON.parse(await readFile(path, "utf8")) as unknown;
}
