import assert from "node:assert/strict";
import { generateKeyPairSync } from "node:crypto";
import { chmod, copyFile, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, win32 } from "node:path";
import test from "node:test";
import { canonicalJson, sha256Hex } from "../src/canonical-json.ts";
import { ensurePinnedArtifact } from "../src/cache-artifact.ts";
import { buildManifest, verifyManifest } from "../src/manifest.ts";
import { isPathWithinRoot } from "../src/path-security.ts";
import {
  HOSTED_SCHEMA_FILES,
  prepareReleaseBundle,
  verifyReleaseBundle,
} from "../src/release-bundle.ts";
import { installRelease } from "../src/installer.ts";
import { readInstalledReleaseState } from "../src/installed-state.ts";
import { parseReleaseSource, ValidationError } from "../src/validation.ts";
import type { ReleaseSource } from "../src/types.ts";

function source(localFile: string): ReleaseSource {
  const fixtureContents = "fake-but-deterministic-mod-jar\n";
  return {
    schemaVersion: 1,
    product: "bglands-client",
    releaseId: "bglands-client-1.2.3",
    version: "1.2.3",
    channel: "stable",
    publishedAt: "2026-08-28T12:00:00.000Z",
    minimumLauncherVersion: "1.0.0",
    game: {
      minecraft: "1.21.8",
      javaMajor: 21,
      loader: { id: "fabric", version: "0.17.2" },
    },
    compatibility: { serverProtocol: 1 },
    artifacts: [{
      id: "bglands-client",
      kind: "client-mod",
      version: "1.2.3",
      localFile,
      sourceIntegrity: {
        algorithm: "sha256",
        sha256: sha256Hex(fixtureContents),
        size: Buffer.byteLength(fixtureContents),
      },
      fileName: "bglands-client-1.2.3.jar",
      installPath: "mods/bglands-client.jar",
      required: true,
      restartRequired: true,
      distribution: {
        type: "direct",
        url: "https://downloads.bglands.fr/client/bglands-client-1.2.3.jar",
      },
    }],
  };
}

async function fixture(): Promise<{
  root: string;
  config: string;
  artifact: string;
}> {
  const root = await mkdtemp(join(tmpdir(), "bglands-launcher-test-"));
  const artifact = join(root, "artifacts", "bglands-client.jar");
  const config = join(root, "release.json");
  await mkdir(dirname(artifact), { recursive: true });
  await writeFile(artifact, "fake-but-deterministic-mod-jar\n");
  await writeFile(config, JSON.stringify(source("artifacts/bglands-client.jar")));
  return { root, config, artifact };
}

test("canonicalJson trie récursivement les clés", () => {
  const left = canonicalJson({ z: 1, a: { d: 4, b: 2 }, list: [3, 1] });
  const right = canonicalJson({ list: [3, 1], a: { b: 2, d: 4 }, z: 1 });
  assert.equal(left, right);
  assert.equal(sha256Hex(left), sha256Hex(right));
});

test("le générateur calcule l'intégrité et ne publie jamais localFile", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  const manifest = await buildManifest({ sourcePath: files.config });
  assert.equal(manifest.payload.artifacts[0]?.integrity.size, 31);
  assert.match(manifest.payload.artifacts[0]?.integrity.sha256 ?? "", /^[a-f0-9]{64}$/);
  assert.equal("localFile" in (manifest.payload.artifacts[0] ?? {}), false);
  await verifyManifest(manifest);
});

test("le générateur refuse de signer un artefact différent de l'intégrité source épinglée", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  await writeFile(files.artifact, "contenu-altéré\n");
  await assert.rejects(() => buildManifest({ sourcePath: files.config }), /intégrité épinglée/);
});

test("le cache de dépendance préfère une seed conforme et reste idempotent sans réseau", async (context) => {
  const root = await mkdtemp(join(tmpdir(), "bglands-cache-test-"));
  context.after(() => rm(root, { recursive: true, force: true }));
  const payload = Buffer.from("dependency-pinned\n");
  const seed = join(root, "seed.jar");
  const destination = join(root, "cache", "dependency.jar");
  await writeFile(seed, payload);
  const options = {
    destination,
    sha256: sha256Hex(payload.toString()),
    size: payload.byteLength,
    url: "https://maven.fabricmc.net/example.jar",
    seedPath: seed,
    download: async () => { throw new Error("le réseau ne doit pas être utilisé"); },
  };
  assert.equal(await ensurePinnedArtifact(options), "seeded");
  assert.equal(await ensurePinnedArtifact(options), "cached");
  assert.deepEqual(await readFile(destination), payload);
});

