/**
 * BrowserHub — the seam between onDemand ("walled") adapters and the resident
 * headful Chrome running in the Docker container (see WALLED-STORES.md).
 *
 * It connects to that Chrome over CDP (puppeteer-core `connect`, no browser is
 * launched here) and fetches a store's API *through* a page already on the store's
 * origin — so the request carries the human-earned `cf_clearance` cookie, the real
 * browser fingerprint, and the host's IP, all consistent. We deliberately do NOT
 * extract the cookie and replay it with undici: Cloudflare would re-challenge on the
 * TLS/JA3 mismatch.
 *
 * When the page is on a Cloudflare challenge (cold session), fetching throws
 * ChallengeRequiredError(solveUrl); SearchService turns that into a `needsSolve`
 * outcome and the UI opens the noVNC stream for the user to solve it once.
 *
 * NOTE: this module is only exercised when an onDemand adapter is enabled AND the
 * browser service is reachable. With no such adapter enabled (default), it is never
 * touched and the 5-store core runs without Chrome.
 */
import puppeteer, { type Browser, type Page } from "puppeteer-core";
import { ChallengeRequiredError } from "./browserErrors.js";
import type { BrowserFetcher } from "./types.js";

export interface BrowserHubOptions {
  /** CDP endpoint of the resident Chrome, e.g. http://127.0.0.1:9222 */
  browserURL: string;
  /** noVNC page the UI opens when a challenge must be solved. */
  solveUrl: string;
  /** Budget for a page navigation (homepage warm-up / challenge check), ms. */
  navTimeoutMs: number;
  /**
   * How long to give Cloudflare's own (non-interactive) challenge script to clear
   * itself after a navigation lands on an interstitial, before we conclude a human
   * is needed. A warm headful browser on a residential IP usually passes managed
   * challenges without any interaction — observed live on autodoc.lv 2026-07-12.
   */
  challengeClearMs: number;
  /**
   * TEST SWITCH (empty in normal operation): store ids treated as if they hit a
   * Cloudflare challenge every time — the fetch lands the tab on the store's
   * human-facing page and throws ChallengeRequiredError. Lets us exercise the noVNC
   * solve overlay end-to-end (kiosk view, bring-to-front, resize-to-fit on a phone)
   * WITHOUT a real challenge, which a warm residential browser clears
   * non-interactively and so can't be provoked on demand.
   */
  forceSolve: ReadonlySet<string>;
}

interface BrowserFetch {
  status: number;
  body: string;
}

export class BrowserHub implements BrowserFetcher {
  private browser: Browser | null = null;
  /**
   * Tabs deliberately kept open because a human still has to solve a challenge on them,
   * keyed by store. Every OTHER tab is closed the moment its fetch finishes, so an idle
   * hub holds no tabs (just Chrome's own about:blank) instead of one-per-store forever.
   * The session itself (cf_clearance and friends) lives in the on-disk profile, not the
   * tab, so closing tabs costs nothing but a re-navigation on the next fetch.
   */
  private solvePages = new Map<string, Page>();
  /** Tail of each store's fetch queue — same-store fetches are serialized (see withStoreLock). */
  private locks = new Map<string, Promise<void>>();

  constructor(private readonly opts: BrowserHubOptions) {}

  private forcedSolve(storeId: string): boolean {
    return this.opts.forceSolve.has(storeId);
  }

  /**
   * Serialize fetches per store: two concurrent fetches for the same store would
   * each open a tab, and if both ended in "keep for solve" the second would
   * overwrite the solvePages entry and leak the first tab.
   */
  private async withStoreLock<T>(storeId: string, fn: () => Promise<T>): Promise<T> {
    const prev = this.locks.get(storeId) ?? Promise.resolve();
    let release!: () => void;
    this.locks.set(
      storeId,
      new Promise((r) => (release = r)),
    );
    await prev;
    try {
      return await fn();
    } finally {
      release();
    }
  }

  private async connect(): Promise<Browser> {
    if (this.browser?.connected) return this.browser;
    // Throws if the container/Chrome isn't running — the caller surfaces "unavailable".
    this.browser = await puppeteer.connect({ browserURL: this.opts.browserURL });
    return this.browser;
  }

  /**
   * A tab for this fetch: reuse the one we left open for this store's pending solve (the
   * human may have just cleared it, so its warm state is exactly what we want to retry on),
   * otherwise open a fresh one.
   */
  private async acquire(storeId: string): Promise<Page> {
    const pending = this.solvePages.get(storeId);
    this.solvePages.delete(storeId);
    if (pending && !pending.isClosed()) return pending;
    const browser = await this.connect();
    return browser.newPage();
  }

