import { isAbsolute, posix } from "node:path";
import type {
  ArtifactKind,
  Distribution,
  LoaderId,
  ReleaseArtifact,
  ReleaseManifest,
  ReleaseSource,
  SourceArtifact,
} from "./types.ts";
import { CANONICALIZATION_VERSION, MANIFEST_SCHEMA_VERSION } from "./types.ts";

const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const IDENTIFIER = /^[a-z0-9][a-z0-9._-]{0,127}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const SAFE_INSTALL_ROOTS = new Set(["mods", "config", "resourcepacks"]);
const ARTIFACT_KINDS = new Set<ArtifactKind>(["client-mod", "dependency-mod", "config", "resource-pack"]);
const LOADERS = new Set<LoaderId>(["fabric", "neoforge"]);
const WIN32_RESERVED_COMPONENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;

export class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ValidationError";
  }
}

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 string(value: unknown, at: string): string {
  if (typeof value !== "string" || value.trim() === "") {
    throw new ValidationError(`${at} doit être une chaîne non vide`);
  }
  return value;
}

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

function boolean(value: unknown, at: string): boolean {
  if (typeof value !== "boolean") {
    throw new ValidationError(`${at} doit être un booléen`);
  }
  return value;
}

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(", ")}`);
  }
}

function semver(value: unknown, at: string): string {
  const parsed = string(value, at);
  if (!SEMVER.test(parsed)) {
    throw new ValidationError(`${at} doit être une version SemVer`);
  }
  return parsed;
}

function identifier(value: unknown, at: string): string {
  const parsed = string(value, at);
  if (!IDENTIFIER.test(parsed)) {
    throw new ValidationError(`${at} contient des caractères interdits`);
  }
  return parsed;
}

function isoDate(value: unknown, at: string): string {
  const parsed = string(value, at);
  const date = new Date(parsed);
  if (!Number.isFinite(date.getTime()) || date.toISOString() !== parsed) {
    throw new ValidationError(`${at} doit être une date ISO UTC canonique (ex: 2026-08-28T12:00:00.000Z)`);
  }
  return parsed;
}

function safeFileName(value: unknown, at: string): string {
  const parsed = string(value, at);
  if (parsed === "." || parsed === ".." || parsed.includes("/") || parsed.includes("\\") || parsed.includes("\0")) {
    throw new ValidationError(`${at} doit être un simple nom de fichier`);
  }
  return parsed;
}

export function safeInstallPath(value: unknown, at: string): string {
  const parsed = string(value, at).replaceAll("\\", "/");
  const normalized = posix.normalize(parsed);
  if (
    parsed !== normalized ||
    isAbsolute(parsed) ||
    parsed.includes("\0") ||
    parsed.startsWith("../") ||
    parsed === ".."
  ) {
    throw new ValidationError(`${at} doit être un chemin relatif normalisé sans traversée`);
  }
  const [root, ...rest] = normalized.split("/");
  if (!root || !SAFE_INSTALL_ROOTS.has(root) || rest.length === 0) {
    throw new ValidationError(`${at} doit cibler mods/, config/ ou resourcepacks/`);
  }
  for (const component of normalized.split("/")) {
    if (
      component.includes(":")
      || component.endsWith(".")
      || component.endsWith(" ")
      || WIN32_RESERVED_COMPONENT.test(component)
    ) {
      throw new ValidationError(`${at} contient un composant interdit sous Windows: ${component}`);
    }
  }
  return normalized;
}

function distribution(value: unknown, at: string): Distribution {
  const input = record(value, at);
  const type = string(input.type, `${at}.type`);
  if (type === "direct") {
    exactKeys(input, ["type", "url"], at);
    const rawUrl = string(input.url, `${at}.url`);
    let url: URL;
    try {
      url = new URL(rawUrl);
    } catch {
      throw new ValidationError(`${at}.url doit être une URL valide`);
    }
    if (url.protocol !== "https:" || url.username || url.password) {
      throw new ValidationError(`${at}.url doit être une URL HTTPS sans identifiants`);
    }
    return { type, url: url.toString() };
  }
  if (type === "curseforge") {
    exactKeys(input, ["type", "projectId", "fileId"], at);
    return {
      type,
      projectId: integer(input.projectId, `${at}.projectId`, 1),
      fileId: integer(input.fileId, `${at}.fileId`, 1),
    };
  }
  throw new ValidationError(`${at}.type doit valoir direct ou curseforge`);
}

function sourceArtifact(value: unknown, at: string): SourceArtifact {
  const input = record(value, at);
  exactKeys(input, [
    "id", "kind", "version", "localFile", "sourceIntegrity", "fileName", "installPath",
    "required", "restartRequired", "distribution",
  ], at);
  const kind = string(input.kind, `${at}.kind`) as ArtifactKind;
  if (!ARTIFACT_KINDS.has(kind)) {
    throw new ValidationError(`${at}.kind n'est pas pris en charge`);
  }
  const sourceIntegrity = record(input.sourceIntegrity, `${at}.sourceIntegrity`);
  exactKeys(sourceIntegrity, ["algorithm", "sha256", "size"], `${at}.sourceIntegrity`);
  if (sourceIntegrity.algorithm !== "sha256") {
    throw new ValidationError(`${at}.sourceIntegrity.algorithm doit valoir sha256`);
  }
  const sourceSha256 = string(sourceIntegrity.sha256, `${at}.sourceIntegrity.sha256`);
  if (!SHA256.test(sourceSha256)) throw new ValidationError(`${at}.sourceIntegrity.sha256 est invalide`);
  return {
    id: identifier(input.id, `${at}.id`),
    kind,
    version: semver(input.version, `${at}.version`),
    localFile: string(input.localFile, `${at}.localFile`),
    sourceIntegrity: {
      algorithm: "sha256",
      sha256: sourceSha256,
      size: integer(sourceIntegrity.size, `${at}.sourceIntegrity.size`, 1),
    },
    fileName: safeFileName(input.fileName, `${at}.fileName`),
    installPath: safeInstallPath(input.installPath, `${at}.installPath`),
    required: boolean(input.required, `${at}.required`),
    restartRequired: boolean(input.restartRequired, `${at}.restartRequired`),
    distribution: distribution(input.distribution, `${at}.distribution`),
  };
}