test("le bundle stable inclut automatiquement les deux schémas et reste reproductible", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  const { privateKey, publicKey } = generateKeyPairSync("ed25519");
  const privatePath = join(files.root, "release-private.pem");
  const trustedPublicPath = join(files.root, "trusted-public.pem");
  const output = join(files.root, "client-release");
  await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
  await writeFile(trustedPublicPath, publicKey.export({ type: "spki", format: "pem" }));
  await chmod(privatePath, 0o600);
  const schemaRef = "https://game.bglands.fr/resources/bglands-client/release-manifest.schema.json";

  await prepareReleaseBundle({
    sourcePath: files.config,
    releaseDirectory: output,
    privateKeyPath: privatePath,
    trustedPublicKeyPath: trustedPublicPath,
    keyId: "release-2026-01",
    schemaRef,
  });
  const firstRelease = await readFile(join(output, "release.json"), "utf8");
  const firstChecksums = await readFile(join(output, "checksums.sha256"), "utf8");
  const checksumNames = firstChecksums.trim().split("\n").map((line) => line.slice(66));
  assert.deepEqual(
    checksumNames,
    ["bglands-client-1.2.3.jar", ...HOSTED_SCHEMA_FILES].sort(),
  );
  assert.deepEqual(
    (await readdir(output)).sort(),
    [
      "bglands-client-1.2.3.jar",
      "checksums.sha256",
      "release-manifest.schema.json",
      "release-public.pem",
      "release-source.schema.json",
      "release.json",
    ].sort(),
  );
  await verifyReleaseBundle({
    releaseDirectory: output,
    publicKeyPath: trustedPublicPath,
    requireSignature: true,
    expectedSchemaRef: schemaRef,
  });

  await prepareReleaseBundle({
    sourcePath: files.config,
    releaseDirectory: output,
    privateKeyPath: privatePath,
    trustedPublicKeyPath: trustedPublicPath,
    keyId: "release-2026-01",
    schemaRef,
  });
  assert.equal(await readFile(join(output, "release.json"), "utf8"), firstRelease);
  assert.equal(await readFile(join(output, "checksums.sha256"), "utf8"), firstChecksums);
  assert.equal((await readdir(output)).some((name) => name.includes("private")), false);
});

test("le bundler refuse une clé privée qui ne correspond pas à la clé publique épinglée", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  const signing = generateKeyPairSync("ed25519");
  const unrelated = generateKeyPairSync("ed25519");
  const privatePath = join(files.root, "private.pem");
  const trustedPublicPath = join(files.root, "trusted.pem");
  await writeFile(privatePath, signing.privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
  await writeFile(trustedPublicPath, unrelated.publicKey.export({ type: "spki", format: "pem" }));
  await chmod(privatePath, 0o600);
  await assert.rejects(
    () => prepareReleaseBundle({
      sourcePath: files.config,
      releaseDirectory: join(files.root, "client-release"),
      privateKeyPath: privatePath,
      trustedPublicKeyPath: trustedPublicPath,
      keyId: "release-2026-01",
      schemaRef: "https://game.bglands.fr/resources/bglands-client/release-manifest.schema.json",
    }),
    /clé publique épinglée/,
  );
});

test("le vérificateur de bundle refuse un schéma absent ou un fichier parasite", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  const { privateKey } = generateKeyPairSync("ed25519");
  const privatePath = join(files.root, "private.pem");
  const output = join(files.root, "client-release");
  await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
  await chmod(privatePath, 0o600);
  await prepareReleaseBundle({
    sourcePath: files.config,
    releaseDirectory: output,
    privateKeyPath: privatePath,
    keyId: "test-key",
    schemaRef: "https://game.bglands.fr/resources/bglands-client/release-manifest.schema.json",
  });
  await rm(join(output, "release-source.schema.json"));
  await assert.rejects(
    () => verifyReleaseBundle({
      releaseDirectory: output,
      publicKeyPath: join(output, "release-public.pem"),
      requireSignature: true,
    }),
    /Contenu du bundle inattendu/,
  );
  await writeFile(join(output, "release-source.schema.json"), "{}\n");
  await writeFile(join(output, "release-private.pem"), "secret parasite\n");
  await assert.rejects(
    () => verifyReleaseBundle({
      releaseDirectory: output,
      publicKeyPath: join(output, "release-public.pem"),
      requireSignature: true,
    }),
    /Contenu du bundle inattendu/,
  );
});

