// src/lib/semantic/render.ts — RenderProvider: Chrome local piloté en CDP brut
// (zéro dépendance npm — Bun.spawn + WebSocket natif). Utilisé par le pipeline
// sémantique (S7) pour rendre le JS d'une page quand l'analyse mobile-first
// (fetch.ts) ne suffit pas. Toute défaillance (Chrome absent, timeout, crash,
// parsing CDP) dégrade en `null` — jamais de throw.

import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MOBILE_UA } from "./fetch";

export type RenderResult = { html: string; finalUrl: string } | null;
export type RenderProvider = (url: string) => Promise<RenderResult>;

/** Cap global pour un rendu complet (spawn + navigation + hydratation + extraction). */
const RENDER_TIMEOUT_MS = 20_000;
/** Délai fixe laissé au JS client pour hydrater après `Page.loadEventFired`. */
const HYDRATION_DELAY_MS = 1_500;

const MACOS_CANDIDATES = [
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
];
const LINUX_CANDIDATES = [
  "/usr/bin/google-chrome",
  "/usr/bin/chromium",
  "/usr/bin/chromium-browser",
];

function candidatePaths(platform: NodeJS.Platform): string[] {
  if (platform === "darwin") return MACOS_CANDIDATES;
  if (platform === "linux") return LINUX_CANDIDATES;
  return [];
}

/**
 * Résout le provider de rendu selon l'environnement : `CHROME_PATH` explicite
 * (prioritaire — pas de fallback auto-détecté si le chemin donné n'existe
 * pas), sinon auto-détection par plateforme (macOS/Linux), sinon `null`
 * (dégradation sans rendu, le pipeline retombe sur le fetch mobile-first).
 *
 * `deps.exists`/`deps.platform` sont injectables pour les tests.
 */
export function resolveRenderProvider(
  env: { chromePath?: string } = {},
  deps: {
    exists?: (path: string) => boolean;
    platform?: NodeJS.Platform;
  } = {},
): RenderProvider | null {
  const exists = deps.exists ?? existsSync;
  const platform = deps.platform ?? process.platform;
  const explicit = env.chromePath ?? process.env.CHROME_PATH;

  if (explicit) {
    return exists(explicit)
      ? (url: string) => chromeLocalRender(explicit, url)
      : null;
  }

  for (const candidate of candidatePaths(platform)) {
    if (exists(candidate)) {
      return (url: string) => chromeLocalRender(candidate, url);
    }
  }

  return null;
}

/**
 * Parse la ligne stderr émise par Chrome au démarrage :
 * `DevTools listening on ws://127.0.0.1:PORT/devtools/browser/<uuid>`.
 * Renvoie l'URL WebSocket du navigateur, ou `null` si absente du chunk.
 */
export function parseDevtoolsWsUrl(chunk: string): string | null {
  const match = chunk.match(/DevTools listening on (ws:\/\/\S+)/);
  return match ? match[1] : null;
}

interface CdpMessage {
  id?: number;
  method?: string;
  sessionId?: string;
  result?: Record<string, unknown>;
  error?: { message: string };
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error("render timeout")), ms);
    promise.then(
      (value) => {
        clearTimeout(timer);
        resolve(value);
      },
      (err) => {
        clearTimeout(timer);
        reject(err instanceof Error ? err : new Error(String(err)));
      },
    );
  });
}

/**
 * Lit le stderr du process Chrome jusqu'à trouver la ligne DevTools, puis
 * continue de le drainer en tâche de fond (sans backpressure) pour ne
 * jamais bloquer Chrome sur un pipe plein.
 */
async function readDevtoolsWsUrl(
  stderr: ReadableStream<Uint8Array> | number | undefined,
): Promise<string | null> {
  if (!(stderr instanceof ReadableStream)) return null;
  const reader = stderr.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  let found: string | null = null;

  while (!found) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    found = parseDevtoolsWsUrl(buf);
  }

  void (async () => {
    try {
      while (true) {
        const { done } = await reader.read();
        if (done) break;
      }
    } catch {
      // process tué / stream fermé — rien à faire
    }
  })();

  return found;
}

function waitForOpen(ws: WebSocket): Promise<void> {
  return new Promise((resolve, reject) => {
    ws.addEventListener("open", () => resolve(), { once: true });
    ws.addEventListener("error", () => reject(new Error("ws error")), {
      once: true,
    });
  });
}

/**
 * Rend `url` avec Chrome headless piloté en CDP brut : spawn éphémère
 * (profil temporaire, port de debug aléatoire) → connexion WebSocket au
 * navigateur → création d'une target dédiée (`Target.createTarget` +
 * `Target.attachToTarget`, protocole flatten — évite la course entre le
 * chargement initial de la page et l'attachement de la session que le flag
 * `--app=<url>` provoquerait) → `Page.navigate` → attente de
 * `Page.loadEventFired` + délai fixe d'hydratation → extraction de
 * `outerHTML` et `location.href` via `Runtime.evaluate`.
 *
 * Nettoyage inconditionnel en `finally` : fermeture du WebSocket, kill du
 * process Chrome, suppression du profil temporaire. Cap global
 * `RENDER_TIMEOUT_MS` : au-delà, kill + `null`. Toute erreur → `null`,
 * jamais de throw.
 */
