import { access, mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchOptions } from "camoufox-js";
import {
  type Browser,
  type BrowserContext,
  firefox,
  type Page,
} from "playwright";

const __dirname = dirname(fileURLToPath(import.meta.url));
const OUTPUT_PATH = join(__dirname, "../storage/jobs.json");
const STATE_PATH = join(__dirname, "../storage/state.json");
const DEBUG_HTML_PATH = join(__dirname, "../storage/debug-last.html");
const DEBUG_SCREENSHOT_PATH = join(__dirname, "../storage/debug-last.png");

interface ScrapedJob {
  title: string;
  employer: string;
  jobUrl: string;
  applicationLink?: string;
  location?: string;
  jobDescription?: string;
  postedVia?: string;
  isRemote?: boolean;
}

interface ScrapedCard {
  docId?: string;
  title: string;
  employer: string;
  location?: string;
  via?: string;
  /** Google's own permalink into this specific listing (from `data-share-url`). Always present when the card matched, so it works as a stable jobUrl even if the detail click fails. */
  shareUrl?: string;
}

interface ScrapedDetail {
  applyLinks: Array<{ href: string; text: string }>;
  description?: string;
}

/**
 * Confirmed against Google's live markup (2026). Google renders each job as
 * a `.EimVGf` card with a `data-share-url` permalink baked in — no `<ul>`/
 * `<li>`/`aria-label="Jobs list"` wrapper as older scraping guides assumed.
 * Kept as a short list (not a single hardcoded class) since Google rotates
 * these obfuscated names periodically.
 */
const CARD_SELECTORS = [".EimVGf", 'li[role="listitem"]', ".iFjolb"] as const;

function parseTerms(raw: string | undefined): string[] {
  if (!raw) return ["software engineer"];
  try {
    const parsed = JSON.parse(raw) as unknown;
    if (Array.isArray(parsed)) {
      return parsed.map((entry) => String(entry).trim()).filter(Boolean);
    }
  } catch {
    // fall through to delimited parsing
  }
  return raw
    .split(/[\n|,]+/)
    .map((entry) => entry.trim())
    .filter(Boolean);
}

/**
 * `udm=8` is Google's current internal id for the Jobs vertical (the legacy
 * `ibp=htl;jobs` deep link now gets silently redirected to it anyway, but
 * only when requested directly — combining the two, or adding `num=`/`gl=`
 * overrides that don't match the browser's real/proxied IP, has been
 * observed to make the vertical come back "no matches" or "can't determine
 * location" even though the widget itself loads correctly). `hl=en` keeps
 * copy in English so downstream text parsing (e.g. "via LinkedIn") is
 * consistent regardless of where the request is geolocated. Location is
 * intentionally NOT set via a `gl` param — it's derived from the real (or
 * `GOOGLE_JOBS_PROXY`) IP through Camoufox's `geoip` option instead.
 */
function buildSearchUrl(term: string): string {
  const params = new URLSearchParams({
    q: `${term} jobs`,
    udm: "8",
    hl: "en",
  });
  return `https://www.google.com/search?${params.toString()}`;
}

/**
 * Launches Camoufox-hardened Firefox. `humanize`/`geoip` reduce (but do not
 * eliminate) Google's automated-traffic detection. `GOOGLE_JOBS_PROXY` (e.g.
 * a residential proxy URL) is the most reliable way to avoid datacenter-IP
 * blocks entirely, if configured.
 */
async function launchBrowser(headless: boolean): Promise<Browser> {
  const proxy = process.env.GOOGLE_JOBS_PROXY?.trim();
  return firefox.launch(
    await launchOptions({
      headless,
      humanize: true,
      geoip: true,
      ...(proxy ? { proxy } : {}),
    }),
  );
}

/** Google shows a cookie/consent wall in some locales before rendering results. */
async function dismissConsent(page: Page): Promise<void> {
  const labels = ["Reject all", "I agree", "Accept all"];
  for (const label of labels) {
    try {
      const button = page.getByRole("button", { name: label }).first();
      if (await button.isVisible({ timeout: 1500 })) {
        await button.click({ timeout: 3000 });
        await page.waitForTimeout(500);
        return;
      }
    } catch {
      // selector not present in this render; try the next label
    }
  }
}

