import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { describe, expect, it } from "vitest";

/**
 * Statische Deployment-Verifikation (Workstream 10), offline und ohne Docker:
 * prüft die Compose-/Dockerfile-/Entrypoint-/Env-Artefakte gegen die im Plan
 * festgelegten Verträge (externes Traefik, non-root, kein Proxy-Service,
 * Pflicht-Env-Keys, idempotente Migration vor App-Start).
 */
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, "..", "..");
const read = (rel: string): string => readFileSync(resolve(root, rel), "utf8");

describe("Deployment-Artefakte", () => {
  it("Dockerfile ist multi-stage, standalone und läuft non-root als UID 1001", () => {
    const df = read("Dockerfile");
    expect(df).toMatch(/AS\s+deps/);
    expect(df).toMatch(/AS\s+builder/);
    expect(df).toMatch(/AS\s+runner/);
    // Standalone-Server wird kopiert und gestartet.
    expect(df).toMatch(/\.next\/standalone/);
    expect(df).toMatch(/server\.js/);
    // Non-root: dedizierte UID/GID 1001 + USER-Wechsel.
    expect(df).toMatch(/1001/);
    expect(df).toMatch(/^USER\s+(nextjs|1001)/m);
    // Entrypoint übernimmt Migration vor App-Start.
    expect(df).toMatch(/entrypoint\.sh/);
  });

  it("Dockerfile kopiert die VOLLSTÄNDIGE pg/drizzle-orm-Laufzeit-Closure ins Runner-Image", () => {
    // Reale Laufzeit-Closure aus node_modules berechnen (nur dependencies,
    // wie sie zur Laufzeit von docker/migrate.mjs + docker/seed.mjs benötigt
    // wird). Jedes Paket MUSS eine COPY-Zeile im Runner-Stage haben, sonst
    // crasht der Container-Start mit ERR_MODULE_NOT_FOUND (z. B. pg-int8,
    // xtend). Schützt vor erneutem Brechen bei pg/drizzle-Updates.
    const df = read("Dockerfile");
    const nodeModules = resolve(root, "node_modules");

    const seen = new Set<string>();
    const resolvePkg = (name: string, fromDir: string): string | null => {
      let cur = fromDir;
      for (;;) {
        const candidate = resolve(cur, "node_modules", name);
        if (existsSync(resolve(candidate, "package.json"))) return candidate;
        const parent = dirname(cur);
        if (parent === cur) break;
        cur = parent;
      }
      const top = resolve(nodeModules, name);
      return existsSync(resolve(top, "package.json")) ? top : null;
    };
    const walk = (dir: string): void => {
      const pkg = JSON.parse(
        readFileSync(resolve(dir, "package.json"), "utf8"),
      ) as { dependencies?: Record<string, string> };
      for (const dep of Object.keys(pkg.dependencies ?? {})) {
        if (seen.has(dep)) continue;
        seen.add(dep);
        const resolved = resolvePkg(dep, dir);
        // Nur skalierbar prüfbar, wenn auflösbar; sonst meldet npm ci es ohnehin.
        if (resolved) walk(resolved);
      }
    };

    const pgRoot = resolve(nodeModules, "pg");
    const drizzleRoot = resolve(nodeModules, "drizzle-orm");
    walk(pgRoot);
    walk(drizzleRoot);

    // pg + drizzle-orm selbst sind ebenfalls Teil der Closure.
    const closure = new Set<string>([...seen, "pg", "drizzle-orm"]);

    const missing = [...closure].filter(
      (name) =>
        !df.includes(`/app/node_modules/${name} ./node_modules/${name}`),
    );
    expect(missing, `nicht ins Runner-Image kopiert: ${missing.join(", ")}`).toEqual(
      [],
    );
  });

  it("Dockerfile bündelt den Seed (build-seed-bundle) und kopiert docker/seed.mjs ins Runner-Image", () => {
    const df = read("Dockerfile");
    // Im builder wird der Seed zu Plain-ESM gebündelt ...
    expect(df).toMatch(/build-seed-bundle\.mjs/);
    // ... und ins Runner-Image kopiert, damit RUN_SEED=true nicht still no-opt.
    expect(df).toMatch(/docker\/seed\.mjs/);
    // Der Bundler-Entrypoint existiert.
    expect(existsSync(resolve(root, "scripts/build-seed-bundle.mjs"))).toBe(true);
    // Das Seed-Quellmodul, das gebündelt wird, existiert.
    expect(existsSync(resolve(root, "src/db/seed/index.ts"))).toBe(true);
  });

  it(".dockerignore schließt node_modules, .next und Secrets aus", () => {
    const di = read(".dockerignore");
    expect(di).toMatch(/node_modules/);
    expect(di).toMatch(/\.next/);
    expect(di).toMatch(/\.env/);
  });

  it("entrypoint.sh hat gültige sh-Syntax, wartet auf Postgres, migriert, startet server.js", () => {
    const ep = resolve(root, "docker/entrypoint.sh");
    expect(existsSync(ep)).toBe(true);
    // Syntaxprüfung (offline, kein Ausführen der Logik).
    execFileSync("sh", ["-n", ep]);
    const src = read("docker/entrypoint.sh");
    expect(src).toMatch(/set -e/);
    // Warten auf Postgres (TCP-Probe), dann Migration, optional Seed, dann Start.
    expect(src).toMatch(/DATABASE_URL/);
    expect(src).toMatch(/migrate/);
    expect(src).toMatch(/RUN_SEED/);
    expect(src).toMatch(/exec node server\.js/);
  });

  it(".env.example listet alle Pflicht-Env-Keys", () => {
    const env = read(".env.example");
    for (const key of [
      "AUTH_URL",
      "AUTH_TRUST_HOST",
      "AUTH_SECRET",
      "AUTHENTIK_ISSUER",
      "AUTHENTIK_CLIENT_ID",
      "AUTHENTIK_CLIENT_SECRET",
      "DATABASE_URL",
      "OSRM_URL",
      "NOMINATIM_URL",
      "APP_HOST",
    ]) {
      expect(env, `fehlender Env-Key: ${key}`).toMatch(new RegExp(`^${key}=`, "m"));
    }
  });

  it("docker-compose.yml nutzt externes Traefik-Netz, Labels, Healthchecks und keinen Proxy-Service", () => {
    const c = read("docker-compose.yml");
    // Genau die vier Fachdienste; kein eigener Proxy/Traefik-SERVICE.
    const servicesBlock = c.slice(c.indexOf("\nservices:"), c.indexOf("\nvolumes:"));
    expect(servicesBlock).toMatch(/^\s{2}app:/m);
    expect(servicesBlock).toMatch(/^\s{2}postgres:/m);
    expect(servicesBlock).toMatch(/^\s{2}osrm:/m);
    expect(servicesBlock).toMatch(/^\s{2}nominatim:/m);
    expect(servicesBlock).not.toMatch(/^\s{2}traefik:/m);
    expect(servicesBlock).not.toMatch(/^\s{2}proxy:/m);
    // Externes Traefik-Netz.
    expect(c).toMatch(/external:\s*true/);
    // Traefik-Labels am App-Service.
    expect(c).toMatch(/traefik\.enable=true/);
    expect(c).toMatch(/Host\(`\$\{APP_HOST\}`\)/);
    expect(c).toMatch(/entrypoints=websecure/);
    expect(c).toMatch(/tls\.certresolver/);
    // Forwarded-Header / sichere Cookies via Env.
    expect(c).toMatch(/AUTH_TRUST_HOST/);
    expect(c).toMatch(/AUTH_URL/);
    // Postgres-Healthcheck.
    expect(c).toMatch(/pg_isready/);
    // App-Healthcheck gegen /api/health.
    expect(c).toMatch(/\/api\/health/);
  });

  it("docker compose config listet genau app/postgres/osrm/nominatim", () => {
    // Offline-tauglich: `docker compose config --services` braucht keine Container.
    let services: string;
    try {
      services = execFileSync(
        "docker",
        [
          "compose",
          "-f",
          "docker-compose.yml",
          "-f",
          "docker-compose.geo.yml",
          "config",
          "--services",
        ],
        { cwd: root, encoding: "utf8", env: { ...process.env, APP_HOST: "floriannetz.example.at" } },
      );
    } catch {
      // Kein Docker in der Sandbox: dieser Teilschritt ist deferred.
      return;
    }
    const set = new Set(services.split("\n").map((s) => s.trim()).filter(Boolean));
    expect(set).toEqual(new Set(["app", "postgres", "osrm", "nominatim"]));
  });

  it("Override-Beispiel macht die App lokal auf :3000 ohne TLS erreichbar", () => {
    const o = read("docker-compose.override.yml.example");
    expect(o).toMatch(/3000:3000/);
  });

  it("Makefile bietet build/up/deploy/data-Ziele", () => {
    const mk = read("Makefile");
    expect(mk).toMatch(/^build:/m);
    expect(mk).toMatch(/^up:/m);
    expect(mk).toMatch(/^deploy:/m);
    expect(mk).toMatch(/^data:/m);
  });

  it("deployment-traefik.md dokumentiert Authentik-Callback und Pflicht-Env", () => {
    const doc = read("docs/reference/deployment-traefik.md");
    expect(doc).toMatch(/https:\/\/\$\{APP_HOST\}\/api\/auth\/callback\/authentik/);
    expect(doc).toMatch(/AUTH_URL/);
    expect(doc).toMatch(/external/);
    expect(doc).toMatch(/\/api\/health/);
  });
});