  /**
   * Done with the tab: close it so idle memory stays flat — UNLESS a human still needs to
   * solve a challenge on it, in which case keep it and bring it to the FRONT so the noVNC
   * stream shows THIS store's page, not whichever tab happened to be active last.
   */
  private async release(storeId: string, page: Page, keepForSolve: boolean): Promise<void> {
    if (page.isClosed()) return;
    if (keepForSolve) {
      this.solvePages.set(storeId, page);
      await page.bringToFront().catch(() => {});
      return;
    }
    await page.close().catch(() => {});
  }

  /**
   * Land the tab on the store's origin so an evaluate-fetch is same-origin and the session
   * cookie rides it. Returns false if warming hit a Cloudflare challenge.
   */
  private async warmOrigin(page: Page, homepage: string): Promise<boolean> {
    const origin = new URL(homepage).origin;
    if (page.url().startsWith(origin)) return true;
    const resp = await page.goto(homepage, {
      waitUntil: "domcontentloaded",
      timeout: this.opts.navTimeoutMs,
    });
    return !(await isChallengePage(page, resp?.status() ?? null));
  }

  /**
   * Fetch a same-origin API URL through the store's page. Returns the raw body
   * (JSON text) for the adapter's pure parse(). Throws ChallengeRequiredError if the
   * session went cold, or a plain Error on a non-2xx API response.
   *
   * `opts.solvePage` is a human-facing URL that renders the store's interactive
   * challenge widget (e.g. the results page, not the raw API). When the API fetch
   * comes back challenged, we navigate the resident browser there BEFORE throwing —
   * so when the user opens the noVNC stream the widget is on screen to solve. Without
   * this the browser would still be sitting on the (un-challenged) homepage.
   */
  async fetchJson(
    storeId: string,
    homepage: string,
    url: string,
    opts: { headers?: Record<string, string>; solvePage?: string } = {},
  ): Promise<string> {
    return this.withStoreLock(storeId, () => this.fetchJsonLocked(storeId, homepage, url, opts));
  }

  private async fetchJsonLocked(
    storeId: string,
    homepage: string,
    url: string,
    opts: { headers?: Record<string, string>; solvePage?: string },
  ): Promise<string> {
    const headers = opts.headers ?? {};
    const page = await this.acquire(storeId);
    let keepForSolve = false;
    try {
      if (this.forcedSolve(storeId)) {
        await page
          .goto(opts.solvePage ?? homepage, {
            waitUntil: "domcontentloaded",
            timeout: this.opts.navTimeoutMs,
          })
          .catch(() => undefined);
        keepForSolve = true;
        throw new ChallengeRequiredError(storeId, this.opts.solveUrl);
      }
      if (!(await this.warmOrigin(page, homepage))) {
        keepForSolve = true;
        throw new ChallengeRequiredError(storeId, this.opts.solveUrl);
      }
      const result = (await page.evaluate(
        async (u: string, h: Record<string, string>) => {
          const r = await fetch(u, {
            credentials: "include",
            headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest", ...h },
          });
          return { status: r.status, body: await r.text() };
        },
        url,
        headers,
      )) as BrowserFetch;

      if (isChallengeResponse(result.status, result.body)) {
        if (opts.solvePage) {
          // Best-effort: surface the challenge widget in the stream. A challenge here is
          // expected, so ignore its own load status/timeout — we throw regardless.
          await page
            .goto(opts.solvePage, {
              waitUntil: "domcontentloaded",
              timeout: this.opts.navTimeoutMs,
            })
            .catch(() => undefined);
        }
        keepForSolve = true;
        throw new ChallengeRequiredError(storeId, this.opts.solveUrl);
      }
      if (result.status < 200 || result.status >= 300) {
        throw new Error(`${storeId}: browser fetch HTTP ${result.status} for ${url}`);
      }
      return result.body;
    } finally {
      await this.release(storeId, page, keepForSolve);
    }
  }

  /**
   * Fetch a server-rendered store page by SAME-ORIGIN fetch through the store's resident
   * page (like fetchJson, but returns HTML for a cheerio parse). Used by stores that are
   * NOT Cloudflare-walled but hand prices only to a warm session — ic24 (Inter Cars)
   * renders prices into the search HTML only once the anonymous session/token from its
   * homepage exists. `warmOrigin` lands the tab on that origin first, so the session cookie
   * rides the fetch. No auto-clear loop: there's no managed challenge to wait out here; a 403/
   * challenge (should it ever appear) surfaces as needsSolve like any walled store.
   */
  async fetchOriginHtml(storeId: string, homepage: string, url: string): Promise<string> {
    return this.withStoreLock(storeId, () => this.fetchOriginHtmlLocked(storeId, homepage, url));
  }

