#!/usr/bin/env node
// JSX one-way flight search + flight-load capture via Playwright.
//
// Usage:
//   npm i -D playwright
//   npx playwright install chromium
//   node scripts/jsx_playwright_search.mjs --origin DAL --destination HOU --date 2026-04-15
//
// Env / flags:
//   --origin       IATA (default: DAL)
//   --destination  IATA (default: HOU)
//   --date         YYYY-MM-DD (default: 2026-04-15)
//   --headful      show the browser (default: headless)
//   --out DIR      where to write captured JSON (default: /tmp/jsx-playwright)
//
// What it does:
//   1. Opens https://www.jsx.com/ in a real Chromium (Akamai/JSX blocks plain
//      fetches — the browser session is what makes the API call work).
//   2. Dismisses consent + marketing popups.
//   3. Selects "One way" from the mat-select trip type dropdown.
//   4. Picks origin station, destination station, depart date from the UI.
//   5. Clicks "Find Flights".
//   6. Listens for api.jsx.com responses and captures the body of
//      POST /api/nsk/v4/availability/search/simple — that payload is what
//      the JSX website uses to render seat counts. Flight loads live there
//      as journey.segments[].legs[].legInfo and journey.fares[].paxFares[]
//      plus the per-journey `seatsAvailable` field.
//   7. Also grabs the low-fare estimate response for sanity.

import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";

// ---------- CLI ----------
function parseArgs(argv) {
  const out = {};
  for (let i = 2; i < argv.length; i++) {
    const a = argv[i];
    if (!a.startsWith("--")) continue;
    const key = a.slice(2);
    const next = argv[i + 1];
    if (next === undefined || next.startsWith("--")) {
      out[key] = true;
    } else {
      out[key] = next;
      i++;
    }
  }
  return out;
}

const args = parseArgs(process.argv);
const origin = (args.origin || "DAL").toUpperCase();
const destination = (args.destination || "HOU").toUpperCase();
const date = args.date || "2026-04-15";
const headless = !args.headful;
const outDir = args.out || "/tmp/jsx-playwright";

const [yearStr, monthStr, dayStr] = date.split("-");
const targetYear = Number(yearStr);
const targetMonth = Number(monthStr); // 1-12
const targetDay = Number(dayStr);
const monthNames = [
  "January","February","March","April","May","June",
  "July","August","September","October","November","December",
];
const targetMonthName = monthNames[targetMonth - 1];

if (!targetYear || !targetMonth || !targetDay) {
  console.error(`Invalid --date '${date}', expected YYYY-MM-DD`);
  process.exit(2);
}

await mkdir(outDir, { recursive: true });

// ---------- Tiny logger ----------
let stepCounter = 0;
function step(label) {
  stepCounter++;
  const prefix = `[${String(stepCounter).padStart(2, "0")}]`;
  const started = Date.now();
  console.log(`${prefix} ▶ ${label}`);
  return (detail) => {
    const ms = Date.now() - started;
    const d = detail ? ` — ${detail}` : "";
    console.log(`${prefix} ✓ ${label} (${ms}ms)${d}`);
  };
}

// ---------- Main ----------
// IMPORTANT: Akamai Bot Manager (used by JSX via api.jsx.com) detects
// Playwright's `launch()`/`launchPersistentContext()` and returns
// ERR_HTTP2_PROTOCOL_ERROR on POST /availability/search/simple — even when
// using `channel: "chrome"`. The page, the token call, and the GET lowfare
// estimate all succeed, but the sensitive POST is blocked.
//
// The working approach: spawn REAL Chrome ourselves (identical to the
// existing jsx_cdp_probe.mjs pattern) and have Playwright attach over CDP.
// Plain-spawned Chrome has the exact TLS+HTTP/2 fingerprint Akamai expects
// from a real user, and Playwright is only used as the scripting interface.

const chromePath =
  args["chrome-path"] || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const cdpPort = Number(args["cdp-port"] || 9231);
const userDataDir = args["user-data-dir"] || join(homedir(), ".cache", "jsx-playwright-profile");
await mkdir(userDataDir, { recursive: true });

