import XCTest
@testable import Flights

/// Integration tests for the airline load fetchers in `AirlineLoadService`.
///
/// These tests hit **live airline APIs**. They will:
/// - Take 10-30s each (network)
/// - Fail loudly when an airline rotates auth, gates on a new app version,
///   or otherwise changes their API shape. That's by design — this is the
///   regression net for "does X airline still work?"
///
/// For each carrier, the test:
/// 1. Uses `RouteExplorerClient` to find a real flight on that carrier
///    departing within the next 24 hours from one of its hubs.
/// 2. Calls `AirlineLoadService.fetchLoad(...)` for that specific flight.
/// 3. Asserts the response is meaningful (non-nil and has at least one
///    of: cabins / standby list / upgrade list / seat availability).
///
/// Pre-existing limitations (NOT bugs in these tests):
/// - JSX (XE) uses a WKWebView path and can't run from unit tests on the
///   simulator without a host scene. Skipped with a `XCTSkip`.
/// - Some carriers (notably AA, AS waitlist) only open the load endpoint
///   close to departure. Tests prefer flights leaving < 24h out and skip
///   with a helpful message if nothing's findable.
final class AirlineLoadIntegrationTests: XCTestCase {

    // Static so the token cache + URLSession survive across tests in
    // a single run, and so the route-explorer rate limit applies once
    // per suite rather than per test.
    private static let routeExplorer = RouteExplorerClient()
    private static let airportDatabase = AirportDatabase()
    private static let loadService = AirlineLoadService(airportDatabase: airportDatabase)

    private var routeExplorer: RouteExplorerClient { Self.routeExplorer }
    private var loadService: AirlineLoadService { Self.loadService }

    /// Airlines whose load endpoint deliberately returns only flight
    /// status (no seat/standby data). We assert non-nil for these and
    /// stop short of the "must have data" check.
    private static let statusOnlyAirlines: Set<String> = ["B6", "EK"]

    /// Hardcoded daily flights used as fallbacks when route-explorer's
    /// `/departures` data doesn't include the carrier we're looking for
    /// (notably some international carriers like EK/KE that aren't in
    /// route-explorer's schedule feed). Each entry is a well-known daily
    /// operation that's been stable over time; if any of these stop
    /// operating, update the entry.
    ///
    /// `dayOffset` controls which day's flight to probe:
    /// - `0` (today) for carriers whose snapshot window is T-1d to T+0 (AM)
    /// - `1` (tomorrow) for carriers whose API only returns future flights
    ///   (SY's Navitaire availability/search drops already-departed legs)
    private static let knownDailyFlights: [String: (flightNumber: String, origin: String, destination: String, dayOffset: Int)] = [
        "EK": ("201", "JFK", "DXB", 1),   // Emirates JFK → Dubai, daily flagship
        "KE": ("82",  "JFK", "ICN", 1),   // Korean Air JFK → Incheon, daily
        "AM": ("58",  "MEX", "MTY", 0),   // Aeromexico — snapshot only T-1d/T+0
        "SY": ("104", "LAS", "MSP", 1),   // Sun Country — Navitaire shows future only
    ]

    // MARK: - Per-airline tests

    func test_AA_americanAirlines() async throws {
        try await runAirlineLoadTest(
            carrier: "AA",
            hubs: ["DFW", "CLT", "PHL", "ORD", "MIA", "PHX"]
        )
    }

    func test_UA_united() async throws {
        try await runAirlineLoadTest(
            carrier: "UA",
            hubs: ["EWR", "IAH", "DEN", "ORD", "SFO", "IAD", "LAX"]
        )
    }

    func test_AS_alaska() async throws {
        try await runAirlineLoadTest(
            carrier: "AS",
            hubs: ["SEA", "PDX", "ANC", "SAN", "LAX"]
        )
    }

    func test_B6_jetBlue() async throws {
        try await runAirlineLoadTest(
            carrier: "B6",
            hubs: ["JFK", "BOS", "FLL", "MCO", "LAX"]
        )
    }