test("une signature Ed25519 valide est acceptée et une altération est rejetée", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  const { privateKey, publicKey } = generateKeyPairSync("ed25519");
  const privatePath = join(files.root, "release-private.pem");
  const publicPath = join(files.root, "release-public.pem");
  await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }));
  await writeFile(publicPath, publicKey.export({ type: "spki", format: "pem" }));
  const manifest = await buildManifest({
    sourcePath: files.config,
    privateKeyPath: privatePath,
    keyId: "release-2026-01",
  });
  await verifyManifest(manifest, { publicKeyPath: publicPath, requireSignature: true });
  const tampered = structuredClone(manifest);
  tampered.payload.version = "9.9.9";
  await assert.rejects(
    () => verifyManifest(tampered, { publicKeyPath: publicPath, requireSignature: true }),
    /digest du payload/,
  );
});

test("la vérification locale contrôle le fichier installé", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  const manifest = await buildManifest({ sourcePath: files.config });
  const instance = join(files.root, "instance");
  const installed = join(instance, "mods", "bglands-client.jar");
  await mkdir(dirname(installed), { recursive: true });
  await writeFile(installed, "fake-but-deterministic-mod-jar\n");
  await verifyManifest(manifest, { artifactRoot: instance });
  await writeFile(installed, "tampered\n");
  await assert.rejects(() => verifyManifest(manifest, { artifactRoot: instance }), /Artefact invalide/);
});

test("le contrôle de racine utilise correctement les séparateurs Windows", () => {
  assert.equal(
    isPathWithinRoot("C:\\Users\\Alice\\BG LANDS", "C:\\Users\\Alice\\BG LANDS\\mods\\client.jar", win32),
    true,
  );
  assert.equal(
    isPathWithinRoot("C:\\Users\\Alice\\BG LANDS", "C:\\Users\\Alice\\BG LANDS-evil\\client.jar", win32),
    false,
  );
  assert.equal(
    isPathWithinRoot("C:\\Users\\Alice\\BG LANDS", "D:\\BG LANDS\\mods\\client.jar", win32),
    false,
  );
  assert.equal(
    isPathWithinRoot("C:\\Users\\Alice\\BG LANDS", "C:\\Users\\Alice\\BG LANDS", win32),
    false,
  );
});

test("les chemins traversants et les URL HTTP sont refusés", () => {
  const unsafePath = source("client.jar");
  unsafePath.artifacts[0]!.installPath = "mods/../../launcher.exe";
  assert.throws(() => parseReleaseSource(unsafePath), ValidationError);

  const unsafeUrl = source("client.jar");
  unsafeUrl.artifacts[0]!.distribution = { type: "direct", url: "http://downloads.bglands.fr/client.jar" };
  assert.throws(() => parseReleaseSource(unsafeUrl), /HTTPS/);
});

test("les chemins incompatibles avec Win32, les ADS et les collisions sans casse sont refusés", () => {
  const forbiddenPaths = [
    "mods/CON",
    "mods/con.jar",
    "config/PRN/settings.json",
    "mods/AUX.txt",
    "resourcepacks/NUL.zip",
    "mods/COM1.jar",
    "mods/com9",
    "mods/LPT1.cfg",
    "mods/lpt9",
    "mods/client.jar:payload.exe",
    "config/profile:secret/options.json",
    "mods/client.jar.",
    "mods/client.jar ",
    "mods/directory./client.jar",
    "mods/directory /client.jar",
  ];
  for (const installPath of forbiddenPaths) {
    const invalid = source("client.jar");
    invalid.artifacts[0]!.installPath = installPath;
    assert.throws(
      () => parseReleaseSource(invalid),
      /composant interdit sous Windows/,
      installPath,
    );
  }

  const collision = source("client.jar");
  collision.artifacts.push({
    ...structuredClone(collision.artifacts[0]!),
    id: "fabric-api",
    kind: "dependency-mod",
    fileName: "fabric-api.jar",
    installPath: "mods/BGLANDS-CLIENT.JAR",
  });
  assert.throws(() => parseReleaseSource(collision), /sans distinction de casse/);
});