export function parseReleaseSource(value: unknown): ReleaseSource {
  const input = record(value, "release");
  exactKeys(input, [
    "$schema", "schemaVersion", "product", "releaseId", "version", "channel",
    "publishedAt", "minimumLauncherVersion", "game", "compatibility", "artifacts",
  ], "release");
  if (input.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
    throw new ValidationError(`release.schemaVersion doit valoir ${MANIFEST_SCHEMA_VERSION}`);
  }
  if (input.product !== "bglands-client") {
    throw new ValidationError("release.product doit valoir bglands-client");
  }
  const channel = string(input.channel, "release.channel");
  if (channel !== "stable" && channel !== "beta" && channel !== "canary") {
    throw new ValidationError("release.channel doit valoir stable, beta ou canary");
  }
  const game = record(input.game, "release.game");
  exactKeys(game, ["minecraft", "javaMajor", "loader"], "release.game");
  const loader = record(game.loader, "release.game.loader");
  exactKeys(loader, ["id", "version"], "release.game.loader");
  const loaderId = string(loader.id, "release.game.loader.id") as LoaderId;
  if (!LOADERS.has(loaderId)) {
    throw new ValidationError("release.game.loader.id doit valoir fabric ou neoforge");
  }
  const compatibility = record(input.compatibility, "release.compatibility");
  exactKeys(compatibility, ["serverProtocol"], "release.compatibility");
  if (!Array.isArray(input.artifacts) || input.artifacts.length === 0) {
    throw new ValidationError("release.artifacts doit contenir au moins un artefact");
  }
  const artifacts = input.artifacts.map((entry, index) => sourceArtifact(entry, `release.artifacts[${index}]`));
  const ids = new Set<string>();
  const pathsCaseInsensitive = new Set<string>();
  const fileNames = new Set<string>();
  for (const artifact of artifacts) {
    if (ids.has(artifact.id)) throw new ValidationError(`ID d'artefact dupliqué: ${artifact.id}`);
    const caseFoldedPath = artifact.installPath.toLowerCase();
    if (pathsCaseInsensitive.has(caseFoldedPath)) {
      throw new ValidationError(`Chemin d'installation dupliqué (sans distinction de casse): ${artifact.installPath}`);
    }
    if (fileNames.has(artifact.fileName)) throw new ValidationError(`Nom de fichier d'artefact dupliqué: ${artifact.fileName}`);
    ids.add(artifact.id);
    pathsCaseInsensitive.add(caseFoldedPath);
    fileNames.add(artifact.fileName);
  }
  if (artifacts.filter((entry) => entry.kind === "client-mod" && entry.required).length !== 1) {
    throw new ValidationError("Une release doit contenir exactement un client-mod obligatoire");
  }
  const result: ReleaseSource = {
    schemaVersion: 1,
    product: "bglands-client",
    releaseId: identifier(input.releaseId, "release.releaseId"),
    version: semver(input.version, "release.version"),
    channel,
    publishedAt: isoDate(input.publishedAt, "release.publishedAt"),
    minimumLauncherVersion: semver(input.minimumLauncherVersion, "release.minimumLauncherVersion"),
    game: {
      minecraft: string(game.minecraft, "release.game.minecraft"),
      javaMajor: integer(game.javaMajor, "release.game.javaMajor", 17),
      loader: {
        id: loaderId,
        version: string(loader.version, "release.game.loader.version"),
      },
    },
    compatibility: {
      serverProtocol: integer(compatibility.serverProtocol, "release.compatibility.serverProtocol", 1),
    },
    artifacts,
  };
  if (typeof input.$schema === "string") result.$schema = input.$schema;
  return result;
}