function launchChromeProcess() {
  const chromeArgs = [
    `--remote-debugging-port=${cdpPort}`,
    `--user-data-dir=${userDataDir}`,
    "--no-first-run",
    "--no-default-browser-check",
    "--disable-default-apps",
    "--disable-popup-blocking",
    "--window-size=1440,1200",
    "about:blank",
  ];
  if (headless) chromeArgs.unshift("--headless=new");
  console.log(`[chrome] launching: ${chromePath} on port ${cdpPort}${headless ? " (headless)" : ""}`);
  const child = spawn(chromePath, chromeArgs, { detached: false, stdio: ["ignore", "pipe", "pipe"] });
  // Silence Chrome's noisy startup/updater output — it writes tons of
  // VERBOSE/ERROR lines to stderr during normal operation. Swallow them
  // unless --debug-chrome is passed.
  if (args["debug-chrome"]) {
    child.stdout.on("data", (b) => { const t = b.toString().trim(); if (t) console.log(`[chrome.out] ${t.slice(0, 200)}`); });
    child.stderr.on("data", (b) => { const t = b.toString().trim(); if (t) console.log(`[chrome.err] ${t.slice(0, 200)}`); });
  } else {
    child.stdout.on("data", () => {});
    child.stderr.on("data", () => {});
  }
  return child;
}

async function waitForCdp() {
  const deadline = Date.now() + 15000;
  while (Date.now() < deadline) {
    try {
      const r = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
      if (r.ok) return await r.json();
    } catch {}
    await delay(250);
  }
  throw new Error("Chrome remote debugger never came up");
}

const chromeProc = launchChromeProcess();
const cdpInfo = await waitForCdp();
console.log(`[chrome] CDP ready: ${cdpInfo.Browser}`);

const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
const contexts = browser.contexts();
const context = contexts[0] || await browser.newContext();
const existingPages = context.pages();
const page = existingPages[0] || await context.newPage();
page.on("console", (msg) => {
  const t = msg.type();
  if (t === "error" || t === "warning") {
    console.log(`    [page.${t}] ${msg.text().slice(0, 200)}`);
  }
});
page.on("pageerror", (err) => console.log(`    [pageerror] ${err.message}`));

// Intercept JSX API responses. We want the body of search/simple (flight
// loads) and lowfare/estimate (per-day cheapest fare). Playwright exposes the
// response body via response.body() — no CDP plumbing needed.
const captured = {
  searchSimple: null,
  lowFare: null,
  allCalls: [],
};

page.on("request", (request) => {
  const url = request.url();
  if (url.includes("/availability/search/simple")) {
    console.log(`    [outgoing request] ${request.method()} ${url}`);
    const post = request.postData();
    if (post) console.log(`    [outgoing body] ${post.slice(0, 800)}`);
    const headers = request.headers();
    console.log(`    [outgoing auth] ${headers.authorization ? headers.authorization.slice(0, 30) + "..." : "NONE"}`);
  }
});
page.on("requestfailed", (request) => {
  if (request.url().includes("api.jsx.com")) {
    console.log(`    [request failed] ${request.method()} ${request.url()} — ${request.failure()?.errorText}`);
  }
});
page.on("response", async (response) => {
  const url = response.url();
  if (!url.includes("api.jsx.com")) return;
  const req = response.request();
  const entry = {
    url,
    method: req.method(),
    status: response.status(),
  };
  captured.allCalls.push(entry);
  try {
    if (url.includes("/availability/search/simple")) {
      const bodyText = await response.text();
      captured.searchSimple = { ...entry, body: bodyText };
      console.log(`    ↳ captured search/simple (${bodyText.length} bytes, status ${response.status()})`);
    } else if (url.includes("lowfare/estimate")) {
      const bodyText = await response.text();
      captured.lowFare = { ...entry, body: bodyText };
      console.log(`    ↳ captured lowfare/estimate (${bodyText.length} bytes)`);
    }
  } catch (err) {
    console.log(`    ↳ failed reading body for ${url}: ${err.message}`);
  }
});

