import Foundation

/// Plain-text risk band derived from the hourly forecast nearest to the
/// requested time. Used by trip-day UI to surface a "heads up" banner
/// without making the user parse raw weather codes.
enum WeatherRisk: Sendable {
    case low
    case moderate
    case high
}

/// Snapshot of conditions at an airport for a specific date. All values
/// are sampled from the hour closest to `date` in Open-Meteo's hourly
/// arrays, with daily precipitation probability folded in.
struct WeatherForecast: Sendable {
    let airport: String
    let temperatureC: Double
    let precipMM: Double
    let windKmh: Double
    let visibilityM: Double
    let weatherCode: Int
    let precipProbabilityPct: Int
    let riskScore: WeatherRisk
    let summary: String
}

/// Open-Meteo forecast client.
///
/// Open-Meteo is a free, key-less public weather API. We hit the
/// `/v1/forecast` endpoint with the airport's lat/lng and ask for the
/// hourly arrays we care about (temperature, precip, wind, visibility,
/// weather code) plus the daily precip probability max. Results are
/// cached per (iata, yyyy-MM-dd) for the lifetime of the actor so a
/// trip view that asks for multiple legs on the same day doesn't fan
/// out duplicate requests.
actor WeatherClient {
    /// Shared singleton so that the per-actor in-memory cache survives
    /// across views and we don't fan out duplicate Open-Meteo requests
    /// when multiple sheets ask about the same endpoint on the same day.
    static let shared = WeatherClient()

    private struct CacheKey: Hashable {
        let iata: String
        let dayKey: String
    }

    private let session: URLSession
    private var cache: [CacheKey: WeatherForecast] = [:]

    init(session: URLSession = .shared) {
        self.session = session
    }

    /// Returns nil when the airport is unknown, the network call fails,
    /// or the response can't be decoded. Callers should treat nil as
    /// "no forecast available" and just hide the weather chip — there's
    /// no recovery worth retrying inline.
    func forecast(forIATA iata: String, on date: Date, database: AirportDatabase) async -> WeatherForecast? {
        let upper = iata.uppercased()

        guard let airport = database.airport(byIATA: upper) else {
            print("[Weather] Unknown airport: \(upper)")
            return nil
        }

        // Bucket the cache by the airport's *local* calendar day. A flight
        // departing JFK at 23:00 EST and an arrival into JFK at 02:00 EST
        // the next morning UTC are the same operational day from the
        // traveller's perspective; using the airport tz keeps the cache key
        // stable for that case.
        //
        // Prefer the curated IANA identifier from AirportDatabase so we
        // observe DST transitions (JFK is EDT in summer, not EST). Fall
        // back to a longitude approximation only for airports we don't
        // have an explicit entry for.
        let airportTZ: TimeZone = database.timeZone(forIATA: upper)
            ?? Self.fallbackTimeZone(for: airport)
        let dayKey = Self.dayKey(for: date, in: airportTZ)
        let key = CacheKey(iata: upper, dayKey: dayKey)
        if let hit = cache[key] {
            return hit
        }

        var comps = URLComponents(string: "https://api.open-meteo.com/v1/forecast")!
        comps.queryItems = [
            URLQueryItem(name: "latitude",  value: String(format: "%.4f", airport.lat)),
            URLQueryItem(name: "longitude", value: String(format: "%.4f", airport.lng)),
            URLQueryItem(name: "hourly",    value: "temperature_2m,precipitation,wind_speed_10m,visibility,weather_code"),
            URLQueryItem(name: "daily",     value: "weathercode,precipitation_probability_max"),
            URLQueryItem(name: "timezone",  value: "auto"),
            URLQueryItem(name: "forecast_days", value: "3"),
        ]

        guard let url = comps.url else {
            print("[Weather] Failed to build URL for \(upper)")
            return nil
        }

        var req = URLRequest(url: url)
        req.timeoutInterval = 12
        req.setValue("application/json", forHTTPHeaderField: "Accept")

        do {
            let (data, response) = try await session.data(for: req)
            if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
                print("[Weather] HTTP \(http.statusCode) for \(upper)")
                return nil
            }
            let decoded = try JSONDecoder().decode(OpenMeteoResponse.self, from: data)
            guard let forecast = Self.materialize(decoded, iata: upper, target: date, airportTZ: airportTZ) else {
                print("[Weather] Empty/invalid hourly arrays for \(upper)")
                return nil
            }
            cache[key] = forecast
            return forecast
        } catch {
            print("[Weather] Fetch failed for \(upper): \(error.localizedDescription)")
            return nil
        }
    }

    // MARK: - Materialization

    private static func materialize(_ raw: OpenMeteoResponse, iata: String, target: Date, airportTZ: TimeZone) -> WeatherForecast? {
        guard let hourly = raw.hourly, !hourly.time.isEmpty else { return nil }

        // Open-Meteo returns times as bare wall-clock strings in the
        // requested timezone (e.g. "2026-05-31T15:00") when we ask for
        // timezone=auto. To find the hourly slot closest to our target
        // Date we have to parse those strings in the *airport's* local
        // timezone — not UTC, otherwise we'd pick a slot offset by the
        // airport's UTC delta.
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime]
        let fallback = DateFormatter()
        fallback.locale = Locale(identifier: "en_US_POSIX")
        fallback.dateFormat = "yyyy-MM-dd'T'HH:mm"
        // Prefer the timezone the Open-Meteo response says it used; fall
        // back to the airport-derived tz when the field is missing.
        let responseTZ = raw.timezone.flatMap(TimeZone.init(identifier:)) ?? airportTZ
        fallback.timeZone = responseTZ

        var bestIndex = 0
        var bestDelta = TimeInterval.greatestFiniteMagnitude

        for (idx, stamp) in hourly.time.enumerated() {
            let parsed = formatter.date(from: stamp) ?? fallback.date(from: stamp)
            guard let parsed else { continue }
            let delta = abs(parsed.timeIntervalSince(target))
            if delta < bestDelta {
                bestDelta = delta
                bestIndex = idx
            }
        }

        func sample(_ arr: [Double?]?) -> Double {
            guard let arr, bestIndex < arr.count, let v = arr[bestIndex] else { return 0 }
            return v
        }
        func sampleInt(_ arr: [Int?]?) -> Int {
            guard let arr, bestIndex < arr.count, let v = arr[bestIndex] else { return 0 }
            return v
        }

        let temperatureC = sample(hourly.temperature_2m)
        let precipMM     = sample(hourly.precipitation)
        let windKmh      = sample(hourly.wind_speed_10m)
        let visibilityM  = sample(hourly.visibility)
        let weatherCode  = sampleInt(hourly.weather_code)

        // Pick the daily index matching the target's calendar day in the
        // airport's local time. Open-Meteo's daily.time entries are bare
        // dates ("2026-05-31") aligned to the response timezone.
        let targetDayKey = dayKey(for: target, in: airportTZ)
        var precipProb = 0
        if let daily = raw.daily, let probs = daily.precipitation_probability_max {
            if let dayIdx = daily.time.firstIndex(of: targetDayKey),
               dayIdx < probs.count,
               let v = probs[dayIdx] {
                precipProb = v
            } else if let firstOpt = probs.first, let first = firstOpt {
                precipProb = first
            }
        }

        let risk = riskBand(precipProb: precipProb,
                            weatherCode: weatherCode,
                            visibilityM: visibilityM,
                            windKmh: windKmh)
        let summary = summarize(weatherCode: weatherCode)

        return WeatherForecast(
            airport: iata,
            temperatureC: temperatureC,
            precipMM: precipMM,
            windKmh: windKmh,
            visibilityM: visibilityM,
            weatherCode: weatherCode,
            precipProbabilityPct: precipProb,
            riskScore: risk,
            summary: summary
        )
    }

    private static func riskBand(precipProb: Int, weatherCode: Int, visibilityM: Double, windKmh: Double) -> WeatherRisk {
        if precipProb > 60 || (95...99).contains(weatherCode) || visibilityM < 2000 {
            return .high
        }
        if precipProb > 30 || windKmh > 40 {
            return .moderate
        }
        return .low
    }

    /// WMO weather code → human one-liner. Codes are grouped by
    /// phenomenon, not intensity — finer breakdowns (light/heavy)
    /// would just clutter the chip.
    private static func summarize(weatherCode code: Int) -> String {
        switch code {
        case 0:        return "Clear sky"
        case 1:        return "Mostly clear"
        case 2:        return "Partly cloudy"
        case 3:        return "Overcast"
        case 45, 48:   return "Fog"
        case 51, 53, 55: return "Drizzle"
        case 56, 57:   return "Freezing drizzle"
        case 61, 63, 65: return "Rain"
        case 66, 67:   return "Freezing rain"
        case 71, 73, 75: return "Snow"
        case 77:       return "Snow grains"
        case 80, 81, 82: return "Rain showers"
        case 85, 86:   return "Snow showers"
        case 95:       return "Thunderstorm"
        case 96, 99:   return "Thunderstorm with hail"
        default:       return "Conditions unavailable"
        }
    }

    /// Returns "yyyy-MM-dd" for `date` interpreted in the given timezone.
    /// Falls back to UTC when no timezone is supplied — this matches the
    /// pre-refactor behaviour for any caller that doesn't yet have an
    /// airport context. Exposed at module-internal scope so tests can
    /// pin the local-day rollover behaviour without going through the
    /// network path.
    static func dayKey(for date: Date, in timeZone: TimeZone? = nil) -> String {
        let fmt = DateFormatter()
        fmt.locale = Locale(identifier: "en_US_POSIX")
        fmt.dateFormat = "yyyy-MM-dd"
        fmt.timeZone = timeZone ?? TimeZone(identifier: "UTC")!
        return fmt.string(from: date)
    }

    /// Fallback timezone when ``AirportDatabase.timeZone(forIATA:)`` doesn't
    /// have an explicit entry. Uses a longitude approximation (15° ≈ 1
    /// hour) which ignores political tz boundaries + DST — only correct
    /// to within an hour, but better than UTC for unmapped airports.
    /// Almost everything the user actually opens hits the curated IANA
    /// table, so this branch is the long-tail safety net.
    private static func fallbackTimeZone(for airport: MapAirport) -> TimeZone {
        let rawHours = (airport.lng / 15.0).rounded()
        let hours = max(-12, min(14, Int(rawHours)))
        return TimeZone(secondsFromGMT: hours * 3600) ?? TimeZone(identifier: "UTC")!
    }
}

// MARK: - Open-Meteo DTOs

private struct OpenMeteoResponse: Decodable {
    let timezone: String?
    let hourly: Hourly?
    let daily: Daily?

    struct Hourly: Decodable {
        let time: [String]
        let temperature_2m: [Double?]?
        let precipitation: [Double?]?
        let wind_speed_10m: [Double?]?
        let visibility: [Double?]?
        let weather_code: [Int?]?
    }

    struct Daily: Decodable {
        let time: [String]
        let weathercode: [Int?]?
        let precipitation_probability_max: [Int?]?
    }
}
