import { BrowserWindow, type BrowserWindowConstructorOptions } from "electron";
import { ChallengeRequiredError } from "../src/core/browserErrors.js";
import type { BrowserDiagnosticEvent, BrowserFetcher } from "../src/core/types.js";

export interface ElectronBrowserHubOptions {
  preload?: string;
  navTimeoutMs?: number;
  challengeClearMs?: number;
  forceSolve?: ReadonlySet<string>;
  holdForcedSolve?: boolean;
  onSolveCleared?: (storeId: string) => void;
  onSolveDismissed?: (storeId: string) => void;
}

interface BrowserFetchResult { status: number; body: string }
interface StoreWindow { window: BrowserWindow; lastStatus: number | null }
interface SolvePageState { ready: boolean; challenged: boolean }

export class ElectronBrowserHub implements BrowserFetcher {
  private readonly windows = new Map<string, StoreWindow>();
  private readonly locks = new Map<string, Promise<void>>();
  private readonly pendingSolve = new Set<string>();
  /** Store-specific proof that the challenged resource itself is usable after the page clears. */
  private readonly solveChecks = new Map<string, () => Promise<boolean>>();
  private readonly forceCleared = new Set<string>();
  private readonly navTimeoutMs: number;
  private readonly challengeClearMs: number;
  private readonly diagnosticEvents = new Map<string, BrowserDiagnosticEvent[]>();

  constructor(private readonly options: ElectronBrowserHubOptions = {}) {
    this.navTimeoutMs = options.navTimeoutMs ?? 30_000;
    this.challengeClearMs = options.challengeClearMs ?? 25_000;
  }

  async fetchJson(
    storeId: string,
    homepage: string,
    url: string,
    opts: { headers?: Record<string, string>; solvePage?: string } = {},
  ): Promise<string> {
    return this.traced(storeId, () => this.withStoreLock(storeId, async () => {
      const entry = this.storeWindow(storeId);
      if (await this.shouldForceSolve(storeId, entry, opts.solvePage ?? homepage)) throw this.challenge(storeId);
      if (!(await this.warmOrigin(entry, homepage))) {
        this.rememberJsonSolveCheck(storeId, entry, url, opts.headers ?? {});
        throw this.challenge(storeId);
      }
      const result = await this.browserFetch(entry, url, opts.headers ?? {}, true);
      if (isChallengeResponse(result.status, result.body)) {
        const solveUrl = opts.solvePage ?? homepage;
        await this.navigate(entry, solveUrl).catch(() => undefined);
        if (await this.challengeClearedWithoutUser(entry)) {
          const retry = await this.browserFetch(entry, url, opts.headers ?? {}, true);
          if (!isChallengeResponse(retry.status, retry.body)) {
            assertSuccess(storeId, url, retry.status);
            return retry.body;
          }
        }
        await this.navigate(entry, opts.solvePage ?? url).catch(() => undefined);
        this.rememberJsonSolveCheck(storeId, entry, url, opts.headers ?? {});
        throw this.challenge(storeId);
      }
      assertSuccess(storeId, url, result.status);
      return result.body;
    }));
  }

  async fetchOriginHtml(storeId: string, homepage: string, url: string): Promise<string> {
    return this.traced(storeId, () => this.withStoreLock(storeId, async () => {
      const entry = this.storeWindow(storeId);
      if (await this.shouldForceSolve(storeId, entry, url)) throw this.challenge(storeId);
      if (!(await this.warmOrigin(entry, homepage))) throw this.challenge(storeId);
      const result = await this.browserFetch(entry, url, {}, false);
      if (isChallengeResponse(result.status, result.body)) {
        await this.navigate(entry, url).catch(() => undefined);
        if (await this.challengeClearedWithoutUser(entry)) {
          const retry = await this.browserFetch(entry, url, {}, false);
          if (!isChallengeResponse(retry.status, retry.body)) {
            assertSuccess(storeId, url, retry.status);
            return retry.body;
          }
          await this.navigate(entry, url).catch(() => undefined);
        }
        throw this.challenge(storeId);
      }
      assertSuccess(storeId, url, result.status);
      return result.body;
    }));
  }