try {
  // ---------- 1. Navigate ----------
  let done = step("Navigate to https://www.jsx.com/");
  await page.goto("https://www.jsx.com/", { waitUntil: "domcontentloaded" });
  // Wait for network to settle so Angular bootstraps, token call completes,
  // and the search form is fully initialized before we touch it.
  try {
    await page.waitForLoadState("networkidle", { timeout: 15000 });
  } catch {}
  await page.waitForTimeout(2000);
  done(`url=${page.url()}`);

  // ---------- 2. Consent + popup ----------
  done = step("Dismiss consent / marketing popups");
  const consentResult = await page.evaluate(() => {
    const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
    const txt = (el) => (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().toLowerCase();
    const actions = [];

    // Osano cookie banner — class-based match is more reliable than text.
    const osanoAccept = document.querySelector(
      ".osano-cm-accept-all, .osano-cm-accept, button.osano-cm-button[class*='accept']"
    );
    if (osanoAccept && visible(osanoAccept)) {
      osanoAccept.click();
      actions.push("osano-accept");
    }

    // Generic consent buttons by text.
    const wanted = new Set(["accept", "accept all", "allow all", "i agree", "agree", "got it"]);
    for (const btn of document.querySelectorAll("button, [role='button']")) {
      if (!visible(btn)) continue;
      if (wanted.has(txt(btn))) { btn.click(); actions.push(`text:${txt(btn)}`); break; }
    }

    // Marketing modal close.
    const close = Array.from(document.querySelectorAll("button, [role='button']"))
      .filter(visible)
      .find((el) => (el.getAttribute("aria-label") || "").toLowerCase() === "close dialog");
    if (close) { close.click(); actions.push("close-dialog"); }

    return { actions };
  });
  await page.waitForTimeout(800);
  // Verify the Osano banner is actually gone — it tends to block pointer events.
  const osanoStillVisible = await page.evaluate(() => {
    const el = document.querySelector(".osano-cm-window, .osano-cm-dialog");
    if (!el) return false;
    return !!(el.offsetWidth || el.offsetHeight);
  });
  if (osanoStillVisible) {
    // Hard-kill it so it doesn't intercept clicks or match selectors.
    await page.evaluate(() => {
      document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach((el) => el.remove());
    });
    consentResult.actions.push("osano-force-removed");
  }
  done(JSON.stringify(consentResult));

  // ---------- 3. One way ----------
  // JSX uses Angular Material mat-select. Open via keyboard/ArrowDown, then
  // click the mat-option. This mirrors the battle-tested Swift flow.
  done = step("Select trip type = One way");
  const oneWayResult = await page.evaluate(async () => {
    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    const combo = Array.from(document.querySelectorAll("[role='combobox']"))
      .find((el) => /round trip|one way|multi city/i.test(el.textContent || ""));
    if (!combo) return { ok: false, reason: "trip-combobox-not-found" };

    const anyOption = () => document.querySelector("mat-option, [role='option']");

    // Strategy 1: plain click.
    combo.scrollIntoView?.({ block: "center" });
    combo.click();
    await sleep(400);

    // Strategy 2: focus + keyboard events.
    if (!anyOption()) {
      combo.focus?.();
      await sleep(100);
      for (const key of ["Enter", " ", "ArrowDown"]) {
        combo.dispatchEvent(new KeyboardEvent("keydown", {
          key, code: key === " " ? "Space" : key, keyCode: key === "Enter" ? 13 : key === " " ? 32 : 40,
          bubbles: true, cancelable: true,
        }));
        await sleep(350);
        if (anyOption()) break;
      }
    }

    // Strategy 3: walk __ngContext__ and call mat-select's .open().
    if (!anyOption()) {
      const ctxKey = Object.keys(combo).find((k) => k.startsWith("__ngContext__"));
      if (ctxKey) {
        for (const item of combo[ctxKey] || []) {
          if (item && typeof item === "object" && typeof item.open === "function") {
            try { item.open(); } catch {}
            await sleep(400);
            if (anyOption()) break;
          }
        }
      }
    }

    const options = Array.from(document.querySelectorAll("mat-option, [role='option']"))
      .filter((el) => el.offsetWidth || el.offsetHeight);
    const oneWay = options.find((el) => /one\s*way/i.test(el.textContent || ""));
    if (!oneWay) return { ok: false, reason: "one-way-option-not-found", visible: options.map((o) => o.textContent.trim()) };

    for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
      oneWay.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
    }
    oneWay.click?.();
    await sleep(500);

    // Verify: return-date input should no longer be visible.
    const returnVisible = Array.from(document.querySelectorAll("input"))
      .some((i) => (i.getAttribute("aria-label") || "").toLowerCase().includes("return date")
        && (i.offsetWidth || i.offsetHeight));
    return { ok: !returnVisible, returnVisible };
  });
  done(JSON.stringify(oneWayResult));
  if (!oneWayResult.ok) throw new Error(`Trip type selection failed: ${JSON.stringify(oneWayResult)}`);

  // ---------- 4. Origin ----------
  done = step(`Select origin = ${origin}`);
  await selectStation(page, 0, origin);
  done();

  // ---------- 5. Destination ----------
  done = step(`Select destination = ${destination}`);
  await selectStation(page, 1, destination);
  done();

  // ---------- 6. Date ----------
  done = step(`Set depart date = ${date}`);
  const dateResult = await page.evaluate(async ({ targetYear, targetMonth, targetDay, targetMonthName }) => {
    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    const input = Array.from(document.querySelectorAll("input")).find((i) => {
      const label = (i.getAttribute("aria-label") || "").toLowerCase();
      return label.includes("depart date") || (label.includes("date") && !label.includes("return"));
    });
    if (!input) return { ok: false, reason: "depart-input-not-found" };

    // Open datepicker: prefer an adjacent mat-datepicker-toggle, else click input.
    const wrapper = input.closest("mat-form-field, jsx-form-field, .form-field, .jsx-form-field");
    let toggle =
      wrapper?.querySelector("mat-datepicker-toggle button") ||
      wrapper?.querySelector("button[aria-label*='calendar' i]") ||
      wrapper?.querySelector("button[aria-label*='date picker' i]") ||
      input;
    toggle.click();
    await sleep(800);

    // Look specifically for the JSX/Material calendar — NOT any [role='dialog']
    // because the Osano cookie banner also uses role="dialog".
    // JSX uses a custom component; we also search cdk-overlay-container which
    // is where Angular Material mounts datepicker overlays.
    const findCalendar = () => {
      const direct = document.querySelector(
        "mat-calendar, .mat-calendar, mat-datepicker-content, [class*='mat-datepicker']"
      );
      if (direct) return direct;
      const overlay = document.querySelector(".cdk-overlay-container");
      if (overlay) {
        // Any visible overlay pane that contains month-name text or day-of-week header.
        const panes = overlay.querySelectorAll(".cdk-overlay-pane, [class*='datepicker'], [class*='calendar'], [class*='date-picker']");
        for (const p of panes) {
          if (!(p.offsetWidth || p.offsetHeight)) continue;
          const text = p.textContent || "";
          if (/january|february|march|april|may|june|july|august|september|october|november|december/i.test(text)) {
            return p;
          }
        }
      }
      // Last resort: any visible element with month + year text that also has a short-day header.
      const candidates = Array.from(document.querySelectorAll("div, section, article"))
        .filter((el) => el.offsetWidth && el.offsetHeight && el.offsetWidth < 1000)
        .filter((el) => /\b(Sun|Mon|Tue|Wed|Thu|Fri|Sat)\b/.test(el.textContent || ""))
        .filter((el) => /january|february|march|april|may|june|july|august|september|october|november|december/i.test(el.textContent || ""));
      return candidates[0] || null;
    };
    let calendar = findCalendar();
    if (!calendar) { input.click(); input.focus?.(); await sleep(500); calendar = findCalendar(); }
    if (!calendar) {
      // Dump what IS visible so we can adapt.
      const overlayDump = (() => {
        const overlay = document.querySelector(".cdk-overlay-container");
        if (!overlay) return "no cdk-overlay-container";
        return Array.from(overlay.children).map((c) => ({
          tag: c.tagName.toLowerCase(),
          cls: (c.className || "").toString().slice(0, 80),
          visible: !!(c.offsetWidth || c.offsetHeight),
          preview: (c.textContent || "").replace(/\s+/g, " ").trim().slice(0, 100),
        }));
      })();
      return { ok: false, reason: "datepicker-did-not-open", overlayDump };
    }

    const next = calendar.querySelector(".mat-calendar-next-button, [aria-label*='Next month' i]");
    const prev = calendar.querySelector(".mat-calendar-previous-button, [aria-label*='Previous month' i]");
    const monthIndex = (name) =>
      ["january","february","march","april","may","june","july","august","september","october","november","december"]
        .indexOf(name.toLowerCase());

    for (let i = 0; i < 24; i++) {
      const header = calendar.querySelector(".mat-calendar-period-button, [class*='calendar-header']");
      const text = header?.textContent?.trim() || "";
      if (text.includes(targetMonthName) && text.includes(String(targetYear))) break;
      const m = text.match(/(\w+)\s+(\d{4})/);
      let forward = true;
      if (m) {
        const cur = parseInt(m[2], 10) * 12 + monthIndex(m[1]);
        const tgt = targetYear * 12 + (targetMonth - 1);
        forward = cur < tgt;
      }
      const btn = forward ? next : prev;
      if (!btn || btn.disabled) break;
      btn.click();
      await sleep(250);
    }

    // Match the day cell by aria-label. JSX/Angular Material day cells have
    // aria-labels like "April 15, 2026" — unambiguous across the two-month
    // range picker and resilient to missed month navigation.
    const ariaTarget = `${targetMonthName} ${targetDay}, ${targetYear}`;
    const ariaTargetLoose = `${targetMonthName} ${targetDay} ${targetYear}`;
    const allCells = Array.from(calendar.querySelectorAll("[aria-label]"))
      .filter((el) => el.offsetWidth || el.offsetHeight);
    let cell = allCells.find((el) => {
      const a = (el.getAttribute("aria-label") || "").trim();
      return a === ariaTarget || a === ariaTargetLoose || a.startsWith(ariaTarget);
    });

    // Fallback: find any element whose aria-label includes the month name,
    // the year, and the exact day number (e.g. "Wednesday, April 15, 2026").
    if (!cell) {
      const dayRe = new RegExp(`\\b${targetDay}\\b`);
      cell = allCells.find((el) => {
        const a = (el.getAttribute("aria-label") || "");
        return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a);
      });
    }

    // Last resort: text-based match restricted to an element whose closest
    // header says the target month.
    if (!cell) {
      const textCells = Array.from(calendar.querySelectorAll(
        ".mat-calendar-body-cell, [class*='calendar-body-cell'], [class*='day-cell'], " +
        "td[role='gridcell'], [role='gridcell'], button[class*='day'], div[class*='day'], span[class*='day']"
      )).filter((el) => el.offsetWidth || el.offsetHeight);
      cell = textCells.find((c) => {
        const text = ((c.innerText || c.textContent || "").trim());
        if (text !== String(targetDay)) return false;
        // Walk up looking for a header that contains the target month.
        let parent = c.parentElement;
        for (let i = 0; i < 8 && parent; i++, parent = parent.parentElement) {
          const header = parent.querySelector?.(
            "[class*='month-header'], [class*='calendar-header'], .mat-calendar-period-button, h2, h3, thead"
          );
          if (header && header.textContent?.includes(targetMonthName) && header.textContent?.includes(String(targetYear))) {
            return true;
          }
        }
        return false;
      });
    }

    if (!cell) {
      const ariaSample = allCells.slice(0, 30).map((el) => ({
        tag: el.tagName.toLowerCase(),
        aria: el.getAttribute("aria-label"),
      }));
      return { ok: false, reason: "day-cell-not-found", ariaTarget, ariaSample };
    }

    cell.scrollIntoView?.({ block: "center" });
    for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
      cell.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
    }
    cell.click?.();
    await sleep(700);

    // JSX custom datepicker has a "DONE" button that must be clicked to
    // commit the selection — without it, the underlying Angular FormControl
    // never gets the new value and the Find Flights submit stays no-op.
    const doneBtn = Array.from(document.querySelectorAll("button, [role='button']"))
      .filter((el) => el.offsetWidth || el.offsetHeight)
      .find((el) => /^\s*done\s*$/i.test((el.innerText || el.textContent || "").trim()));
    let doneClicked = false;
    if (doneBtn) {
      doneBtn.scrollIntoView?.({ block: "center" });
      for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
        doneBtn.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
      }
      doneBtn.click?.();
      doneClicked = true;
      await sleep(600);
    }

    // Force Angular form update (blur everything, mark controls touched).
    document.body.click();
    for (const i of document.querySelectorAll("input")) {
      i.dispatchEvent(new Event("blur", { bubbles: true }));
    }
    await sleep(400);

    return { ok: true, value: input.value, doneClicked };
  }, { targetYear, targetMonth, targetDay, targetMonthName });
  done(JSON.stringify(dateResult));
  if (!dateResult.ok) throw new Error(`Date selection failed: ${JSON.stringify(dateResult)}`);

  // ---------- 6b. Force Angular form revalidation ----------
  done = step("Force Angular form update");
  await page.evaluate(async () => {
    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    // Blur everything — most Angular reactive forms recompute validity on blur.
    document.body.click();
    for (const input of document.querySelectorAll("input")) {
      input.dispatchEvent(new Event("blur", { bubbles: true }));
    }
    await sleep(300);
    // Walk __ngContext__ and call markAllAsTouched + updateValueAndValidity on
    // every FormGroup/FormControl we can find. This is the exact trick that
    // works in JSXWebViewFetcher.swift.
    let touched = 0;
    for (const el of document.querySelectorAll("*")) {
      const ctxKey = Object.keys(el).find((k) => k.startsWith("__ngContext__"));
      if (!ctxKey) continue;
      for (const item of el[ctxKey] || []) {
        if (item && typeof item === "object" && (item.controls || item.control)) {
          const form = item.controls ? item : item.control?.controls ? item.control : null;
          if (form) {
            try { form.markAllAsTouched?.(); form.updateValueAndValidity?.(); touched++; } catch {}
          }
        }
      }
      if (touched > 20) break;
    }
    await sleep(400);
  });
  done();

  // ---------- 7. Find Flights + wait for search/simple in parallel ----------
  done = step("Click Find Flights (and wait for search/simple response)");
  // Start waiting BEFORE we click so we don't miss the fast response.
  const searchResponsePromise = page
    .waitForResponse(
      (r) => r.url().includes("/availability/search/simple") && r.request().method() === "POST",
      { timeout: 25000 }
    )
    .catch(() => null);

  // Use Playwright's locator.click() — waits for the button to be enabled
  // and retries internally. Retry up to 3 times in case Angular's form state
  // isn't ready on the first attempt.
  const findBtn = page.getByRole("button", { name: /find flights/i }).first();
  let clicked = false;
  let lastErr = null;
  for (let attempt = 1; attempt <= 3 && !clicked; attempt++) {
    try {
      await findBtn.waitFor({ state: "visible", timeout: 5000 });
      await page.waitForTimeout(300);
      await findBtn.click({ timeout: 5000, force: attempt >= 2 });
      clicked = true;
    } catch (err) {
      lastErr = err;
      await page.waitForTimeout(600);
    }
  }
  if (!clicked) throw new Error(`Find Flights click failed: ${lastErr?.message}`);

  // Wait up to 25s for the search/simple response.
  const searchResponse = await searchResponsePromise;
  if (searchResponse && !captured.searchSimple) {
    // In rare cases our page.on("response") handler hasn't captured it yet.
    try {
      const body = await searchResponse.text();
      captured.searchSimple = {
        url: searchResponse.url(),
        method: "POST",
        status: searchResponse.status(),
        body,
      };
    } catch {}
  }
  done(captured.searchSimple ? `status=${captured.searchSimple.status}, url=${page.url()}` : `NOT CAPTURED (url=${page.url()})`);

  // ---------- 9. Save artifacts + summarize flight loads ----------
  done = step("Write artifacts");
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
  const baseName = `${origin}-${destination}-${date}-${stamp}`;
  const artifactPaths = {};

  if (captured.searchSimple) {
    const p = join(outDir, `${baseName}.search-simple.json`);
    await writeFile(p, captured.searchSimple.body);
    artifactPaths.searchSimple = p;
  }
  if (captured.lowFare) {
    const p = join(outDir, `${baseName}.lowfare.json`);
    await writeFile(p, captured.lowFare.body);
    artifactPaths.lowFare = p;
  }
  const callsPath = join(outDir, `${baseName}.calls.json`);
  await writeFile(callsPath, JSON.stringify(captured.allCalls, null, 2));
  artifactPaths.calls = callsPath;
  done(JSON.stringify(artifactPaths));

  if (captured.searchSimple) {
    console.log("\n=== Flight loads ===");
    try {
      const parsed = JSON.parse(captured.searchSimple.body);
      const loads = extractFlightLoads(parsed);
      if (loads.length === 0) {
        console.log("(no journeys in response)");
      } else {
        for (const load of loads) {
          const depTime = load.departTime.replace("T", " ").slice(0, 16);
          const arrTime = load.arriveTime.replace("T", " ").slice(0, 16);
          const lowest = load.lowestFare != null ? `$${load.lowestFare}` : "—";
          console.log(
            `  ${load.flightNumber.padEnd(9)} ${load.departure}→${load.arrival}  ` +
            `${depTime} → ${arrTime}  stops=${load.stops}  ` +
            `seats=${load.totalAvailable}  from=${lowest}`
          );
          for (const c of load.classes) {
            console.log(
              `      class=${c.classOfService} bundle=${c.productClass}  ` +
              `count=${c.availableCount}  fare=$${c.fareTotal ?? "?"}  rev=$${c.revenueTotal ?? "?"}`
            );
          }
        }
      }
    } catch (err) {
      console.log(`  (failed to parse search/simple body: ${err.message})`);
    }
  } else {
    console.log("\n!! search/simple response was never captured — check for Akamai block or UI flow break.");
    console.log("   All api.jsx.com calls seen:");
    for (const c of captured.allCalls) {
      console.log(`    ${c.method} ${c.status} ${c.url}`);
    }
    process.exitCode = 1;
  }
} catch (err) {
  console.error("\nFATAL:", err.message);
  try {
    const shot = join(outDir, `crash-${Date.now()}.png`);
    await page.screenshot({ path: shot, fullPage: true });
    console.error("screenshot:", shot);
  } catch {}
  process.exitCode = 1;
} finally {
  try { await browser.close(); } catch {}
  if (chromeProc && !chromeProc.killed) {
    chromeProc.kill("SIGTERM");
    // Give it a moment to exit cleanly before the process exits.
    await delay(300);
  }
}

