import XCTest
@testable import Flights

/// Unit tests for `WeatherClient`'s timezone correctness and the shared-cache contract.
///
/// These tests are intentionally written against the **post-fix** API surface
/// (`WeatherClient.dayKey(for:in:)` and `WeatherClient.shared` with an injectable
/// `URLSession`). Until the production code adopts that shape, they will not
/// compile / will not pass — that's the TDD contract for the timezone-bug phase.
///
/// Why the test exists:
///
/// 1. **Local-day key bug.** A flight departing 2026-12-31T22:00:00-05:00
///    (10 PM Eastern at JFK) is on December 31 in the airport's wall clock,
///    but is 2027-01-01 03:00 UTC. The current implementation builds the
///    cache key in UTC (see `WeatherClient.swift:217-223`), which causes the
///    daily precip-probability lookup to land on the *wrong* calendar day —
///    surfacing tomorrow's forecast as if it were tonight's.
///
/// 2. **Shared cache.** The UI currently spins up a fresh `WeatherClient()`
///    per view (see `LiveFlightDetailSheet.swift:898`), so the per-actor
///    cache never hits across legs of a trip. The fix is `WeatherClient.shared`
///    plus an injectable session so two requests for the same (iata, day)
///    issue a single network call.
final class WeatherClientTests: XCTestCase {

    // MARK: - dayKey timezone correctness

    /// 10 PM Eastern on Dec 31 is still Dec 31 to a JFK traveller, even though
    /// its UTC representation rolls past midnight into Jan 1. The day key must
    /// be derived in the airport's local zone or every NYE evening flight will
    /// fetch tomorrow's daily precip probability.
    func test_dayKey_usesAirportLocalTimeZone_notUTC() throws {
        // 2026-12-31T22:00:00 America/New_York
        var comps = DateComponents()
        comps.year = 2026; comps.month = 12; comps.day = 31
        comps.hour = 22; comps.minute = 0; comps.second = 0
        comps.timeZone = TimeZone(identifier: "America/New_York")
        var cal = Calendar(identifier: .gregorian)
        cal.timeZone = TimeZone(identifier: "America/New_York")!
        let date = cal.date(from: comps)!

        let nyc = TimeZone(identifier: "America/New_York")!
        let key = WeatherClient.dayKey(for: date, in: nyc)

        XCTAssertEqual(
            key, "2026-12-31",
            "10pm Eastern on NYE must resolve to the local Dec 31, not UTC's Jan 1."
        )
    }

    /// Same instant, asked for in Tokyo — should report Jan 1 (Tokyo is +9,
    /// so 10pm EST Dec 31 == 12pm JST Jan 1). Proves the helper is honouring
    /// its `tz` argument and not silently defaulting to UTC.
    func test_dayKey_respectsCallerProvidedTimeZone() throws {
        var comps = DateComponents()
        comps.year = 2026; comps.month = 12; comps.day = 31
        comps.hour = 22; comps.minute = 0; comps.second = 0
        comps.timeZone = TimeZone(identifier: "America/New_York")
        var cal = Calendar(identifier: .gregorian)
        cal.timeZone = TimeZone(identifier: "America/New_York")!
        let date = cal.date(from: comps)!

        let tokyo = TimeZone(identifier: "Asia/Tokyo")!
        let key = WeatherClient.dayKey(for: date, in: tokyo)

        XCTAssertEqual(
            key, "2027-01-01",
            "Same instant viewed in Tokyo is already Jan 1 — helper must use the supplied tz."
        )
    }

    /// Sanity: noon local on a normal day round-trips through the helper for
    /// every supported zone. Guards against accidentally re-introducing a
    /// hard-coded "UTC" inside the formatter.
    func test_dayKey_noonLocal_matchesCalendarDay() throws {
        for id in ["America/Los_Angeles", "America/New_York", "Europe/London", "Asia/Tokyo", "Australia/Sydney"] {
            let tz = TimeZone(identifier: id)!
            var cal = Calendar(identifier: .gregorian)
            cal.timeZone = tz
            var comps = DateComponents()
            comps.year = 2026; comps.month = 6; comps.day = 15
            comps.hour = 12; comps.minute = 0
            comps.timeZone = tz
            let date = cal.date(from: comps)!
            XCTAssertEqual(
                WeatherClient.dayKey(for: date, in: tz),
                "2026-06-15",
                "Noon \(id) on 2026-06-15 must round-trip to that calendar day."
            )
        }
    }

    // MARK: - Shared cache + single-flight network behaviour

    /// Two `forecast(...)` calls for the same airport and local day should
    /// hit the network once. The fix is `WeatherClient.shared` plus an
    /// injectable `URLSession` so we can count requests against a stub
    /// protocol — and `LiveFlightDetailSheet` must adopt `.shared` for the
    /// production cache to actually share.
    func test_shared_cachesPerLocalDay_acrossCalls() async throws {
        let db = AirportDatabase()
        try XCTSkipIf(db.airport(byIATA: "JFK") == nil, "airports.json missing JFK; cannot exercise weather fetch")

        // Single-shot stub that returns the same canned Open-Meteo payload
        // for any URL. The counter is incremented on every network request.
        let counter = RequestCounter()
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [StubURLProtocol.self]
        StubURLProtocol.counter = counter
        StubURLProtocol.responder = { _ in
            let body = Self.openMeteoFixture()
            return (HTTPURLResponse(
                url: URL(string: "https://api.open-meteo.com/v1/forecast")!,
                statusCode: 200, httpVersion: "HTTP/1.1",
                headerFields: ["Content-Type": "application/json"]
            )!, body)
        }
        let session = URLSession(configuration: config)
        let client = WeatherClient(session: session)

        // 8 AM Eastern at JFK — squarely inside Open-Meteo's fixture window.
        let date = Self.localDate(2026, 6, 15, 8, "America/New_York")

        _ = await client.forecast(forIATA: "JFK", on: date, database: db)
        _ = await client.forecast(forIATA: "JFK", on: date, database: db)

        let hits = await counter.value
        XCTAssertEqual(
            hits, 1,
            "Second call for the same (iata, local day) must be served from cache, not re-fetched."
        )

        StubURLProtocol.responder = nil
        StubURLProtocol.counter = nil
    }

