import Foundation
import WebKit

// MARK: - Public types

/// One fare class (e.g. Q/HO "Hop-On", Q/AI "All-In") inside a JSX flight.
struct JSXFareClass: Sendable {
    let classOfService: String     // "Q", "N", "S", "B"
    let productClass: String       // "HO" (Hop-On), "AI" (All-In)
    let availableCount: Int        // seats sellable at this class
    let fareTotal: Double          // price incl. taxes
    let revenueTotal: Double       // base fare
    let fareBasisCode: String?     // "Q00AHOXA"
}

/// One unique flight surfaced by the JSX /availability/search/simple response.
/// The response typically contains several flights for a given route/date; each
/// entry here represents one of them.
struct JSXFlight: Sendable {
    let flightNumber: String       // "XE280"
    let carrierCode: String        // "XE"
    let origin: String             // "DAL"
    let destination: String        // "HOU"
    let departureLocal: String     // "2026-04-15T10:50:00"
    let arrivalLocal: String       // "2026-04-15T12:05:00"
    let stops: Int
    let equipmentType: String?     // "ER4"
    let totalAvailable: Int        // sum of classes[].availableCount
    let lowestFareTotal: Double?   // min of classes[].fareTotal
    let classes: [JSXFareClass]    // per-class breakdown
}

/// Day-level route availability from `/api/nsk/v1/availability/lowfare/estimate`.
/// Fires automatically once origin+destination are set, so it's the cheapest
/// fallback when the full `/search/simple` POST isn't reachable.
struct JSXLowFareEstimate: Sendable {
    let date: String               // "YYYY-MM-DD"
    let available: Int             // seats available for sale that day
    let lowestPrice: Double?       // lowest advertised fare, if any
}

/// Result of one run of the JSX WKWebView flow.
struct JSXSearchResult: Sendable {
    let flights: [JSXFlight]       // one entry per unique flight, empty if search/simple was unreachable
    let rawSearchBody: String?     // raw search/simple JSON, preserved for debug
    let lowFareFallback: JSXLowFareEstimate?  // day-level fallback if search/simple failed
    let error: String?             // non-nil iff the flow hit a fatal step failure
}

// MARK: - Fetcher

/// Drives the jsx.com SPA inside a WKWebView to capture the
/// /api/nsk/v4/availability/search/simple response (the one JSX's own website
/// uses to render fares and loads). The flow is broken into explicit steps,
/// each with an action AND a post-condition verification, so failures are
/// pinpointable from the console log.
@MainActor
final class JSXWebViewFetcher: NSObject {
    func fetchAvailability(
        origin: String,
        destination: String,
        date: String
    ) async -> JSXSearchResult {
        let flow = JSXFlow(origin: origin, destination: destination, date: date)
        return await flow.run()
    }
}

// MARK: - Flow orchestrator

@MainActor
private final class JSXFlow {
    let origin: String
    let destination: String
    let date: String

    private var webView: WKWebView!
    private var stepNumber = 0
    private var capturedBody: String?

    // Parsed date parts (used by step 14 aria-label match).
    private let targetYear: Int
    private let targetMonth: Int   // 1-12
    private let targetDay: Int
    private let targetMonthName: String

    init(origin: String, destination: String, date: String) {
        self.origin = origin.uppercased()
        self.destination = destination.uppercased()
        self.date = date

        let parts = date.split(separator: "-")
        let year = parts.count == 3 ? Int(parts[0]) ?? 0 : 0
        let month = parts.count == 3 ? Int(parts[1]) ?? 0 : 0
        let day = parts.count == 3 ? Int(parts[2]) ?? 0 : 0
        let months = [
            "January","February","March","April","May","June",
            "July","August","September","October","November","December"
        ]
        self.targetYear = year
        self.targetMonth = month
        self.targetDay = day
        self.targetMonthName = (month >= 1 && month <= 12) ? months[month - 1] : ""
    }

    // MARK: - Run