// ---------- helpers ----------

async function selectStation(page, index, code) {
  // Open the station picker at position `index` (0 = origin, 1 = destination)
  // then click the matching option by IATA code.
  const result = await page.evaluate(async ({ index, code }) => {
    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);

    const triggers = Array.from(document.querySelectorAll(
      "[aria-label='Station select'], [aria-label*='Station select']"
    )).filter(visible);
    const trigger = triggers[index];
    if (!trigger) return { ok: false, reason: "trigger-not-found", triggerCount: triggers.length };

    trigger.click();
    await sleep(800);

    // The dropdown sometimes has a search box — try to type the code first.
    const searchInput = Array.from(document.querySelectorAll("input"))
      .filter(visible)
      .find((i) => (i.getAttribute("placeholder") || "").toLowerCase() === "airport or city");
    if (searchInput) {
      const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
      searchInput.focus();
      setter ? setter.call(searchInput, code) : (searchInput.value = code);
      searchInput.dispatchEvent(new InputEvent("input", { bubbles: true, data: code }));
      searchInput.dispatchEvent(new Event("change", { bubbles: true }));
      await sleep(500);
    }

    const items = Array.from(document.querySelectorAll(
      "li[role='option'].station-options__item, li[role='option']"
    )).filter(visible);
    if (items.length === 0) return { ok: false, reason: "no-dropdown-items" };

    const pattern = new RegExp(`(^|\\b)${code}(\\b|$)`, "i");
    const match = items.find((el) => pattern.test((el.innerText || el.textContent || "").trim()));
    if (!match) {
      return {
        ok: false,
        reason: "code-not-in-list",
        code,
        sample: items.slice(0, 10).map((i) => i.textContent.trim().slice(0, 60)),
      };
    }

    match.scrollIntoView?.({ block: "center" });
    for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
      match.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
    }
    match.click?.();
    await sleep(500);

    // Angular API fallback if click did not update the trigger text.
    const after = Array.from(document.querySelectorAll("[aria-label='Station select']"))[index];
    const afterText = after?.textContent.trim() || "";
    if (!afterText.includes(code)) {
      const ctxKey = Object.keys(match).find((k) => k.startsWith("__ngContext__"));
      if (ctxKey) {
        const ctx = match[ctxKey];
        for (const item of ctx) {
          if (!item || typeof item !== "object") continue;
          for (const m of ["_selectViaInteraction", "select", "onSelect", "onClick", "handleClick"]) {
            if (typeof item[m] === "function") {
              try { item[m](); } catch {}
            }
          }
        }
      }
      await sleep(400);
    }

    const finalText = Array.from(document.querySelectorAll("[aria-label='Station select']"))[index]
      ?.textContent.trim() || "";
    return { ok: finalText.includes(code), finalText };
  }, { index, code });

  if (!result.ok) {
    throw new Error(`Station ${code} (index ${index}) failed: ${JSON.stringify(result)}`);
  }
}

