import Foundation

/// Resolves direct-flight schedules for a route+date by scraping two open
/// FlightAware web pages. Replaces ``RouteExplorerClient`` for the
/// destination-set search path now that route-explorer's `/api/token`
/// endpoint is gated behind Cloudflare Turnstile.
///
/// Pipeline (canonical reference: `scripts/probe_flightaware.py`):
///
///   1. Resolve dep / arr IATAs to ICAO via ``AirportDatabase/icao(forIATA:)``.
///   2. GET `https://flightaware.com/analysis/route.rvt?origin=<ICAO>&destination=<ICAO>`
///      and pull every distinct flight ident from its "Itemized List" table.
///   3. For each ident: GET `https://flightaware.com/live/flight/<ident>`
///      and brace-balance-extract the inlined `var trackpollBootstrap = {...};`
///      JSON blob.
///   4. From `flights[*].activityLog.flights`, project each leg whose
///      origin/destination match and whose `gateDepartureTimes.scheduled`
///      falls on the requested local-departure date (in the origin's TZ).
///   5. Wrap each match as a single-leg ``RouteConnection`` and ship.
///
/// Boundary conditions:
///  * `activityLog` covers ~14 days back + ~1–2 days forward per ident.
///    Far-future dates return an empty result — callers should surface a
///    "schedules become available within ~48h" hint.
///  * No auth, no cookies, no WKWebView. Plain `URLSession`. The user agent
///    is set to iOS Safari for parity with how `/live/flight/<ident>` renders
///    its JSON blob — FlightAware's HTML shape is identical to what a real
///    browser receives, validated by `probe_flightaware.py`.
actor FlightAwareScheduleClient {

    // MARK: - Errors

    enum ClientError: Error, LocalizedError {
        case unknownAirport(iata: String)
        case routePageFailed(status: Int)
        case noOperatingFlights
        case trackpollMissing(ident: String)
        case decodingFailed(underlying: Error)

        var errorDescription: String? {
            switch self {
            case .unknownAirport(let iata):
                return "We don't have an ICAO mapping for \(iata) yet."
            case .routePageFailed(let status):
                return "FlightAware route lookup failed (HTTP \(status))."
            case .noOperatingFlights:
                return "FlightAware lists no recent flights on this route."
            case .trackpollMissing(let ident):
                return "FlightAware returned no schedule for \(ident)."
            case .decodingFailed(let error):
                return "Could not parse FlightAware response: \(error.localizedDescription)"
            }
        }
    }

    // MARK: - Properties

    private let session: URLSession
    private let database: AirportDatabase
    private let calendar: Calendar
    private let blobClient: BlobRouteClient

    /// Cap the number of distinct idents we fan out trackpoll fetches for.
    /// Busy routes (DAL→HOU surfaces ~46 idents — including private/business
    /// jet callsigns we don't care about) would otherwise spend ~25 seconds
    /// pulling 500 KB pages. 16 is enough for any commercial-carrier route.
    private static let maxIdentsPerRoute = 16

    /// Curated hub catalog. For a 1-stop search like DFW→AMS we only
    /// look for via-airports that show up here — without this filter
    /// we'd fan out blob fetches against every one of DFW's 263
    /// destinations. Covers US majors, European majors, ME/Asia hubs
    /// commonly used for transatlantic + transpacific connections.
    private static let connectionHubs: Set<String> = [
        // US majors
        "ATL", "JFK", "LGA", "EWR", "ORD", "BOS", "IAH", "IAD", "PHL",
        "CLT", "MSP", "DTW", "DEN", "LAX", "SFO", "SEA", "LAS", "MIA",
        "DFW", "BWI", "DCA", "MCO", "FLL", "SLC", "PHX",
        // Europe
        "LHR", "LGW", "MAN", "CDG", "ORY", "FRA", "MUC", "AMS",
        "MAD", "BCN", "FCO", "MXP", "IST", "ZRH", "VIE", "BRU",
        "DUB", "LIS", "CPH", "ARN", "OSL", "HEL", "WAW", "PRG",
        "BUD", "ATH",
        // Middle East
        "DXB", "DOH", "AUH", "TLV", "RUH", "JED", "AMM",
        // Asia
        "ICN", "NRT", "HND", "HKG", "SIN", "BKK", "PEK", "PVG",
        "TPE", "KIX", "MNL", "KUL", "CGK", "DEL", "BOM",
        // Oceania / Africa / Latam
        "SYD", "MEL", "AKL", "JNB", "CPT", "ADD", "GRU", "EZE",
        "MEX", "PTY", "BOG", "LIM", "SCL",
    ]

    /// Layover bounds for 1-stop connections. 45 minutes is the
    /// industry baseline for domestic minimum connection time;
    /// 8 hours is the upper bound past which it's no longer a
    /// reasonable single-day connection.
    private static let minLayoverMinutes = 45
    private static let maxLayoverMinutes = 8 * 60

    // MARK: - Init

    init(database: AirportDatabase, blobClient: BlobRouteClient = BlobRouteClient()) {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 25
        config.requestCachePolicy = .reloadIgnoringLocalCacheData
        session = URLSession(configuration: config)
        self.database = database
        self.blobClient = blobClient
        var cal = Calendar(identifier: .gregorian)
        cal.timeZone = TimeZone(identifier: "UTC")!
        self.calendar = cal
    }

    // MARK: - Public API

    /// Look up direct flights for `(origin → destination, date)` and return
    /// them as single-leg ``RouteConnection``s so the existing UI surface
    /// (``ConnectionRow`` / ``RouteFlight`` consumers) keeps working.
    ///
    /// `date` is treated as a calendar day in the **origin's local
    /// timezone**. A search for "2026-06-06" returns flights whose
    /// scheduled departure-day in the origin TZ equals 2026-06-06.
    func searchDirectFlights(
        from origin: String,
        to destination: String,
        date: Date
    ) async throws -> RouteSearchResult {
        let depIATA = origin.uppercased()
        let arrIATA = destination.uppercased()
        guard let depICAO = database.icao(forIATA: depIATA) else {
            throw ClientError.unknownAirport(iata: depIATA)
        }
        guard let arrICAO = database.icao(forIATA: arrIATA) else {
            throw ClientError.unknownAirport(iata: arrIATA)
        }

        let idents = try await fetchOperatingIdents(
            depICAO: depICAO, arrICAO: arrICAO
        )
        guard !idents.isEmpty else { throw ClientError.noOperatingFlights }

        var legs: [RouteFlight] = []
        var seenLegKeys = Set<String>()
        // Fan out trackpoll fetches concurrently — they're independent and
        // dominate wall-clock for routes with many operating idents.
        try await withThrowingTaskGroup(of: [RouteFlight].self) { group in
            for ident in idents.prefix(Self.maxIdentsPerRoute) {
                group.addTask { [self] in
                    do {
                        return try await fetchScheduledLegs(
                            ident: ident,
                            depIATA: depIATA,
                            arrIATA: arrIATA,
                            on: date
                        )
                    } catch {
                        // A single ident failing should not poison the whole
                        // route search — fall through with empty results.
                        return []
                    }
                }
            }
            for try await batch in group {
                for leg in batch {
                    let key = leg.id
                    if seenLegKeys.insert(key).inserted {
                        legs.append(leg)
                    }
                }
            }
        }

        // One connection per direct leg, sorted by scheduled departure.
        let sortedLegs = legs.sorted { $0.departure.dateTime < $1.departure.dateTime }
        let connections = sortedLegs.map { leg in
            RouteConnection(
                durationMinutes: leg.durationMinutes,
                score: 0,
                flights: [leg]
            )
        }
        return RouteSearchResult(connections: connections, appendix: nil)
    }

    /// Find 1-stop itineraries from `origin` to `destination` on `date`.
    ///
    /// Algorithm:
    ///   1. Pull `origin`'s blob route catalog → list of destinations
    ///      it serves directly.
    ///   2. Intersect with the curated `connectionHubs` set so we only
    ///      try hubs that plausibly have onward transatlantic /
    ///      transpacific service.
    ///   3. For each candidate via-hub `H`, check the blob whether
    ///      `H` serves `destination` directly.
    ///   4. For surviving `H`s, fan out two `searchDirectFlights` calls
    ///      in parallel: `origin → H` on `date`, `H → destination` on
    ///      `date` and `date + 1` (long-haul connections frequently
    ///      cross midnight in the layover hub).
    ///   5. Join every `(leg1, leg2)` pair whose layover at `H` falls
    ///      inside `[minLayoverMinutes, maxLayoverMinutes]` and return
    ///      one ``RouteConnection`` per valid join.
    ///
    /// Wall-clock budget: with ~10 candidate hubs surviving the filter
    /// and 2 FA fetches per hub, we make ~20 HTTP requests in parallel.
    /// On a warm network it returns in ~5s. We cap candidates to keep
    /// the search bounded.
    func searchOneStopConnections(
        from origin: String,
        to destination: String,
        date: Date
    ) async -> [RouteConnection] {
        let depIATA = origin.uppercased()
        let arrIATA = destination.uppercased()

        // Step 1+2: candidate via-hubs DFW serves AND that are curated.
        let originDestinations: [BlobRoute]
        do {
            let catalog = try await blobClient.catalog(for: depIATA)
            originDestinations = catalog.routes
        } catch {
            return []
        }
        let curatedCandidates = originDestinations.compactMap { route -> String? in
            let via = route.dest
            guard via != arrIATA else { return nil }   // skip the direct
            guard Self.connectionHubs.contains(via) else { return nil }
            // Honour seasonality so we don't suggest a hub that's not
            // operating from origin on `date`.
            guard route.isOperating(on: date) else { return nil }
            return via
        }
        // Step 3: filter to hubs that also serve `destination`.
        var validVias: [String] = []
        await withTaskGroup(of: (String, Bool).self) { group in
            for via in curatedCandidates.prefix(30) {
                group.addTask { [self] in
                    (via, await blobClient.serves(origin: via, destination: arrIATA))
                }
            }
            for await (via, serves) in group {
                if serves { validVias.append(via) }
            }
        }
        guard !validVias.isEmpty else { return [] }

        // Step 4: fan out 2 FA lookups per via-hub in parallel.
        let nextDay = calendar.date(byAdding: .day, value: 1, to: date) ?? date

        struct LegPair {
            let leg1: [RouteFlight]
            let leg2: [RouteFlight]
        }
        var pairsByVia: [String: LegPair] = [:]
        await withTaskGroup(of: (String, [RouteFlight], [RouteFlight]).self) { group in
            for via in validVias {
                group.addTask { [self] in
                    async let leg1Result = (try? await searchDirectFlights(
                        from: depIATA, to: via, date: date
                    )) ?? RouteSearchResult(connections: [], appendix: nil)
                    async let leg2Today = (try? await searchDirectFlights(
                        from: via, to: arrIATA, date: date
                    )) ?? RouteSearchResult(connections: [], appendix: nil)
                    async let leg2Tomorrow = (try? await searchDirectFlights(
                        from: via, to: arrIATA, date: nextDay
                    )) ?? RouteSearchResult(connections: [], appendix: nil)
                    let (l1, l2t, l2n) = await (leg1Result, leg2Today, leg2Tomorrow)
                    let leg1Flights = l1.connections.compactMap { $0.flights.first }
                    let leg2Flights = (l2t.connections + l2n.connections)
                        .compactMap { $0.flights.first }
                    return (via, leg1Flights, leg2Flights)
                }
            }
            for await (via, leg1Flights, leg2Flights) in group {
                pairsByVia[via] = LegPair(leg1: leg1Flights, leg2: leg2Flights)
            }
        }

        // Step 5: join legs by layover validity.
        var connections: [RouteConnection] = []
        for (_, pair) in pairsByVia {
            for leg1 in pair.leg1 {
                for leg2 in pair.leg2 {
                    let layoverMin = Int(
                        leg2.departure.dateTime.timeIntervalSince(leg1.arrival.dateTime) / 60
                    )
                    guard layoverMin >= Self.minLayoverMinutes,
                          layoverMin <= Self.maxLayoverMinutes
                    else { continue }
                    let total = leg1.durationMinutes + layoverMin + leg2.durationMinutes
                    connections.append(RouteConnection(
                        durationMinutes: total,
                        score: 0,
                        flights: [leg1, leg2]
                    ))
                }
            }
        }
        return connections.sorted { $0.firstDeparture < $1.firstDeparture }
    }

    // MARK: - Step 1: route.rvt → distinct idents

    /// Parse the FlightAware "Route Analysis" page and return the distinct
    /// flight idents that have recently operated this route. Order matches
    /// the page (most-recent first), so the cap honors recency.
    func fetchOperatingIdents(depICAO: String, arrICAO: String) async throws -> [String] {
        let url = URL(string:
            "https://flightaware.com/analysis/route.rvt"
            + "?origin=\(depICAO)&destination=\(arrICAO)"
        )!
        let html = try await fetchHTML(url: url)
        return Self.parseIdents(routeHTML: html)
    }

    /// Strip tags, collapse whitespace, then match the row shape:
    ///   `<Dow> HH:MM[AP]M <TZ?> <IDENT> <ORIGIN_ICAO> ...`
    /// Returns idents in first-seen order, deduped.
    static func parseIdents(routeHTML: String) -> [String] {
        let stripped = routeHTML
            .replacingOccurrences(
                of: #"<[^>]+>"#,
                with: " ",
                options: .regularExpression
            )
            .replacingOccurrences(
                of: #"\s+"#,
                with: " ",
                options: .regularExpression
            )
        let pattern = #"(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat)\s+\d{1,2}:\d{2}[AP]M.+?([A-Z]{2,3}\d{1,4})\s+[A-Z]{4}\s+"#
        guard let regex = try? NSRegularExpression(
            pattern: pattern,
            options: [.dotMatchesLineSeparators]
        ) else { return [] }
        let range = NSRange(stripped.startIndex..., in: stripped)
        let matches = regex.matches(in: stripped, range: range)
        var idents: [String] = []
        var seen = Set<String>()
        for m in matches where m.numberOfRanges >= 2 {
            guard let r = Range(m.range(at: 1), in: stripped) else { continue }
            let ident = String(stripped[r])
            if seen.insert(ident).inserted {
                idents.append(ident)
            }
        }
        return idents
    }

    // MARK: - Step 2: trackpoll → RouteFlight

    /// Pull and project the `trackpollBootstrap` blob for a single ident.
    func fetchScheduledLegs(
        ident: String,
        depIATA: String,
        arrIATA: String,
        on date: Date
    ) async throws -> [RouteFlight] {
        let url = URL(string: "https://flightaware.com/live/flight/\(ident)")!
        let html = try await fetchHTML(url: url)
        guard let blob = Self.extractTrackpollBlob(from: html) else {
            throw ClientError.trackpollMissing(ident: ident)
        }
        let decoded: TrackpollBootstrap
        do {
            decoded = try JSONDecoder().decode(
                TrackpollBootstrap.self,
                from: Data(blob.utf8)
            )
        } catch {
            throw ClientError.decodingFailed(underlying: error)
        }

        let carrierIATA = Self.airlineIATA(forICAO: Self.identCarrierICAO(ident))
            ?? Self.identCarrierICAO(ident)
        let flightNumber = Self.identFlightNumber(ident)

        var legs: [RouteFlight] = []
        for (_, flight) in decoded.flights {
            for leg in flight.activityLog.flights {
                guard leg.origin.iata == depIATA, leg.destination.iata == arrIATA
                else { continue }
                guard let depSec = leg.gateDepartureTimes?.scheduled,
                      let arrSec = leg.gateArrivalTimes?.scheduled
                else { continue }
                let depDate = Date(timeIntervalSince1970: TimeInterval(depSec))
                let arrDate = Date(timeIntervalSince1970: TimeInterval(arrSec))

                // Date filter is *origin-local*: a 23:50 departure on the 6th
                // appears as the 7th in UTC for negative-offset airports.
                let originTZ = Self.parseTZ(leg.origin.TZ)
                if !Self.sameLocalDay(depDate, target: date, tz: originTZ) {
                    continue
                }

                let durationMin = Int((arrDate.timeIntervalSince(depDate)) / 60)
                let endpoint = { (e: TrackpollEndpoint, instant: Date) -> RouteEndpoint in
                    RouteEndpoint(
                        airportIata: e.iata,
                        dateTime: instant,
                        terminal: e.terminal
                    )
                }
                let leg = RouteFlight(
                    id: "FA-\(ident)-\(depSec)-\(depIATA)-\(arrIATA)",
                    carrierIata: carrierIATA,
                    carrierIcao: Self.identCarrierICAO(ident),
                    flightNumber: flightNumber,
                    flightSuffix: nil,
                    departure: endpoint(leg.origin, depDate),
                    arrival: endpoint(leg.destination, arrDate),
                    durationMinutes: max(0, durationMin),
                    equipmentIata: leg.aircraftType,
                    serviceType: nil,
                    isCodeshare: false,
                    stops: 0,
                    stopCodes: nil,
                    totalSeats: nil,
                    classes: nil,
                    inFlightService: nil,
                    isWetlease: nil,
                    codeshares: nil
                )
                legs.append(leg)
            }
        }
        return legs
    }

    /// Locate `var trackpollBootstrap = {…};` and return the JSON for the
    /// brace-balanced object literal. Returns nil if the marker isn't found
    /// or if the braces are unbalanced (malformed page).
    static func extractTrackpollBlob(from html: String) -> String? {
        guard let markerRange = html.range(
            of: #"var\s+trackpollBootstrap\s*=\s*\{"#,
            options: .regularExpression
        ) else { return nil }

        // Position the cursor at the opening brace.
        let start = html.index(before: markerRange.upperBound)
        var depth = 0
        var inString = false
        var index = start
        while index < html.endIndex {
            let c = html[index]
            if inString {
                if c == "\\" {
                    index = html.index(after: index)
                    if index >= html.endIndex { return nil }
                } else if c == "\"" {
                    inString = false
                }
            } else {
                if c == "\"" {
                    inString = true
                } else if c == "{" {
                    depth += 1
                } else if c == "}" {
                    depth -= 1
                    if depth == 0 {
                        let end = html.index(after: index)
                        return String(html[start..<end])
                    }
                }
            }
            index = html.index(after: index)
        }
        return nil
    }

    // MARK: - Helpers

    private func fetchHTML(url: URL) async throws -> String {
        var req = URLRequest(url: url)
        req.setValue(Self.safariUA, forHTTPHeaderField: "User-Agent")
        req.setValue(
            "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            forHTTPHeaderField: "Accept"
        )
        let (data, response) = try await session.data(for: req)
        guard let http = response as? HTTPURLResponse else {
            throw ClientError.routePageFailed(status: -1)
        }
        guard (200..<300).contains(http.statusCode) else {
            throw ClientError.routePageFailed(status: http.statusCode)
        }
        return String(data: data, encoding: .utf8) ?? ""
    }

    private static let safariUA =
        "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
        + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 "
        + "Mobile/15E148 Safari/604.1"

    /// "AAL220" → "AAL". "BAW296" → "BAW".
    static func identCarrierICAO(_ ident: String) -> String {
        var icao = ""
        for c in ident {
            if c.isLetter { icao.append(c) } else { break }
        }
        return icao
    }

    /// "AAL220" → 220.
    static func identFlightNumber(_ ident: String) -> Int {
        var digits = ""
        for c in ident.reversed() {
            if c.isNumber { digits.insert(c, at: digits.startIndex) } else { break }
        }
        return Int(digits) ?? 0
    }

    /// Carrier ICAO → IATA prefix for human-facing flight numbers. Covers
    /// the major commercial carriers FlightAware uses idents for. Returns
    /// nil for callsigns we don't recognise (private jets, regionals we
    /// haven't mapped) — caller falls back to the raw ICAO prefix.
    static func airlineIATA(forICAO icao: String) -> String? {
        return airlineICAOToIATA[icao]
    }

    private static let airlineICAOToIATA: [String: String] = [
        // US majors + low-cost
        "AAL": "AA", "DAL": "DL", "UAL": "UA", "SWA": "WN", "ASA": "AS",
        "JBU": "B6", "FFT": "F9", "NKS": "NK", "AAY": "G4", "HAL": "HA",
        // US regionals
        "SKW": "OO", "RPA": "YX", "AWI": "9E", "ENY": "MQ", "EDV": "9E",
        "EJM": "AX", "ASH": "9E", "JIA": "OH", "PDT": "PT", "GJS": "ZW",
        "TCF": "9X",
        // Europe
        "BAW": "BA", "DLH": "LH", "KLM": "KL", "AFR": "AF", "VIR": "VS",
        "IBE": "IB", "SAS": "SK", "FIN": "AY", "TAP": "TP", "AZA": "AZ",
        "SWR": "LX", "AUA": "OS", "LOT": "LO", "TRA": "HV", "EZY": "U2",
        "RYR": "FR", "WZZ": "W6", "PGT": "PC", "AEE": "A3", "TVS": "QS",
        "CFE": "BA",
        // Oceania
        "QFA": "QF", "VOZ": "VA", "ANZ": "NZ", "JST": "JQ",
        // Asia
        "ANA": "NH", "JAL": "JL", "ACA": "AC", "WJA": "WS",
        "EVA": "BR", "CAL": "CI", "CES": "MU", "CCA": "CA", "CSN": "CZ",
        "AAR": "OZ", "KAL": "KE", "SIA": "SQ", "THA": "TG", "CPA": "CX",
        "AIC": "AI", "GIA": "GA", "MAS": "MH", "PAL": "PR", "BRU": "B7",
        // Middle East
        "QTR": "QR", "UAE": "EK", "ETD": "EY", "RJA": "RJ", "SVA": "SV",
        // Africa
        "ETH": "ET", "MEA": "ME", "MSR": "MS", "RAM": "AT", "KQA": "KQ",
        // Latin America
        "LAN": "LA", "TAM": "JJ", "AVA": "AV", "AMX": "AM", "VIV": "VB",
        "VOI": "Y4", "CMP": "CM",
        // Israel
        "ELY": "LY",
        // Cargo (operates passenger-numbered flights occasionally)
        "FDX": "FX", "UPS": "5X", "NCA": "KZ", "GTI": "5Y",
        // Charter / leasure operators we've seen on US routes
        "TZP": "ZX", "JSX": "XE",
    ]

    /// Strip leading colon (FlightAware emits ":America/Chicago" rather than
    /// the canonical "America/Chicago") and convert to a `TimeZone`. Falls
    /// back to UTC when the string is missing/unparseable.
    private static func parseTZ(_ raw: String?) -> TimeZone {
        guard var v = raw else { return TimeZone(identifier: "UTC")! }
        if v.hasPrefix(":") { v.removeFirst() }
        return TimeZone(identifier: v) ?? TimeZone(identifier: "UTC")!
    }

    /// True when `instant` falls on `target`'s calendar day in `tz`.
    private static func sameLocalDay(_ instant: Date, target: Date, tz: TimeZone) -> Bool {
        var cal = Calendar(identifier: .gregorian)
        cal.timeZone = tz
        return cal.isDate(instant, inSameDayAs: target)
    }
}

// MARK: - trackpollBootstrap shape (just what we need)

/// Decoded subset of FlightAware's `trackpollBootstrap` JSON literal. The
/// real blob has dozens of fields per leg; we model only what feeds our
/// ``RouteFlight`` projection.
struct TrackpollBootstrap: Decodable {
    let flights: [String: TrackpollFlight]
}

struct TrackpollFlight: Decodable {
    let activityLog: TrackpollActivityLog
}

struct TrackpollActivityLog: Decodable {
    let flights: [TrackpollLeg]
}

struct TrackpollLeg: Decodable {
    let origin: TrackpollEndpoint
    let destination: TrackpollEndpoint
    let aircraftType: String?
    let aircraftTypeFriendly: String?
    let gateDepartureTimes: TrackpollTimes?
    let gateArrivalTimes: TrackpollTimes?
    let takeoffTimes: TrackpollTimes?
    let landingTimes: TrackpollTimes?
}

struct TrackpollEndpoint: Decodable {
    let iata: String
    let icao: String?
    let TZ: String?
    let gate: String?
    let terminal: String?
}

struct TrackpollTimes: Decodable {
    let scheduled: Int?
    let estimated: Int?
    let actual: Int?
}
