import { Builder, By, until, type WebDriver } from 'selenium-webdriver';
import { Options as FirefoxOptions } from 'selenium-webdriver/firefox.js';
import * as cheerio from 'cheerio';
import pLimit from 'p-limit';
import pRetry from 'p-retry';
import path from 'path';
import type { AppConfig, NovelDefinition, StealthConfig } from '../config/types.ts';
import type { Cache } from './cache.ts';
import type { ChapterView, TextFragment, Style } from '../types.ts';
import { analyzeTocPage, formatDiagnosis } from './analyzer.ts';

export type DriverFactory = () => Promise<WebDriver>;

async function executeStealthSteps(driver: WebDriver, stealth: StealthConfig): Promise<void> {
  if (stealth.steps && stealth.steps.length > 0) {
    for (const step of stealth.steps) {
      switch (step.type) {
        case 'waitMs':
          await driver.sleep(step.ms);
          break;
        case 'waitForElement':
          await driver.wait(until.elementLocated(By.css(step.selector)), step.timeoutMs ?? 10_000);
          break;
        case 'click': {
          const el = await driver.findElement(By.css(step.selector));
          await el.click();
          break;
        }
        case 'scrollToBottom':
          await driver.executeScript('window.scrollTo(0, document.body.scrollHeight)');
          break;
      }
    }
  } else if (stealth.waitMs != null) {
    await driver.sleep(stealth.waitMs);
  }
}

function buildDefaultDriverFactory(def: NovelDefinition, appConfig: AppConfig): DriverFactory {
  return async () => {
    const options = new FirefoxOptions();

    for (const addonName of (def.stealth?.addons ?? [])) {
      const addonPath = path.resolve(appConfig.addons.directory, addonName);
      options.addExtensions(addonPath);
    }

    return new Builder()
      .usingServer(appConfig.selenium.remoteUrl)
      .forBrowser('firefox')
      .setFirefoxOptions(options)
      .build();
  };
}

export async function fetchToc(
  def: NovelDefinition,
  id: number,
  cache: Cache,
  appConfig: AppConfig,
  driverFactory?: DriverFactory,
): Promise<string[]> {
  const cached = cache.tryGetToc(id);
  if (cached) return cached;

  const sourceUrl = def.source.url;
  if (!sourceUrl) throw new Error(`Novel "${def.slug}" is missing source.url for TOC fetch`);

  const tocCfg = def.selectors?.toc;
  if (!tocCfg) throw new Error(`Novel "${def.slug}" is missing selectors.toc`);

  if (!def.stealth?.enabled) {
    throw new Error(
      `Novel "${def.slug}" requires stealth: true — the TOC page cannot be fetched without a real browser and uBlock`,
    );
  }

  const createDriver = driverFactory ?? buildDefaultDriverFactory(def, appConfig);
  const driver = await createDriver();

  // Captured on every attempt so the analyzer always has the most recent page.
  let lastHtml = '';
  let lastActualUrl: string | undefined;

  try {
    const urls = await pRetry(
      async () => {
        await driver.get(sourceUrl);

        // Capture page immediately after navigation so the analyzer always has
        // something even if a later step (e.g. waitForElement) times out.
        lastHtml = (await driver.executeScript(
          'return document.documentElement.outerHTML',
        )) as string;
        lastActualUrl = (await driver.executeScript(
          'return window.location.href',
        )) as string;

        await executeStealthSteps(driver, def.stealth!);

        // Re-capture after steps complete — dynamic content should now be present.
        lastHtml = (await driver.executeScript(
          'return document.documentElement.outerHTML',
        )) as string;
        lastActualUrl = (await driver.executeScript(
          'return window.location.href',
        )) as string;

        const $ = cheerio.load(lastHtml);
        const container = $(tocCfg.scope);

        if (!container.length) {
          throw new Error(`TOC container "${tocCfg.scope}" not found`);
        }

        const pattern = new RegExp(tocCfg.linkPattern);
        const found: string[] = [];
        container.find('a').each((_, el) => {
          const href = $(el).attr('href');
          if (href && pattern.test(href)) found.push(href);
        });

        if (found.length === 0) {
          throw new Error(
            `No links matching "${tocCfg.linkPattern}" found within "${tocCfg.scope}"`,
          );
        }

        return found;
      },
      {
        retries: 3,
        onFailedAttempt: err => {
          // p-retry passes a plain frozen context {error, attemptNumber, retriesLeft, ...}
          // NOT an Error subclass, so err.message is always undefined.
          // err.error is the original thrown value.
          const cause: unknown = (err as { error?: unknown }).error;
          const msg =
            cause instanceof Error
              ? (cause.message || cause.constructor.name)
              : cause != null
                ? String(cause)
                : `attempt ${err.attemptNumber} failed`;
          console.warn(
            `[scraper] TOC fetch attempt ${err.attemptNumber}/${err.attemptNumber + err.retriesLeft} failed: ${msg}`,
          );
        },
      },
    );

    cache.saveToc(id, urls);
    return urls;
  } catch (err) {
    // All retries exhausted — run the page analyzer and surface a useful error.
    const diagnosis = lastHtml
      ? analyzeTocPage(lastHtml, tocCfg.scope, tocCfg.linkPattern, lastActualUrl)
      : null;

    const baseMessage = err instanceof Error ? err.message : String(err);
    const fullMessage = diagnosis
      ? formatDiagnosis(diagnosis)
      : `TOC fetch failed (no page was captured): ${baseMessage}`;

    throw new Error(fullMessage);
  } finally {
    await driver.quit();
  }
}

