import Foundation

/// Talks to the public route-explorer.com API surface (StaffTraveler's web
/// proxy). No login required — `/api/token` issues an anonymous HMAC token,
/// IP-rate-limited at 10/min, that we attach as `X-API-Token` to subsequent
/// `/api/flight-search` calls.
///
/// Endpoint allowlist exposed by the proxy: `/route`, `/route-batch`,
/// `/flight`, `/departures`, `/schedule`. We use `/route` (with `maxStops`)
/// for connection finding and `/departures` for the "where can I go" view.
actor RouteExplorerClient {

    // MARK: - Errors

    enum ClientError: Error, LocalizedError {
        case tokenFetchFailed(status: Int)
        case requestFailed(status: Int, body: String?)
        case decodingFailed(underlying: Error)
        /// Legacy. Server returned 403 `reason: "clearance"`.
        /// Retained for backwards compat with any in-tree callers; the
        /// production path now throws ``needsTokenRefresh`` instead.
        case needsClearance
        /// No usable token in ``RouteExplorerTokenStore`` (never captured
        /// or expired). Caller should open the bookmarklet refresh flow.
        case needsTokenRefresh

        var errorDescription: String? {
            switch self {
            case .tokenFetchFailed(let status):
                return "Could not get an API token (HTTP \(status))."
            case .requestFailed(let status, let body):
                return "Request failed (HTTP \(status)). \(body ?? "")"
            case .decodingFailed(let error):
                return "Could not parse response: \(error.localizedDescription)"
            case .needsClearance:
                return "Verification required."
            case .needsTokenRefresh:
                return "Route-explorer token missing or expired. Open Settings → Tools → Connect route-explorer to refresh."
            }
        }
    }

    // MARK: - Properties

    private let session: URLSession
    private let sessionDelegate = LoggingURLSessionDelegate(tag: "RE")
    private let baseURL = URL(string: "https://route-explorer.com")!
    private let dateFormatter: DateFormatter

    /// Cached token. Tokens have a server-side TTL we don't know exactly;
    /// we refresh defensively after 30 minutes or on first use.
    private var cachedToken: (value: String, expiresAt: Date)?

    // MARK: - Init

    init() {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 20
        config.requestCachePolicy = .reloadIgnoringLocalCacheData
        session = URLSession(configuration: config, delegate: nil, delegateQueue: nil)
        // The delegate above is initialized so it can be reused if we
        // later swap to a delegated session. The current session uses
        // the configuration path because the existing fetch path is
        // WKWebView-based, not URLSession; if/when that flips back, the
        // delegate gets the trace.
        _ = sessionDelegate

        let f = DateFormatter()
        f.calendar = Calendar(identifier: .gregorian)
        f.locale = Locale(identifier: "en_US_POSIX")
        f.timeZone = TimeZone(identifier: "UTC")
        f.dateFormat = "yyyy-MM-dd"
        dateFormatter = f
    }

    // MARK: - Public search API

    /// Find direct + multi-stop itineraries between two airports on one date.
    func searchRoutes(
        from origin: String,
        to destination: String,
        date: Date,
        maxStops: Int = 1,
        includeInterline: Bool = false,
        sortBy: RouteSortOption = .departureEarliest,
        limit: Int = 100
    ) async throws -> RouteSearchResult {
        let dateStr = dateFormatter.string(from: date)
        // All sorts apply client-side. Upstream is told to use
        // `departure_time` so the result order is stable; RoutePlannerView
        // reorders after the fetch returns.
        let serverSort = sortBy.apiValue ?? "departure_time"
        let payload: [String: Any] = [
            "departureAirportIata": origin.uppercased(),
            "arrivalAirportIata":   destination.uppercased(),
            "departureDates":       [dateStr],
            "maxStops":             maxStops,
            "sortBy":               serverSort,
            "includeInterline":     includeInterline,
            "limit":                limit,
            "includeAppendix":      true
        ]
        return try await callFlightSearch(endpoint: "/route", json: payload)
    }

    /// Schedule lookup for a specific flight number across a date range.
    /// Powers the live-flight detail sheet — given an ICAO callsign like
    /// `AAL1234` we resolve it to `(carrier: "AA", flightNumber: 1234)` and
    /// pull the operating record, which carries real departure + arrival
    /// airports and times.
    ///
    /// Returns `nil` if the carrier/flight isn't in route-explorer's
    /// schedule feed (typical for regional codeshares, charter ops, and
    /// carriers the upstream platform doesn't index).
    func searchSchedule(
        carrierCode: String,
        flightNumber: Int,
        startDate: Date,
        endDate: Date? = nil
    ) async -> [RouteFlight] {
        let startStr = dateFormatter.string(from: startDate)
        let endStr   = dateFormatter.string(from: endDate ?? startDate)
        let payload: [String: Any] = [
            "carrierCode":      carrierCode.uppercased(),
            "flightNumber":     flightNumber,
            "startDate":        startStr,
            "endDate":          endStr,
            "limit":            20,
            "includeAppendix":  true
        ]
        do {
            let token = try await currentToken()
            let body = try JSONSerialization.data(withJSONObject: [
                "endpoint": "/schedule",
                "body":     ["json": payload]
            ])
            let (status, data) = try await postFlightSearch(token: token, body: body)
            guard status == 200 else { return [] }
            let decoded = try JSONDecoder.routeExplorer().decode(
                RouteExplorerScheduleResponse.self, from: data
            )
            return decoded.json.flights
        } catch {
            return []
        }
    }

    /// All departures from an airport on a date. We filter by time window
    /// client-side because the upstream endpoint doesn't accept one.
    func searchDepartures(
        from origin: String,
        date: Date,
        maxStops: Int = 0,
        limit: Int = 200
    ) async throws -> RouteSearchResult {
        let dateStr = dateFormatter.string(from: date)
        let payload: [String: Any] = [
            "departureAirportIata": origin.uppercased(),
            "departureDates":       [dateStr],
            "maxStops":             maxStops,
            "limit":                limit,
            "includeAppendix":      true
        ]
        return try await callFlightSearch(endpoint: "/departures", json: payload)
    }

    // MARK: - Token

    /// Returns a usable token. Prefers the user-supplied token from
    /// ``RouteExplorerTokenStore`` (captured via the Safari bookmarklet
    /// flow); falls back to ``cachedToken`` only if a previous in-app
    /// fetch managed to mint one (rare since the gate moved).
    ///
    /// Throws ``ClientError/needsTokenRefresh`` when there is no stored
    /// token — `RoutePlannerView` catches this and routes the user to
    /// the bookmarklet setup screen.
    private func currentToken() async throws -> String {
        if let cached = cachedToken, cached.expiresAt > Date() {
            return cached.value
        }
        // User-supplied token from the Safari bookmarklet capture.
        let stored = await MainActor.run { RouteExplorerTokenStore.shared }
        let token = await MainActor.run { stored.token }
        let exp = await MainActor.run { stored.expiresAt }
        if let token, let exp, exp > Date(), !token.isEmpty {
            // Keep the in-actor cache aligned with the store.
            cachedToken = (token, exp)
            return token
        }
        throw ClientError.needsTokenRefresh
    }

    /// Real iPhone Safari UA — WKWebView's default ("Mobile/15E148"
    /// only) is missing the `Version/x.x Safari/604.1` suffix that
    /// Cloudflare uses to identify true Safari. Setting this on the
    /// WebView gets us past the simplest UA-based filters.
    private static let safariUA: String =
        "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"

    /// Runs an XHR from inside a WKWebView that's been navigated to
    /// `https://route-explorer.com/`. The page context provides the
    /// real Safari TLS fingerprint and any first-party cookies the
    /// edge expects. Returns the response body as a string, or
    /// throws with the real upstream status code on failure.
    private func fetchViaWebView(
        method: String,
        apiPath: String,
        extraHeaders: [String: String],
        requestBody: Data?
    ) async throws -> String {
        let fetcher = await WebViewFetcher()
        var headers: [String: String] = [
            "Accept": "application/json",
            "Content-Type": "application/json"
        ]
        for (k, v) in extraHeaders { headers[k] = v }

        // For POST, body is interpolated verbatim into a JS literal.
        // The body we send is already a JSON-encoded byte string, so
        // wrapping in JSON.stringify(...) re-emits the same string.
        var bodyJS: String?
        if let requestBody {
            let raw = String(data: requestBody, encoding: .utf8) ?? "null"
            bodyJS = "JSON.stringify(\(raw))"
        }

        let result = await fetcher.fetch(
            navigateTo: "https://route-explorer.com/",
            fetchURL: "https://route-explorer.com\(apiPath)",
            method: method,
            headers: headers,
            body: bodyJS,
            userAgent: Self.safariUA,
            includeCredentials: true
        )
        if let err = result.error {
            // WebViewFetcher returns errors in the form "HTTP <code>: <body>"
            // or a free-form description. A 403 whose body contains
            // `"reason":"clearance"` is the Turnstile gate — surface it
            // distinctly so the caller can present the gate sheet.
            let upstreamStatus = Self.extractStatus(from: err) ?? -1
            print("[RouteExplorer] WebView \(method) \(apiPath) failed: \(err)")
            if upstreamStatus == 403, err.contains("\"reason\":\"clearance\"") {
                throw ClientError.needsClearance
            }
            throw ClientError.tokenFetchFailed(status: upstreamStatus)
        }
        guard let data = result.data else {
            throw ClientError.tokenFetchFailed(status: -1)
        }
        return data
    }

    /// Pull the integer HTTP status code from WebViewFetcher's
    /// "HTTP <code>: ..." formatted error string. Returns nil for
    /// anything we can't parse.
    private static func extractStatus(from err: String) -> Int? {
        guard let range = err.range(of: #"HTTP (\d+)"#, options: .regularExpression),
              let codeRange = err[range].range(of: #"\d+"#, options: .regularExpression)
        else { return nil }
        return Int(err[range][codeRange])
    }

    /// Browser-shaped headers — `/api/token` and `/api/flight-search` are
    /// gated by Origin/Referer in production. Without these the server
    /// returns 403 even though the endpoints are otherwise anonymous.
    private static func applyBrowserHeaders(to request: inout URLRequest) {
        request.setValue(
            "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) "
                + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 "
                + "Mobile/15E148 Safari/604.1",
            forHTTPHeaderField: "User-Agent"
        )
        request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
        request.setValue("https://route-explorer.com/", forHTTPHeaderField: "Referer")
        request.setValue("https://route-explorer.com", forHTTPHeaderField: "Origin")
    }

    // MARK: - Flight search proxy

    private func callFlightSearch(
        endpoint: String,
        json: [String: Any]
    ) async throws -> RouteSearchResult {
        let token = try await currentToken()
        let outerBody: [String: Any] = [
            "endpoint": endpoint,
            "body":     ["json": json]
        ]
        let bodyData = try JSONSerialization.data(withJSONObject: outerBody)

        let (status, data) = try await postFlightSearch(
            token: token,
            body: bodyData
        )
        if status == 200 {
            return try decode(data: data)
        }
        // 403 reason:"token" → token expired or rotated. Clear and surface
        // a refresh request so the caller can route the user to Settings.
        let bodyStr = String(data: data, encoding: .utf8) ?? ""
        if status == 403, bodyStr.contains("\"reason\":\"token\"") {
            cachedToken = nil
            await MainActor.run { RouteExplorerTokenStore.shared.clear() }
            throw ClientError.needsTokenRefresh
        }
        throw ClientError.requestFailed(status: status, body: bodyStr)
    }

    /// Direct URLSession POST to `/api/flight-search`. Per the
    /// 2026-06-05 forensic probe (see `notes/turnstile.md` and the
    /// captured `[BOOT] isSimulator=false` diagnostic on a real
    /// device), this endpoint validates the X-API-Token alone — it does
    /// *not* gate on the `rex_clearance` clearance cookie that blocks
    /// `/api/token`. So once we have a token (minted by the user in
    /// Safari and handed to us via the `flights://routeexplorer-token`
    /// scheme), plain URLSession works.
    ///
    /// Returns `(statusCode, responseBody)` so the caller can branch on
    /// 403 reason:"token" → token-expired → kick off a refresh.
    private func postFlightSearch(
        token: String,
        body: Data
    ) async throws -> (Int, Data) {
        var req = URLRequest(url: baseURL.appendingPathComponent("api/flight-search"))
        req.httpMethod = "POST"
        req.httpBody = body
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.setValue("application/json", forHTTPHeaderField: "Accept")
        req.setValue(token, forHTTPHeaderField: "X-API-Token")
        req.setValue("https://route-explorer.com/", forHTTPHeaderField: "Referer")
        req.setValue("https://route-explorer.com", forHTTPHeaderField: "Origin")
        req.setValue(Self.safariUA, forHTTPHeaderField: "User-Agent")
        req.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
        // If the bookmarklet also captured JS-visible cookies (`am_user_session`
        // etc.), forward them; harmless if the endpoint doesn't require them.
        let cookieHeader = await MainActor.run {
            RouteExplorerTokenStore.shared.capturedCookieHeader
        }
        if let cookieHeader, !cookieHeader.isEmpty {
            req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
        }

        DiagnosticLogger.shared.log("RE", "postFlightSearch", [
            "url": req.url?.absoluteString ?? "?",
            "bodyLen": body.count,
            "hasCookies": !(cookieHeader ?? "").isEmpty,
        ])
        let (data, response) = try await session.data(for: req)
        let status = (response as? HTTPURLResponse)?.statusCode ?? -1
        DiagnosticLogger.shared.log("RE", "postFlightSearchResult", [
            "status": status,
            "bodyLen": data.count,
            "preview": String((String(data: data, encoding: .utf8) ?? "").prefix(220)),
        ])
        return (status, data)
    }

    private func decode(data: Data) throws -> RouteSearchResult {
        do {
            let response = try JSONDecoder.routeExplorer().decode(RouteExplorerResponse.self, from: data)
            return RouteSearchResult(
                connections: response.json.connections,
                appendix: response.json.appendix
            )
        } catch {
            throw ClientError.decodingFailed(underlying: error)
        }
    }
}