  async fetchPageHtml(
    storeId: string,
    url: string,
    opts: { waitForSelector?: string } = {},
  ): Promise<string> {
    return this.traced(storeId, () => this.withStoreLock(storeId, async () => {
      const entry = this.storeWindow(storeId);
      if (await this.shouldForceSolve(storeId, entry, url)) throw this.challenge(storeId);
      await this.navigate(entry, url);
      this.trace(storeId, "navigation_finished", entry.lastStatus ?? undefined);
      // A navigation status of 403 alone is not proof that an interactive challenge is
      // still present. Cloudflare can keep that original status after its script has
      // replaced the document with the real results page. Trust the rendered challenge
      // markers here; otherwise a normal-looking page loops through solve forever.
      if (await this.isChallenge(entry, false)) {
        this.trace(storeId, "challenge_detected", entry.lastStatus ?? undefined);
        if (!(await this.waitForChallengeClear(entry))) throw this.challenge(storeId);
        this.trace(storeId, "challenge_cleared", entry.lastStatus ?? undefined);
        // Cloudflare's successful managed check already redirects/replaces this document
        // with the requested results. Navigating to the same URL again immediately can
        // trigger a fresh check before the new clearance has settled, creating a solve loop.
      }
      let selectorFound = false;
      if (opts.waitForSelector) {
        selectorFound = await entry.window.webContents.executeJavaScript(
          `new Promise(resolve => { const done = () => resolve(true); if (document.querySelector(${JSON.stringify(opts.waitForSelector)})) return done(); const observer = new MutationObserver(() => { if (document.querySelector(${JSON.stringify(opts.waitForSelector)})) { observer.disconnect(); done(); } }); observer.observe(document.documentElement, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); resolve(false); }, 5000); })`,
          true,
        ).catch(() => false);
        this.trace(storeId, selectorFound ? "selector_found" : "selector_missing", entry.lastStatus ?? undefined);
      }
      if (entry.lastStatus === 403 && !selectorFound) {
        throw new Error(`${storeId}: browser navigation HTTP 403 without an interactive challenge or result marker for ${url}`);
      }
      const html = await entry.window.webContents.executeJavaScript("document.documentElement.outerHTML", true);
      this.trace(storeId, "response", entry.lastStatus ?? 200, "complete");
      return html;
    }));
  }

  takeDiagnostics(storeId: string): BrowserDiagnosticEvent[] {
    const events = this.diagnosticEvents.get(storeId) ?? [];
    this.diagnosticEvents.delete(storeId);
    return events;
  }

  private async traced<T>(storeId: string, operation: () => Promise<T>): Promise<T> {
    this.diagnosticEvents.set(storeId, []);
    this.trace(storeId, "operation_started");
    try {
      const result = await operation();
      if (!(this.diagnosticEvents.get(storeId) ?? []).some((event) => event.event === "response")) {
        this.trace(storeId, "response", 200, "complete");
      }
      return result;
    } catch (error) {
      this.trace(storeId, "error", undefined, error instanceof Error ? error.message : String(error));
      throw error;
    }
  }

  private trace(storeId: string, event: BrowserDiagnosticEvent["event"], status?: number, detail?: string): void {
    const events = this.diagnosticEvents.get(storeId) ?? [];
    if (events.length >= 30) return;
    events.push({
      at: Date.now(),
      event,
      ...(status ? { status } : {}),
      ...(detail ? { detail: detail.replace(/https?:\/\/\S+/gi, "[url removed]").slice(0, 120) } : {}),
    });
    this.diagnosticEvents.set(storeId, events);
  }

  async showSolve(storeId: string): Promise<void> {
    const entry = this.windows.get(storeId);
    if (!entry || !this.pendingSolve.has(storeId)) throw new Error(`${storeId}: no challenge window is waiting`);
    const heldForcedSolve = this.options.forceSolve?.has(storeId) && this.options.holdForcedSolve;
    if (this.options.forceSolve?.has(storeId) && !heldForcedSolve) {
      this.forceCleared.add(storeId);
    }
    // Do not pre-emptively declare success from a single hidden-page sample. During a
    // Cloudflare navigation there can be a brief loaded-looking gap before its widget is
    // attached; treating that gap as cleared made this window disappear immediately.
    entry.window.show();
    entry.window.focus();
    void this.monitorVisibleSolve(storeId, entry);
  }