const errorStyle: Style = {
  display: 'inline',
  fontFamily: 'sans-serif',
  fontSize: '12pt',
  fontStyle: 'regular',
  fontWeight: 'bold',
  color: 'red',
};

export async function fetchChapters(
  def: NovelDefinition,
  id: number,
  from: number,
  tocUrls: string[],
  cache: Cache,
): Promise<ChapterView> {
  const to = from + tocUrls.length - 1;

  const cached = cache.tryGetChapters(id, from, to);
  if (cached) return cached;

  const contentScope = def.selectors?.content?.scope ?? '#chr-content';
  const minBytes = def.download?.minContentBytes ?? 10;
  const parallelism = def.download?.parallelism ?? 4;

  const limit = pLimit(parallelism);

  const chapterResults = await Promise.all(
    tocUrls.map((url, i) =>
      limit(async (): Promise<{ index: number; content: string }> => {
        const index = from + i;
        try {
          return await pRetry(
            async () => {
              const response = await fetch(url);
              if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);

              const html = await response.text();
              const $ = cheerio.load(html);
              const rawText = $(contentScope).text().trim();

              if (rawText.length < minBytes) {
                throw new Error(
                  `Content at "${url}" is too short (${rawText.length} bytes, minimum ${minBytes})`,
                );
              }

              const fragments: TextFragment[] = rawText
                .split(/[\n\r]+/)
                .map(line => line.trim())
                .filter(Boolean)
                .map(body => ({ body }));

              return { index, content: JSON.stringify(fragments) };
            },
            {
              retries: 2,
              onFailedAttempt: err => {
                const cause: unknown = (err as { error?: unknown }).error;
                const msg =
                  cause instanceof Error ? cause.message || cause.constructor.name : String(cause ?? err);
                console.warn(`[scraper] Chapter ${index} attempt ${err.attemptNumber} failed: ${msg}`);
              },
            },
          );
        } catch (err) {
          const message = err instanceof Error ? err.message : String(err);
          console.error(`[scraper] Chapter ${index} permanently failed: ${message}`);
          const errorFragments: TextFragment[] = [
            { body: `Chapter ${index} failed to download`, style: errorStyle },
            { body: message, style: { ...errorStyle, fontWeight: 'light' } },
          ];
          return { index, content: JSON.stringify(errorFragments) };
        }
      }),
    ),
  );

  const files = cache.saveChapters(id, chapterResults);
  return { id, min: from, max: to, chapters: files };
}