    func test_KE_koreanAir() async throws {
        try await runAirlineLoadTest(
            carrier: "KE",
            hubs: ["ICN", "LAX", "JFK", "SFO", "ATL"]
        )
    }

    func test_EK_emirates() async throws {
        try await runAirlineLoadTest(
            carrier: "EK",
            hubs: ["DXB", "JFK", "LAX", "ORD", "IAD", "SFO", "BOS"]
        )
    }

    func test_AM_aeromexico() async throws {
        // Route-explorer doesn't include AM in /departures data, so this
        // always falls through to the known-daily fallback (AM0058 MEX-MTY).
        try await runAirlineLoadTest(
            carrier: "AM",
            hubs: ["MEX", "GDL", "MTY", "CUN"]
        )
    }

    func test_SY_sunCountry() async throws {
        // Sun Country runs on Navitaire; the load endpoint takes flight
        // number + route + date. Route-explorer doesn't cover SY well, so
        // most runs hit the known-daily fallback (SY104 LAS-MSP).
        try await runAirlineLoadTest(
            carrier: "SY",
            hubs: ["MSP", "LAS", "MCO", "DEN"]
        )
    }

    func test_XE_jsx() async throws {
        // JSX uses a WKWebView path that needs a host scene / main thread.
        // Skipped here; manual verification via the app remains.
        throw XCTSkip("JSX uses WKWebView and cannot run from a unit-test bundle.")
    }

    // MARK: - Helpers

    /// Pulls departures from `hubs` for `carrier`, picks the first flight
    /// leaving in (now, now+24h), and runs the airline-specific fetcher.
    /// XCTSkips (rather than fails) if no flight can be found at all —
    /// that's a route-explorer / schedule problem, not a load-fetcher bug.
    private func runAirlineLoadTest(
        carrier: String,
        hubs: [String],
        file: StaticString = #file,
        line: UInt = #line
    ) async throws {
        let now = Date()
        let cutoff = now.addingTimeInterval(24 * 3600)

        var pickedFlight: RouteFlight?
        var pickedHub: String?

        for hub in hubs {
            let candidate = await departuresWithRetry(from: hub, after: now, before: cutoff, carrier: carrier)
            if let candidate {
                pickedFlight = candidate
                pickedHub = hub
                break
            }
        }

        // Try the discovered flight first when route-explorer found one.
        if let flight = pickedFlight, let hub = pickedHub {
            NSLog("[\(carrier)Test] Using \(carrier)\(flight.flightNumber) \(flight.departure.airportIata)→\(flight.arrival.airportIata) departing \(flight.departure.dateTime) (hub queried: \(hub))")
            let load = await loadService.fetchLoad(
                airlineCode: flight.carrierIata,
                flightNumber: "\(flight.flightNumber)",
                date: flight.departure.dateTime,
                origin: flight.departure.airportIata,
                destination: flight.arrival.airportIata,
                departureTime: nil
            )
            if load != nil {
                let flightLabel = "\(carrier)\(flight.flightNumber) \(flight.departure.airportIata)→\(flight.arrival.airportIata)"
                try assertLoad(load, carrier: carrier, flightLabel: flightLabel, file: file, line: line)
                return
            }
            NSLog("[\(carrier)Test] Discovered flight returned nil; trying known-daily fallback if available")
        }

        // Fallback: known-good daily flight. Triggers when route-explorer
        // found nothing OR when the discovered flight returned nil (e.g. a
        // regional carrier op that isn't in the upstream load system).
        // dayOffset in the table controls today-vs-tomorrow based on each
        // carrier's snapshot window quirks.
        if let known = Self.knownDailyFlights[carrier] {
            let probeDate = Date().addingTimeInterval(TimeInterval(known.dayOffset * 86400))
            NSLog("[\(carrier)Test] Using known daily \(carrier)\(known.flightNumber) \(known.origin)→\(known.destination) +\(known.dayOffset)d")
            let load = await loadService.fetchLoad(
                airlineCode: carrier,
                flightNumber: known.flightNumber,
                date: probeDate,
                origin: known.origin,
                destination: known.destination,
                departureTime: nil
            )
            try assertLoad(load, carrier: carrier, flightLabel: "\(carrier)\(known.flightNumber) \(known.origin)→\(known.destination)", file: file, line: line)
            return
        }

        throw XCTSkip("Could not find a working \(carrier) flight in the next 24h from any of: \(hubs.joined(separator: ", "))")
    }