// Pull a per-flight load summary out of the Navitaire availability payload.
//
// Real shape (confirmed via live DAL→HOU response):
//   data.results[n].trips[n].journeysAvailableByMarket["DAL|HOU"][] → journey
//   journey.segments[n].identifier.{carrierCode,identifier}        → flight no
//   journey.segments[n].designator.{origin,destination,departure,arrival}
//   journey.fares[n].fareAvailabilityKey                           → links to
//   data.faresAvailable[key].totals.fareTotal                      → price
//   journey.fares[n].details[n].availableCount                     → load
//   data.faresAvailable[key].fares[n].classOfService               → fare class
function extractFlightLoads(payload) {
  const loads = [];
  const results = payload?.data?.results || [];
  const faresAvailable = payload?.data?.faresAvailable || {};

  for (const result of results) {
    const trips = result?.trips || [];
    for (const trip of trips) {
      const byMarket = trip?.journeysAvailableByMarket || {};
      for (const marketKey of Object.keys(byMarket)) {
        const journeys = byMarket[marketKey] || [];
        for (const j of journeys) {
          // Build flight-number string from each segment so we cover direct + connection.
          const segs = j?.segments || [];
          const flightNumbers = segs
            .map((s) => `${s?.identifier?.carrierCode || ""}${s?.identifier?.identifier || ""}`)
            .filter(Boolean)
            .join(" + ");
          const firstSeg = segs[0];
          const lastSeg = segs[segs.length - 1];
          const departure = j?.designator?.origin || firstSeg?.designator?.origin || "?";
          const arrival = j?.designator?.destination || lastSeg?.designator?.destination || "?";
          const departTime = j?.designator?.departure || firstSeg?.designator?.departure || "?";
          const arriveTime = j?.designator?.arrival || lastSeg?.designator?.arrival || "?";

          // Each journey.fares entry links via fareAvailabilityKey to a
          // faresAvailable record that carries price + class-of-service.
          const classLoads = [];
          for (const f of j?.fares || []) {
            const key = f?.fareAvailabilityKey;
            const fareRecord = key ? faresAvailable[key] : null;
            const totals = fareRecord?.totals || {};
            const classOfService = fareRecord?.fares?.[0]?.classOfService || "?";
            const productClass = fareRecord?.fares?.[0]?.productClass || "?";
            const availableCount = (f?.details || [])
              .reduce((max, d) => Math.max(max, d?.availableCount ?? 0), 0);
            classLoads.push({
              fareAvailabilityKey: key,
              classOfService,
              productClass,
              availableCount,
              fareTotal: totals.fareTotal ?? null,
              revenueTotal: totals.revenueTotal ?? null,
            });
          }

          // Total "seats available for sale" at this price point = sum of details.
          // Lowest fare = cheapest fareTotal across the fare buckets.
          const totalAvailable = classLoads.reduce((sum, c) => sum + (c.availableCount || 0), 0);
          const lowestFare = classLoads
            .map((c) => c.fareTotal)
            .filter((p) => p != null)
            .reduce((min, p) => (min == null || p < min ? p : min), null);

          loads.push({
            market: marketKey,
            flightNumber: flightNumbers || "?",
            departure,
            arrival,
            departTime,
            arriveTime,
            stops: j?.stops ?? 0,
            totalAvailable,
            lowestFare,
            classes: classLoads,
          });
        }
      }
    }
  }
  return loads;
}
