import Foundation

/// The slice of `FlightService` SisterFlightService consumes. Defined here
/// so tests can inject a mock without standing up a live FlightConnections
/// session. FlightService conforms below (its public methods already match
/// these signatures verbatim).
protocol FlightScheduleProvider: Sendable {
    func searchAirports(term: String) async throws -> [Airport]
    func allSchedules(
        dep: String,
        des: String,
        onProgress: @Sendable @escaping (Int, Int) -> Void
    ) async throws -> [FlightSchedule]
}

extension FlightService: FlightScheduleProvider {}

/// Discovers backup-itinerary candidates ("sister flights") for nonrev / standby travelers:
/// every flight operating the same origin-destination pair on the same calendar day,
/// surfaced with predicted load so the user can pick the lightest backup.
///
/// Backed by `FlightScheduleProvider` (FlightConnections in production, mocks in tests).
/// Predicted load is optional — if `LoadFactorService` is wired in later, plug it into
/// `loadPredictor` to populate `SisterFlight.predictedLoad`. Sort puts the emptiest
/// flights on top.
actor SisterFlightService {

    static let shared = SisterFlightService(flightService: FlightService.shared)

    // MARK: - Public Types

    struct SisterFlight: Sendable, Identifiable {
        let id: String
        let carrier: String
        let flightNumber: Int
        let scheduledDeparture: Date
        let scheduledArrival: Date
        let aircraftDisplay: String?
        let predictedLoad: Double?
        let isYourFlight: Bool
    }

    // MARK: - Dependencies

    private let flightService: FlightScheduleProvider

    /// Optional hook for predicted load. Signature: (carrier IATA, flight number, date) -> 0...1 load fraction.
    /// Leave nil until `LoadFactorService` lands; the service then sets this on init.
    private let loadPredictor: (@Sendable (String, Int, Date) async -> Double?)?

    // MARK: - Init

    init(
        flightService: FlightScheduleProvider,
        loadPredictor: (@Sendable (String, Int, Date) async -> Double?)? = nil
    ) {
        self.flightService = flightService
        self.loadPredictor = loadPredictor
    }

    // MARK: - Public API

    /// Returns every scheduled flight on the origin/destination pair operating on the
    /// given local date. Results are sorted by predictedLoad ascending (nil last),
    /// breaking ties on scheduledDeparture ascending. The flight matching
    /// `currentFlight` (carrier IATA + flight number) is flagged with `isYourFlight = true`.
    func sisterFlights(
        origin: String,
        dest: String,
        date: Date,
        currentFlight: (carrier: String, number: Int)?
    ) async -> [SisterFlight] {
        let originIATA = origin.uppercased()
        let destIATA = dest.uppercased()

        guard let originId = await resolveAirportId(iata: originIATA) else {
            print("[SisterFlight] could not resolve origin IATA \(originIATA)")
            return []
        }
        guard let destId = await resolveAirportId(iata: destIATA) else {
            print("[SisterFlight] could not resolve dest IATA \(destIATA)")
            return []
        }

        let schedules: [FlightSchedule]
        do {
            schedules = try await flightService.allSchedules(
                dep: originId,
                des: destId,
                onProgress: { _, _ in }
            )
        } catch {
            print("[SisterFlight] allSchedules failed: \(error.localizedDescription)")
            return []
        }

        let operating = schedules.filter { $0.operatesOn(date: date) }
        print("[SisterFlight] \(operating.count)/\(schedules.count) schedules operate on \(date)")

        var results: [SisterFlight] = []
        results.reserveCapacity(operating.count)

        for schedule in operating {
            guard let (depDate, arrDate) = scheduledDates(for: schedule, on: date) else {
                continue
            }

            let flightNumberInt = parseFlightNumber(schedule.flightNumber)
            let carrierIATA = schedule.airline.iata.uppercased()
            let aircraft = displayAircraft(schedule)

            let isYours: Bool = {
                guard let current = currentFlight, let fn = flightNumberInt else { return false }
                return current.carrier.uppercased() == carrierIATA && current.number == fn
            }()

            let load: Double? = await {
                guard let predictor = self.loadPredictor, let fn = flightNumberInt else {
                    return nil
                }
                return await predictor(carrierIATA, fn, depDate)
            }()

            let id = "\(carrierIATA)-\(schedule.flightNumber)-\(Int(depDate.timeIntervalSince1970))"

            results.append(SisterFlight(
                id: id,
                carrier: carrierIATA,
                flightNumber: flightNumberInt ?? 0,
                scheduledDeparture: depDate,
                scheduledArrival: arrDate,
                aircraftDisplay: aircraft,
                predictedLoad: load,
                isYourFlight: isYours
            ))
        }

        results.sort { lhs, rhs in
            switch (lhs.predictedLoad, rhs.predictedLoad) {
            case let (l?, r?):
                if l != r { return l < r }
                return lhs.scheduledDeparture < rhs.scheduledDeparture
            case (_?, nil):
                return true
            case (nil, _?):
                return false
            case (nil, nil):
                return lhs.scheduledDeparture < rhs.scheduledDeparture
            }
        }

        return results
    }

    // MARK: - Helpers

    /// Resolves an IATA code (e.g. "JFK") to the FlightConnections internal airport ID via the autocomplete API.
    private func resolveAirportId(iata: String) async -> String? {
        do {
            let matches = try await flightService.searchAirports(term: iata)
            if let exact = matches.first(where: { $0.iata.uppercased() == iata }) {
                return exact.id
            }
            return matches.first?.id
        } catch {
            print("[SisterFlight] searchAirports failed for \(iata): \(error.localizedDescription)")
            return nil
        }
    }

    /// Combines the user-picked date with the schedule's HH:mm strings, interpreted in UTC,
    /// to produce concrete departure/arrival Dates. Arrival rolls to the next day if it falls
    /// before departure (red-eye).
    private func scheduledDates(for schedule: FlightSchedule, on date: Date) -> (Date, Date)? {
        var utc = Calendar(identifier: .gregorian)
        utc.timeZone = TimeZone(identifier: "UTC")!

        let localComponents = Calendar.current.dateComponents([.year, .month, .day], from: date)
        guard let day = utc.date(from: localComponents) else { return nil }

        guard let dep = applyTime(schedule.departureTime, to: day, calendar: utc) else { return nil }
        guard var arr = applyTime(schedule.arrivalTime, to: day, calendar: utc) else { return nil }

        if arr < dep {
            arr = utc.date(byAdding: .day, value: 1, to: arr) ?? arr
        }

        return (dep, arr)
    }

    private func applyTime(_ hhmm: String, to day: Date, calendar: Calendar) -> Date? {
        let parts = hhmm.split(separator: ":")
        guard parts.count >= 2,
              let hour = Int(parts[0]),
              let minute = Int(parts[1]) else { return nil }
        return calendar.date(bySettingHour: hour, minute: minute, second: 0, of: day)
    }

    /// FlightSchedule.flightNumber is a String like "DL 1234" or "1234" — pull the trailing integer.
    private func parseFlightNumber(_ raw: String) -> Int? {
        let trimmed = raw.trimmingCharacters(in: .whitespaces)
        if let direct = Int(trimmed) { return direct }
        let lastToken = trimmed.split(whereSeparator: { !$0.isNumber }).last.map(String.init) ?? ""
        return Int(lastToken)
    }

    private func displayAircraft(_ schedule: FlightSchedule) -> String? {
        let aircraft = schedule.aircraft.trimmingCharacters(in: .whitespaces)
        return aircraft.isEmpty ? nil : aircraft
    }
}