  private async fetchOriginHtmlLocked(
    storeId: string,
    homepage: string,
    url: string,
  ): Promise<string> {
    const page = await this.acquire(storeId);
    let keepForSolve = false;
    try {
      if (this.forcedSolve(storeId)) {
        await page
          .goto(url, { waitUntil: "domcontentloaded", timeout: this.opts.navTimeoutMs })
          .catch(() => undefined);
        keepForSolve = true;
        throw new ChallengeRequiredError(storeId, this.opts.solveUrl);
      }
      if (!(await this.warmOrigin(page, homepage))) {
        keepForSolve = true;
        throw new ChallengeRequiredError(storeId, this.opts.solveUrl);
      }
      const result = (await page.evaluate(async (u: string) => {
        const r = await fetch(u, { credentials: "include" });
        return { status: r.status, body: await r.text() };
      }, url)) as BrowserFetch;

      if (isChallengeResponse(result.status, result.body)) {
        keepForSolve = true;
        throw new ChallengeRequiredError(storeId, this.opts.solveUrl);
      }
      if (result.status < 200 || result.status >= 300) {
        throw new Error(`${storeId}: browser fetch HTTP ${result.status} for ${url}`);
      }
      return result.body;
    } finally {
      await this.release(storeId, page, keepForSolve);
    }
  }

  /**
   * Fetch a server-rendered store page by NAVIGATING the store's resident page to it
   * (rd24 / euautodalas / autodoc — HTML stores, unlike trodo's JSON API). Returns the
   * serialized DOM for the adapter's pure parse().
   *
   * Challenge handling: a navigation that lands on a Cloudflare interstitial is given
   * `challengeClearMs` to clear itself — managed challenges usually pass non-interactively
   * in this warm headful browser (no evasion: it's Cloudflare's own script running to
   * completion in a real browser). Only if it doesn't clear do we throw
   * ChallengeRequiredError — and the page is already sitting on the challenge widget,
   * so the noVNC stream shows exactly what the user must solve. No solvePage needed.
   */
  async fetchPageHtml(
    storeId: string,
    url: string,
    opts: { waitForSelector?: string } = {},
  ): Promise<string> {
    return this.withStoreLock(storeId, () => this.fetchPageHtmlLocked(storeId, url, opts));
  }

  private async fetchPageHtmlLocked(
    storeId: string,
    url: string,
    opts: { waitForSelector?: string },
  ): Promise<string> {
    const page = await this.acquire(storeId);
    let keepForSolve = false;
    try {
      if (this.forcedSolve(storeId)) {
        await page
          .goto(url, { waitUntil: "domcontentloaded", timeout: this.opts.navTimeoutMs })
          .catch(() => undefined);
        keepForSolve = true;
        throw new ChallengeRequiredError(storeId, this.opts.solveUrl);
      }
      let resp = await page.goto(url, {
        waitUntil: "domcontentloaded",
        timeout: this.opts.navTimeoutMs,
      });
      if (await isChallengePage(page, resp?.status() ?? null)) {
        const cleared = await this.waitForChallengeClear(page);
        if (!cleared) {
          keepForSolve = true;
          throw new ChallengeRequiredError(storeId, this.opts.solveUrl);
        }
        // Re-navigate once with the fresh cf_clearance so the DOM we serialize is the
        // real results page, not whatever state the challenge reload left behind.
        resp = await page.goto(url, {
          waitUntil: "domcontentloaded",
          timeout: this.opts.navTimeoutMs,
        });
        if (await isChallengePage(page, resp?.status() ?? null)) {
          keepForSolve = true;
          throw new ChallengeRequiredError(storeId, this.opts.solveUrl);
        }
      }
      if (opts.waitForSelector) {
        await page.waitForSelector(opts.waitForSelector, { timeout: 5000 }).catch(() => null);
      }
      return await page.content();
    } finally {
      await this.release(storeId, page, keepForSolve);
    }
  }

  /** Poll for Cloudflare's script clearing itself (title leaves "Just a moment…"). */
  private async waitForChallengeClear(page: Page): Promise<boolean> {
    const deadline = Date.now() + this.opts.challengeClearMs;
    while (Date.now() < deadline) {
      await new Promise((r) => setTimeout(r, 1000));
      const title = await page.title().catch(() => "");
      if (title && !/just a moment/i.test(title)) return true;
    }
    return false;
  }

  async close(): Promise<void> {
    // disconnect() detaches CDP without killing the resident Chrome.
    await this.browser?.disconnect();
    this.browser = null;
    this.solvePages.clear();
  }
}

/** A Cloudflare interstitial reached by navigation (403 or the challenge markup). */
async function isChallengePage(page: Page, status: number | null): Promise<boolean> {
  if (status === 403) return true;
  const title = await page.title().catch(() => "");
  return /just a moment/i.test(title);
}

/** A Cloudflare interstitial reached by fetch() (challenge HTML instead of JSON). */
function isChallengeResponse(status: number, body: string): boolean {
  if (status === 403) return true;
  return /just a moment|challenge-platform|cf-mitigated|cf_chl/i.test(body.slice(0, 4000));
}
