import { createPrivateKey, createPublicKey } from "node:crypto";
import { constants } from "node:fs";
import {
  chmod,
  copyFile,
  lstat,
  mkdir,
  mkdtemp,
  readFile,
  readdir,
  rename,
  rm,
  stat,
  writeFile,
} from "node:fs/promises";
import { basename, dirname, join, parse, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildManifest, hashFile, verifyManifest } from "./manifest.ts";
import type { ReleaseManifest } from "./types.ts";
import { parseReleaseSource, ValidationError } from "./validation.ts";

export const RELEASE_METADATA_FILES = ["release.json", "checksums.sha256", "release-public.pem"] as const;
export const HOSTED_SCHEMA_FILES = ["release-manifest.schema.json", "release-source.schema.json"] as const;
const NON_HOSTED_PLACEHOLDERS = new Set([".gitkeep"]);

const SAFE_HOSTED_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const CHECKSUM_LINE = /^([a-f0-9]{64})  ([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/;
const DEFAULT_SCHEMA_DIRECTORY = fileURLToPath(new URL("../schema", import.meta.url));

async function assertRegularFile(path: string, description: string): Promise<void> {
  const metadata = await lstat(path).catch(() => null);
  if (metadata === null || !metadata.isFile() || metadata.isSymbolicLink() || metadata.size < 1) {
    throw new ValidationError(`${description} doit être un fichier régulier non vide: ${path}`);
  }
}

async function atomicWrite(path: string, contents: string | Buffer, mode = 0o644): Promise<void> {
  await mkdir(dirname(path), { recursive: true });
  const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
  try {
    await writeFile(temporary, contents, { mode, flag: "wx" });
    await rename(temporary, path);
  } finally {
    await rm(temporary, { force: true });
  }
}

async function atomicCopy(source: string, destination: string): Promise<void> {
  await assertRegularFile(source, "La source");
  const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`;
  try {
    await copyFile(source, temporary, constants.COPYFILE_EXCL);
    await chmod(temporary, 0o644);
    await rename(temporary, destination);
  } finally {
    await rm(temporary, { force: true });
  }
}

export async function installHostedSchemas(
  releaseDirectory: string,
  schemaDirectory = DEFAULT_SCHEMA_DIRECTORY,
): Promise<void> {
  await mkdir(releaseDirectory, { recursive: true });
  for (const name of HOSTED_SCHEMA_FILES) {
    await atomicCopy(resolve(schemaDirectory, name), resolve(releaseDirectory, name));
  }
}

function expectedChecksummedFiles(manifest: ReleaseManifest): string[] {
  const names = [...manifest.payload.artifacts.map((artifact) => artifact.fileName), ...HOSTED_SCHEMA_FILES];
  const unique = new Set(names);
  if (unique.size !== names.length) {
    throw new ValidationError("Les noms des artefacts et des schémas hébergés doivent être uniques");
  }
  return [...unique].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
}

/**
 * Writes the exact checksum allowlist consumed by Velocity. The two public JSON
 * schemas are always included; release metadata is deliberately excluded to
 * avoid a signature/checksum cycle.
 */
export async function writeReleaseChecksums(
  releaseDirectory: string,
  manifest: ReleaseManifest,
  requireCopiedArtifacts = false,
): Promise<void> {
  const artifactIntegrity = new Map(
    manifest.payload.artifacts.map((artifact) => [artifact.fileName, artifact.integrity] as const),
  );
  const lines: string[] = [];
  for (const name of expectedChecksummedFiles(manifest)) {
    if (!SAFE_HOSTED_FILE.test(name)) throw new ValidationError(`Nom de fichier hébergé invalide: ${name}`);
    const published = artifactIntegrity.get(name);
    if (published !== undefined && !requireCopiedArtifacts) {
      lines.push(`${published.sha256}  ${name}`);
      continue;
    }
    const actual = await hashFile(resolve(releaseDirectory, name));
    if (published !== undefined && (actual.sha256 !== published.sha256 || actual.size !== published.size)) {
      throw new ValidationError(`La copie publiée ne correspond pas au manifeste: ${name}`);
    }
    lines.push(`${actual.sha256}  ${name}`);
  }
  await atomicWrite(resolve(releaseDirectory, "checksums.sha256"), `${lines.join("\n")}\n`);
}

function parseChecksums(contents: string): Map<string, string> {
  const result = new Map<string, string>();
  const lines = contents.split("\n").filter((line) => line.length > 0);
  if (lines.length === 0) throw new ValidationError("checksums.sha256 est vide");
  for (const line of lines) {
    const match = CHECKSUM_LINE.exec(line);
    if (match === null) throw new ValidationError(`Ligne checksums.sha256 invalide: ${line}`);
    const hash = match[1];
    const name = match[2];
    if (hash === undefined || name === undefined) throw new ValidationError("Ligne checksums.sha256 invalide");
    if (RELEASE_METADATA_FILES.includes(name as typeof RELEASE_METADATA_FILES[number])) {
      throw new ValidationError(`Un fichier de métadonnées ne doit pas être auto-référencé: ${name}`);
    }
    if (result.has(name)) throw new ValidationError(`Checksum dupliqué: ${name}`);
    result.set(name, hash);
  }
  return result;
}

export interface VerifyReleaseBundleOptions {
  releaseDirectory: string;
  publicKeyPath?: string;
  requireSignature?: boolean;
  expectedSchemaRef?: string;
}

export async function verifyReleaseBundle(options: VerifyReleaseBundleOptions): Promise<ReleaseManifest> {
  const directory = resolve(options.releaseDirectory);
  const directoryMetadata = await lstat(directory).catch(() => null);
  if (directoryMetadata === null || !directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) {
    throw new ValidationError(`Le bundle doit être un dossier réel: ${directory}`);
  }
  const rawManifest = JSON.parse(await readFile(resolve(directory, "release.json"), "utf8")) as unknown;
  const manifest = await verifyManifest(rawManifest, {
    ...(options.publicKeyPath === undefined ? {} : { publicKeyPath: options.publicKeyPath }),
    requireSignature: options.requireSignature ?? false,
  });
  const bundledPublicKey = createPublicKey(await readFile(resolve(directory, "release-public.pem"), "utf8"));
  if (bundledPublicKey.asymmetricKeyType !== "ed25519") {
    throw new ValidationError("release-public.pem doit contenir une clé Ed25519");
  }
  if (options.publicKeyPath !== undefined) {
    const trustedPublicKey = createPublicKey(await readFile(options.publicKeyPath, "utf8"));
    const bundledDer = bundledPublicKey.export({ type: "spki", format: "der" });
    const trustedDer = trustedPublicKey.export({ type: "spki", format: "der" });
    if (!Buffer.from(bundledDer).equals(Buffer.from(trustedDer))) {
      throw new ValidationError("release-public.pem ne correspond pas à la clé publique épinglée");
    }
  }
  if (options.expectedSchemaRef !== undefined && manifest.$schema !== options.expectedSchemaRef) {
    throw new ValidationError(`Référence de schéma inattendue: ${manifest.$schema}`);
  }
  let schemaUrl: URL;
  try {
    schemaUrl = new URL(manifest.$schema);
  } catch {
    throw new ValidationError("manifest.$schema doit être une URL HTTPS publique");
  }
  if (schemaUrl.protocol !== "https:" || basename(schemaUrl.pathname) !== HOSTED_SCHEMA_FILES[0]) {
    throw new ValidationError("manifest.$schema doit cibler release-manifest.schema.json en HTTPS");
  }

  const expectedChecked = new Set(expectedChecksummedFiles(manifest));
  const expectedAll = new Set([...RELEASE_METADATA_FILES, ...expectedChecked]);
  const entries = await readdir(directory, { withFileTypes: true });
  const hostedEntries = entries.filter((entry) => !NON_HOSTED_PLACEHOLDERS.has(entry.name));
  for (const entry of hostedEntries) {
    if (!entry.isFile() || entry.isSymbolicLink() || !SAFE_HOSTED_FILE.test(entry.name)) {
      throw new ValidationError(`Entrée de bundle non sûre: ${entry.name}`);
    }
  }
  const actualAll = new Set(hostedEntries.map((entry) => entry.name));
  if (actualAll.size !== expectedAll.size || [...expectedAll].some((name) => !actualAll.has(name))) {
    throw new ValidationError(
      `Contenu du bundle inattendu (attendu=${[...expectedAll].sort().join(",")}; reçu=${[...actualAll].sort().join(",")})`,
    );
  }

  const checksums = parseChecksums(await readFile(resolve(directory, "checksums.sha256"), "utf8"));
  if (checksums.size !== expectedChecked.size || [...expectedChecked].some((name) => !checksums.has(name))) {
    throw new ValidationError("checksums.sha256 ne décrit pas exactement tous les artefacts et schémas hébergés");
  }
  for (const [name, expectedHash] of checksums) {
    const actual = await hashFile(resolve(directory, name));
    if (actual.sha256 !== expectedHash) throw new ValidationError(`SHA-256 de bundle invalide: ${name}`);
  }
  for (const artifact of manifest.payload.artifacts) {
    const actual = await hashFile(resolve(directory, artifact.fileName));
    if (actual.sha256 !== artifact.integrity.sha256 || actual.size !== artifact.integrity.size) {
      throw new ValidationError(`Artefact différent du manifeste: ${artifact.fileName}`);
    }
  }
  return manifest;
}

export interface PrepareReleaseBundleOptions {
  sourcePath: string;
  releaseDirectory: string;
  privateKeyPath: string;
  trustedPublicKeyPath?: string;
  keyId: string;
  schemaRef: string;
  schemaDirectory?: string;
}

export async function prepareReleaseBundle(options: PrepareReleaseBundleOptions): Promise<ReleaseManifest> {
  const sourcePath = resolve(options.sourcePath);
  const output = resolve(options.releaseDirectory);
  const parent = dirname(output);
  if (output === parse(output).root || output === parent || !SAFE_HOSTED_FILE.test(basename(output))) {
    throw new ValidationError(`Dossier de sortie dangereux: ${output}`);
  }
  const existingOutput = await lstat(output).catch(() => null);
  if (existingOutput !== null && (!existingOutput.isDirectory() || existingOutput.isSymbolicLink())) {
    throw new ValidationError(`Le dossier de sortie ne peut pas être un lien ou un fichier: ${output}`);
  }
  await assertRegularFile(options.privateKeyPath, "La clé privée Ed25519");
  const privateKeyPath = resolve(options.privateKeyPath);
  if (privateKeyPath.startsWith(`${output}/`)) {
    throw new ValidationError("La clé privée ne doit jamais se trouver dans le dossier public de release");
  }
  const privateKeyMetadata = await stat(privateKeyPath);
  if ((privateKeyMetadata.mode & 0o077) !== 0) {
    throw new ValidationError("La clé privée Ed25519 doit être réservée à son propriétaire (mode 0600/0400)");
  }
  const privateKeyPem = await readFile(privateKeyPath, "utf8");
  const privateKey = createPrivateKey(privateKeyPem);
  if (privateKey.asymmetricKeyType !== "ed25519") throw new ValidationError("La clé de release doit être Ed25519");
  const publicKeyPem = createPublicKey(privateKey).export({ type: "spki", format: "pem" }).toString();
  if (options.trustedPublicKeyPath !== undefined) {
    const trustedPublicKeyPath = resolve(options.trustedPublicKeyPath);
    await assertRegularFile(trustedPublicKeyPath, "La clé publique épinglée");
    const trustedPublicKey = createPublicKey(await readFile(trustedPublicKeyPath, "utf8"));
    if (trustedPublicKey.asymmetricKeyType !== "ed25519") {
      throw new ValidationError("La clé publique épinglée doit être Ed25519");
    }
    const trustedDer = trustedPublicKey.export({ type: "spki", format: "der" });
    const derivedDer = createPublicKey(privateKey).export({ type: "spki", format: "der" });
    if (!Buffer.from(trustedDer).equals(Buffer.from(derivedDer))) {
      throw new ValidationError("La clé privée ne correspond pas à la clé publique épinglée pour ce keyId");
    }
  }

  const sourceRaw = JSON.parse(await readFile(sourcePath, "utf8")) as unknown;
  const source = parseReleaseSource(sourceRaw);
  if (source.channel !== "stable") throw new ValidationError("client-release ne publie que le canal stable");
  const sourceDirectory = dirname(sourcePath);
  const sourceFiles = new Map<string, string>();
  for (const artifact of source.artifacts) {
    const localPath = resolve(sourceDirectory, artifact.localFile);
    await assertRegularFile(localPath, `Artefact source ${artifact.id}`);
    sourceFiles.set(artifact.fileName, localPath);
  }

  await mkdir(parent, { recursive: true });
  const staging = await mkdtemp(join(parent, `.${basename(output)}.stage-`));
  let backup: string | undefined;
  let promoted = false;
  try {
    for (const [name, localPath] of sourceFiles) await atomicCopy(localPath, resolve(staging, name));
    await installHostedSchemas(staging, options.schemaDirectory ?? DEFAULT_SCHEMA_DIRECTORY);
    await atomicWrite(resolve(staging, "release-public.pem"), publicKeyPem);
    const manifest = await buildManifest({
      sourcePath,
      privateKeyPath,
      keyId: options.keyId,
      schemaRef: options.schemaRef,
    });
    await atomicWrite(resolve(staging, "release.json"), `${JSON.stringify(manifest, null, 2)}\n`);
    await writeReleaseChecksums(staging, manifest, true);
    await chmod(staging, 0o755);
    await verifyReleaseBundle({
      releaseDirectory: staging,
      publicKeyPath: resolve(staging, "release-public.pem"),
      requireSignature: true,
      expectedSchemaRef: options.schemaRef,
    });

    if (existingOutput !== null) {
      backup = `${output}.previous-${process.pid}-${Date.now()}`;
      await rename(output, backup);
    }
    try {
      await rename(staging, output);
      promoted = true;
    } catch (error) {
      if (backup !== undefined) await rename(backup, output);
      backup = undefined;
      throw error;
    }
    if (backup !== undefined) await rm(backup, { recursive: true, force: true });
    return manifest;
  } finally {
    if (!promoted) await rm(staging, { recursive: true, force: true });
  }
}