export async function chromeLocalRender(
  chromePath: string,
  url: string,
): Promise<RenderResult> {
  // Regroupées dans un objet mutable (plutôt que des `let` séparés) pour que
  // le `finally` lise le type déclaré et non un type resserré à `null` par
  // l'analyse de flux — les affectations ont lieu dans la closure `work`.
  const state: {
    proc: ReturnType<typeof Bun.spawn> | null;
    tmpDir: string | null;
    ws: WebSocket | null;
  } = { proc: null, tmpDir: null, ws: null };

  const work = async (): Promise<RenderResult> => {
    state.tmpDir = mkdtempSync(join(tmpdir(), "seo-insight-render-"));

    const proc = Bun.spawn(
      [
        chromePath,
        "--headless=new",
        "--remote-debugging-port=0",
        `--user-agent=${MOBILE_UA}`,
        `--user-data-dir=${state.tmpDir}`,
        "--disable-gpu",
        "--disable-extensions",
        "--disable-background-networking",
        "--disable-sync",
        "--disable-translate",
        "--disable-dev-shm-usage",
        "--no-first-run",
        "--no-default-browser-check",
        "--mute-audio",
        "--hide-scrollbars",
      ],
      { stderr: "pipe", stdout: "ignore" },
    );
    state.proc = proc;

    const wsUrl = await readDevtoolsWsUrl(proc.stderr);
    if (!wsUrl) return null;

    const ws = new WebSocket(wsUrl);
    state.ws = ws;
    await waitForOpen(ws);

    const pending = new Map<
      number,
      {
        resolve: (v: Record<string, unknown>) => void;
        reject: (e: Error) => void;
      }
    >();
    let nextId = 0;
    let onLoad: (() => void) | null = null;

    ws.addEventListener("message", (ev: MessageEvent) => {
      let msg: CdpMessage;
      try {
        msg = JSON.parse(String(ev.data));
      } catch {
        return;
      }
      if (typeof msg.id === "number") {
        const p = pending.get(msg.id);
        if (!p) return;
        pending.delete(msg.id);
        if (msg.error) p.reject(new Error(msg.error.message));
        else p.resolve(msg.result ?? {});
      } else if (msg.method === "Page.loadEventFired") {
        onLoad?.();
      }
    });

    const send = (
      method: string,
      params: Record<string, unknown> = {},
      sessionId?: string,
    ): Promise<Record<string, unknown>> => {
      const id = ++nextId;
      const payload: Record<string, unknown> = { id, method, params };
      if (sessionId) payload.sessionId = sessionId;
      return new Promise((resolve, reject) => {
        pending.set(id, { resolve, reject });
        try {
          ws.send(JSON.stringify(payload));
        } catch (err) {
          pending.delete(id);
          reject(err instanceof Error ? err : new Error(String(err)));
        }
      });
    };

    const created = await send("Target.createTarget", { url: "about:blank" });
    const targetId = created.targetId;
    if (typeof targetId !== "string") return null;

    const attached = await send("Target.attachToTarget", {
      targetId,
      flatten: true,
    });
    const sessionId = attached.sessionId;
    if (typeof sessionId !== "string") return null;

    await send("Page.enable", {}, sessionId);

    const loadEventFired = new Promise<void>((resolve) => {
      onLoad = resolve;
    });
    await send("Page.navigate", { url }, sessionId);
    await loadEventFired;

    await sleep(HYDRATION_DELAY_MS);

    const htmlEval = await send(
      "Runtime.evaluate",
      { expression: "document.documentElement.outerHTML", returnByValue: true },
      sessionId,
    );
    const urlEval = await send(
      "Runtime.evaluate",
      { expression: "location.href", returnByValue: true },
      sessionId,
    );

    const html = isRecord(htmlEval.result) ? htmlEval.result.value : undefined;
    const finalUrl = isRecord(urlEval.result)
      ? urlEval.result.value
      : undefined;
    if (typeof html !== "string" || typeof finalUrl !== "string") return null;

    return { html, finalUrl };
  };

  try {
    return await withTimeout(work(), RENDER_TIMEOUT_MS);
  } catch {
    return null;
  } finally {
    try {
      state.ws?.close();
    } catch {
      // ignore
    }
    try {
      state.proc?.kill("SIGKILL");
    } catch {
      // ignore
    }
    if (state.tmpDir) {
      try {
        rmSync(state.tmpDir, { recursive: true, force: true });
      } catch {
        // ignore
      }
    }
  }
}