/** Thrown when Google serves a CAPTCHA / rate-limit challenge instead of results. */
class GoogleBlockedError extends Error {}

/** Google serves an interstitial CAPTCHA page for automated / datacenter traffic. */
async function isBlockedPage(page: Page): Promise<boolean> {
  if (/\/sorry\//.test(page.url())) return true;
  try {
    return await page.evaluate(() => {
      const bodyText = document.body?.textContent ?? "";
      return (
        document.querySelector("#captcha-form") !== null ||
        document.querySelector(".g-recaptcha") !== null ||
        /unusual traffic/i.test(bodyText)
      );
    });
  } catch {
    return false;
  }
}

/**
 * In headed/interactive mode, give a human at the keyboard a chance to solve
 * the CAPTCHA in the visible browser window instead of failing immediately.
 * Polls until the challenge clears (Google's form redirects on success) or
 * the timeout elapses.
 */
async function waitForHumanToUnblock(
  page: Page,
  timeoutMs: number,
): Promise<boolean> {
  const deadline = Date.now() + timeoutMs;
  console.error(
    `[google-jobs] Google is showing a CAPTCHA in the open browser window — please solve it there. Waiting up to ${Math.round(timeoutMs / 1000)}s...`,
  );
  while (Date.now() < deadline) {
    await page.waitForTimeout(3_000);
    if (!(await isBlockedPage(page))) {
      console.error("[google-jobs] CAPTCHA cleared, resuming.");
      return true;
    }
  }
  return false;
}

async function waitForJobsList(page: Page): Promise<boolean> {
  for (const selector of CARD_SELECTORS) {
    try {
      await page.waitForSelector(selector, { timeout: 8_000 });
      return true;
    } catch {
      // try the next fallback selector
    }
  }
  return false;
}

/**
 * Google sometimes lands correctly on the Jobs vertical/tab but reports no
 * matches for the query (distinct from a CAPTCHA block or a selector-rot
 * failure) — e.g. "It looks like there aren't any 'Jobs' matches on this topic."
 */
async function isEmptyJobsVertical(page: Page): Promise<boolean> {
  try {
    return await page.evaluate(() => {
      const bodyText = document.body?.textContent ?? "";
      return /aren.t any .Jobs. matches/i.test(bodyText);
    });
  } catch {
    return false;
  }
}

function countCardsScript(selectors: readonly string[]): number {
  for (const selector of selectors) {
    const count = document.querySelectorAll(selector).length;
    if (count > 0) return count;
  }
  return 0;
}

/** Google lazy-loads additional cards (via a custom `<infinity-scrolling>` element) as the page is scrolled. */
function scrollPageScript(): number {
  const before = window.scrollY;
  window.scrollTo(0, document.body.scrollHeight);
  return window.scrollY - before;
}

async function growJobsList(
  page: Page,
  selectors: readonly string[],
  targetCount: number,
): Promise<void> {
  for (let attempt = 0; attempt < 8; attempt += 1) {
    const count = await page.evaluate(countCardsScript, selectors);
    if (count >= targetCount) return;
    const scrolled = await page.evaluate(scrollPageScript);
    if (scrolled <= 0) return;
    await page.waitForTimeout(900);
  }
}

function extractCardsScript(selectors: readonly string[]): ScrapedCard[] {
  // NOTE: this whole function is serialized via Function.prototype.toString()
  // and re-run inside the browser page by Playwright — it cannot reference
  // anything from the outer module scope (constants, other functions, etc.).
  // All helpers must be declared inside this function body.
  function clean(value: string | null | undefined): string {
    return (value ?? "").replace(/\s+/g, " ").trim();
  }

  /** Extracts Google's internal `htidocid` from a job's `data-share-url`, matching `data-encoded-docid` in the detail panel's apply-link list. */
  function extractDocId(shareUrl: string | null): string | undefined {
    if (!shareUrl) return undefined;
    try {
      const url = new URL(shareUrl, "https://www.google.com");
      return url.searchParams.get("htidocid") ?? undefined;
    } catch {
      return undefined;
    }
  }

  function findCards(): Element[] {
    for (const selector of selectors) {
      const items = Array.from(document.querySelectorAll(selector));
      if (items.length > 0) return items;
    }
    return [];
  }

  const cards: ScrapedCard[] = [];

  for (const item of findCards()) {
    // Known current classes first (title/employer/location+via), falling
    // back to a leaf-node text heuristic if Google has rotated them.
    const titleEl = item.querySelector(".tNxQIb, h2, [role='heading']");
    const title = clean(titleEl?.textContent);
    if (!title) continue;

    const wHYlTdEls = Array.from(item.querySelectorAll(".wHYlTd"));
    let employer = clean(wHYlTdEls[0]?.textContent);
    let location: string | undefined;
    let via: string | undefined;

    const viaBlock = wHYlTdEls.find((el) =>
      /\bvia\b/i.test(el.textContent ?? ""),
    );
    if (viaBlock) {
      const text = clean(viaBlock.textContent);
      const match = text.match(/^(.*?)\s*(?:•|·)\s*via\s+(.+)$/i);
      if (match) {
        location = match[1] || undefined;
        via = match[2] || undefined;
      } else {
        via = clean(text.replace(/^via\s*/i, ""));
      }
    }

    if (!employer || !title || employer === title) {
      // Selector rot fallback: scan leaf text nodes structurally.
      const leafTexts = Array.from(item.querySelectorAll("div, span"))
        .filter((el) => el.children.length === 0)
        .map((el) => clean(el.textContent))
        .filter((text) => text && text !== title && !/^[•·|,]+$/.test(text));
      employer = employer || leafTexts[0] || "Unknown Employer";
      if (!via) {
        const viaText = leafTexts.find((text) => /^via\b/i.test(text));
        via = viaText ? clean(viaText.replace(/^via\s*/i, "")) : undefined;
        location =
          location ?? leafTexts.slice(1).find((text) => text !== viaText);
      }
    }

    const shareUrlRaw = item.getAttribute("data-share-url") ?? undefined;
    const docId = extractDocId(shareUrlRaw ?? null);

    cards.push({
      docId,
      title,
      employer: employer || "Unknown Employer",
      location,
      via,
      shareUrl: shareUrlRaw,
    });
  }

  return cards;
}

/**
 * Scopes apply-link extraction to the clicked job's own `data-encoded-docid`
 * block. Google's detail viewer can have adjacent/related jobs' apply links
 * present in the DOM simultaneously, so a document-wide "first apply link"
 * scan can silently pick up the wrong job's link.
 */
function extractDetailScript(docId: string | undefined): ScrapedDetail {
  function clean(value: string | null | undefined): string {
    return (value ?? "").replace(/\s+/g, " ").trim();
  }

  function collectApplyLinks(
    root: ParentNode,
  ): Array<{ href: string; text: string }> {
    const links: Array<{ href: string; text: string }> = [];
    for (const anchor of Array.from(root.querySelectorAll("a[href]"))) {
      const href = (anchor as HTMLAnchorElement).href;
      const text =
        clean(anchor.textContent) || clean(anchor.getAttribute("aria-label"));
      if (!href) continue;
      if (
        !/apply/i.test(text) &&
        !/apply/i.test(anchor.getAttribute("aria-label") ?? "")
      ) {
        continue;
      }
      if (
        href.includes("google.com/search") ||
        href.includes("accounts.google.com")
      ) {
        continue;
      }
      links.push({ href, text });
    }
    return links;
  }

  let applyLinks: Array<{ href: string; text: string }> = [];
  if (docId) {
    const scoped = document.querySelector(`[data-encoded-docid="${docId}"]`);
    if (scoped) applyLinks = collectApplyLinks(scoped);
  }
  if (applyLinks.length === 0) {
    applyLinks = collectApplyLinks(document);
  }

  // `.textContent` also pulls in text from nested <style>/<script> tags
  // (Google embeds scoped theme <style> blocks inside content divs) and from
  // the job-list sidebar itself (which concatenates every visible card's
  // text and easily outsizes a real description) — exclude both.
  const blocks = Array.from(document.querySelectorAll("div"))
    .filter(
      (el) =>
        el.children.length <= 3 && !el.querySelector("style, script, .EimVGf"),
    )
    .map((el) => clean(el.textContent))
    .filter((text) => text.length > 200);
  blocks.sort((a, b) => b.length - a.length);

  return {
    applyLinks,
    description: blocks[0]?.slice(0, 5_000),
  };
}

async function openCardAndExtractDetail(
  page: Page,
  selectors: readonly string[],
  index: number,
  docId: string | undefined,
): Promise<ScrapedDetail | null> {
  const elementHandle = await page.evaluateHandle(
    (args: { selectors: readonly string[]; idx: number }) => {
      for (const selector of args.selectors) {
        const items = document.querySelectorAll(selector);
        if (items.length > 0) return items[args.idx] ?? null;
      }
      return null;
    },
    { selectors, idx: index },
  );

  const element = elementHandle.asElement();
  if (!element) return null;

  await element.scrollIntoViewIfNeeded();
  // A single click is unreliable here: clicking a card while a *different*
  // card's detail pane is already open frequently just closes the open pane
  // instead of switching to the new one (confirmed by watching real runs —
  // it alternates open/closed rather than opening the newly clicked card).
  // A double-click reopens reliably; verified across 6 sequential cards.
  await element.dblclick({ timeout: 5_000, delay: 80 });
  if (docId) {
    try {
      await page.waitForSelector(`[data-encoded-docid="${docId}"]`, {
        timeout: 6_000,
      });
    } catch {
      // fall through — extractDetailScript falls back to a document-wide
      // scan for apply links if the docid-scoped block never shows up.
    }
  }
  await page.waitForTimeout(500);
  return page.evaluate(extractDetailScript, docId);
}

async function saveDebugArtifacts(page: Page): Promise<void> {
  try {
    await mkdir(dirname(DEBUG_HTML_PATH), { recursive: true });
    await writeFile(DEBUG_HTML_PATH, await page.content(), "utf-8");
    await page.screenshot({ path: DEBUG_SCREENSHOT_PATH, fullPage: true });
  } catch (error) {
    console.error(
      `[google-jobs] failed to save debug artifacts: ${error instanceof Error ? error.message : String(error)}`,
    );
  }
}

async function scrapeTerm(
  context: BrowserContext,
  term: string,
  remainingCap: number,
  options: { headless: boolean; unblockTimeoutMs: number },
): Promise<ScrapedJob[]> {
  const page = await context.newPage();
  const out: ScrapedJob[] = [];

  try {
    await page.goto(buildSearchUrl(term), {
      waitUntil: "domcontentloaded",
      timeout: 45_000,
    });
    await dismissConsent(page);
    await page.waitForTimeout(1_500);

    if (await isBlockedPage(page)) {
      const solvedByHuman =
        !options.headless &&
        (await waitForHumanToUnblock(page, options.unblockTimeoutMs));
      if (!solvedByHuman) {
        throw new GoogleBlockedError(
          "Google blocked this request (CAPTCHA / unusual traffic detection). This is common from shared, VPN, or datacenter IPs. Try again later, reduce run frequency, run from a residential network, set GOOGLE_JOBS_PROXY, or set GOOGLE_JOBS_HEADLESS=false to solve it yourself.",
        );
      }
    }

    const listReady = await waitForJobsList(page);
    if (!listReady) {
      if (await isEmptyJobsVertical(page)) {
        console.error(
          `[google-jobs] Google's Jobs tab reported no matches for "${term}" (not a block or selector issue — try a broader/more common phrasing).`,
        );
      } else {
        console.error(
          `[google-jobs] Jobs list did not appear for "${term}" (url: ${page.url()}). Saved a screenshot + HTML dump to ${DEBUG_SCREENSHOT_PATH} for inspection.`,
        );
        await saveDebugArtifacts(page);
      }
      return out;
    }

    await growJobsList(page, CARD_SELECTORS, remainingCap);

    const cards = await page.evaluate(extractCardsScript, CARD_SELECTORS);
    const limit = Math.min(cards.length, remainingCap);

    for (let index = 0; index < limit; index += 1) {
      const card = cards[index];
      // Google's own permalink into this specific listing — always available
      // from the list view, so results stay unique/useful even if the detail
      // click below fails for this card.
      const shareUrl = card.shareUrl;
      try {
        const detail = await openCardAndExtractDetail(
          page,
          CARD_SELECTORS,
          index,
          card.docId,
        );
        const applyLink = detail?.applyLinks[0];
        const jobUrl = shareUrl ?? applyLink?.href ?? page.url();

        out.push({
          title: card.title,
          employer: card.employer,
          jobUrl,
          applicationLink: applyLink?.href,
          location: card.location,
          jobDescription: detail?.description,
          postedVia: card.via,
          isRemote: /remote/i.test(`${card.location ?? ""} ${card.title}`),
        });
      } catch (error) {
        console.error(
          `[google-jobs] card ${index} for "${term}" failed: ${
            error instanceof Error ? error.message : String(error)
          }`,
        );
        if (shareUrl) {
          // Detail click failed, but we still have a unique, valid link to
          // the listing from the card itself — don't drop the job entirely.
          out.push({
            title: card.title,
            employer: card.employer,
            jobUrl: shareUrl,
            location: card.location,
            postedVia: card.via,
            isRemote: /remote/i.test(`${card.location ?? ""} ${card.title}`),
          });
        }
      }
      await page.waitForTimeout(300 + Math.floor(Math.random() * 400));
    }
  } finally {
    await page.close();
  }

  return out;
}

async function main(): Promise<void> {
  const terms = parseTerms(process.env.GOOGLE_JOBS_SEARCH_TERMS);
  const maxJobsEnv = Number.parseInt(
    process.env.GOOGLE_JOBS_MAX_JOBS ?? "30",
    10,
  );
  const cap = Number.isFinite(maxJobsEnv)
    ? Math.max(1, Math.min(maxJobsEnv, 150))
    : 30;
  // Headed mode lets a human solve a CAPTCHA in the visible window; only
  // useful when run directly on a machine with a display, not on a headless
  // cron/server pipeline.
  const headless = process.env.GOOGLE_JOBS_HEADLESS !== "false";
  const unblockTimeoutMs = Number.parseInt(
    process.env.GOOGLE_JOBS_UNBLOCK_TIMEOUT_MS ?? "300000",
    10,
  );

  const browser = await launchBrowser(headless);
  const hasSavedState = await access(STATE_PATH)
    .then(() => true)
    .catch(() => false);
  const context = await browser.newContext(
    hasSavedState ? { storageState: STATE_PATH } : {},
  );
  // tsx/esbuild compiles this file with `keepNames`, which injects
  // `__name(fn, "fn")` calls into any named function it transpiles —
  // including ones later serialized into `page.evaluate()` scripts via
  // `Function.prototype.toString()`. That helper only exists in our Node
  // process, not in the browser page, so without this no-op polyfill any
  // evaluate script with a nested named function throws
  // `ReferenceError: __name is not defined`.
  await context.addInitScript(() => {
    (window as unknown as { __name?: (fn: unknown) => unknown }).__name = (
      fn: unknown,
    ) => fn;
  });

  const all: ScrapedJob[] = [];
  const seenUrls = new Set<string>();

  try {
    for (const term of terms) {
      if (all.length >= cap) break;
      try {
        const rows = await scrapeTerm(context, term, cap - all.length, {
          headless,
          unblockTimeoutMs,
        });
        for (const row of rows) {
          if (all.length >= cap) break;
          if (seenUrls.has(row.jobUrl)) continue;
          seenUrls.add(row.jobUrl);
          all.push(row);
        }
      } catch (error) {
        if (error instanceof GoogleBlockedError) {
          // IP-level block affects the whole run; stop trying further terms
          // and let it propagate so the process exits non-zero with a clear message.
          throw error;
        }
        console.error(
          `[google-jobs] term "${term}" failed: ${
            error instanceof Error ? error.message : String(error)
          }`,
        );
      }
    }
  } finally {
    // Persist cookies/session (even a partially solved one) so future
    // headless/automated runs can reuse it and hit fewer CAPTCHA walls.
    await mkdir(dirname(STATE_PATH), { recursive: true });
    await context.storageState({ path: STATE_PATH }).catch(() => undefined);
    await browser.close();
  }

  await mkdir(dirname(OUTPUT_PATH), { recursive: true });
  await writeFile(OUTPUT_PATH, JSON.stringify(all, null, 2), "utf-8");
}

main().catch((error) => {
  console.error(error instanceof Error ? error.message : String(error));
  process.exit(1);
});