test("exactement un mod client obligatoire est exigé", () => {
  const invalid = source("client.jar");
  invalid.artifacts[0]!.required = false;
  assert.throws(() => parseReleaseSource(invalid), /exactement un client-mod obligatoire/);
});

test("l'updater installe atomiquement, reste idempotent et sauvegarde la version précédente", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  const { privateKey, publicKey } = generateKeyPairSync("ed25519");
  const privatePath = join(files.root, "release-private.pem");
  const publicPath = join(files.root, "release-public.pem");
  await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }));
  await writeFile(publicPath, publicKey.export({ type: "spki", format: "pem" }));
  const cache = join(files.root, "cache");
  const instance = join(files.root, "instance");
  await mkdir(cache);
  await mkdir(instance);
  await copyFile(files.artifact, join(cache, "bglands-client-1.2.3.jar"));
  const environment = {
    launcherVersion: "1.0.0",
    minecraft: "1.21.8",
    javaMajor: 21,
    loader: "fabric" as const,
    loaderVersion: "0.17.2",
    serverProtocol: 1,
  };
  const firstManifest = await buildManifest({
    sourcePath: files.config,
    privateKeyPath: privatePath,
    keyId: "release-2026-01",
  });
  const first = await installRelease({
    manifest: firstManifest,
    publicKeyPath: publicPath,
    artifactCache: cache,
    instanceRoot: instance,
    environment,
  });
  assert.equal(first.changed, true);
  const installed = join(instance, "mods", "bglands-client.jar");
  assert.equal(await readFile(installed, "utf8"), "fake-but-deterministic-mod-jar\n");

  const repeated = await installRelease({
    manifest: firstManifest,
    publicKeyPath: publicPath,
    artifactCache: cache,
    instanceRoot: instance,
    environment,
  });
  assert.equal(repeated.changed, false);

  await writeFile(files.artifact, "new-signed-client-mod\n");
  const secondSource = source("artifacts/bglands-client.jar");
  secondSource.releaseId = "bglands-client-2.0.0";
  secondSource.version = "2.0.0";
  secondSource.artifacts[0]!.version = "2.0.0";
  secondSource.artifacts[0]!.sourceIntegrity = {
    algorithm: "sha256",
    sha256: sha256Hex("new-signed-client-mod\n"),
    size: Buffer.byteLength("new-signed-client-mod\n"),
  };
  secondSource.artifacts[0]!.fileName = "bglands-client-2.0.0.jar";
  secondSource.artifacts[0]!.distribution = {
    type: "direct",
    url: "https://downloads.bglands.fr/client/bglands-client-2.0.0.jar",
  };
  await writeFile(files.config, JSON.stringify(secondSource));
  await copyFile(files.artifact, join(cache, "bglands-client-2.0.0.jar"));
  const secondManifest = await buildManifest({
    sourcePath: files.config,
    privateKeyPath: privatePath,
    keyId: "release-2026-01",
  });
  const second = await installRelease({
    manifest: secondManifest,
    publicKeyPath: publicPath,
    artifactCache: cache,
    instanceRoot: instance,
    environment,
  });
  assert.equal(second.changed, true);
  assert.equal(await readFile(installed, "utf8"), "new-signed-client-mod\n");
  const releaseBackups = join(instance, ".bglands", "backups", "bglands-client-2.0.0");
  const firstTransactions = (await readdir(releaseBackups, { withFileTypes: true }))
    .filter((entry) => entry.isDirectory() && entry.name.startsWith("transaction-"));
  assert.equal(firstTransactions.length, 1);
  assert.equal(await readFile(
    join(releaseBackups, firstTransactions[0]!.name, "mods", "bglands-client.jar"),
    "utf8",
  ), "fake-but-deterministic-mod-jar\n");

  // A repair of the same release must not collide with its existing backup.
  await writeFile(installed, "corrupted-after-install\n");
  const repaired = await installRelease({
    manifest: secondManifest,
    publicKeyPath: publicPath,
    artifactCache: cache,
    instanceRoot: instance,
    environment,
  });
  assert.equal(repaired.changed, true);
  assert.equal(await readFile(installed, "utf8"), "new-signed-client-mod\n");
  const repairedTransactions = (await readdir(releaseBackups, { withFileTypes: true }))
    .filter((entry) => entry.isDirectory() && entry.name.startsWith("transaction-"));
  assert.equal(repairedTransactions.length, 2);
  const backupContents = await Promise.all(repairedTransactions.map((entry) => readFile(
    join(releaseBackups, entry.name, "mods", "bglands-client.jar"),
    "utf8",
  )));
  assert.deepEqual(new Set(backupContents), new Set([
    "fake-but-deterministic-mod-jar\n",
    "corrupted-after-install\n",
  ]));
});