    /// Shared assertion path for both the dynamic-discovery and
    /// hardcoded-fallback test routes.
    private func assertLoad(
        _ load: FlightLoad?,
        carrier: String,
        flightLabel: String,
        file: StaticString,
        line: UInt
    ) throws {
        XCTAssertNotNil(
            load,
            "\(carrier) load fetcher returned nil for \(flightLabel). "
                + "Check the [\(carrier)] console logs above for the underlying failure mode.",
            file: file,
            line: line
        )

        guard let load else { return }

        NSLog("[\(carrier)Test] ✅ cabins=\(load.cabins.count) standby=\(load.standbyList.count) upgrade=\(load.upgradeList.count) seatAvail=\(load.seatAvailability.count)")

        if Self.statusOnlyAirlines.contains(carrier) {
            XCTAssertEqual(load.airlineCode, carrier)
            return
        }

        let hasAnyData = !load.cabins.isEmpty
            || !load.standbyList.isEmpty
            || !load.upgradeList.isEmpty
            || !load.seatAvailability.isEmpty

        XCTAssertTrue(
            hasAnyData,
            "\(carrier) returned a FlightLoad but every collection is empty — "
                + "the endpoint likely succeeded but with no data for this flight, "
                + "or the response shape changed.",
            file: file,
            line: line
        )
    }

    /// Fetch departures from `hub` and pick the first flight matching
    /// `carrier` in the time window. On HTTP 429 (route-explorer rate
    /// limit), parse `retryAfter` and retry once after that delay.
    private func departuresWithRetry(
        from hub: String,
        after: Date,
        before: Date,
        carrier: String,
        attemptsRemaining: Int = 2
    ) async -> RouteFlight? {
        do {
            let result = try await routeExplorer.searchDepartures(
                from: hub, date: after, maxStops: 0, limit: 300
            )
            let allLegs = result.connections.flatMap { $0.flights }
            let inWindow = allLegs.filter { $0.departure.dateTime > after && $0.departure.dateTime <= before }
            let carrierMatches = inWindow.filter { $0.carrierIata == carrier }
            NSLog("[\(carrier)Test] hub \(hub): legs=\(allLegs.count) inWindow=\(inWindow.count) \(carrier)Matches=\(carrierMatches.count)")
            return carrierMatches.first
        } catch let RouteExplorerClient.ClientError.requestFailed(status: 429, body: body) {
            let retryAfter = parseRetryAfter(body: body) ?? 25
            NSLog("[\(carrier)Test] hub \(hub) rate-limited (429), sleeping \(retryAfter)s then retrying (attemptsRemaining=\(attemptsRemaining - 1))")
            if attemptsRemaining <= 1 { return nil }
            try? await Task.sleep(nanoseconds: UInt64(retryAfter) * 1_000_000_000)
            return await departuresWithRetry(from: hub, after: after, before: before, carrier: carrier, attemptsRemaining: attemptsRemaining - 1)
        } catch let RouteExplorerClient.ClientError.tokenFetchFailed(status: 429) {
            NSLog("[\(carrier)Test] hub \(hub) token rate-limited (429), sleeping 25s then retrying (attemptsRemaining=\(attemptsRemaining - 1))")
            if attemptsRemaining <= 1 { return nil }
            try? await Task.sleep(nanoseconds: 25 * 1_000_000_000)
            return await departuresWithRetry(from: hub, after: after, before: before, carrier: carrier, attemptsRemaining: attemptsRemaining - 1)
        } catch {
            NSLog("[\(carrier)Test] hub \(hub) lookup failed: \(error)")
            return nil
        }
    }

    private func parseRetryAfter(body: String?) -> Int? {
        guard let body, let data = body.data(using: .utf8) else { return nil }
        if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
            return json["retryAfter"] as? Int
        }
        return nil
    }
}
