import { app } from "electron";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { validateMicrosoftClientId } from "./microsoft-config.js";

export interface LauncherConfig {
  schemaVersion: 1;
  launcherVersion: string;
  releaseManifestUrl: string;
  releaseHost: string;
  communityUrl: string;
  server: { host: string; port: number };
  microsoft: {
    clientId: string;
    authority: string;
    scopes: string[];
    source: "missing" | "embedded" | "local";
  };
  defaults: LauncherSettings;
}

export interface LauncherSettings {
  minimumMemoryMb: number;
  maximumMemoryMb: number;
  width: number;
  height: number;
  autoConnect: boolean;
  closeLauncherOnGameStart: boolean;
  modules: {
    keystrokes: boolean;
    cps: boolean;
    ping: boolean;
    serverAddress: boolean;
    resources: boolean;
    coordinates: boolean;
    minimap: boolean;
  };
}

export interface LauncherPaths {
  root: string;
  instance: string;
  minecraft: string;
  runtime: string;
  cache: string;
  logs: string;
  state: string;
  settings: string;
  microsoftConfig: string;
  msalCache: string;
  config: string;
}

const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;

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

function text(value: unknown, at: string, allowEmpty = false): string {
  if (typeof value !== "string" || (!allowEmpty && value.trim() === "")) throw new Error(`${at} est invalide`);
  return value.trim();
}

function integer(value: unknown, at: string, min: number, max: number): number {
  if (!Number.isSafeInteger(value) || (value as number) < min || (value as number) > max) {
    throw new Error(`${at} doit être un entier entre ${min} et ${max}`);
  }
  return value as number;
}

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

function parseSettings(raw: unknown): LauncherSettings {
  const value = object(raw, "defaults");
  const rawModules = value.modules === undefined ? {} : object(value.modules, "defaults.modules");
  const moduleFlag = (name: keyof LauncherSettings["modules"], fallback: boolean): boolean => {
    const candidate = rawModules[name];
    return candidate === undefined ? fallback : bool(candidate, `defaults.modules.${name}`);
  };
  const result: LauncherSettings = {
    minimumMemoryMb: integer(value.minimumMemoryMb, "defaults.minimumMemoryMb", 512, 32_768),
    maximumMemoryMb: integer(value.maximumMemoryMb, "defaults.maximumMemoryMb", 1_024, 65_536),
    width: integer(value.width, "defaults.width", 640, 7_680),
    height: integer(value.height, "defaults.height", 360, 4_320),
    autoConnect: bool(value.autoConnect, "defaults.autoConnect"),
    closeLauncherOnGameStart: bool(value.closeLauncherOnGameStart, "defaults.closeLauncherOnGameStart"),
    modules: {
      keystrokes: moduleFlag("keystrokes", true),
      cps: moduleFlag("cps", true),
      ping: moduleFlag("ping", true),
      serverAddress: moduleFlag("serverAddress", true),
      resources: moduleFlag("resources", true),
      coordinates: moduleFlag("coordinates", true),
      minimap: moduleFlag("minimap", false),
    },
  };
  if (result.minimumMemoryMb > result.maximumMemoryMb) throw new Error("La RAM minimale dépasse la RAM maximale");
  return result;
}

export function validateSettings(raw: unknown): LauncherSettings {
  return parseSettings(raw);
}

function parseConfig(raw: unknown): LauncherConfig {
  const value = object(raw, "config");
  if (value.schemaVersion !== 1) throw new Error("config.schemaVersion doit valoir 1");
  const releaseUrl = new URL(text(value.releaseManifestUrl, "config.releaseManifestUrl"));
  const releaseHost = text(value.releaseHost, "config.releaseHost").toLowerCase();
  if (releaseUrl.protocol !== "https:" || releaseUrl.username || releaseUrl.password || releaseUrl.hostname !== releaseHost) {
    throw new Error("Le manifeste doit être une URL HTTPS du domaine de release épinglé");
  }
  const communityUrl = new URL(text(value.communityUrl, "config.communityUrl"));
  if (communityUrl.protocol !== "https:" || communityUrl.username || communityUrl.password || communityUrl.hostname !== releaseHost) {
    throw new Error("Le flux communautaire doit utiliser le domaine HTTPS de release épinglé");
  }
  const server = object(value.server, "config.server");
  const microsoft = object(value.microsoft, "config.microsoft");
  const launcherVersion = text(value.launcherVersion, "config.launcherVersion");
  if (!SEMVER.test(launcherVersion)) throw new Error("config.launcherVersion n'est pas SemVer");
  const clientId = text(microsoft.clientId, "config.microsoft.clientId", true);
  if (clientId !== "") validateMicrosoftClientId(clientId);
  const authority = new URL(text(microsoft.authority, "config.microsoft.authority"));
  if (authority.protocol !== "https:" || authority.hostname !== "login.microsoftonline.com") {
    throw new Error("L'autorité Microsoft n'est pas autorisée");
  }
  if (!Array.isArray(microsoft.scopes) || microsoft.scopes.some((scope) => typeof scope !== "string")) {
    throw new Error("config.microsoft.scopes est invalide");
  }
  return {
    schemaVersion: 1,
    launcherVersion,
    releaseManifestUrl: releaseUrl.toString(),
    releaseHost,
    communityUrl: communityUrl.toString(),
    server: {
      host: text(server.host, "config.server.host"),
      port: integer(server.port, "config.server.port", 1, 65_535),
    },
    microsoft: {
      clientId,
      authority: authority.toString().replace(/\/$/, ""),
      scopes: microsoft.scopes.map((scope) => scope.trim()).filter(Boolean),
      source: clientId === "" ? "missing" : "embedded",
    },
    defaults: parseSettings(value.defaults),
  };
}

function resourcePath(name: string): string {
  return resolve(app.getAppPath(), "dist", "resources", name);
}

export function resolveLauncherPaths(): LauncherPaths {
  const platformRoot = process.platform === "win32" && process.env.LOCALAPPDATA
    ? process.env.LOCALAPPDATA
    : app.getPath("userData");
  if (!isAbsolute(platformRoot)) throw new Error("Le dossier local du launcher n'est pas absolu");
  const root = join(platformRoot, "BGLands", "Launcher");
  return {
    root,
    instance: join(root, "instance"),
    minecraft: join(root, "minecraft"),
    runtime: join(root, "runtime"),
    cache: join(root, "cache"),
    logs: join(root, "logs"),
    state: join(root, "state.json"),
    settings: join(root, "settings.json"),
    microsoftConfig: join(root, "microsoft.json"),
    msalCache: join(root, "auth.cache"),
    config: resourcePath("launcher.config.json"),
  };
}

export async function loadLauncherConfig(paths: LauncherPaths): Promise<LauncherConfig> {
  const config = parseConfig(JSON.parse(await readFile(paths.config, "utf8")) as unknown);
  try {
    const local = object(JSON.parse(await readFile(paths.microsoftConfig, "utf8")) as unknown, "microsoft");
    if (local.schemaVersion !== 1) throw new Error("microsoft.schemaVersion doit valoir 1");
    config.microsoft.clientId = validateMicrosoftClientId(local.clientId);
    config.microsoft.source = "local";
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
      // A malformed local override never bricks the launcher. The settings page
      // remains available so the administrator can replace it with a valid ID.
    }
  }
  return config;
}

export async function persistMicrosoftClientId(path: string, raw: unknown): Promise<string> {
  const clientId = validateMicrosoftClientId(raw);
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
  const temporary = `${path}.tmp-${process.pid}`;
  await rm(temporary, { force: true });
  await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, clientId }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
  await rename(temporary, path);
  return clientId;
}