test("une release N+1 retire seulement l'ancien artefact géré et le conserve dans la transaction", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  const { privateKey, publicKey } = generateKeyPairSync("ed25519");
  const privatePath = join(files.root, "private.pem");
  const publicPath = join(files.root, "public.pem");
  await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }));
  await writeFile(publicPath, publicKey.export({ type: "spki", format: "pem" }));
  const cache = join(files.root, "cache");
  const instance = join(files.root, "instance");
  await mkdir(cache);
  await mkdir(instance);
  const retiredSourcePath = join(files.root, "artifacts", "legacy-options.json");
  const retiredContents = "{\"legacy\":true}\n";
  await writeFile(retiredSourcePath, retiredContents);

  const releaseN = source("artifacts/bglands-client.jar");
  releaseN.artifacts.push({
    id: "legacy-options",
    kind: "config",
    version: "1.2.3",
    localFile: "artifacts/legacy-options.json",
    sourceIntegrity: {
      algorithm: "sha256",
      sha256: sha256Hex(retiredContents),
      size: Buffer.byteLength(retiredContents),
    },
    fileName: "legacy-options.json",
    installPath: "config/bglands/legacy-options.json",
    required: true,
    restartRequired: true,
    distribution: { type: "direct", url: "https://downloads.bglands.fr/client/legacy-options.json" },
  });
  await writeFile(files.config, JSON.stringify(releaseN));
  await copyFile(files.artifact, join(cache, "bglands-client-1.2.3.jar"));
  await copyFile(retiredSourcePath, join(cache, "legacy-options.json"));
  const manifestN = await buildManifest({
    sourcePath: files.config,
    privateKeyPath: privatePath,
    keyId: "test-key",
  });
  const environment = {
    launcherVersion: "1.0.0",
    minecraft: "1.21.8",
    javaMajor: 21,
    loader: "fabric" as const,
    loaderVersion: "0.17.2",
    serverProtocol: 1,
  };
  await installRelease({
    manifest: manifestN,
    publicKeyPath: publicPath,
    artifactCache: cache,
    instanceRoot: instance,
    environment,
  });
  const retiredInstalledPath = join(instance, "config", "bglands", "legacy-options.json");
  assert.equal(await readFile(retiredInstalledPath, "utf8"), retiredContents);
  const personalFile = join(instance, "mods", "mon-mod-personnel.jar");
  await writeFile(personalFile, "personal-and-unmanaged\n");

  const releaseN1 = source("artifacts/bglands-client.jar");
  releaseN1.releaseId = "bglands-client-1.3.0";
  releaseN1.version = "1.3.0";
  releaseN1.artifacts[0]!.version = "1.3.0";
  releaseN1.artifacts[0]!.fileName = "bglands-client-1.3.0.jar";
  releaseN1.artifacts[0]!.distribution = {
    type: "direct",
    url: "https://downloads.bglands.fr/client/bglands-client-1.3.0.jar",
  };
  await writeFile(files.config, JSON.stringify(releaseN1));
  await copyFile(files.artifact, join(cache, "bglands-client-1.3.0.jar"));
  const manifestN1 = await buildManifest({
    sourcePath: files.config,
    privateKeyPath: privatePath,
    keyId: "test-key",
  });
  const result = await installRelease({
    manifest: manifestN1,
    publicKeyPath: publicPath,
    artifactCache: cache,
    instanceRoot: instance,
    environment,
  });
  assert.deepEqual(result.removed, ["config/bglands/legacy-options.json"]);
  assert.equal(await existsForTest(retiredInstalledPath), false);
  assert.equal(await readFile(personalFile, "utf8"), "personal-and-unmanaged\n");

  const backupRoot = join(instance, ".bglands", "backups", "bglands-client-1.3.0");
  const transactions = (await readdir(backupRoot, { withFileTypes: true }))
    .filter((entry) => entry.isDirectory() && entry.name.startsWith("transaction-"));
  assert.equal(transactions.length, 1);
  assert.equal(await readFile(
    join(backupRoot, transactions[0]!.name, "config", "bglands", "legacy-options.json"),
    "utf8",
  ), retiredContents);
});