    /// Confirms the singleton exists and is the shared instance, so the UI
    /// pivot to `WeatherClient.shared` actually deduplicates across views.
    func test_sharedSingleton_isStable() {
        let a = WeatherClient.shared
        let b = WeatherClient.shared
        XCTAssertTrue(a === b, "WeatherClient.shared must vend the same actor instance across calls.")
    }

    /// The forecast surface must use the local-day daily precip probability,
    /// not the UTC-day one. With the fixture below, June 15 local has
    /// precipProbability=42 and June 16 has 88 — a UTC-keyed lookup at 10pm
    /// Eastern would land on the wrong bucket and return 88.
    func test_forecast_dailyPrecipProbability_usesLocalDay() async throws {
        let db = AirportDatabase()
        try XCTSkipIf(db.airport(byIATA: "JFK") == nil, "airports.json missing JFK; cannot exercise weather fetch")

        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [StubURLProtocol.self]
        StubURLProtocol.counter = RequestCounter()
        StubURLProtocol.responder = { _ in
            let body = Self.openMeteoFixture()
            return (HTTPURLResponse(
                url: URL(string: "https://api.open-meteo.com/v1/forecast")!,
                statusCode: 200, httpVersion: "HTTP/1.1",
                headerFields: ["Content-Type": "application/json"]
            )!, body)
        }
        let session = URLSession(configuration: config)
        let client = WeatherClient(session: session)

        // 10 PM local on June 15 NYC — UTC would resolve to June 16.
        let date = Self.localDate(2026, 6, 15, 22, "America/New_York")
        let forecast = await client.forecast(forIATA: "JFK", on: date, database: db)
        XCTAssertNotNil(forecast)
        XCTAssertEqual(forecast?.airport, "JFK")
        XCTAssertEqual(
            forecast?.precipProbabilityPct, 42,
            "Daily precip prob must reflect the local day's bucket (42), not the UTC day after (88)."
        )

        StubURLProtocol.responder = nil
        StubURLProtocol.counter = nil
    }

    // MARK: - Fixtures / helpers

    /// Open-Meteo's `timezone=auto` response with hourly entries spanning
    /// the night of 2026-06-15 and into 06-16 (America/New_York), plus two
    /// daily entries — one with precipProb=42 (the 15th) and one with 88
    /// (the 16th) so we can detect which day the client picked.
    private static func openMeteoFixture() -> Data {
        let json = """
        {
          "timezone": "America/New_York",
          "hourly": {
            "time": [
              "2026-06-15T20:00",
              "2026-06-15T21:00",
              "2026-06-15T22:00",
              "2026-06-15T23:00",
              "2026-06-16T00:00",
              "2026-06-16T01:00"
            ],
            "temperature_2m":   [21.0, 20.5, 20.0, 19.5, 19.0, 18.5],
            "precipitation":    [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
            "wind_speed_10m":   [10.0, 10.0, 10.0, 10.0, 10.0, 10.0],
            "visibility":       [20000.0, 20000.0, 20000.0, 20000.0, 20000.0, 20000.0],
            "weather_code":     [1, 1, 1, 1, 2, 2]
          },
          "daily": {
            "time": ["2026-06-15", "2026-06-16"],
            "weathercode": [1, 2],
            "precipitation_probability_max": [42, 88]
          }
        }
        """
        return Data(json.utf8)
    }

    private static func localDate(_ y: Int, _ m: Int, _ d: Int, _ h: Int, _ tzID: String) -> Date {
        var cal = Calendar(identifier: .gregorian)
        cal.timeZone = TimeZone(identifier: tzID)!
        var comps = DateComponents()
        comps.year = y; comps.month = m; comps.day = d
        comps.hour = h; comps.minute = 0; comps.second = 0
        comps.timeZone = TimeZone(identifier: tzID)
        return cal.date(from: comps)!
    }
}

// MARK: - Test doubles

/// Thread-safe call counter for the stub URLProtocol. Lives outside the
/// actor system so the protocol class can touch it from arbitrary queues.
final class RequestCounter: @unchecked Sendable {
    private let lock = NSLock()
    private var _value = 0
    var value: Int {
        lock.lock(); defer { lock.unlock() }
        return _value
    }
    func bump() {
        lock.lock(); defer { lock.unlock() }
        _value += 1
    }
}

/// Minimal URLProtocol that hands every request to `responder` and bumps
/// `counter`. Lets us assert "exactly one fetch" without leaning on the
/// real network.
///
/// The static `responder` / `counter` fields are accessed serially from one
/// test at a time (XCTest runs tests sequentially within a class), so a
/// plain `static var` is safe here without nonisolated-unsafe annotations.
final class StubURLProtocol: URLProtocol {
    static var responder: ((URLRequest) -> (HTTPURLResponse, Data))?
    static var counter: RequestCounter?

    override class func canInit(with request: URLRequest) -> Bool { responder != nil }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        Self.counter?.bump()
        guard let responder = Self.responder else {
            client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
            return
        }
        let (response, data) = responder(request)
        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: data)
        client?.urlProtocolDidFinishLoading(self)
    }

    override func stopLoading() {}
}
