import { lstat, readFile, realpath } from "node:fs/promises";
import { resolve } from "node:path";
import { isPathWithinRoot } from "./path-security.ts";
import { safeInstallPath, ValidationError } from "./validation.ts";

const IDENTIFIER = /^[a-z0-9][a-z0-9._-]{0,127}$/;
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const SHA256 = /^[a-f0-9]{64}$/;
const MAX_STATE_BYTES = 1024 * 1024;
const MAX_STATE_ARTIFACTS = 128;

export interface InstalledArtifactState {
  id: string;
  installPath: string;
  integrity: {
    algorithm: "sha256";
    sha256: string;
    size: number;
  };
}

export interface InstalledReleaseState {
  schemaVersion: 1;
  releaseId: string;
  version: string;
  payloadSha256: string;
  installedAt: string;
  artifacts: InstalledArtifactState[];
}

function record(value: unknown, at: string): Record<string, unknown> {
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
    throw new ValidationError(`${at} doit être un objet`);
  }
  return value as Record<string, unknown>;
}

function exactKeys(value: Record<string, unknown>, allowed: readonly string[], at: string): void {
  const allowedSet = new Set(allowed);
  const unexpected = Object.keys(value).filter((key) => !allowedSet.has(key));
  if (unexpected.length > 0) {
    throw new ValidationError(`${at} contient des champs inconnus: ${unexpected.join(", ")}`);
  }
  const missing = allowed.filter((key) => !(key in value));
  if (missing.length > 0) {
    throw new ValidationError(`${at} ne contient pas les champs requis: ${missing.join(", ")}`);
  }
}

function strictString(value: unknown, at: string, pattern: RegExp): string {
  if (typeof value !== "string" || !pattern.test(value)) {
    throw new ValidationError(`${at} est invalide`);
  }
  return value;
}

function positiveInteger(value: unknown, at: string): number {
  if (!Number.isSafeInteger(value) || (value as number) < 1) {
    throw new ValidationError(`${at} doit être un entier positif`);
  }
  return value as number;
}

export function parseInstalledReleaseState(value: unknown): InstalledReleaseState {
  const state = record(value, "installedState");
  exactKeys(state, [
    "schemaVersion", "releaseId", "version", "payloadSha256", "installedAt", "artifacts",
  ], "installedState");
  if (state.schemaVersion !== 1) throw new ValidationError("installedState.schemaVersion doit valoir 1");
  if (!Array.isArray(state.artifacts) || state.artifacts.length < 1 || state.artifacts.length > MAX_STATE_ARTIFACTS) {
    throw new ValidationError(`installedState.artifacts doit contenir entre 1 et ${MAX_STATE_ARTIFACTS} artefacts`);
  }
  const installedAt = strictString(
    state.installedAt,
    "installedState.installedAt",
    /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
  );
  if (!Number.isFinite(new Date(installedAt).getTime()) || new Date(installedAt).toISOString() !== installedAt) {
    throw new ValidationError("installedState.installedAt doit être une date ISO UTC canonique");
  }

  const artifacts = state.artifacts.map((rawArtifact, index): InstalledArtifactState => {
    const at = `installedState.artifacts[${index}]`;
    const artifact = record(rawArtifact, at);
    exactKeys(artifact, ["id", "installPath", "integrity"], at);
    const integrity = record(artifact.integrity, `${at}.integrity`);
    exactKeys(integrity, ["algorithm", "sha256", "size"], `${at}.integrity`);
    if (integrity.algorithm !== "sha256") {
      throw new ValidationError(`${at}.integrity.algorithm doit valoir sha256`);
    }
    return {
      id: strictString(artifact.id, `${at}.id`, IDENTIFIER),
      installPath: safeInstallPath(artifact.installPath, `${at}.installPath`),
      integrity: {
        algorithm: "sha256",
        sha256: strictString(integrity.sha256, `${at}.integrity.sha256`, SHA256),
        size: positiveInteger(integrity.size, `${at}.integrity.size`),
      },
    };
  });
  const ids = new Set<string>();
  const paths = new Set<string>();
  for (const artifact of artifacts) {
    if (ids.has(artifact.id)) throw new ValidationError(`ID dupliqué dans installedState: ${artifact.id}`);
    const caseFoldedPath = artifact.installPath.toLowerCase();
    if (paths.has(caseFoldedPath)) {
      throw new ValidationError(`Chemin dupliqué dans installedState: ${artifact.installPath}`);
    }
    ids.add(artifact.id);
    paths.add(caseFoldedPath);
  }
  return {
    schemaVersion: 1,
    releaseId: strictString(state.releaseId, "installedState.releaseId", IDENTIFIER),
    version: strictString(state.version, "installedState.version", SEMVER),
    payloadSha256: strictString(state.payloadSha256, "installedState.payloadSha256", SHA256),
    installedAt,
    artifacts,
  };
}

export async function readInstalledReleaseState(instanceRoot: string): Promise<InstalledReleaseState | null> {
  const statePath = resolve(instanceRoot, ".bglands", "installed-release.json");
  let metadata;
  try {
    metadata = await lstat(statePath);
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
    throw error;
  }
  if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size < 1 || metadata.size > MAX_STATE_BYTES) {
    throw new ValidationError("installed-release.json doit être un fichier régulier de taille raisonnable");
  }
  const actualStatePath = await realpath(statePath);
  if (!isPathWithinRoot(instanceRoot, actualStatePath)) {
    throw new ValidationError("installed-release.json sort de l'instance");
  }
  let raw: unknown;
  try {
    raw = JSON.parse(await readFile(actualStatePath, "utf8")) as unknown;
  } catch (error) {
    if (error instanceof SyntaxError) throw new ValidationError("installed-release.json contient un JSON invalide");
    throw error;
  }
  return parseInstalledReleaseState(raw);
}