  dismissSolve(storeId: string): boolean {
    const window = this.windows.get(storeId)?.window;
    if (!window?.isVisible()) return false;
    window.close();
    return true;
  }

  async close(): Promise<void> {
    for (const { window } of this.windows.values()) window.destroy();
    this.windows.clear();
    this.pendingSolve.clear();
    this.solveChecks.clear();
  }

  private storeWindow(storeId: string): StoreWindow {
    const existing = this.windows.get(storeId);
    if (existing && !existing.window.isDestroyed()) return existing;
    const webPreferences: BrowserWindowConstructorOptions["webPreferences"] = {
      partition: `persist:store-${safeStoreId(storeId)}`,
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
      // Managed challenges execute timers/scripts while the store window is hidden. If
      // Electron throttles that page, the check stalls until showSolve() makes it visible,
      // causing a one-second challenge flash even when no human action is required.
      backgroundThrottling: false,
    };
    const window = new BrowserWindow({
      show: false,
      width: 1100,
      height: 800,
      title: `Store verification — ${storeId}`,
      autoHideMenuBar: true,
      webPreferences,
    });
    window.webContents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false));
    window.on("close", (event) => {
      if (this.windows.get(storeId)?.window === window) {
        event.preventDefault();
        window.hide();
        if (this.pendingSolve.has(storeId)) this.options.onSolveDismissed?.(storeId);
      }
    });
    const entry: StoreWindow = { window, lastStatus: null };
    window.webContents.on("did-navigate", (_event, _url, responseCode) => { entry.lastStatus = responseCode; });
    this.windows.set(storeId, entry);
    return entry;
  }

  private async navigate(entry: StoreWindow, url: string): Promise<void> {
    entry.lastStatus = null;
    const navigation = entry.window.loadURL(url);
    const timeout = new Promise<never>((_resolve, reject) => {
      const timer = setTimeout(() => {
        entry.window.webContents.stop();
        reject(new Error(`navigation timed out after ${this.navTimeoutMs}ms: ${url}`));
      }, this.navTimeoutMs);
      navigation.finally(() => clearTimeout(timer)).catch(() => undefined);
    });
    await Promise.race([navigation, timeout]);
  }

  private async warmOrigin(entry: StoreWindow, homepage: string): Promise<boolean> {
    const origin = new URL(homepage).origin;
    if (entry.window.webContents.getURL().startsWith(origin)) return !(await this.isChallenge(entry));
    await this.navigate(entry, homepage);
    if (!(await this.isChallenge(entry))) return true;
    return this.waitForChallengeClear(entry);
  }

  private async browserFetch(entry: StoreWindow, url: string, headers: Record<string, string>, json: boolean): Promise<BrowserFetchResult> {
    const script = `(async () => { const response = await fetch(${JSON.stringify(url)}, { credentials: "include", headers: ${JSON.stringify(json ? { Accept: "application/json", "X-Requested-With": "XMLHttpRequest", ...headers } : headers)} }); return { status: response.status, body: await response.text() }; })()`;
    return entry.window.webContents.executeJavaScript(script, true) as Promise<BrowserFetchResult>;
  }

  private async isChallenge(entry: StoreWindow, includeNavigationStatus = true): Promise<boolean> {
    if (includeNavigationStatus && entry.lastStatus === 403) return true;
    return (await this.solvePageState(entry)).challenged;
  }

  private async solvePageState(entry: StoreWindow): Promise<SolvePageState> {
    if (entry.window.webContents.isLoadingMainFrame()) return { ready: false, challenged: true };
    return entry.window.webContents.executeJavaScript(
      `(() => {
        const text = (document.title + " " + (document.body?.innerText ?? "").slice(0, 8000)).toLowerCase();
        const marker = [...document.querySelectorAll('#challenge-platform,.challenge-platform,.cf-challenge,[class*=cf-chl],[id*=cf-chl],iframe[src*="challenges.cloudflare.com"],iframe[src*=turnstile]')]
          .some(element => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; });
        return { ready: document.readyState === 'complete', challenged: text.includes('just a moment') || text.includes('verify you are human') || marker };
      })()`,
      true,
    ).catch(() => ({ ready: false, challenged: true })) as Promise<SolvePageState>;
  }

  private async waitForChallengeClear(entry: StoreWindow): Promise<boolean> {
    const deadline = Date.now() + this.challengeClearMs;
    while (Date.now() < deadline) {
      await delay(750);
      if (!(await this.isChallenge(entry, false))) return true;
    }
    return false;
  }

  private async challengeClearedWithoutUser(entry: StoreWindow): Promise<boolean> {
    if (!(await this.isChallenge(entry))) return true;
    return this.waitForChallengeClear(entry);
  }

  private challenge(storeId: string): ChallengeRequiredError {
    this.pendingSolve.add(storeId);
    return new ChallengeRequiredError(storeId, `app://solve/${encodeURIComponent(storeId)}`);
  }

  private rememberJsonSolveCheck(
    storeId: string,
    entry: StoreWindow,
    url: string,
    headers: Record<string, string>,
  ): void {
    let validEmptySamples = 0;
    this.solveChecks.set(storeId, async () => {
      const result = await this.browserFetch(entry, url, headers, true).catch(() => null);
      if (!result || isChallengeResponse(result.status, result.body)) return false;
      if (result.status < 200 || result.status >= 300) return false;
      if (storeId !== "trodo") return true;

      // Trodo can briefly return its valid envelope with no products while the new
      // clearance/session is settling. A product-bearing response is immediately usable;
      // two consecutive valid empty envelopes are accepted as a genuine no-match search.
      const products = trodoProducts(result.body);
      if (!products) return false;
      if (products.length > 0) return true;
      validEmptySamples++;
      return validEmptySamples >= 2;
    });
  }

  private async shouldForceSolve(storeId: string, entry: StoreWindow, url: string): Promise<boolean> {
    if (!this.options.forceSolve?.has(storeId) || this.forceCleared.has(storeId)) return false;
    await this.navigate(entry, url).catch(() => undefined);
    return true;
  }

  private async monitorVisibleSolve(storeId: string, entry: StoreWindow): Promise<void> {
    let clearSamples = 0;
    while (this.pendingSolve.has(storeId) && !entry.window.isDestroyed() && entry.window.isVisible()) {
      const heldForcedSolve = this.options.holdForcedSolve &&
        this.options.forceSolve?.has(storeId) &&
        !this.forceCleared.has(storeId);
      const forcedClear = this.forceCleared.has(storeId);
      const state = forcedClear ? { ready: true, challenged: false } : await this.solvePageState(entry);
      clearSamples = !heldForcedSolve && state.ready && !state.challenged ? clearSamples + 1 : 0;
      // Two consecutive settled samples prevent a transient loading gap from closing the
      // verification window and retrying Trodo before its clearance/session is usable.
      if (clearSamples >= 2) {
        const check = this.solveChecks.get(storeId);
        const resourceReady = forcedClear || !check || await check();
        if (!resourceReady) {
          clearSamples = 1;
          await delay(750);
          continue;
        }
        this.pendingSolve.delete(storeId);
        this.solveChecks.delete(storeId);
        entry.window.hide();
        this.options.onSolveCleared?.(storeId);
        return;
      }
      await delay(750);
    }
  }

  private async withStoreLock<T>(storeId: string, run: () => Promise<T>): Promise<T> {
    const previous = this.locks.get(storeId) ?? Promise.resolve();
    let release!: () => void;
    this.locks.set(storeId, new Promise<void>((resolve) => { release = resolve; }));
    await previous;
    try { return await run(); } finally { release(); }
  }
}

function safeStoreId(storeId: string): string {
  if (!/^[a-z0-9_-]+$/i.test(storeId)) throw new Error("invalid store id");
  return storeId;
}

function assertSuccess(storeId: string, url: string, status: number): void {
  if (status < 200 || status >= 300) throw new Error(`${storeId}: browser fetch HTTP ${status} for ${url}`);
}

function isChallengeResponse(status: number, body: string): boolean {
  return status === 403 || /just a moment|challenge-platform|cf-mitigated|cf_chl/i.test(body.slice(0, 4000));
}

function trodoProducts(body: string): unknown[] | null {
  try {
    const envelope = JSON.parse(body) as Array<{ products?: unknown[] }>;
    return Array.isArray(envelope?.[0]?.products) ? envelope[0].products : null;
  } catch {
    return null;
  }
}

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