import { createWriteStream } from "node:fs";
import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { hashFile, verifyManifest } from "../../../launcher-manifest/src/manifest.ts";
import { installRelease } from "../../../launcher-manifest/src/installer.ts";
import type { ReleaseArtifact, ReleaseManifest } from "../../../launcher-manifest/src/types.ts";
import type { LauncherConfig, LauncherPaths } from "./config.js";
import type { LauncherLogger } from "./logger.js";
import { assertAllowedReleaseUrl } from "./network-security.js";

const MAX_MANIFEST_BYTES = 512 * 1024;
const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024;
const MAX_RELEASE_BYTES = 2 * 1024 * 1024 * 1024;
const TRUSTED_KEY_ID = "bglands-release-2026-01";
const TRUSTED_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA+tXZ3acem6UT9Y+g+gNq3YqXJ8DLLLYxm9kgwprggdo=
-----END PUBLIC KEY-----
`;

export interface ReleaseProgress {
  stage: "manifest" | "download" | "install";
  message: string;
  current?: number;
  total?: number;
}

interface LauncherReleaseState {
  schemaVersion: 1;
  highestPublishedAt: string;
  releaseId: string;
  version: string;
  payloadSha256: string;
}

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

async function pinnedFetch(raw: string, expectedHost: string, signal?: AbortSignal): Promise<Response> {
  let url = assertAllowedReleaseUrl(raw, expectedHost);
  for (let redirects = 0; redirects <= 3; redirects += 1) {
    const response = await fetch(url, {
      redirect: "manual",
      signal,
      headers: { "User-Agent": "BG-LANDS-Launcher/1.0" },
    });
    if ([301, 302, 303, 307, 308].includes(response.status)) {
      if (redirects === 3) throw new Error("Trop de redirections de téléchargement");
      const location = response.headers.get("location");
      if (!location) throw new Error("Redirection sans destination");
      url = assertAllowedReleaseUrl(new URL(location, url).toString(), expectedHost);
      continue;
    }
    if (!response.ok) throw new Error(`Téléchargement refusé (${response.status})`);
    return response;
  }
  throw new Error("Téléchargement impossible");
}

async function readBoundedJson(response: Response): Promise<unknown> {
  const declared = Number(response.headers.get("content-length") ?? "0");
  if (declared > MAX_MANIFEST_BYTES) throw new Error("Manifeste de release trop volumineux");
  if (!response.body) throw new Error("Manifeste de release vide");
  const reader = response.body.getReader();
  const chunks: Uint8Array[] = [];
  let received = 0;
  try {
    while (true) {
      const next = await reader.read();
      if (next.done) break;
      received += next.value.byteLength;
      if (received > MAX_MANIFEST_BYTES) {
        await reader.cancel("manifest-too-large");
        throw new Error("Manifeste de release trop volumineux");
      }
      chunks.push(next.value);
    }
  } finally {
    reader.releaseLock();
  }
  const bytes = new Uint8Array(received);
  let offset = 0;
  for (const chunk of chunks) {
    bytes.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
}

async function readState(path: string): Promise<LauncherReleaseState | undefined> {
  try {
    const raw = JSON.parse(await readFile(path, "utf8")) as Partial<LauncherReleaseState>;
    if (
      raw.schemaVersion === 1
      && typeof raw.highestPublishedAt === "string"
      && typeof raw.releaseId === "string"
      && typeof raw.version === "string"
      && typeof raw.payloadSha256 === "string"
    ) return raw as LauncherReleaseState;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
  }
  return undefined;
}

async function writeState(path: string, manifest: ReleaseManifest): Promise<void> {
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
  const temporary = `${path}.tmp-${process.pid}`;
  const state: LauncherReleaseState = {
    schemaVersion: 1,
    highestPublishedAt: manifest.payload.publishedAt,
    releaseId: manifest.payload.releaseId,
    version: manifest.payload.version,
    payloadSha256: manifest.integrity.payloadSha256,
  };
  await rm(temporary, { force: true });
  await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { flag: "wx", mode: 0o600 });
  await rename(temporary, path);
}

function enforceNoDowngrade(manifest: ReleaseManifest, state: LauncherReleaseState | undefined): void {
  if (!state) return;
  const incoming = Date.parse(manifest.payload.publishedAt);
  const highest = Date.parse(state.highestPublishedAt);
  if (incoming < highest && manifest.integrity.payloadSha256 !== state.payloadSha256) {
    throw new Error("Release plus ancienne refusée afin d'empêcher un retour arrière malveillant");
  }
  if (
    incoming === highest
    && manifest.payload.releaseId === state.releaseId
    && manifest.integrity.payloadSha256 !== state.payloadSha256
  ) throw new Error("Release déjà connue modifiée après publication; installation refusée");
}

async function downloadArtifact(
  artifact: ReleaseArtifact,
  destination: string,
  expectedHost: string,
  signal: AbortSignal | undefined,
  onProgress: (progress: ReleaseProgress) => void,
): Promise<void> {
  if (artifact.distribution.type !== "direct") {
    throw new Error(`Distribution non prise en charge par ce launcher: ${artifact.id}`);
  }
  if (artifact.integrity.size > MAX_ARTIFACT_BYTES) throw new Error(`Artefact trop volumineux: ${artifact.id}`);
  if (await exists(destination)) {
    const current = await hashFile(destination);
    if (current.sha256 === artifact.integrity.sha256 && current.size === artifact.integrity.size) return;
    await rm(destination, { force: true });
  }
  const response = await pinnedFetch(artifact.distribution.url, expectedHost, signal);
  const declared = Number(response.headers.get("content-length") ?? "0");
  if (declared > artifact.integrity.size || declared > MAX_ARTIFACT_BYTES) {
    throw new Error(`Taille HTTP inattendue pour ${artifact.id}`);
  }
  if (!response.body) throw new Error(`Réponse vide pour ${artifact.id}`);
  await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
  const temporary = `${destination}.part-${process.pid}`;
  await rm(temporary, { force: true });
  let received = 0;
  const meter = new Transform({
    transform(chunk: Buffer, _encoding, callback) {
      received += chunk.length;
      if (received > artifact.integrity.size || received > MAX_ARTIFACT_BYTES) {
        callback(new Error(`Téléchargement trop volumineux pour ${artifact.id}`));
        return;
      }
      onProgress({ stage: "download", message: `Téléchargement de ${artifact.id}`, current: received, total: artifact.integrity.size });
      callback(null, chunk);
    },
  });
  try {
    await pipeline(Readable.fromWeb(response.body as never), meter, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
    const actual = await hashFile(temporary);
    if (actual.size !== artifact.integrity.size || actual.sha256 !== artifact.integrity.sha256) {
      throw new Error(`Signature SHA-256 invalide pour ${artifact.id}`);
    }
    await rename(temporary, destination);
  } catch (error) {
    await rm(temporary, { force: true });
    throw error;
  }
}

export class ClientReleaseManager {
  constructor(
    private readonly config: LauncherConfig,
    private readonly paths: LauncherPaths,
    private readonly logger: LauncherLogger,
  ) {}

  async fetchVerifiedManifest(signal?: AbortSignal): Promise<ReleaseManifest> {
    const response = await pinnedFetch(this.config.releaseManifestUrl, this.config.releaseHost, signal);
    const manifest = await verifyManifest(await readBoundedJson(response), {
      publicKeyPem: TRUSTED_PUBLIC_KEY_PEM,
      requireSignature: true,
    });
    if (manifest.signature?.keyId !== TRUSTED_KEY_ID) throw new Error("Identifiant de clé de release non approuvé");
    if (manifest.payload.artifacts.length > 128) throw new Error("La release contient trop d'artefacts");
    const total = manifest.payload.artifacts.reduce((sum, artifact) => sum + artifact.integrity.size, 0);
    if (total > MAX_RELEASE_BYTES) throw new Error("La release dépasse la taille maximale autorisée");
    enforceNoDowngrade(manifest, await readState(this.paths.state));
    return manifest;
  }

  async synchronize(
    onProgress: (progress: ReleaseProgress) => void,
    signal?: AbortSignal,
  ): Promise<ReleaseManifest> {
    onProgress({ stage: "manifest", message: "Vérification de la release signée" });
    const manifest = await this.fetchVerifiedManifest(signal);
    const cacheRoot = join(this.paths.cache, manifest.integrity.payloadSha256);
    await mkdir(cacheRoot, { recursive: true, mode: 0o700 });
    for (const artifact of manifest.payload.artifacts) {
      await downloadArtifact(artifact, join(cacheRoot, artifact.fileName), this.config.releaseHost, signal, onProgress);
    }
    onProgress({ stage: "install", message: "Installation atomique du client BG LANDS" });
    const result = await installRelease({
      manifest,
      publicKeyPem: TRUSTED_PUBLIC_KEY_PEM,
      artifactCache: cacheRoot,
      instanceRoot: this.paths.instance,
      environment: {
        launcherVersion: this.config.launcherVersion,
        minecraft: manifest.payload.game.minecraft,
        javaMajor: manifest.payload.game.javaMajor,
        loader: manifest.payload.game.loader.id,
        loaderVersion: manifest.payload.game.loader.version,
        serverProtocol: 1,
      },
    });
    await writeState(this.paths.state, result.manifest);
    await this.logger.info(result.changed ? "Client BG LANDS installé" : "Client BG LANDS déjà à jour", {
      releaseId: manifest.payload.releaseId,
      version: manifest.payload.version,
    });
    return result.manifest;
  }
}