test("un état local traversant ou lié symboliquement est rejeté avant toute mutation", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  const { privateKey, publicKey } = generateKeyPairSync("ed25519");
  const privatePath = join(files.root, "private.pem");
  const publicPath = join(files.root, "public.pem");
  await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }));
  await writeFile(publicPath, publicKey.export({ type: "spki", format: "pem" }));
  const cache = join(files.root, "cache");
  const instance = join(files.root, "instance");
  await mkdir(cache);
  await mkdir(instance);
  await copyFile(files.artifact, join(cache, "bglands-client-1.2.3.jar"));
  const manifest = await buildManifest({
    sourcePath: files.config,
    privateKeyPath: privatePath,
    keyId: "test-key",
  });
  const environment = {
    launcherVersion: "1.0.0",
    minecraft: "1.21.8",
    javaMajor: 21,
    loader: "fabric" as const,
    loaderVersion: "0.17.2",
    serverProtocol: 1,
  };
  await installRelease({
    manifest,
    publicKeyPath: publicPath,
    artifactCache: cache,
    instanceRoot: instance,
    environment,
  });
  const statePath = join(instance, ".bglands", "installed-release.json");
  const malicious = JSON.parse(await readFile(statePath, "utf8")) as {
    artifacts: Array<{ installPath: string }>;
  };
  malicious.artifacts[0]!.installPath = "mods/../../do-not-touch.txt";
  await writeFile(statePath, JSON.stringify(malicious));
  const outside = join(files.root, "do-not-touch.txt");
  await writeFile(outside, "user-data\n");
  await assert.rejects(() => installRelease({
    manifest,
    publicKeyPath: publicPath,
    artifactCache: cache,
    instanceRoot: instance,
    environment,
  }), /chemin relatif normalisé/);
  assert.equal(await readFile(outside, "utf8"), "user-data\n");
  assert.equal(
    await readFile(join(instance, "mods", "bglands-client.jar"), "utf8"),
    "fake-but-deterministic-mod-jar\n",
  );

  const validStateOutside = join(files.root, "valid-state-outside.json");
  await writeFile(validStateOutside, JSON.stringify({ ...malicious, artifacts: [{
    ...malicious.artifacts[0],
    installPath: "mods/bglands-client.jar",
    id: "bglands-client",
    integrity: manifest.payload.artifacts[0]!.integrity,
  }] }));
  await rm(statePath);
  await symlink(validStateOutside, statePath);
  await assert.rejects(() => readInstalledReleaseState(instance), /fichier régulier/);
});

test("l'updater refuse une combinaison Minecraft incompatible avant installation", async (context) => {
  const files = await fixture();
  context.after(() => rm(files.root, { recursive: true, force: true }));
  const { privateKey, publicKey } = generateKeyPairSync("ed25519");
  const privatePath = join(files.root, "private.pem");
  const publicPath = join(files.root, "public.pem");
  await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }));
  await writeFile(publicPath, publicKey.export({ type: "spki", format: "pem" }));
  const manifest = await buildManifest({ sourcePath: files.config, privateKeyPath: privatePath, keyId: "test-key" });
  const cache = join(files.root, "cache");
  const instance = join(files.root, "instance");
  await mkdir(cache);
  await mkdir(instance);
  await copyFile(files.artifact, join(cache, "bglands-client-1.2.3.jar"));
  await assert.rejects(() => installRelease({
    manifest,
    publicKeyPath: publicPath,
    artifactCache: cache,
    instanceRoot: instance,
    environment: {
      launcherVersion: "1.0.0",
      minecraft: "1.20.1",
      javaMajor: 21,
      loader: "fabric",
      loaderVersion: "0.17.2",
      serverProtocol: 1,
    },
  }), /Minecraft 1.20.1 incompatible/);
  assert.equal(await existsForTest(join(instance, "mods", "bglands-client.jar")), false);
});

async function existsForTest(path: string): Promise<boolean> {
  try {
    await readFile(path);
    return true;
  } catch {
    return false;
  }
}
