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)

        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)"
            }
        }
    }

    // MARK: - Properties

    private let session: URLSession
    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)

        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 url = baseURL.appendingPathComponent("api/flight-search")
            let body = try JSONSerialization.data(withJSONObject: [
                "endpoint": "/schedule",
                "body":     ["json": payload]
            ])
            var req = URLRequest(url: url)
            req.httpMethod = "POST"
            Self.applyBrowserHeaders(to: &req)
            req.setValue("application/json", forHTTPHeaderField: "Content-Type")
            req.setValue("application/json", forHTTPHeaderField: "Accept")
            req.setValue(token, forHTTPHeaderField: "X-API-Token")
            req.httpBody = body

            let (data, response) = try await session.data(for: req)
            let status = (response as? HTTPURLResponse)?.statusCode ?? -1
            guard (200...299).contains(status) 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

    private func currentToken() async throws -> String {
        if let cached = cachedToken, cached.expiresAt > Date() {
            return cached.value
        }
        let url = baseURL.appendingPathComponent("api/token")
        var req = URLRequest(url: url)
        req.httpMethod = "GET"
        Self.applyBrowserHeaders(to: &req)
        req.setValue("application/json", forHTTPHeaderField: "Accept")

        let (data, response) = try await session.data(for: req)
        let status = (response as? HTTPURLResponse)?.statusCode ?? -1
        if status != 200 {
            let bodyStr = String(data: data, encoding: .utf8) ?? "<no body>"
            print("[RouteExplorer] /api/token failed status=\(status) body=\(bodyStr.prefix(300))")
            throw ClientError.tokenFetchFailed(status: status)
        }

        struct TokenResponse: Decodable { let token: String }
        do {
            let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
            cachedToken = (decoded.token, Date().addingTimeInterval(30 * 60))
            return decoded.token
        } catch {
            throw ClientError.decodingFailed(underlying: error)
        }
    }

    /// 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 url = baseURL.appendingPathComponent("api/flight-search")

        let outerBody: [String: Any] = [
            "endpoint": endpoint,
            "body":     ["json": json]
        ]
        let bodyData = try JSONSerialization.data(withJSONObject: outerBody)

        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        Self.applyBrowserHeaders(to: &req)
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.setValue("application/json", forHTTPHeaderField: "Accept")
        req.setValue(token, forHTTPHeaderField: "X-API-Token")
        req.httpBody = bodyData

        let (data, response) = try await session.data(for: req)
        let status = (response as? HTTPURLResponse)?.statusCode ?? -1

        // 401 / 403 likely means the token rotated. Drop cache and retry once.
        if status == 401 || status == 403 {
            cachedToken = nil
            return try await retryAfterTokenRotation(endpoint: endpoint, json: json)
        }

        guard (200...299).contains(status) else {
            let bodyStr = String(data: data, encoding: .utf8)
            throw ClientError.requestFailed(status: status, body: bodyStr)
        }

        return try decode(data: data)
    }

    private func retryAfterTokenRotation(
        endpoint: String,
        json: [String: Any]
    ) async throws -> RouteSearchResult {
        let token = try await currentToken()
        let url = baseURL.appendingPathComponent("api/flight-search")

        let outerBody: [String: Any] = [
            "endpoint": endpoint,
            "body":     ["json": json]
        ]
        let bodyData = try JSONSerialization.data(withJSONObject: outerBody)

        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        Self.applyBrowserHeaders(to: &req)
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.setValue(token, forHTTPHeaderField: "X-API-Token")
        req.httpBody = bodyData

        let (data, response) = try await session.data(for: req)
        let status = (response as? HTTPURLResponse)?.statusCode ?? -1
        guard (200...299).contains(status) else {
            throw ClientError.requestFailed(status: status, body: String(data: data, encoding: .utf8))
        }
        return try decode(data: 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)
        }
    }
}