function releaseArtifact(value: unknown, at: string): ReleaseArtifact {
  const input = record(value, at);
  exactKeys(input, [
    "id", "kind", "version", "fileName", "installPath", "required",
    "restartRequired", "distribution", "integrity",
  ], at);
  const integrity = record(input.integrity, `${at}.integrity`);
  exactKeys(integrity, ["algorithm", "sha256", "size"], `${at}.integrity`);
  if (integrity.algorithm !== "sha256") {
    throw new ValidationError(`${at}.integrity.algorithm doit valoir sha256`);
  }
  const sha256 = string(integrity.sha256, `${at}.integrity.sha256`);
  if (!SHA256.test(sha256)) throw new ValidationError(`${at}.integrity.sha256 est invalide`);
  const size = integer(integrity.size, `${at}.integrity.size`, 1);
  const source = sourceArtifact({
    id: input.id,
    kind: input.kind,
    version: input.version,
    localFile: "manifest-only",
    sourceIntegrity: { algorithm: "sha256", sha256, size },
    fileName: input.fileName,
    installPath: input.installPath,
    required: input.required,
    restartRequired: input.restartRequired,
    distribution: input.distribution,
  }, at);
  const { localFile: _ignored, sourceIntegrity: _sourceIntegrity, ...published } = source;
  return {
    ...published,
    integrity: {
      algorithm: "sha256",
      sha256,
      size,
    },
  };
}

export function parseReleaseManifest(value: unknown): ReleaseManifest {
  const input = record(value, "manifest");
  exactKeys(input, ["$schema", "schemaVersion", "payload", "integrity", "signature"], "manifest");
  if (input.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
    throw new ValidationError(`manifest.schemaVersion doit valoir ${MANIFEST_SCHEMA_VERSION}`);
  }
  const payloadInput = record(input.payload, "manifest.payload");
  const payloadAsSource = parseReleaseSource({
    ...payloadInput,
    schemaVersion: 1,
    artifacts: Array.isArray(payloadInput.artifacts)
      ? payloadInput.artifacts.map((entry) => {
          const published = record(entry, "manifest.payload.artifacts[]");
          return {
            id: published.id,
            kind: published.kind,
            version: published.version,
            localFile: "manifest-only",
            sourceIntegrity: published.integrity,
            fileName: published.fileName,
            installPath: published.installPath,
            required: published.required,
            restartRequired: published.restartRequired,
            distribution: published.distribution,
          };
        })
      : payloadInput.artifacts,
  });
  const artifactsInput = payloadInput.artifacts;
  if (!Array.isArray(artifactsInput)) throw new ValidationError("manifest.payload.artifacts doit être un tableau");
  const artifacts = artifactsInput.map((entry, index) => releaseArtifact(entry, `manifest.payload.artifacts[${index}]`));
  const { $schema: _schema, schemaVersion: _version, artifacts: _sourceArtifacts, ...payloadBase } = payloadAsSource;
  const integrity = record(input.integrity, "manifest.integrity");
  exactKeys(integrity, ["canonicalization", "algorithm", "payloadSha256"], "manifest.integrity");
  if (integrity.canonicalization !== CANONICALIZATION_VERSION || integrity.algorithm !== "sha256") {
    throw new ValidationError("Algorithme d'intégrité de manifeste non pris en charge");
  }
  const payloadSha256 = string(integrity.payloadSha256, "manifest.integrity.payloadSha256");
  if (!SHA256.test(payloadSha256)) throw new ValidationError("manifest.integrity.payloadSha256 est invalide");
  let signature: ReleaseManifest["signature"] = null;
  if (input.signature !== null) {
    const signatureInput = record(input.signature, "manifest.signature");
    exactKeys(signatureInput, ["algorithm", "keyId", "valueBase64"], "manifest.signature");
    if (signatureInput.algorithm !== "Ed25519") throw new ValidationError("Seules les signatures Ed25519 sont acceptées");
    const valueBase64 = string(signatureInput.valueBase64, "manifest.signature.valueBase64");
    if (!/^[A-Za-z0-9+/]+={0,2}$/.test(valueBase64)) throw new ValidationError("Signature Base64 invalide");
    signature = {
      algorithm: "Ed25519",
      keyId: identifier(signatureInput.keyId, "manifest.signature.keyId"),
      valueBase64,
    };
  }
  return {
    $schema: string(input.$schema, "manifest.$schema"),
    schemaVersion: 1,
    payload: { ...payloadBase, artifacts },
    integrity: {
      canonicalization: CANONICALIZATION_VERSION,
      algorithm: "sha256",
      payloadSha256,
    },
    signature,
  };
}