    func run() async -> JSXSearchResult {
        logHeader("JSX FLOW START: \(origin) → \(destination) on \(date)")

        guard targetYear > 0, targetMonth >= 1, targetMonth <= 12, targetDay >= 1 else {
            return fail("Invalid date '\(date)' (expected YYYY-MM-DD)")
        }

        // ---- STEP 01 ----
        let s01 = await nativeStep("Create WKWebView") { [weak self] in
            guard let self else { return (false, "self gone", [:]) }
            let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1280, height: 900))
            wv.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"
            self.webView = wv
            return (true, "created 1280x900 with Safari UA", [:])
        } verify: { [weak self] in
            guard let self else {
                return [Verification(name: "webView non-nil", passed: false, detail: "flow deallocated")]
            }
            return [Verification(name: "webView non-nil", passed: self.webView != nil, detail: "")]
        }
        if !s01.ok { return fail("Step 01 failed: \(s01.message)") }

        // ---- STEP 02 ----
        let s02 = await nativeStep("Navigate to https://www.jsx.com/") { [weak self] in
            guard let self else { return (false, "self gone", [:]) }
            guard let url = URL(string: "https://www.jsx.com/") else {
                return (false, "invalid URL", [:])
            }
            let loaded = await self.navigateAndWait(url)
            return (loaded, loaded ? "didFinishNavigation" : "navigation failed", [:])
        } verify: { [weak self] in
            guard let self else {
                return [Verification(name: "location.href contains jsx.com", passed: false, detail: "flow deallocated")]
            }
            let v = await self.asyncVerify(name: "location.href contains jsx.com", js: """
                return await (async () => {
                    const href = location.href || "";
                    return JSON.stringify({ ok: href.includes("jsx.com"), detail: href });
                })();
            """)
            return [v]
        }
        if !s02.ok { return fail("Step 02 failed: \(s02.message)") }

        // ---- STEP 03 ----
        let s03 = await jsStep(
            "Wait for SPA bootstrap (poll for station buttons)",
            action: """
            return await (async () => {
                const deadline = Date.now() + 15000;
                let count = 0;
                while (Date.now() < deadline) {
                    count = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length;
                    if (count >= 2) break;
                    await new Promise(r => setTimeout(r, 250));
                }
                return JSON.stringify({
                    ok: count >= 2,
                    message: count >= 2 ? ("station buttons visible: " + count) : ("timeout; count=" + count),
                    stationButtonCount: count
                });
            })();
            """,
            verifiers: [
                Verifier(name: "≥2 station buttons visible", js: """
                return await (async () => {
                    const n = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length;
                    return JSON.stringify({ ok: n >= 2, detail: "count=" + n });
                })();
                """)
            ]
        )
        if !s03.ok { return fail("Step 03 failed: \(s03.message)") }

        // ---- STEP 04 ----
        // No network interception. Previous runs wrapped window.fetch AND
        // XMLHttpRequest.prototype.{open,send} to observe api.jsx.com
        // traffic. That wrapper was quite possibly the reason Angular's
        // POST /availability/search/simple failed with "xhr error event"
        // in WebKit — adding an `error` event listener before calling
        // origSend can alter request scheduling in WebKit's NetworkProcess.
        //
        // Instead, we use PerformanceObserver to passively record every
        // api.jsx.com resource entry. PerformanceObserver is pure
        // observation — it doesn't touch the request pipeline at all — so
        // whatever's different about our traffic vs real user traffic
        // isn't coming from here.
        //
        // We still need to read the RESPONSE BODY for search/simple
        // somehow. Plan: after `component.search()` runs, walk Angular's
        // __ngContext__ tree looking for the parsed availability data the
        // SPA stored into its own services/stores — no network
        // interception required. This is what the `state-first` fetcher
        // architecture is built around.
        let s04 = await jsStep(
            "Install passive performance observer",
            action: """
            return await (async () => {
                if (window.__jsxProbe) {
                    return JSON.stringify({ ok: true, message: "already installed", already: true });
                }
                window.__jsxProbe = {
                    resources: []
                };
                try {
                    const po = new PerformanceObserver(list => {
                        for (const entry of list.getEntries()) {
                            if (!entry.name || !entry.name.includes("api.jsx.com")) continue;
                            window.__jsxProbe.resources.push({
                                url: entry.name,
                                startTime: Math.round(entry.startTime),
                                duration: Math.round(entry.duration),
                                transferSize: entry.transferSize || 0,
                                responseEnd: Math.round(entry.responseEnd || 0),
                                initiatorType: entry.initiatorType || ""
                            });
                        }
                    });
                    po.observe({ type: "resource", buffered: true });
                    window.__jsxProbe.observer = po;
                } catch (err) {
                    return JSON.stringify({
                        ok: false,
                        message: "PerformanceObserver install failed: " + String(err && err.message ? err.message : err)
                    });
                }
                return JSON.stringify({ ok: true, message: "passive PerformanceObserver installed (no fetch/XHR wrapping)" });
            })();
            """,
            verifiers: [
                Verifier(name: "__jsxProbe.resources exists", js: """
                return await (async () => {
                    const p = window.__jsxProbe;
                    const ok = p && Array.isArray(p.resources);
                    return JSON.stringify({ ok, detail: ok ? "ready" : "missing" });
                })();
                """)
            ]
        )
        if !s04.ok { return fail("Step 04 failed: \(s04.message)") }

        // ---- STEP 05 ----
        let s05 = await jsStep(
            "Dismiss Osano cookie banner",
            action: """
            return await (async () => {
                const visible = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
                const txt = el => (el.innerText || el.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase();
                const actions = [];

                // Class-based accept (most reliable on Osano).
                const classAccept = document.querySelector(
                    ".osano-cm-accept-all, .osano-cm-accept, button.osano-cm-button[class*='accept']"
                );
                if (classAccept && visible(classAccept)) {
                    classAccept.click();
                    actions.push("class-accept");
                }

                // Text fallback.
                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; }
                }

                // Force-remove the banner element regardless of whether the click "stuck",
                // because role='dialog' on the Osano window confuses calendar detection.
                await new Promise(r => setTimeout(r, 300));
                document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach(el => {
                    el.remove();
                    actions.push("removed-node");
                });

                return JSON.stringify({ ok: true, message: actions.join(", ") || "nothing to do", actions });
            })();
            """,
            verifiers: [
                Verifier(name: "no .osano-cm-window visible", js: """
                return await (async () => {
                    const el = document.querySelector(".osano-cm-window");
                    const gone = !el || !(el.offsetWidth || el.offsetHeight);
                    return JSON.stringify({ ok: gone, detail: gone ? "gone" : "still visible" });
                })();
                """),
                Verifier(name: "no .osano-cm-dialog visible", js: """
                return await (async () => {
                    const el = document.querySelector(".osano-cm-dialog");
                    const gone = !el || !(el.offsetWidth || el.offsetHeight);
                    return JSON.stringify({ ok: gone, detail: gone ? "gone" : "still visible" });
                })();
                """)
            ]
        )
        if !s05.ok { return fail("Step 05 failed: \(s05.message)") }

        // ---- STEP 06 ----
        _ = await jsStep(
            "Dismiss marketing modal (if present)",
            action: """
            return await (async () => {
                const visible = el => !!(el.offsetWidth || el.offsetHeight);
                const close = Array.from(document.querySelectorAll("button, [role='button']"))
                    .filter(visible)
                    .find(el => (el.getAttribute("aria-label") || "").toLowerCase() === "close dialog");
                if (close) { close.click(); return JSON.stringify({ ok: true, message: "closed dialog" }); }
                return JSON.stringify({ ok: true, message: "no dialog found" });
            })();
            """,
            verifiers: []
        )

        // ---- STEP 07 ----
        let s07 = await jsStep(
            "Select trip type = One Way",
            action: """
            return await (async () => {
                const sleep = ms => new Promise(r => setTimeout(r, ms));
                const visible = el => !!(el.offsetWidth || el.offsetHeight);
                const combo = Array.from(document.querySelectorAll("[role='combobox']"))
                    .find(el => /round trip|one way|multi city/i.test(el.textContent || ""));
                if (!combo) return JSON.stringify({ ok: false, message: "trip combobox not found" });

                const anyOption = () => Array.from(document.querySelectorAll("mat-option, [role='option']"))
                    .find(visible);

                const tried = [];

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

                // Strategy 2: focus + keyboard events.
                if (!anyOption()) {
                    combo.focus && 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
                        }));
                        tried.push("key:" + key);
                        await sleep(350);
                        if (anyOption()) break;
                    }
                }

                // Strategy 3: walk __ngContext__ for a mat-select with .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(); tried.push("ngContext.open"); } catch (_) {}
                                await sleep(400);
                                if (anyOption()) break;
                            }
                        }
                    }
                }

                const options = Array.from(document.querySelectorAll("mat-option, [role='option']"))
                    .filter(visible);
                if (options.length === 0) {
                    return JSON.stringify({ ok: false, message: "dropdown did not open", tried });
                }

                const oneWay = options.find(el => /one\\s*way/i.test(el.textContent || ""));
                if (!oneWay) {
                    return JSON.stringify({
                        ok: false,
                        message: "One Way option not found",
                        tried,
                        visibleOptions: options.map(o => (o.textContent || "").trim()).slice(0, 10)
                    });
                }

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

                // Angular API fallback if the click didn't commit.
                const stillHasReturn = Array.from(document.querySelectorAll("input")).some(i => {
                    const label = (i.getAttribute("aria-label") || "").toLowerCase();
                    return label.includes("return date") && (i.offsetWidth || i.offsetHeight);
                });
                let usedAngular = false;
                if (stillHasReturn) {
                    const ctxKey = Object.keys(oneWay).find(k => k.startsWith("__ngContext__"));
                    if (ctxKey) {
                        for (const item of oneWay[ctxKey] || []) {
                            if (item && typeof item === "object") {
                                if (typeof item._selectViaInteraction === "function") {
                                    try { item._selectViaInteraction(); usedAngular = true; break; } catch (_) {}
                                }
                                if (typeof item.select === "function" && item.value !== undefined) {
                                    try { item.select(); usedAngular = true; break; } catch (_) {}
                                }
                            }
                        }
                    }
                    await sleep(500);
                }

                return JSON.stringify({
                    ok: true,
                    message: "one way dispatched" + (usedAngular ? " (angular fallback)" : ""),
                    tried, usedAngular
                });
            })();
            """,
            verifiers: [
                Verifier(name: "no return-date input visible", js: """
                return await (async () => {
                    const still = Array.from(document.querySelectorAll("input")).some(i => {
                        const label = (i.getAttribute("aria-label") || "").toLowerCase();
                        return label.includes("return date") && (i.offsetWidth || i.offsetHeight);
                    });
                    return JSON.stringify({ ok: !still, detail: still ? "return-date still visible" : "hidden" });
                })();
                """),
                Verifier(name: "combobox text contains 'One Way'", js: """
                return await (async () => {
                    const combo = Array.from(document.querySelectorAll("[role='combobox']"))
                        .find(el => /round trip|one way|multi city/i.test(el.textContent || ""));
                    const text = combo ? (combo.textContent || "").replace(/\\s+/g, " ").trim() : "";
                    const ok = /one\\s*way/i.test(text);
                    return JSON.stringify({ ok, detail: text.slice(0, 80) });
                })();
                """)
            ]
        )
        if !s07.ok { return fail("Step 07 failed: \(s07.message)") }

        // ---- STEP 08 ----
        let s08 = await jsStep(
            "Open origin station picker",
            action: """
            return await (async () => {
                const sleep = ms => new Promise(r => setTimeout(r, ms));
                const stations = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']"));
                if (stations.length < 2) return JSON.stringify({ ok: false, message: "found " + stations.length + " station buttons, need 2" });
                stations[0].click();
                await sleep(800);
                const items = Array.from(document.querySelectorAll(
                    "li[role='option'].station-options__item, li[role='option']"
                )).filter(el => el.offsetWidth || el.offsetHeight);
                return JSON.stringify({
                    ok: items.length > 0,
                    message: "dropdown items: " + items.length,
                    count: items.length
                });
            })();
            """,
            verifiers: [
                Verifier(name: "dropdown items visible", js: """
                return await (async () => {
                    const items = Array.from(document.querySelectorAll(
                        "li[role='option'].station-options__item, li[role='option']"
                    )).filter(el => el.offsetWidth || el.offsetHeight);
                    return JSON.stringify({ ok: items.length > 0, detail: "count=" + items.length });
                })();
                """)
            ]
        )
        if !s08.ok { return fail("Step 08 failed: \(s08.message)") }

        // ---- STEP 09 ----
        let s09 = await jsStep(
            "Select origin = \(origin)",
            action: selectStationActionJS(indexIsDestination: false),
            verifiers: [
                Verifier(
                    name: "station button[0] contains \(origin)",
                    js: verifyStationButtonJS(index: 0, code: origin)
                )
            ]
        )
        if !s09.ok { return fail("Step 09 failed: \(s09.message)") }

        // ---- STEP 10 ----
        let s10 = await jsStep(
            "Open destination station picker",
            action: """
            return await (async () => {
                const sleep = ms => new Promise(r => setTimeout(r, ms));
                await sleep(300);
                const stations = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']"));
                if (stations.length < 2) return JSON.stringify({ ok: false, message: "lost station buttons" });
                stations[1].click();
                await sleep(800);
                const items = Array.from(document.querySelectorAll(
                    "li[role='option'].station-options__item, li[role='option']"
                )).filter(el => el.offsetWidth || el.offsetHeight);
                return JSON.stringify({ ok: items.length > 0, message: "dropdown items: " + items.length });
            })();
            """,
            verifiers: [
                Verifier(name: "dropdown items visible", js: """
                return await (async () => {
                    const items = Array.from(document.querySelectorAll(
                        "li[role='option'].station-options__item, li[role='option']"
                    )).filter(el => el.offsetWidth || el.offsetHeight);
                    return JSON.stringify({ ok: items.length > 0, detail: "count=" + items.length });
                })();
                """)
            ]
        )
        if !s10.ok { return fail("Step 10 failed: \(s10.message)") }

        // ---- STEP 11 ----
        let s11 = await jsStep(
            "Select destination = \(destination)",
            action: selectStationActionJS(indexIsDestination: true),
            verifiers: [
                Verifier(
                    name: "station button[1] contains \(destination)",
                    js: verifyStationButtonJS(index: 1, code: destination)
                )
            ]
        )
        if !s11.ok { return fail("Step 11 failed: \(s11.message)") }

        // ---- STEP 12 ----
        // JSX's picker renders in two phases: first the overlay shell (with
        // month-name text in it), then the actual day cells a few hundred
        // milliseconds later. We must wait for phase 2 — otherwise step 13
        // runs before any [aria-label="Month D, YYYY"] exists in the DOM and
        // gives up.
        let s12 = await jsStep(
            "Open depart datepicker (wait for day cells to render)",
            action: """
            return await (async () => {
                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 JSON.stringify({ ok: false, message: "depart-date input not found" });

                const wrapper = input.closest("mat-form-field, jsx-form-field, .form-field, .jsx-form-field");
                let toggle =
                    (wrapper && wrapper.querySelector("mat-datepicker-toggle button")) ||
                    (wrapper && wrapper.querySelector("button[aria-label*='calendar' i]")) ||
                    (wrapper && wrapper.querySelector("button[aria-label*='date picker' i]")) ||
                    input;
                toggle.click();
                await sleep(400);

                // Count real day cells: any visible element whose aria-label
                // matches the pattern "<Month> <Day>, <Year>".
                const DATE_ARIA = /\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},?\\s+\\d{4}\\b/;
                const countDayCells = () => Array.from(document.querySelectorAll("[aria-label]"))
                    .filter(el => (el.offsetWidth || el.offsetHeight) && DATE_ARIA.test(el.getAttribute("aria-label") || ""))
                    .length;

                // Poll up to 5 seconds for day cells to actually render.
                const deadline = Date.now() + 5000;
                let reopens = 0;
                let lastCellCount = 0;
                while (Date.now() < deadline) {
                    lastCellCount = countDayCells();
                    if (lastCellCount > 0) break;
                    // Halfway through, try re-clicking the input in case the
                    // first open didn't take.
                    if (reopens === 0 && Date.now() - (deadline - 5000) > 2000) {
                        input.click();
                        if (input.focus) input.focus();
                        reopens++;
                    }
                    await sleep(150);
                }

                return JSON.stringify({
                    ok: lastCellCount > 0,
                    message: lastCellCount > 0
                        ? ("picker open, " + lastCellCount + " day cells rendered" + (reopens ? " (reopened " + reopens + "x)" : ""))
                        : "picker opened but no day cells rendered within 5s",
                    dayCellCount: lastCellCount,
                    reopens
                });
            })();
            """,
            verifiers: [
                Verifier(name: "≥1 day-aria-label cell rendered", js: """
                return await (async () => {
                    const DATE_ARIA = /\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},?\\s+\\d{4}\\b/;
                    const n = Array.from(document.querySelectorAll("[aria-label]"))
                        .filter(el => (el.offsetWidth || el.offsetHeight) && DATE_ARIA.test(el.getAttribute("aria-label") || ""))
                        .length;
                    return JSON.stringify({ ok: n > 0, detail: "day cells=" + n });
                })();
                """)
            ]
        )
        if !s12.ok { return fail("Step 12 failed: \(s12.message)") }

        // ---- STEP 13 ----
        let s13 = await jsStep(
            "Ensure target day cell is visible (\(targetMonthName) \(targetDay), \(targetYear))",
            action: navigateMonthActionJS(),
            verifiers: [
                Verifier(name: "aria-label '\(targetMonthName) \(targetDay), \(targetYear)' present", js: """
                return await (async () => {
                    const targetMonthName = "\(targetMonthName)";
                    const targetYear = \(targetYear);
                    const targetDay = \(targetDay);
                    const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear;
                    const cells = Array.from(document.querySelectorAll("[aria-label]"))
                        .filter(el => el.offsetWidth || el.offsetHeight);
                    const exact = cells.some(el => {
                        const a = (el.getAttribute("aria-label") || "").trim();
                        return a === ariaExact || a.startsWith(ariaExact);
                    });
                    if (exact) return JSON.stringify({ ok: true, detail: "exact match" });
                    const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b");
                    const loose = cells.some(el => {
                        const a = el.getAttribute("aria-label") || "";
                        return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a);
                    });
                    return JSON.stringify({ ok: loose, detail: loose ? "loose match" : "cell not found" });
                })();
                """)
            ]
        )
        if !s13.ok { return fail("Step 13 failed: \(s13.message)") }

        // ---- STEP 14 ----
        let s14 = await jsStep(
            "Click day cell for \(targetMonthName) \(targetDay), \(targetYear) (by aria-label)",
            action: clickDayCellActionJS(),
            verifiers: [
                Verifier(name: "day cell click was dispatched", js: """
                return await (async () => {
                    // Post-condition check: the depart-date input should now have
                    // a value. If not, the click missed.
                    const input = Array.from(document.querySelectorAll("input")).find(i => {
                        const label = (i.getAttribute("aria-label") || "").toLowerCase();
                        return label.includes("depart date");
                    });
                    const val = (input && input.value) || "";
                    return JSON.stringify({ ok: val.length > 0, detail: "input.value=" + (val || "(empty)") });
                })();
                """)
            ]
        )
        if !s14.ok { return fail("Step 14 failed: \(s14.message)") }

        // ---- STEP 15 ----
        let s15 = await jsStep(
            "Click DONE button to commit date",
            action: """
            return await (async () => {
                const sleep = ms => new Promise(r => setTimeout(r, ms));
                const visible = el => !!(el.offsetWidth || el.offsetHeight);
                const doneBtn = Array.from(document.querySelectorAll("button, [role='button']"))
                    .filter(visible)
                    .find(el => /^\\s*done\\s*$/i.test((el.innerText || el.textContent || "").trim()));
                if (!doneBtn) {
                    return JSON.stringify({ ok: false, message: "DONE button not found" });
                }
                doneBtn.scrollIntoView({ block: "center" });
                for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
                    doneBtn.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
                }
                if (typeof doneBtn.click === "function") doneBtn.click();
                await sleep(600);
                return JSON.stringify({ ok: true, message: "dispatched click on DONE" });
            })();
            """,
            verifiers: [
                Verifier(name: "datepicker panel no longer visible", js: """
                return await (async () => {
                    const candidates = document.querySelectorAll(
                        "mat-calendar, .mat-calendar, mat-datepicker-content, [class*='mat-datepicker'], .cdk-overlay-container [class*='datepicker']"
                    );
                    let anyVisible = false;
                    for (const c of candidates) { if (c.offsetWidth || c.offsetHeight) { anyVisible = true; break; } }
                    return JSON.stringify({ ok: !anyVisible, detail: anyVisible ? "still visible" : "closed" });
                })();
                """),
                Verifier(name: "depart-date input has a value", js: """
                return await (async () => {
                    const inputs = Array.from(document.querySelectorAll("input")).filter(i =>
                        (i.getAttribute("aria-label") || "").toLowerCase().includes("depart date")
                    );
                    const withValue = inputs.filter(i => (i.value || "").length > 0);
                    const firstVal = (inputs[0] && inputs[0].value) || "";
                    return JSON.stringify({
                        ok: withValue.length > 0,
                        detail: "value=" + (firstVal || "(empty)")
                    });
                })();
                """),
                Verifier(name: "only one visible depart-date input", js: """
                return await (async () => {
                    const visibles = Array.from(document.querySelectorAll("input"))
                        .filter(i => (i.getAttribute("aria-label") || "").toLowerCase().includes("depart date"))
                        .filter(i => i.offsetWidth || i.offsetHeight);
                    return JSON.stringify({ ok: visibles.length === 1, detail: "count=" + visibles.length });
                })();
                """)
            ]
        )
        if !s15.ok { return fail("Step 15 failed: \(s15.message)") }

        // ---- STEP 16 ----
        let s16 = await jsStep(
            "Force Angular form revalidation",
            action: """
            return await (async () => {
                const sleep = ms => new Promise(r => setTimeout(r, ms));
                document.body.click();
                for (const i of document.querySelectorAll("input")) {
                    i.dispatchEvent(new Event("blur", { bubbles: true }));
                }
                await sleep(300);
                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 && item.control.controls ? item.control : null);
                            if (form) {
                                try { if (form.markAllAsTouched) form.markAllAsTouched(); } catch (_) {}
                                try { if (form.updateValueAndValidity) form.updateValueAndValidity(); } catch (_) {}
                                touched++;
                            }
                        }
                    }
                    if (touched > 30) break;
                }
                await sleep(400);
                return JSON.stringify({ ok: true, message: "touched " + touched + " form controls", touched });
            })();
            """,
            verifiers: [
                Verifier(name: "Find Flights button is enabled", js: """
                return await (async () => {
                    const btn = Array.from(document.querySelectorAll("button"))
                        .find(b => /find flights/i.test(b.textContent || ""));
                    if (!btn) return JSON.stringify({ ok: false, detail: "button not found" });
                    const disabled = btn.disabled || btn.getAttribute("aria-disabled") === "true";
                    return JSON.stringify({
                        ok: !disabled,
                        detail: "disabled=" + btn.disabled + " aria-disabled=" + btn.getAttribute("aria-disabled")
                    });
                })();
                """)
            ]
        )
        if !s16.ok { return fail("Step 16 failed: \(s16.message)") }

        // ---- STEP 17 ----
        // Translate the successful real-Chrome flow into WKWebView terms:
        // after one-way/origin/destination/date are set and DONE has been
        // pressed, prime the search component's date state, click the real
        // FIND FLIGHTS button, and wait for booking/select + Angular
        // availability state. If that path still does not materialize
        // state, fall back to component.search(), then finally to the
        // direct in-page POST.
        let s17 = await jsStep(
            "Submit JSX search (Find Flights → component.search → direct fetch)",
            action: """
            return await (async () => {
                const sleep = ms => new Promise(r => setTimeout(r, ms));
                const safeStringify = (value) => {
                    const seen = new WeakSet();
                    return JSON.stringify(value, (_key, current) => {
                        if (typeof current === "function") return undefined;
                        if (current instanceof Date) return current.toISOString();
                        if (!current || typeof current !== "object") return current;
                        if (seen.has(current)) return "[Circular]";
                        seen.add(current);
                        return current;
                    });
                };
                const uiSearchWaitMs = 15000;

                const readToken = () => {
                    try {
                        const raw = sessionStorage.getItem("navitaire.digital.token");
                        if (!raw) return "";
                        const parsed = JSON.parse(raw);
                        return (parsed && parsed.token) || "";
                    } catch (_) { return ""; }
                };
                const resourceStats = () => {
                    const resources = (window.__jsxProbe && Array.isArray(window.__jsxProbe.resources))
                        ? window.__jsxProbe.resources
                        : [];
                    const searchResources = resources.filter(r => (r.url || "").includes("/availability/search/simple"));
                    return {
                        count: searchResources.length,
                        completed: searchResources.filter(r => (r.responseEnd || 0) > 0).length
                    };
                };

                const shapeScore = (obj) => {
                    if (!obj || typeof obj !== "object") return 0;
                    try {
                        const hasFares = !!(obj.faresAvailable && typeof obj.faresAvailable === "object"
                            && Object.keys(obj.faresAvailable).length > 0);
                        if (Array.isArray(obj.results)) {
                            for (const x of obj.results) {
                                if (x && Array.isArray(x.trips)) {
                                    for (const t of x.trips) {
                                        if (t && t.journeysAvailableByMarket) {
                                            return hasFares ? 3 : 2;
                                        }
                                    }
                                }
                            }
                        }
                        if (hasFares) return 2;
                        if (obj.journeysAvailableByMarket) return 1;
                    } catch (_) {}
                    return 0;
                };

                const jsonSafe = (v, d = 0) => {
                    if (d > 10) return null;
                    if (v === null || v === undefined) return v;
                    if (typeof v === "function") return undefined;
                    if (v instanceof Date) return v.toISOString();
                    if (typeof v !== "object") return v;
                    if (Array.isArray(v)) {
                        const out = [];
                        for (let i = 0; i < Math.min(v.length, 100); i++) {
                            const e = jsonSafe(v[i], d + 1);
                            if (e !== undefined) out.push(e);
                        }
                        return out;
                    }
                    const out = {};
                    for (const k of Object.keys(v)) {
                        if (k.startsWith("_$") || k === "__proto__") continue;
                        let c;
                        try { c = v[k]; } catch (_) { continue; }
                        const s = jsonSafe(c, d + 1);
                        if (s !== undefined) out[k] = s;
                    }
                    return out;
                };

                const harvestAvailability = () => {
                    const seen = new WeakSet();
                    let bestHit = null;
                    let bestScore = 0;
                    const find = (obj, depth) => {
                        if (depth > 6 || !obj || typeof obj !== "object") return;
                        if (seen.has(obj)) return;
                        seen.add(obj);
                        const score = shapeScore(obj);
                        if (score > bestScore) {
                            bestHit = obj;
                            bestScore = score;
                        }
                        if (bestScore >= 3) return;
                        if (Array.isArray(obj)) {
                            for (let i = 0; i < Math.min(obj.length, 40); i++) {
                                find(obj[i], depth + 1);
                                if (bestScore >= 3) return;
                            }
                            return;
                        }
                        const keys = Object.keys(obj);
                        for (let i = 0; i < Math.min(keys.length, 40); i++) {
                            const k = keys[i];
                            if (k.startsWith("_$") || k === "__proto__") continue;
                            let child;
                            try { child = obj[k]; } catch (_) { continue; }
                            find(child, depth + 1);
                            if (bestScore >= 3) return;
                        }
                    };

                    for (const el of document.querySelectorAll("*")) {
                        const k = Object.keys(el).find(k => k.startsWith("__ngContext__"));
                        if (!k) continue;
                        const ctx = el[k];
                        if (!Array.isArray(ctx)) continue;
                        for (const item of ctx) {
                            find(item, 0);
                            if (bestScore >= 3) return bestHit;
                        }
                    }
                    return bestHit;
                };

                const findComponent = () => {
                    for (const el of document.querySelectorAll("*")) {
                        const ctxKey = Object.keys(el).find(k => k.startsWith("__ngContext__"));
                        if (!ctxKey) continue;
                        const ctx = el[ctxKey];
                        if (!Array.isArray(ctx)) continue;
                        for (let idx = 0; idx < ctx.length; idx++) {
                            const item = ctx[idx];
                            if (!item || typeof item !== "object") continue;
                            if ("beginDate" in item && "origin" in item
                                && "destination" in item && typeof item.search === "function") {
                                const proto = Object.getPrototypeOf(item);
                                return {
                                    component: item,
                                    componentInfo: {
                                        ctor: proto && proto.constructor ? proto.constructor.name : "?",
                                        idx,
                                        elTag: (el.tagName || "").toLowerCase(),
                                        elClass: (el.className || "").toString().slice(0, 120)
                                    }
                                };
                            }
                        }
                    }
                    return { component: null, componentInfo: null };
                };

                const setControlValue = (control, value) => {
                    if (!control || typeof control !== "object") return false;
                    try {
                        if (typeof control.setValue === "function") {
                            control.setValue(value, { emitEvent: true });
                            return true;
                        }
                    } catch (_) {}
                    try {
                        if (typeof control.patchValue === "function") {
                            control.patchValue(value, { emitEvent: true });
                            return true;
                        }
                    } catch (_) {}
                    return false;
                };

                const listControlEntries = (root, prefix = "", out = [], seen = new WeakSet()) => {
                    if (!root || typeof root !== "object" || seen.has(root)) return out;
                    seen.add(root);
                    const controls = root.controls;
                    if (!controls || typeof controls !== "object") return out;

                    if (Array.isArray(controls)) {
                        for (let idx = 0; idx < controls.length; idx++) {
                            const child = controls[idx];
                            const path = prefix ? prefix + "." + idx : String(idx);
                            out.push({ path, control: child });
                            listControlEntries(child, path, out, seen);
                        }
                        return out;
                    }

                    for (const key of Object.keys(controls)) {
                        const child = controls[key];
                        const path = prefix ? prefix + "." + key : key;
                        out.push({ path, control: child });
                        listControlEntries(child, path, out, seen);
                    }
                    return out;
                };

                const controlHasChildren = (control) =>
                    !!(control && typeof control === "object" && control.controls
                        && (Array.isArray(control.controls) || typeof control.controls === "object"));

                const invalidControlPaths = (form) => {
                    if (!form || typeof form !== "object") return [];
                    return listControlEntries(form)
                        .filter(entry => !controlHasChildren(entry.control))
                        .filter(entry => {
                            const control = entry.control;
                            return !!(control && typeof control === "object" && control.invalid === true);
                        })
                        .slice(0, 12)
                        .map(entry => entry.path);
                };

                const readResources = () => {
                    try {
                        const raw = sessionStorage.getItem("navitaire.digital.resources");
                        return raw ? JSON.parse(raw) : null;
                    } catch (_) {
                        return null;
                    }
                };

                const stationFromResources = (code) => {
                    const resources = readResources();
                    const stations = resources && resources.entities && resources.entities.stations
                        && resources.entities.stations.values;
                    return (stations && stations[code]) || null;
                };

                const stationValueLooksRight = (value, code) => {
                    if (!value || typeof value !== "object") return false;
                    const stationCode = String(value.stationCode || value.code || value.locationCode || "").toUpperCase();
                    if (stationCode === code) return true;
                    const shortName = String(
                        (value.locationDetails && value.locationDetails.shortName)
                        || value.shortName
                        || value.name
                        || ""
                    ).toUpperCase();
                    return shortName.includes(code);
                };

                const safePreview = (value, depth = 0) => {
                    if (depth > 2) return null;
                    if (value === null || value === undefined) return value;
                    if (typeof value === "function") return "[function]";
                    if (value instanceof Date) return value.toISOString();
                    if (typeof value !== "object") return value;
                    if (Array.isArray(value)) return value.slice(0, 8).map(v => safePreview(v, depth + 1));
                    const out = {};
                    for (const key of Object.keys(value).slice(0, 12)) {
                        try { out[key] = safePreview(value[key], depth + 1); } catch (_) {}
                    }
                    return out;
                };

                const stationDisplayText = (station) => {
                    if (!station || typeof station !== "object") return "";
                    const shortName = String(
                        (station.locationDetails && station.locationDetails.shortName)
                        || station.shortName
                        || station.name
                        || station.stationCode
                        || ""
                    ).trim();
                    const region = String(
                        (station.locationDetails && station.locationDetails.provinceStateCode)
                        || station.provinceStateCode
                        || ""
                    ).trim();
                    if (shortName && region) return shortName + ", " + region;
                    return shortName || region;
                };

                const pushEmitter = (target, value) => {
                    if (!target || typeof target !== "object") return false;
                    try {
                        if (typeof target.next === "function") {
                            target.next(value);
                            return true;
                        }
                    } catch (_) {}
                    try {
                        if (typeof target.emit === "function") {
                            target.emit(value);
                            return true;
                        }
                    } catch (_) {}
                    return false;
                };

                const componentStateSnapshot = (component) => ({
                    origin: stationValueLooksRight(component && component.origin, "\(origin)"),
                    destination: stationValueLooksRight(component && component.destination, "\(destination)"),
                    beginDateType: component && component.beginDate instanceof Date ? "date" : typeof (component && component.beginDate),
                    tripType: component && component.tripType || null,
                    fromInputText: component && component.fromStation && component.fromStation.inputText || null,
                    toInputText: component && component.toStation && component.toStation.inputText || null,
                    departureSelectionComplete: component && component.departureSelectionComplete === true,
                    arrivalSelectionComplete: component && component.arrivalSelectionComplete === true,
                    searchStart: component && "searchStart" in component ? component.searchStart : null,
                    searchComplete: component && "searchComplete" in component ? component.searchComplete : null,
                    searchButtonDisabled: !!(component && component.searchButton && component.searchButton.disabled),
                    searchButtonAriaDisabled: component && component.searchButton
                        && typeof component.searchButton.getAttribute === "function"
                        ? component.searchButton.getAttribute("aria-disabled")
                        : null
                });

                const pickerPreview = (picker) => ({
                    inputText: picker && picker.inputText || null,
                    showResults: picker && picker.showResults || false,
                    hasInputTextStream: !!(picker && picker.inputText$),
                    hasSelectedUpdated: !!(picker && picker.selectedUpdated)
                });

                const unlockButton = (button) => {
                    if (!button || typeof button !== "object") return false;
                    let touched = false;
                    try {
                        if (typeof button.removeAttribute === "function") {
                            button.removeAttribute("disabled");
                            touched = true;
                        }
                    } catch (_) {}
                    try {
                        button.disabled = false;
                        touched = true;
                    } catch (_) {}
                    try {
                        if (typeof button.setAttribute === "function") {
                            button.setAttribute("aria-disabled", "false");
                            touched = true;
                        }
                    } catch (_) {}
                    return touched;
                };

                const primeComponentState = (component, mode = "minimal") => {
                    if (!component || typeof component !== "object") return { primed: false };
                    const desiredDate = new Date(\(targetYear), \(targetMonth - 1), \(targetDay), 0, 0, 0);
                    const originStation = stationFromResources("\(origin)");
                    const destinationStation = stationFromResources("\(destination)");
                    const componentAssignments = [];
                    const methodCalls = [];
                    const notes = [];
                    const selection = {
                        originStation: originStation || null,
                        destinationStation: destinationStation || null,
                        departureDate: desiredDate,
                        returnDate: null,
                        tripType: "OneWay"
                    };
                    const callMaybe = (name, args) => {
                        if (!component || typeof component[name] !== "function") return;
                        try {
                            component[name](...args);
                            methodCalls.push(name + "(ok)");
                        } catch (err) {
                            methodCalls.push(name + "(" + String(err && err.message ? err.message : err) + ")");
                        }
                    };

                    try {
                        component.beginDate = desiredDate;
                        componentAssignments.push("beginDate");
                    } catch (_) {}
                    try {
                        if ("endDate" in component) {
                            component.endDate = null;
                            componentAssignments.push("endDate");
                        }
                    } catch (_) {}
                    try {
                        if ("tripType" in component) {
                            component.tripType = "OneWay";
                            componentAssignments.push("tripType");
                        }
                    } catch (_) {}
                    if (originStation) {
                        try {
                            component.origin = originStation;
                            componentAssignments.push("origin");
                        } catch (_) {}
                    }
                    if (destinationStation) {
                        try {
                            component.destination = destinationStation;
                            componentAssignments.push("destination");
                        } catch (_) {}
                    }

                    try {
                        if (!component.latestTripSelection || typeof component.latestTripSelection !== "object") {
                            component.latestTripSelection = {};
                            componentAssignments.push("latestTripSelection={}");
                        }
                        if (component.latestTripSelection && typeof component.latestTripSelection === "object") {
                            Object.assign(component.latestTripSelection, selection);
                            componentAssignments.push("latestTripSelection");
                        }
                    } catch (_) {}

                    for (const key of ["departureSelectionComplete", "arrivalSelectionComplete"]) {
                        try {
                            if (key in component) {
                                component[key] = true;
                                componentAssignments.push(key);
                            }
                        } catch (_) {}
                    }
                    for (const key of ["searchStart", "searchComplete"]) {
                        try {
                            if (!(key in component)) continue;
                            const current = component[key];
                            if (typeof current === "boolean" || current == null) {
                                component[key] = false;
                                componentAssignments.push(key);
                            }
                        } catch (_) {}
                    }

                    const syncStationPicker = (picker, station, label, assignmentName) => {
                        if (!picker || typeof picker !== "object" || !station) return;
                        try {
                            picker.inputText = label;
                            componentAssignments.push(assignmentName + ".inputText");
                        } catch (_) {}
                        try {
                            picker.showResults = false;
                            componentAssignments.push(assignmentName + ".showResults");
                        } catch (_) {}
                        if (mode !== "inputs") {
                            return;
                        }
                        if (pushEmitter(picker.inputText$, label)) {
                            componentAssignments.push(assignmentName + ".inputText$");
                        }
                        if (pushEmitter(picker.selectedUpdated, station)) {
                            componentAssignments.push(assignmentName + ".selectedUpdated");
                        }
                    };

                    if (mode === "signals" || mode === "inputs" || mode === "methods") {
                        syncStationPicker(component.fromStation, originStation, stationDisplayText(originStation), "fromStation");
                        syncStationPicker(component.toStation, destinationStation, stationDisplayText(destinationStation), "toStation");
                    }

                    if (component.searchButton && unlockButton(component.searchButton)) {
                        componentAssignments.push("searchButton.unlocked");
                    }

                    if (mode === "methods") {
                        if (originStation) callMaybe("fromUpdated", [originStation]);
                        if (destinationStation) callMaybe("toUpdated", [destinationStation]);
                        if (originStation && destinationStation && typeof component.setSelectedStations === "function") {
                            try {
                                component.setSelectedStations(selection);
                                methodCalls.push("setSelectedStations(selection)");
                            } catch (err1) {
                                try {
                                    component.setSelectedStations(originStation, destinationStation);
                                    methodCalls.push("setSelectedStations(origin,destination)");
                                } catch (err2) {
                                    methodCalls.push("setSelectedStations(" + String(err2 && err2.message ? err2.message : err2) + ")");
                                }
                            }
                        }
                        callMaybe("validateTripSelection", []);
                        callMaybe("validateDates", []);
                        try {
                            if (component.changeDetectorRef && typeof component.changeDetectorRef.detectChanges === "function") {
                                component.changeDetectorRef.detectChanges();
                                methodCalls.push("detectChanges(ok)");
                            }
                        } catch (err) {
                            methodCalls.push("detectChanges(" + String(err && err.message ? err.message : err) + ")");
                        }
                    }

                    const form = component.form && typeof component.form === "object" ? component.form : null;
                    if (!originStation) notes.push("origin station missing from resources");
                    if (!destinationStation) notes.push("destination station missing from resources");

                    return {
                        primed: true,
                        strategy: mode,
                        beginDate: desiredDate.toISOString(),
                        componentAssignments,
                        formControlKeys: form ? listControlEntries(form).map(entry => entry.path).slice(0, 30) : [],
                        originResolved: originStation ? {
                            stationCode: originStation.stationCode || "",
                            shortName: (originStation.locationDetails && originStation.locationDetails.shortName) || originStation.name || ""
                        } : null,
                        destinationResolved: destinationStation ? {
                            stationCode: destinationStation.stationCode || "",
                            shortName: (destinationStation.locationDetails && destinationStation.locationDetails.shortName) || destinationStation.name || ""
                        } : null,
                        formState: form ? {
                            valid: form.valid === true,
                            invalid: form.invalid === true,
                            invalidPaths: invalidControlPaths(form)
                        } : null,
                        methodCalls,
                        notes,
                        componentState: componentStateSnapshot(component),
                        componentPreview: {
                            origin: safePreview(component.origin),
                            destination: safePreview(component.destination),
                            latestTripSelection: safePreview(component.latestTripSelection),
                            fromStation: pickerPreview(component.fromStation),
                            toStation: pickerPreview(component.toStation)
                        }
                    };
                };

                const clickFindFlights = async (component, forceEnable = false) => {
                    const visible = el => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
                    const button = Array.from(document.querySelectorAll("button, [role='button'], input[type='submit']"))
                        .filter(visible)
                        .find(el => /find flights/i.test(el.innerText || el.textContent || el.value || ""));
                    if (!button) {
                        return { found: false, clicked: false, message: "Find Flights button not found" };
                    }

                    const text = (button.innerText || button.textContent || button.value || "")
                        .replace(/\\s+/g, " ")
                        .trim();
                    const disabledBefore = !!button.disabled || button.getAttribute("aria-disabled") === "true";
                    if (disabledBefore && forceEnable) {
                        unlockButton(button);
                        if (component && component.searchButton && component.searchButton !== button) {
                            unlockButton(component.searchButton);
                        }
                        await sleep(60);
                    }
                    const disabled = !!button.disabled || button.getAttribute("aria-disabled") === "true";
                    if (disabled) {
                        return {
                            found: true,
                            clicked: false,
                            message: "Find Flights button disabled",
                            text,
                            disabled,
                            ariaDisabled: button.getAttribute("aria-disabled"),
                            forceEnableAttempted: forceEnable
                        };
                    }

                    button.scrollIntoView({ block: "center" });
                    try { button.focus(); } catch (_) {}
                    for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
                        try {
                            button.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
                        } catch (_) {}
                    }
                    try {
                        if (typeof button.click === "function") button.click();
                    } catch (_) {}
                    await sleep(700);

                    return {
                        found: true,
                        clicked: true,
                        message: "clicked Find Flights",
                        text,
                        disabled: false,
                        ariaDisabled: button.getAttribute("aria-disabled"),
                        forceEnableAttempted: forceEnable,
                        disabledBefore
                    };
                };

                const waitForAvailability = async (timeoutMs) => {
                    const deadline = Date.now() + timeoutMs;
                    let stats = resourceStats();
                    while (Date.now() < deadline) {
                        const hit = harvestAvailability();
                        stats = resourceStats();
                        if (hit) return { hit, href: location.href, stats };
                        if (location.href.includes("/booking/select") && stats.count > 0) {
                            await sleep(400);
                            const retry = harvestAvailability();
                            if (retry) return { hit: retry, href: location.href, stats: resourceStats() };
                        }
                        await sleep(300);
                    }
                    return { hit: null, href: location.href, stats };
                };

                let componentSearchError = null;
                let componentInfo = null;
                let primedComponent = null;
                let componentStateBefore = null;
                let initialClickResult = null;
                let clickResult = null;
                let primeAttempts = [];
                let component = null;
                try {
                    const found = findComponent();
                    componentInfo = found.componentInfo;
                    component = found.component;
                    if (component) {
                        componentStateBefore = componentStateSnapshot(component);
                    }
                } catch (err) {
                    componentSearchError = String(err && err.message ? err.message : err);
                }

                try {
                    initialClickResult = await clickFindFlights(component, false);
                    clickResult = initialClickResult;
                    if (initialClickResult && initialClickResult.clicked) {
                        const waited = await waitForAvailability(uiSearchWaitMs);
                        if (waited.hit) {
                            const serialized = safeStringify({ data: jsonSafe(waited.hit) });
                            return safeStringify({
                                ok: true,
                                message: "Find Flights click produced availability state"
                                    + (waited.href.includes("/booking/select") ? " on booking/select" : ""),
                                via: "find-flights-click",
                                component: componentInfo,
                                componentStateBefore,
                                initialClickResult,
                                clickResult,
                                componentSearchError,
                                searchResourceCount: waited.stats.count,
                                completedResourceCount: waited.stats.completed,
                                href: waited.href,
                                body: serialized
                            });
                        }
                    }
                } catch (err) {
                    clickResult = {
                        found: true,
                        clicked: false,
                        message: "Find Flights click threw: " + String(err && err.message ? err.message : err)
                    };
                }

                if (component) {
                    primedComponent = primeComponentState(component, "minimal");
                    primeAttempts.push(primedComponent);
                    try {
                        clickResult = await clickFindFlights(component, true);
                        if (clickResult && clickResult.clicked) {
                            const waited = await waitForAvailability(uiSearchWaitMs);
                            if (waited.hit) {
                                const serialized = safeStringify({ data: jsonSafe(waited.hit) });
                                return safeStringify({
                                    ok: true,
                                    message: "Find Flights click produced availability after minimal component sync"
                                        + (waited.href.includes("/booking/select") ? " on booking/select" : ""),
                                    via: "primed-find-flights-click",
                                    component: componentInfo,
                                    componentStateBefore,
                                    primedComponent,
                                    primeAttempts,
                                    initialClickResult,
                                    clickResult,
                                    componentSearchError,
                                    searchResourceCount: waited.stats.count,
                                    completedResourceCount: waited.stats.completed,
                                    href: waited.href,
                                    body: serialized
                                });
                            }
                        }
                    } catch (err) {
                        clickResult = {
                            found: true,
                            clicked: false,
                            message: "primed Find Flights click threw: " + String(err && err.message ? err.message : err)
                        };
                    }

                    primedComponent = primeComponentState(component, "inputs");
                    primeAttempts.push(primedComponent);
                }

                try {
                    if (component) {
                        try {
                            const r = component.search();
                            if (r && typeof r.then === "function") {
                                try { await r; } catch (err) {
                                    componentSearchError = String(err && err.message ? err.message : err);
                                }
                            }
                        } catch (err) {
                            componentSearchError = String(err && err.message ? err.message : err);
                        }

                        const waited = await waitForAvailability(uiSearchWaitMs);
                        if (waited.hit) {
                            const serialized = safeStringify({ data: jsonSafe(waited.hit) });
                            return safeStringify({
                                ok: true,
                                message: "component.search() produced availability state"
                                    + (waited.href.includes("/booking/select") ? " on booking/select" : ""),
                                via: "component-search",
                                component: componentInfo,
                                componentStateBefore,
                                primedComponent,
                                primeAttempts,
                                initialClickResult,
                                clickResult,
                                componentSearchError,
                                searchResourceCount: waited.stats.count,
                                completedResourceCount: waited.stats.completed,
                                href: waited.href,
                                body: serialized
                            });
                        }

                        primedComponent = primeComponentState(component, "methods");
                        primeAttempts.push(primedComponent);
                        try {
                            const retry = component.search();
                            if (retry && typeof retry.then === "function") {
                                try { await retry; } catch (err) {
                                    componentSearchError = String(err && err.message ? err.message : err);
                                }
                            }
                        } catch (err) {
                            componentSearchError = String(err && err.message ? err.message : err);
                        }

                        const retried = await waitForAvailability(uiSearchWaitMs);
                        if (retried.hit) {
                            const serialized = safeStringify({ data: jsonSafe(retried.hit) });
                            return safeStringify({
                                ok: true,
                                message: "component.search() produced availability after method sync"
                                    + (retried.href.includes("/booking/select") ? " on booking/select" : ""),
                                via: "component-search-method-sync",
                                component: componentInfo,
                                componentStateBefore,
                                primedComponent,
                                primeAttempts,
                                initialClickResult,
                                clickResult,
                                componentSearchError,
                                searchResourceCount: retried.stats.count,
                                completedResourceCount: retried.stats.completed,
                                href: retried.href,
                                body: serialized
                            });
                        }
                    }
                } catch (err) {
                    componentSearchError = String(err && err.message ? err.message : err);
                }

                const token = readToken();
                if (!token) {
                    const stats = resourceStats();
                    return safeStringify({
                        ok: false,
                        message: "component.search() found no availability state and no token in sessionStorage",
                        via: "neither",
                        componentSearchError,
                        component: componentInfo,
                        componentStateBefore,
                        primedComponent,
                        primeAttempts,
                        initialClickResult,
                        clickResult,
                        searchResourceCount: stats.count,
                        completedResourceCount: stats.completed,
                        href: location.href
                    });
                }

                const url = "https://api.jsx.com/api/nsk/v4/availability/search/simple";
                const body = JSON.stringify({
                    beginDate: "\(date)",
                    destination: "\(destination)",
                    origin: "\(origin)",
                    passengers: { types: [{ count: 1, type: "ADT" }] },
                    taxesAndFees: 2,
                    filters: {
                        maxConnections: 4,
                        compressionType: 1,
                        sortOptions: [4],
                        fareTypes: ["R"],
                        exclusionType: 2
                    },
                    numberOfFaresPerJourney: 10,
                    codes: { currencyCode: "USD" },
                    ssrCollectionsMode: 1
                });
                const lowFareUrl = (() => {
                    const qs = new URLSearchParams({
                        origin: "\(origin)",
                        destination: "\(destination)",
                        currencyCode: "USD",
                        includeTaxesAndFees: "true",
                        startDate: "\(date)",
                        endDate: "\(date)",
                        numberOfPassengers: "1"
                    });
                    return "https://api.jsx.com/api/nsk/v1/availability/lowfare/estimate?" + qs.toString();
                })();

                const warmLowFare = async () => {
                    const warmToken = readToken();
                    if (!warmToken) {
                        return { ok: false, status: 0, error: "missing token for lowfare warmup" };
                    }
                    try {
                        const resp = await fetch(lowFareUrl, {
                            method: "GET",
                            credentials: "include",
                            headers: {
                                "Accept": "application/json, text/plain, */*",
                                "Authorization": warmToken
                            }
                        });
                        const text = await resp.text();
                        return {
                            ok: resp.status >= 200 && resp.status < 300 && text.length > 0,
                            status: resp.status,
                            length: text.length
                        };
                    } catch (err) {
                        return {
                            ok: false,
                            status: 0,
                            error: String(err && err.message ? err.message : err)
                        };
                    }
                };

                const runDirectFetch = async () => {
                    const directToken = readToken();
                    if (!directToken) {
                        return {
                            ok: false,
                            status: 0,
                            length: 0,
                            error: "missing token before direct fetch"
                        };
                    }
                    try {
                        const resp = await fetch(url, {
                            method: "POST",
                            credentials: "include",
                            headers: {
                                "Content-Type": "application/json",
                                "Accept": "application/json, text/plain, */*",
                                "Authorization": directToken
                            },
                            body
                        });
                        const text = await resp.text();
                        return {
                            ok: resp.status >= 200 && resp.status < 300 && text.length > 100,
                            status: resp.status,
                            length: text.length,
                            body: text
                        };
                    } catch (err) {
                        return {
                            ok: false,
                            status: 0,
                            length: 0,
                            error: String(err && err.message ? err.message : err)
                        };
                    }
                };

                let directFetchResult;
                let lowFareWarmup = { ok: false, status: 0, skipped: true };
                directFetchResult = await runDirectFetch();
                if (!directFetchResult.ok) {
                    lowFareWarmup = await warmLowFare();
                    await sleep(lowFareWarmup.ok ? 800 : 1600);
                    directFetchResult = await runDirectFetch();
                }
                if (!directFetchResult.ok && (directFetchResult.error || "").includes("Load failed")) {
                    await sleep(1600);
                    directFetchResult = await runDirectFetch();
                }

                if (directFetchResult.ok) {
                    const stats = resourceStats();
                    return safeStringify({
                        ok: true,
                        message: "direct fetch POST succeeded status=" + directFetchResult.status
                            + " len=" + directFetchResult.length,
                        via: "direct-fetch",
                        componentSearchError,
                        component: componentInfo,
                        componentStateBefore,
                        primedComponent,
                        primeAttempts,
                        initialClickResult,
                        clickResult,
                        directStatus: directFetchResult.status,
                        directLength: directFetchResult.length,
                        lowFareWarmup,
                        searchResourceCount: stats.count,
                        completedResourceCount: stats.completed,
                        href: location.href,
                        body: directFetchResult.body
                    });
                }

                const waited = await waitForAvailability(5000);
                if (waited.hit) {
                    const serialized = safeStringify({ data: jsonSafe(waited.hit) });
                    return safeStringify({
                        ok: true,
                        message: "harvested from SPA state after direct fetch fallback",
                        via: "state-harvest",
                        componentSearchError,
                        component: componentInfo,
                        componentStateBefore,
                        primedComponent,
                        primeAttempts,
                        initialClickResult,
                        clickResult,
                        directFetchError: directFetchResult.error || ("status " + directFetchResult.status),
                        lowFareWarmup,
                        searchResourceCount: waited.stats.count,
                        completedResourceCount: waited.stats.completed,
                        href: waited.href,
                        body: serialized
                    });
                }

                return safeStringify({
                    ok: false,
                    message: "component.search() and direct fetch both failed to produce availability"
                        + " (direct: " + (directFetchResult.error || ("status=" + directFetchResult.status)) + ")",
                    via: "neither",
                    componentSearchError,
                    directStatus: directFetchResult.status,
                    directError: directFetchResult.error || null,
                    lowFareWarmup,
                    component: componentInfo,
                    componentStateBefore,
                    primedComponent,
                    primeAttempts,
                    initialClickResult,
                    clickResult,
                    searchResourceCount: waited.stats.count,
                    completedResourceCount: waited.stats.completed,
                    href: waited.href
                });
            })();
            """,
            verifiers: [
                Verifier(name: "body present in action result", js: """
                return await (async () => {
                    // The action sets body as an action data field; the
                    // runner can't see it from JS. Just confirm SPA is still
                    // on jsx.com (not a redirect or error page).
                    return JSON.stringify({
                        ok: location.href.includes("jsx.com"),
                        detail: location.href
                    });
                })();
                """)
            ]
        )

        // Log the component identity + network observation.
        if let info = s17.data["component"] as? [String: Any] {
            print("[JSX] ▸ flight search component: ctor=\(info["ctor"] ?? "?")"
                + " idx=\(info["idx"] ?? "?")"
                + " host=<\(info["elTag"] ?? "?") class='\(info["elClass"] ?? "")'>")
        }
        if let searchCount = s17.data["searchResourceCount"] as? Int {
            let completed = (s17.data["completedResourceCount"] as? Int) ?? 0
            print("[JSX] ▸ /search/simple resource entries: \(searchCount) total, \(completed) completed")
        }
        if let initialClickInfo = s17.data["initialClickResult"] as? [String: Any] {
            print("[JSX] ▸ Find Flights initial attempt: \(describe(initialClickInfo))")
        }
        if let clickInfo = s17.data["clickResult"] as? [String: Any] {
            print("[JSX] ▸ Find Flights attempt: \(describe(clickInfo))")
        }

        // ---- STEP 18 ----
        // Parse whatever step 17 harvested. If step 17 produced a `body`
        // field, that's the wrapped availability payload; feed it straight
        // to parseFlights(). If not, fall back to a direct GET of
        // /lowfare/estimate from the page context (GETs are known to
        // succeed from WKWebView) so the UI still has something to show.
        var parsedFlights: [JSXFlight] = []
        let rawHarvested: String? = s17.data["body"] as? String
        var lowFareFallback: JSXLowFareEstimate? = nil
        self.capturedBody = rawHarvested

        let s18 = await nativeStep("Parse harvested availability data") { [weak self] in
            guard let self else { return (false, "self gone", [:]) }

            // PRIMARY: try GraphQL /api/v2/graph/searchAvailability — the
            // endpoint AIRLINE_INTEGRATION_GUIDE.md recommends. If it
            // succeeds, use those flights and skip the /search/simple
            // harvest / native REST fallback entirely.
            if let graphQLFlights = await self.nativePOSTGraphQLAvailability(),
               !graphQLFlights.isEmpty {
                parsedFlights = graphQLFlights
                for flight in graphQLFlights {
                    let classesText = flight.classes.map { c in
                        "\(c.classOfService)/\(c.productClass):\(c.availableCount)@$\(Int(c.fareTotal))"
                    }.joined(separator: ", ")
                    let low = flight.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a"
                    print("[JSX] │    \(flight.flightNumber)  \(flight.origin)→\(flight.destination)  "
                        + "\(flight.departureLocal) → \(flight.arrivalLocal)  "
                        + "stops=\(flight.stops)  seats=\(flight.totalAvailable)  from=\(low)  [\(classesText)]")
                }
                return (true, "decoded \(graphQLFlights.count) flights via GraphQL searchAvailability",
                        ["count": graphQLFlights.count, "via": "graphql"])
            }
            print("[JSX] ▸ GraphQL path returned nothing; falling back to /search/simple harvest")

            if let rawHarvested, !rawHarvested.isEmpty {
                parsedFlights = self.parseFlights(from: rawHarvested)
                if parsedFlights.isEmpty {
                    return (false, "parser returned 0 flights from \(rawHarvested.count)-byte harvested payload",
                            ["rawLength": rawHarvested.count])
                }
                for flight in parsedFlights {
                    let classesText = flight.classes.map { c in
                        "\(c.classOfService)/\(c.productClass):\(c.availableCount)@$\(Int(c.fareTotal))"
                    }.joined(separator: ", ")
                    let low = flight.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a"
                    print("[JSX] │    \(flight.flightNumber)  \(flight.origin)→\(flight.destination)  "
                        + "\(flight.departureLocal) → \(flight.arrivalLocal)  "
                        + "stops=\(flight.stops)  seats=\(flight.totalAvailable)  from=\(low)  [\(classesText)]")
                }
                return (true, "decoded \(parsedFlights.count) flights from harvested SPA state",
                        ["count": parsedFlights.count])
            } else {
                print("[JSX] ▸ no harvested payload; trying native URLSession POST /search/simple")
                if let nativeBody = await self.nativePOSTSearchSimple() {
                    self.capturedBody = nativeBody
                    parsedFlights = self.parseFlights(from: nativeBody)
                    if !parsedFlights.isEmpty {
                        for flight in parsedFlights {
                            let classesText = flight.classes.map { c in
                                "\(c.classOfService)/\(c.productClass):\(c.availableCount)@$\(Int(c.fareTotal))"
                            }.joined(separator: ", ")
                            let low = flight.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a"
                            print("[JSX] │    \(flight.flightNumber)  \(flight.origin)→\(flight.destination)  "
                                + "\(flight.departureLocal) → \(flight.arrivalLocal)  "
                                + "stops=\(flight.stops)  seats=\(flight.totalAvailable)  from=\(low)  [\(classesText)]")
                        }
                        return (true, "decoded \(parsedFlights.count) flights from native URLSession POST",
                                ["count": parsedFlights.count, "via": "native-urlsession"])
                    }
                    print("[JSX] │    native URLSession POST returned \(nativeBody.count) bytes but parser found 0 flights")
                }
                print("[JSX] ▸ no harvested payload; fetching /lowfare/estimate directly from page context")
                if let lf = await self.fetchLowFareEstimateDirectly() {
                    lowFareFallback = lf
                    print("[JSX] │    lowfare fallback: \(lf.date) available=\(lf.available)"
                        + (lf.lowestPrice.map { " lowestPrice=$\(Int($0))" } ?? ""))
                    return (true, "using low-fare fallback (\(lf.available) seats on \(lf.date))",
                            ["available": lf.available, "date": lf.date])
                }
                return (false, "no harvested payload and direct low-fare fetch failed", [:])
            }
        } verify: { [weak self] in
            guard let self else { return [] }
            var results: [Verification] = []
            if !parsedFlights.isEmpty {
                results.append(Verification(
                    name: "flights.count > 0",
                    passed: true,
                    detail: "count=\(parsedFlights.count)"
                ))
                let allMarketMatch = parsedFlights.allSatisfy {
                    $0.origin == self.origin && $0.destination == self.destination
                }
                results.append(Verification(
                    name: "every flight origin/destination matches \(self.origin)|\(self.destination)",
                    passed: allMarketMatch,
                    detail: ""
                ))
            } else if lowFareFallback != nil {
                results.append(Verification(
                    name: "low-fare fallback present",
                    passed: true,
                    detail: ""
                ))
            }
            return results
        }
        if !s18.ok {
            return fail("Step 18 failed: \(s18.message)")
        }

        logHeader(
            parsedFlights.isEmpty
                ? "JSX FLOW COMPLETE: low-fare fallback only (\(lowFareFallback?.available ?? 0) seats on \(lowFareFallback?.date ?? "?"))"
                : "JSX FLOW COMPLETE: \(parsedFlights.count) unique flights"
        )
        self.webView = nil
        return JSXSearchResult(
            flights: parsedFlights,
            rawSearchBody: rawHarvested,
            lowFareFallback: lowFareFallback,
            error: nil
        )
    }

    // MARK: - Low-fare direct fetch (step 18 fallback)

    private func nativePOSTSearchSimple() async -> String? {
        guard let webView = self.webView else {
            print("[JSX] │    native POST: webView missing")
            return nil
        }
        guard let token = await readNavitaireToken() else {
            print("[JSX] │    native POST: missing navitaire token")
            return nil
        }

        let allCookies = await allCookies(from: webView.configuration.websiteDataStore.httpCookieStore)
        let jsxCookies = allCookies.filter { cookie in
            let domain = cookie.domain.lowercased()
            return domain == "api.jsx.com"
                || domain == "jsx.com"
                || domain == ".jsx.com"
                || domain.hasSuffix(".jsx.com")
        }
        let cookieHeader = jsxCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")

        guard let url = URL(string: "https://api.jsx.com/api/nsk/v4/availability/search/simple") else {
            print("[JSX] │    native POST: invalid search/simple URL")
            return nil
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.timeoutInterval = 30
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
        request.setValue(token, forHTTPHeaderField: "Authorization")
        request.setValue("https://www.jsx.com", forHTTPHeaderField: "Origin")
        request.setValue("https://www.jsx.com/home/search", forHTTPHeaderField: "Referer")
        request.setValue(
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
                + "(KHTML, like Gecko) Version/17.4 Safari/605.1.15",
            forHTTPHeaderField: "User-Agent"
        )
        if !cookieHeader.isEmpty {
            request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
        }

        let body: [String: Any] = [
            "beginDate": self.date,
            "destination": self.destination,
            "origin": self.origin,
            "passengers": ["types": [["count": 1, "type": "ADT"]]],
            "taxesAndFees": 2,
            "filters": [
                "maxConnections": 4,
                "compressionType": 1,
                "sortOptions": [4],
                "fareTypes": ["R"],
                "exclusionType": 2
            ],
            "numberOfFaresPerJourney": 10,
            "codes": ["currencyCode": "USD"],
            "ssrCollectionsMode": 1
        ]
        guard let bodyData = try? JSONSerialization.data(withJSONObject: body) else {
            print("[JSX] │    native POST: failed to serialize request body")
            return nil
        }
        request.httpBody = bodyData

        let config = URLSessionConfiguration.ephemeral
        config.timeoutIntervalForRequest = 30
        config.timeoutIntervalForResource = 30
        let session = URLSession(configuration: config)

        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse else {
                print("[JSX] │    native POST: non-HTTP response")
                return nil
            }
            let text = String(data: data, encoding: .utf8) ?? ""
            print("[JSX] │    native POST: status=\(http.statusCode), body=\(text.count) bytes, cookies=\(jsxCookies.count)")
            guard (200..<300).contains(http.statusCode), text.count > 100 else {
                let preview = String(text.prefix(300))
                print("[JSX] │    native POST preview: \(preview)")
                return nil
            }
            return text
        } catch {
            let nsError = error as NSError
            print("[JSX] │    native POST error: \(error.localizedDescription) [\(nsError.domain) \(nsError.code)]")
            return nil
        }
    }

    /// POST /api/v2/graph/searchAvailability — the endpoint recommended by
    /// AIRLINE_INTEGRATION_GUIDE.md. Unlike /search/simple, GraphQL responses
    /// inline fare details under each fare (`passengerFares[].fareAmount`) so
    /// the parser doesn't need to join against `faresAvailable`. We still
    /// reuse the navitaire.digital.token acquired via WKWebView (15-min idle).
    /// Returns parsed flights on success so `run()` can short-circuit the
    /// /search/simple harvest when this path works.
    private func nativePOSTGraphQLAvailability() async -> [JSXFlight]? {
        guard let webView = self.webView else {
            print("[JSX] │    GraphQL: webView missing")
            return nil
        }
        guard let token = await readNavitaireToken() else {
            print("[JSX] │    GraphQL: missing navitaire token")
            return nil
        }

        let allCookies = await allCookies(from: webView.configuration.websiteDataStore.httpCookieStore)
        let jsxCookies = allCookies.filter { cookie in
            let domain = cookie.domain.lowercased()
            return domain == "api.jsx.com"
                || domain == "jsx.com"
                || domain == ".jsx.com"
                || domain.hasSuffix(".jsx.com")
        }
        let cookieHeader = jsxCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")

        guard let url = URL(string: "https://api.jsx.com/api/v2/graph/searchAvailability") else {
            print("[JSX] │    GraphQL: invalid URL")
            return nil
        }

        // GraphQL query — structure mirrors AIRLINE_INTEGRATION_GUIDE.md §4.
        // Kept on a single line inside the JSON to avoid escape issues.
        let graphQLQuery = """
        { availabilityv5(request: { criteria: [{ dates: { beginDate: \"\(self.date)\", endDate: \"\(self.date)\" }, stations: { originStationCodes: [\"\(self.origin)\"], destinationStationCodes: [\"\(self.destination)\"] } }], passengers: { types: [{ count: 1, type: \"ADT\" }] }, codes: { currencyCode: \"USD\" } }) { results { trips { date journeysAvailableByMarket { key value { journeyKey stops designator { arrival departure destination origin } segments { identifier { identifier carrierCode } legs { legInfo { equipmentType } } } fares { details { availableCount classOfService productClass passengerFares { fareAmount } } } } } } } } }
        """

        let body: [String: Any] = [
            "cachedResults": false,
            "query": graphQLQuery
        ]
        guard let bodyData = try? JSONSerialization.data(withJSONObject: body) else {
            print("[JSX] │    GraphQL: failed to serialize body")
            return nil
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.timeoutInterval = 30
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
        request.setValue(token, forHTTPHeaderField: "Authorization")
        request.setValue("https://www.jsx.com", forHTTPHeaderField: "Origin")
        request.setValue("https://www.jsx.com/home/search", forHTTPHeaderField: "Referer")
        request.setValue(
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
                + "(KHTML, like Gecko) Version/17.4 Safari/605.1.15",
            forHTTPHeaderField: "User-Agent"
        )
        if !cookieHeader.isEmpty {
            request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
        }
        request.httpBody = bodyData

        print("[JSX] │    GraphQL POST \(url) \(self.origin)→\(self.destination) on \(self.date)")

        let config = URLSessionConfiguration.ephemeral
        config.timeoutIntervalForRequest = 30
        config.timeoutIntervalForResource = 30
        let urlSession = URLSession(configuration: config)

        do {
            let (data, response) = try await urlSession.data(for: request)
            guard let http = response as? HTTPURLResponse else {
                print("[JSX] │    GraphQL: non-HTTP response")
                return nil
            }
            let text = String(data: data, encoding: .utf8) ?? ""
            print("[JSX] │    GraphQL status=\(http.statusCode), body=\(text.count) bytes")
            // Dump raw body so real field names / missing fields are visible.
            // GraphQL bodies are small; print up to 4000 chars in two chunks
            // so the Xcode console doesn't truncate mid-line.
            let preview = text.prefix(4000)
            print("[JSX] │    GraphQL raw body: \(preview)")
            guard (200..<300).contains(http.statusCode), text.count > 50 else {
                return nil
            }

            self.capturedBody = text  // preserve raw for debug logging
            let flights = parseGraphQLFlights(from: text)
            print("[JSX] │    GraphQL parsed \(flights.count) flights")
            return flights.isEmpty ? nil : flights
        } catch {
            let ns = error as NSError
            print("[JSX] │    GraphQL error: \(error.localizedDescription) [\(ns.domain) \(ns.code)]")
            return nil
        }
    }

    /// Parser for the GraphQL response shape. GraphQL differs from
    /// /search/simple in two ways:
    ///   1. `data.availabilityv5.results[...]` (extra `availabilityv5` wrapper)
    ///   2. `journeysAvailableByMarket` is an array of `{ key, value }` pairs
    ///      instead of a dict keyed by "ORIG|DEST"
    ///   3. Fare details are inlined with passengerFares[].fareAmount — no
    ///      separate `faresAvailable` join.
    fileprivate func parseGraphQLFlights(from rawJSON: String) -> [JSXFlight] {
        guard let data = rawJSON.data(using: .utf8),
              let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            print("[JSX] │  parseGraphQLFlights: root JSON not a dict")
            return []
        }

        if let errors = root["errors"] as? [[String: Any]], !errors.isEmpty {
            print("[JSX] │  parseGraphQLFlights: GraphQL errors: \(errors)")
        }

        guard let dataObj = root["data"] as? [String: Any],
              let availability = dataObj["availabilityv5"] as? [String: Any],
              let results = availability["results"] as? [[String: Any]] else {
            print("[JSX] │  parseGraphQLFlights: missing data.availabilityv5.results")
            return []
        }

        let marketKey = "\(self.origin)|\(self.destination)"
        var flights: [JSXFlight] = []
        var seenKeys = Set<String>()

        for result in results {
            let trips = result["trips"] as? [[String: Any]] ?? []
            for trip in trips {
                let byMarket = trip["journeysAvailableByMarket"] as? [[String: Any]] ?? []
                for pair in byMarket {
                    let key = (pair["key"] as? String) ?? marketKey
                    let journeys = pair["value"] as? [[String: Any]] ?? []
                    for journey in journeys {
                        if let flight = buildFlightFromGraphQL(journey: journey, marketKey: key) {
                            let dedupeKey = "\(flight.flightNumber)|\(flight.departureLocal)"
                            if seenKeys.insert(dedupeKey).inserted {
                                flights.append(flight)
                            }
                        }
                    }
                }
            }
        }

        flights.sort { $0.departureLocal < $1.departureLocal }
        return flights
    }

    private func buildFlightFromGraphQL(journey: [String: Any], marketKey: String) -> JSXFlight? {
        let segments = journey["segments"] as? [[String: Any]] ?? []
        guard !segments.isEmpty else { return nil }

        let firstSeg = segments[0]
        let firstIdentifier = firstSeg["identifier"] as? [String: Any] ?? [:]
        let carrierCode = (firstIdentifier["carrierCode"] as? String) ?? ""
        let baseNumber = (firstIdentifier["identifier"] as? String) ?? ""
        var fullFlightNumber = "\(carrierCode)\(baseNumber)"
        if segments.count > 1 {
            let extras = segments.dropFirst().compactMap { seg -> String? in
                let ident = seg["identifier"] as? [String: Any] ?? [:]
                let cc = (ident["carrierCode"] as? String) ?? ""
                let num = (ident["identifier"] as? String) ?? ""
                let combined = "\(cc)\(num)"
                return combined.isEmpty ? nil : combined
            }
            if !extras.isEmpty {
                fullFlightNumber += "+" + extras.joined(separator: "+")
            }
        }

        let designator = journey["designator"] as? [String: Any] ?? [:]
        let originCode = (designator["origin"] as? String)
            ?? marketOrigin(marketKey)
            ?? ""
        let destinationCode = (designator["destination"] as? String)
            ?? marketDestination(marketKey)
            ?? ""
        let departureLocal = (designator["departure"] as? String) ?? ""
        let arrivalLocal = (designator["arrival"] as? String) ?? ""

        let stops = (journey["stops"] as? Int)
            ?? ((journey["stops"] as? NSNumber)?.intValue ?? 0)

        var equipmentType: String?
        if let legs = firstSeg["legs"] as? [[String: Any]],
           let firstLeg = legs.first,
           let legInfo = firstLeg["legInfo"] as? [String: Any] {
            equipmentType = legInfo["equipmentType"] as? String
        }

        var classes: [JSXFareClass] = []
        let fares = journey["fares"] as? [[String: Any]] ?? []
        for fareEntry in fares {
            let details = fareEntry["details"] as? [[String: Any]] ?? []
            for detail in details {
                let availableCount = (detail["availableCount"] as? Int)
                    ?? ((detail["availableCount"] as? NSNumber)?.intValue ?? 0)
                let classOfService = (detail["classOfService"] as? String) ?? ""
                let productClass = (detail["productClass"] as? String) ?? ""

                var fareTotal: Double = 0
                if let pax = detail["passengerFares"] as? [[String: Any]],
                   let first = pax.first {
                    fareTotal = doubleFromJSON(first["fareAmount"]) ?? 0
                }

                classes.append(JSXFareClass(
                    classOfService: classOfService,
                    productClass: productClass,
                    availableCount: availableCount,
                    fareTotal: fareTotal,
                    revenueTotal: fareTotal,
                    fareBasisCode: nil
                ))
            }
        }

        let totalAvailable = classes.reduce(0) { $0 + $1.availableCount }
        let lowestFareTotal = classes.map { $0.fareTotal }.filter { $0 > 0 }.min()

        return JSXFlight(
            flightNumber: fullFlightNumber,
            carrierCode: carrierCode,
            origin: originCode,
            destination: destinationCode,
            departureLocal: departureLocal,
            arrivalLocal: arrivalLocal,
            stops: stops,
            equipmentType: equipmentType,
            totalAvailable: totalAvailable,
            lowestFareTotal: lowestFareTotal,
            classes: classes
        )
    }

    private func readNavitaireToken(waitUpTo seconds: TimeInterval = 3) async -> String? {
        let deadline = Date().addingTimeInterval(seconds)
        let js = """
        return await (async () => {
            try {
                const raw = sessionStorage.getItem("navitaire.digital.token");
                if (!raw) return JSON.stringify({ ok: false, token: "" });
                const parsed = JSON.parse(raw);
                const token = (parsed && parsed.token) || "";
                return JSON.stringify({ ok: !!token, token });
            } catch (_) {
                return JSON.stringify({ ok: false, token: "" });
            }
        })();
        """

        while Date() < deadline {
            if let result = await runJS(js),
               let token = result["token"] as? String,
               !token.isEmpty {
                return token
            }
            try? await Task.sleep(nanoseconds: 200_000_000)
        }
        return nil
    }

    private func allCookies(from store: WKHTTPCookieStore) async -> [HTTPCookie] {
        await withCheckedContinuation { continuation in
            store.getAllCookies { cookies in
                continuation.resume(returning: cookies)
            }
        }
    }

    /// Fire a direct GET /api/nsk/v1/availability/lowfare/estimate from the
    /// page context. GETs are known to succeed from WKWebView against
    /// api.jsx.com (it's only POST /availability/search/simple that the
    /// network stack can't complete). Uses the anonymous token from
    /// sessionStorage for auth.
    ///
    /// This is the graceful-degradation path when the SPA-state harvest in
    /// step 18 can't find any availability data (either because search()
    /// didn't fire the POST or the POST failed). It gives us a day-level
    /// seat count and cheapest fare so the UI can at least show something.
    private func fetchLowFareEstimateDirectly() async -> JSXLowFareEstimate? {
        let js = """
        return await (async () => {
            const readToken = () => {
                try {
                    const raw = sessionStorage.getItem("navitaire.digital.token");
                    if (!raw) return "";
                    const parsed = JSON.parse(raw);
                    return (parsed && parsed.token) || "";
                } catch (_) { return ""; }
            };
            const token = readToken();
            if (!token) return JSON.stringify({ ok: false, message: "no token" });

            const qs = new URLSearchParams({
                origin: "\(origin)",
                destination: "\(destination)",
                currencyCode: "USD",
                includeTaxesAndFees: "true",
                startDate: "\(date)",
                endDate: "\(date)",
                numberOfPassengers: "1"
            });
            const url = "https://api.jsx.com/api/nsk/v1/availability/lowfare/estimate?" + qs.toString();

            try {
                const resp = await fetch(url, {
                    method: "GET",
                    credentials: "include",
                    headers: {
                        "Accept": "application/json, text/plain, */*",
                        "Authorization": token
                    }
                });
                const body = await resp.text();
                return JSON.stringify({
                    ok: resp.status >= 200 && resp.status < 300,
                    status: resp.status,
                    body
                });
            } catch (err) {
                return JSON.stringify({
                    ok: false,
                    message: "fetch error: " + String(err && err.message ? err.message : err)
                });
            }
        })();
        """

        let result = await runJS(js)
        guard let ok = result?["ok"] as? Bool, ok,
              let body = result?["body"] as? String, !body.isEmpty else { return nil }
        guard let data = body.data(using: .utf8),
              let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let dataDict = root["data"] as? [String: Any],
              let lowFares = dataDict["lowFares"] as? [[String: Any]] else {
            return nil
        }
        // The estimate response is keyed by YYYY-MM-DD on `date`. Find the
        // entry whose date matches ours (prefix-match so T00:00:00 suffixes
        // don't matter).
        let match = lowFares.first { entry in
            if let d = entry["date"] as? String { return d.hasPrefix(self.date) }
            return false
        } ?? lowFares.first
        guard let match else { return nil }

        let available = (match["available"] as? Int)
            ?? (match["available"] as? NSNumber)?.intValue
            ?? 0
        let price: Double?
        if let d = match["price"] as? Double { price = d }
        else if let n = match["price"] as? NSNumber { price = n.doubleValue }
        else { price = nil }

        return JSXLowFareEstimate(date: self.date, available: available, lowestPrice: price)
    }

    // MARK: - Step infrastructure

    struct Verification {
        let name: String
        let passed: Bool
        let detail: String
    }

    struct Verifier {
        let name: String
        let js: String
    }

    struct StepOutcome {
        let ok: Bool
        let message: String
        let data: [String: Any]
        let verifications: [Verification]
    }

    /// JavaScript step — runs an action JS snippet, then each verifier JS snippet.
    /// Every verifier is run so the log shows all assertions, even when later ones would fail.
    private func jsStep(
        _ name: String,
        action: String,
        verifiers: [Verifier]
    ) async -> StepOutcome {
        stepNumber += 1
        let label = "STEP \(String(format: "%02d", stepNumber))"
        print("[JSX] ┌─ \(label) \(name)")
        let start = Date()

        let actionResult = await runJS(action)
        let actionOk = (actionResult?["ok"] as? Bool) ?? false
        let actionMessage = (actionResult?["message"] as? String) ?? (actionOk ? "ok" : "failed")
        var stepData: [String: Any] = [:]
        if let dict = actionResult {
            for (k, v) in dict where k != "ok" && k != "message" { stepData[k] = v }
        }
        print("[JSX] │  action: \(truncate(actionMessage))")

        var verifications: [Verification] = []
        var allOk = actionOk
        for verifier in verifiers {
            let res = await runJS(verifier.js)
            let passed = (res?["ok"] as? Bool) ?? false
            let detail = (res?["detail"] as? String) ?? ""
            verifications.append(Verification(name: verifier.name, passed: passed, detail: detail))
            let glyph = passed ? "✓" : "✗"
            let suffix = detail.isEmpty ? "" : " — \(truncate(detail))"
            print("[JSX] │  verify \(glyph) \(verifier.name)\(suffix)")
            if !passed { allOk = false }
        }

        if !actionOk {
            // Print any extra fields the action returned (sample arrays,
            // counts, etc.) BEFORE the generic diagnostic dump.
            for (k, v) in stepData.sorted(by: { $0.key < $1.key }) {
                print("[JSX] │    action.\(k): \(truncate(describe(v), limit: 1200))")
            }
            await dumpDiagnostics(reason: "action failed: \(actionMessage)")
        } else if !allOk {
            for (k, v) in stepData.sorted(by: { $0.key < $1.key }) {
                print("[JSX] │    action.\(k): \(truncate(describe(v), limit: 1200))")
            }
            await dumpDiagnostics(reason: "verification failed")
        }

        let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start))
        let status = allOk ? "OK" : "FAIL"
        print("[JSX] └─ \(label) \(status) (\(elapsed))")
        return StepOutcome(ok: allOk, message: actionMessage, data: stepData, verifications: verifications)
    }

    /// Render a JSON-deserialized value compactly for diagnostic logging.
    private func describe(_ value: Any) -> String {
        if let arr = value as? [Any] {
            let items = arr.prefix(8).map { describe($0) }.joined(separator: ", ")
            return "[" + items + (arr.count > 8 ? ", …\(arr.count - 8) more" : "") + "]"
        }
        if let dict = value as? [String: Any] {
            let kv = dict.sorted { $0.key < $1.key }
                .map { "\($0.key)=\(describe($0.value))" }
                .joined(separator: " ")
            return "{" + kv + "}"
        }
        return String(describing: value)
    }

    /// Swift-side step — runs an async action closure, then an async verification closure.
    /// Used for parsing the JSON body on the Swift side so the same reporting format applies.
    private func nativeStep(
        _ name: String,
        action: () async -> (ok: Bool, message: String, data: [String: Any]),
        verify: () async -> [Verification]
    ) async -> StepOutcome {
        stepNumber += 1
        let label = "STEP \(String(format: "%02d", stepNumber))"
        print("[JSX] ┌─ \(label) \(name)")
        let start = Date()

        let (actionOk, actionMessage, stepData) = await action()
        print("[JSX] │  action: \(truncate(actionMessage))")

        let verifications = await verify()
        var allOk = actionOk
        for v in verifications {
            let glyph = v.passed ? "✓" : "✗"
            let suffix = v.detail.isEmpty ? "" : " — \(truncate(v.detail))"
            print("[JSX] │  verify \(glyph) \(v.name)\(suffix)")
            if !v.passed { allOk = false }
        }

        let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start))
        let status = allOk ? "OK" : "FAIL"
        print("[JSX] └─ \(label) \(status) (\(elapsed))")
        return StepOutcome(ok: allOk, message: actionMessage, data: stepData, verifications: verifications)
    }

    /// Convenience used by step 02's verify closure.
    fileprivate func asyncVerify(name: String, js: String) async -> Verification {
        let res = await runJS(js)
        let passed = (res?["ok"] as? Bool) ?? false
        let detail = (res?["detail"] as? String) ?? ""
        return Verification(name: name, passed: passed, detail: detail)
    }

    // MARK: - JS runner

    private func runJS(_ js: String) async -> [String: Any]? {
        guard let webView = self.webView else { return nil }
        do {
            let raw = try await webView.callAsyncJavaScript(js, contentWorld: .page)
            guard let text = raw as? String else { return nil }
            guard let data = text.data(using: .utf8) else { return nil }
            let parsed = try JSONSerialization.jsonObject(with: data)
            return parsed as? [String: Any]
        } catch {
            let nsError = error as NSError
            let message = (nsError.userInfo["WKJavaScriptExceptionMessage"] as? String)
                ?? (nsError.userInfo["WKJavaScriptExceptionMessageKey"] as? String)
                ?? ""
            let line = (nsError.userInfo["WKJavaScriptExceptionLineNumber"] as? NSNumber)?.intValue
                ?? (nsError.userInfo["WKJavaScriptExceptionLineNumberKey"] as? NSNumber)?.intValue
            let column = (nsError.userInfo["WKJavaScriptExceptionColumnNumber"] as? NSNumber)?.intValue
                ?? (nsError.userInfo["WKJavaScriptExceptionColumnNumberKey"] as? NSNumber)?.intValue
            let sourceURL = (nsError.userInfo["WKJavaScriptExceptionSourceURL"] as? URL)?.absoluteString
                ?? (nsError.userInfo["WKJavaScriptExceptionSourceURLKey"] as? URL)?.absoluteString
                ?? ""
            var detail = error.localizedDescription
            if !message.isEmpty {
                detail += " — \(message)"
            }
            if let line {
                detail += " @ line \(line)"
                if let column {
                    detail += ":\(column)"
                }
            }
            if !sourceURL.isEmpty {
                detail += " [\(sourceURL)]"
            }
            print("[JSX] │  runJS exception: \(detail)")
            return nil
        }
    }

    // MARK: - Diagnostics

    private func dumpDiagnostics(reason: String) async {
        print("[JSX] │  ⚠ diagnostic dump (\(reason)):")
        let diag = await runJS("""
        return await (async () => {
            const stationBtns = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length;
            const matOptions = Array.from(document.querySelectorAll("mat-option")).filter(e => e.offsetWidth || e.offsetHeight).length;
            const calendar = !!document.querySelector("mat-calendar, .mat-calendar, [class*='mat-datepicker']");
            const findBtn = Array.from(document.querySelectorAll("button")).find(b => /find flights/i.test(b.textContent || ""));
            const findBtnState = findBtn ? { disabled: findBtn.disabled, aria: findBtn.getAttribute("aria-disabled") } : null;
            const probe = window.__jsxProbe || {};
            const shortenUrl = u => (u || "").split("?")[0].split("/").slice(-3).join("/");
            const recentResources = Array.isArray(probe.resources)
                ? probe.resources.slice(-8).map(r => ({
                    url: shortenUrl(r.url),
                    duration: r.duration || 0,
                    transferSize: r.transferSize || 0,
                    responseEnd: r.responseEnd || 0,
                    initiatorType: r.initiatorType || ""
                }))
                : [];
            const searchResources = recentResources.filter(r => String(r.url || "").includes("availability/search/simple"));
            // Surface any error-flagged inputs so we can spot form validation failures.
            const errorMarkers = Array.from(document.querySelectorAll(
                "[class*='ng-invalid'], [class*='mat-form-field-invalid'], [class*='error']"
            )).filter(e => e.offsetWidth || e.offsetHeight)
                .slice(0, 5)
                .map(e => ({
                    tag: e.tagName.toLowerCase(),
                    cls: (e.className || "").toString().slice(0, 80),
                    aria: e.getAttribute("aria-label") || ""
                }));
            return JSON.stringify({
                ok: true,
                href: location.href,
                stationBtns, matOptions, calendar,
                findBtnState,
                recentResources,
                searchResources,
                errorMarkers
            });
        })();
        """)
        if let diag {
            for (k, v) in diag where k != "ok" {
                print("[JSX] │    \(k): \(v)")
            }
        } else {
            print("[JSX] │    (diagnostic JS failed)")
        }
    }

    // MARK: - JS snippet builders

    private func selectStationActionJS(indexIsDestination: Bool) -> String {
        let index = indexIsDestination ? 1 : 0
        let code = indexIsDestination ? destination : origin
        return """
        return await (async () => {
            const sleep = ms => new Promise(r => setTimeout(r, ms));
            const visible = el => !!(el.offsetWidth || el.offsetHeight);
            const idx = \(index);
            const code = "\(code)";

            // Type into the "Airport or city" search input if the dropdown has one.
            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") && Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
                searchInput.focus();
                if (setter) setter.call(searchInput, code); else 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 JSON.stringify({ ok: false, message: "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 JSON.stringify({
                    ok: false,
                    message: "no match for " + 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 }));
            }
            if (typeof match.click === "function") match.click();
            await sleep(500);

            // Angular API fallback.
            const after = () => Array.from(document.querySelectorAll("[aria-label='Station select']"))[idx];
            let finalText = (after() && after().textContent || "").trim();
            let usedAngular = false;
            if (!finalText.includes(code)) {
                const ctxKey = Object.keys(match).find(k => k.startsWith("__ngContext__"));
                if (ctxKey) {
                    for (const item of match[ctxKey] || []) {
                        if (!item || typeof item !== "object") continue;
                        for (const m of ["_selectViaInteraction", "select", "onSelect", "onClick", "handleClick"]) {
                            if (typeof item[m] === "function") {
                                try { item[m](); usedAngular = true; break; } catch (_) {}
                            }
                        }
                        if (usedAngular) break;
                    }
                }
                await sleep(400);
                finalText = (after() && after().textContent || "").trim();
            }

            return JSON.stringify({
                ok: finalText.includes(code),
                message: "clicked '" + (match.textContent || "").trim().slice(0, 40) + "'" + (usedAngular ? " (angular fallback)" : ""),
                finalText: finalText.slice(0, 80),
                usedAngular
            });
        })();
        """
    }

    private func verifyStationButtonJS(index: Int, code: String) -> String {
        return """
        return await (async () => {
            const btn = Array.from(document.querySelectorAll("[aria-label='Station select']"))[\(index)];
            const text = btn ? (btn.textContent || "").replace(/\\s+/g, " ").trim() : "";
            return JSON.stringify({ ok: text.includes("\(code)"), detail: text.slice(0, 80) });
        })();
        """
    }

    private func navigateMonthActionJS() -> String {
        return """
        return await (async () => {
            const sleep = ms => new Promise(r => setTimeout(r, ms));
            const targetYear = \(targetYear);
            const targetMonth = \(targetMonth);
            const targetDay = \(targetDay);
            const targetMonthName = "\(targetMonthName)";
            const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear;

            // JSX uses a custom date picker (NOT Angular Material's mat-calendar),
            // so rather than read month headers (which use unknown markup) we
            // simply poll for the target day's aria-label existing.
            const findTargetCell = () => {
                const cells = Array.from(document.querySelectorAll("[aria-label]"))
                    .filter(el => el.offsetWidth || el.offsetHeight);
                // Exact aria-label first.
                let cell = cells.find(el => {
                    const a = (el.getAttribute("aria-label") || "").trim();
                    return a === ariaExact || a.startsWith(ariaExact);
                });
                if (cell) return { cell, matchedBy: "exact" };
                // Loose: contains month + year + day word-boundary.
                const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b");
                cell = cells.find(el => {
                    const a = el.getAttribute("aria-label") || "";
                    return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a);
                });
                return cell ? { cell, matchedBy: "loose" } : null;
            };

            const waitForTarget = async (ms) => {
                const deadline = Date.now() + ms;
                while (Date.now() < deadline) {
                    const hit = findTargetCell();
                    if (hit) return hit;
                    await sleep(150);
                }
                return findTargetCell();
            };

            // Case A: target cell is already visible in the current month view.
            // This is the common case for near-term dates because JSX shows
            // two months at a time by default.
            let hit = await waitForTarget(3000);
            if (hit) {
                return JSON.stringify({
                    ok: true,
                    message: "target cell visible without navigation (" + hit.matchedBy + " match)",
                    attempts: 0
                });
            }

            // Case B: target is in a different month. Walk forward or back.
            // Selectors are deliberately broad because JSX's picker is custom —
            // neither .mat-calendar-next-button nor any Material class applies.
            const findNext = () => document.querySelector(
                ".mat-calendar-next-button, [aria-label*='Next month' i], [aria-label*='next' i], " +
                "button[class*='next'], [class*='next-month']"
            );
            const findPrev = () => document.querySelector(
                ".mat-calendar-previous-button, [aria-label*='Previous month' i], [aria-label*='prev' i], " +
                "button[class*='prev'], [class*='prev-month']"
            );

            const now = new Date();
            const currentAbs = now.getFullYear() * 12 + now.getMonth();
            const targetAbs = targetYear * 12 + (targetMonth - 1);
            const forward = currentAbs <= targetAbs;

            let attempts = 0;
            for (let i = 0; i < 24; i++) {
                attempts++;
                const btn = forward ? findNext() : findPrev();
                if (!btn || btn.disabled) {
                    return JSON.stringify({
                        ok: false,
                        message: "target not visible and no " + (forward ? "next" : "prev") + " button",
                        attempts
                    });
                }
                btn.click();
                hit = await waitForTarget(1500);
                if (hit) {
                    return JSON.stringify({
                        ok: true,
                        message: "navigated " + attempts + " step(s), target cell present (" + hit.matchedBy + " match)",
                        attempts
                    });
                }
            }

            return JSON.stringify({
                ok: false,
                message: "target cell not found after " + attempts + " navigation attempts",
                attempts
            });
        })();
        """
    }

    private func clickDayCellActionJS() -> String {
        return """
        return await (async () => {
            const sleep = ms => new Promise(r => setTimeout(r, ms));

            // IMPORTANT: search the whole document, not a scoped calendar
            // container. JSX's custom picker has no predictable wrapper class,
            // and step 13 already confirmed the target aria-label is present
            // in the global document. Scoping here causes a mismatch where
            // step 13 finds the cell and step 14 doesn't.
            const targetMonthName = "\(targetMonthName)";
            const targetYear = \(targetYear);
            const targetDay = \(targetDay);
            const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear;
            const ariaLoose = targetMonthName + " " + targetDay + " " + targetYear;

            const ariaCells = Array.from(document.querySelectorAll("[aria-label]"))
                .filter(el => el.offsetWidth || el.offsetHeight);

            // Primary: exact aria-label match.
            let cell = ariaCells.find(el => {
                const a = (el.getAttribute("aria-label") || "").trim();
                return a === ariaExact || a === ariaLoose || a.startsWith(ariaExact);
            });
            let matchedBy = cell ? "aria-exact" : null;

            // Secondary: aria-label contains month, year, and day as a word
            // boundary. This is what step 13's loose match uses.
            if (!cell) {
                const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b");
                cell = ariaCells.find(el => {
                    const a = el.getAttribute("aria-label") || "";
                    return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a);
                });
                if (cell) matchedBy = "aria-loose";
            }

            if (!cell) {
                // Collect diagnostics: all visible aria-labels that contain
                // the month name (so we can see how JSX actually formats them).
                const monthMatches = ariaCells
                    .filter(el => (el.getAttribute("aria-label") || "").includes(targetMonthName))
                    .slice(0, 15)
                    .map(el => ({
                        tag: el.tagName.toLowerCase(),
                        role: el.getAttribute("role") || "",
                        aria: el.getAttribute("aria-label"),
                        disabled: el.getAttribute("aria-disabled") || el.disabled || false
                    }));
                return JSON.stringify({
                    ok: false,
                    message: "day cell not found for '" + ariaExact + "' (ariaCells=" + ariaCells.length + ", month matches=" + monthMatches.length + ")",
                    ariaExact,
                    ariaCellCount: ariaCells.length,
                    monthMatches
                });
            }

            // Sanity check: is the cell disabled? JSX marks past-date cells as
            // aria-disabled="true" — clicking them won't commit the selection.
            const disabled = cell.getAttribute("aria-disabled") === "true"
                || cell.getAttribute("disabled") !== null
                || cell.classList.contains("disabled")
                || cell.classList.contains("mat-calendar-body-disabled");
            if (disabled) {
                return JSON.stringify({
                    ok: false,
                    message: "target day cell is disabled (" + matchedBy + "): " + (cell.getAttribute("aria-label") || ""),
                    matchedBy,
                    ariaLabel: cell.getAttribute("aria-label"),
                    className: (cell.className || "").toString().slice(0, 200)
                });
            }

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

            return JSON.stringify({
                ok: true,
                message: "clicked day cell (" + matchedBy + ") aria='" + (cell.getAttribute("aria-label") || "") + "'",
                matchedBy,
                clickedAriaLabel: cell.getAttribute("aria-label") || ""
            });
        })();
        """
    }

    // MARK: - Parsing

    /// Walk the Navitaire availability payload and extract one `JSXFlight` per
    /// unique journey in `data.results[].trips[].journeysAvailableByMarket["ORG|DST"][]`.
    /// Joins each `journey.fares[].fareAvailabilityKey` to the top-level
    /// `data.faresAvailable[<key>]` record for price + class-of-service.
    fileprivate func parseFlights(from rawJSON: String) -> [JSXFlight] {
        guard let data = rawJSON.data(using: .utf8),
              let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let payload = root["data"] as? [String: Any] else {
            print("[JSX] │  parseFlights: root JSON not a dict with 'data'")
            return []
        }

        let faresAvailable = payload["faresAvailable"] as? [String: Any] ?? [:]
        let results = payload["results"] as? [[String: Any]] ?? []
        let marketKey = "\(origin)|\(destination)"

        var flights: [JSXFlight] = []
        var seenKeys = Set<String>()

        for result in results {
            let trips = result["trips"] as? [[String: Any]] ?? []
            for trip in trips {
                let byMarket = trip["journeysAvailableByMarket"] as? [String: Any] ?? [:]

                // Prefer the market that exactly matches our route, but fall back to
                // every market key in case JSX uses multi-origin/multi-dest notation.
                var candidateKeys: [String] = []
                if byMarket[marketKey] != nil { candidateKeys.append(marketKey) }
                for k in byMarket.keys where k != marketKey { candidateKeys.append(k) }

                for key in candidateKeys {
                    let journeys = byMarket[key] as? [[String: Any]] ?? []
                    for journey in journeys {
                        if let flight = buildFlight(
                            journey: journey,
                            fallbackMarketKey: key,
                            faresAvailable: faresAvailable
                        ) {
                            let dedupeKey = "\(flight.flightNumber)|\(flight.departureLocal)"
                            if seenKeys.insert(dedupeKey).inserted {
                                flights.append(flight)
                            }
                        }
                    }
                }
            }
        }

        // Sort by departure local time for stable logging/UI.
        flights.sort { $0.departureLocal < $1.departureLocal }
        return flights
    }

    private func buildFlight(
        journey: [String: Any],
        fallbackMarketKey: String,
        faresAvailable: [String: Any]
    ) -> JSXFlight? {
        let segments = journey["segments"] as? [[String: Any]] ?? []
        guard !segments.isEmpty else { return nil }

        // Flight number pieces come from segments[0].identifier. For multi-segment
        // journeys we concatenate the segment flight numbers so the dedupe key is unique.
        let firstSeg = segments[0]
        let lastSeg = segments[segments.count - 1]

        let firstIdentifier = firstSeg["identifier"] as? [String: Any] ?? [:]
        let carrierCode = (firstIdentifier["carrierCode"] as? String) ?? ""
        let baseNumber = (firstIdentifier["identifier"] as? String) ?? ""
        var fullFlightNumber = "\(carrierCode)\(baseNumber)"
        if segments.count > 1 {
            let extras = segments.dropFirst().compactMap { seg -> String? in
                let ident = seg["identifier"] as? [String: Any] ?? [:]
                let cc = (ident["carrierCode"] as? String) ?? ""
                let num = (ident["identifier"] as? String) ?? ""
                let combined = "\(cc)\(num)"
                return combined.isEmpty ? nil : combined
            }
            if !extras.isEmpty {
                fullFlightNumber += "+" + extras.joined(separator: "+")
            }
        }

        // Designators: prefer the journey-level designator if present; fall back to segments.
        let journeyDesignator = journey["designator"] as? [String: Any]
        let firstDesignator = firstSeg["designator"] as? [String: Any] ?? [:]
        let lastDesignator = lastSeg["designator"] as? [String: Any] ?? [:]

        let originCode = (journeyDesignator?["origin"] as? String)
            ?? (firstDesignator["origin"] as? String)
            ?? marketOrigin(fallbackMarketKey)
            ?? ""
        let destinationCode = (journeyDesignator?["destination"] as? String)
            ?? (lastDesignator["destination"] as? String)
            ?? marketDestination(fallbackMarketKey)
            ?? ""
        let departureLocal = (journeyDesignator?["departure"] as? String)
            ?? (firstDesignator["departure"] as? String)
            ?? ""
        let arrivalLocal = (journeyDesignator?["arrival"] as? String)
            ?? (lastDesignator["arrival"] as? String)
            ?? ""

        let stops = (journey["stops"] as? Int) ?? ((journey["stops"] as? NSNumber)?.intValue ?? 0)

        // Equipment type lives on segments[0].legs[0].legInfo.equipmentType.
        var equipmentType: String?
        if let legs = firstSeg["legs"] as? [[String: Any]], let firstLeg = legs.first,
           let legInfo = firstLeg["legInfo"] as? [String: Any] {
            equipmentType = legInfo["equipmentType"] as? String
        }

        // Build per-class breakdown by joining journey.fares[] → data.faresAvailable[key].
        var classes: [JSXFareClass] = []
        let journeyFares = journey["fares"] as? [[String: Any]] ?? []
        for fareEntry in journeyFares {
            guard let key = fareEntry["fareAvailabilityKey"] as? String else { continue }
            guard let record = faresAvailable[key] as? [String: Any] else { continue }

            // availableCount is the sum of details[].availableCount for this fare bucket.
            var availableCount = 0
            if let details = fareEntry["details"] as? [[String: Any]] {
                for detail in details {
                    if let n = detail["availableCount"] as? Int { availableCount += n }
                    else if let n = (detail["availableCount"] as? NSNumber)?.intValue { availableCount += n }
                }
            }

            let totals = record["totals"] as? [String: Any] ?? [:]
            let fareTotal = doubleFromJSON(totals["fareTotal"]) ?? 0
            let revenueTotal = doubleFromJSON(totals["revenueTotal"]) ?? 0

            let recordFares = record["fares"] as? [[String: Any]] ?? []
            let primary = recordFares.first ?? [:]
            let classOfService = (primary["classOfService"] as? String) ?? ""
            let productClass = (primary["productClass"] as? String) ?? ""
            let fareBasis = primary["fareBasisCode"] as? String

            classes.append(JSXFareClass(
                classOfService: classOfService,
                productClass: productClass,
                availableCount: availableCount,
                fareTotal: fareTotal,
                revenueTotal: revenueTotal,
                fareBasisCode: fareBasis
            ))
        }

        let totalAvailable = classes.reduce(0) { $0 + $1.availableCount }
        let lowestFareTotal = classes.map { $0.fareTotal }.filter { $0 > 0 }.min()

        return JSXFlight(
            flightNumber: fullFlightNumber,
            carrierCode: carrierCode,
            origin: originCode,
            destination: destinationCode,
            departureLocal: departureLocal,
            arrivalLocal: arrivalLocal,
            stops: stops,
            equipmentType: equipmentType,
            totalAvailable: totalAvailable,
            lowestFareTotal: lowestFareTotal,
            classes: classes
        )
    }

    private func marketOrigin(_ key: String) -> String? {
        key.split(separator: "|").first.map(String.init)
    }

    private func marketDestination(_ key: String) -> String? {
        let parts = key.split(separator: "|")
        return parts.count >= 2 ? String(parts[1]) : nil
    }

    private func doubleFromJSON(_ value: Any?) -> Double? {
        if let d = value as? Double { return d }
        if let n = value as? NSNumber { return n.doubleValue }
        if let i = value as? Int { return Double(i) }
        if let s = value as? String { return Double(s) }
        return nil
    }

    // MARK: - Navigation

    private func navigateAndWait(_ url: URL) async -> Bool {
        guard let webView = self.webView else { return false }
        return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
            let delegate = NavDelegate(continuation: cont)
            webView.navigationDelegate = delegate
            webView.load(URLRequest(url: url))
            objc_setAssociatedObject(webView, "jsxNavDelegate", delegate, .OBJC_ASSOCIATION_RETAIN)
        }
    }

    // MARK: - Logging helpers

    private func logHeader(_ msg: String) {
        print("[JSX] ══════════════════════════════════════════════════════════")
        print("[JSX] \(msg)")
        print("[JSX] ══════════════════════════════════════════════════════════")
    }

    private func truncate(_ s: String, limit: Int = 180) -> String {
        s.count <= limit ? s : "\(s.prefix(limit))… (\(s.count) chars)"
    }

    private func fail(_ reason: String) -> JSXSearchResult {
        logHeader("JSX FLOW FAILED: \(reason)")
        self.webView = nil
        return JSXSearchResult(
            flights: [],
            rawSearchBody: capturedBody,
            lowFareFallback: nil,
            error: reason
        )
    }
}

// MARK: - Navigation delegate

private final class NavDelegate: NSObject, WKNavigationDelegate {
    private var continuation: CheckedContinuation<Bool, Never>?
    init(continuation: CheckedContinuation<Bool, Never>) { self.continuation = continuation }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        continuation?.resume(returning: true); continuation = nil
    }

    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
        print("[JSX] navigation failed: \(error.localizedDescription)")
        continuation?.resume(returning: false); continuation = nil
    }

    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
        print("[JSX] provisional navigation failed: \(error.localizedDescription)")
        continuation?.resume(returning: false); continuation = nil
    }
}
