import Foundation
import PassKit
import Combine

/// Watches Apple Wallet's PKPassLibrary for newly-added boarding passes
/// and emits parsed flight data. The app can subscribe and prompt to
/// log when one shows up.
///
/// PKPassLibrary read access doesn't require the
/// `pass-type-identifiers` entitlement (which is only needed to write
/// passes you own). Listening to library-change notifications and
/// reading metadata of any boarding pass works on a default app.
@MainActor
final class WalletPassObserver: ObservableObject {
    static let shared = WalletPassObserver()

    @Published private(set) var pendingPass: ParsedPass?

    struct ParsedPass: Hashable {
        let flightDate: Date
        let carrierIATA: String?
        let flightNumber: String?
        let departureIATA: String?
        let arrivalIATA: String?
        let seat: String?
        let serialNumber: String
    }

    private var library: PKPassLibrary?
    private var token: NSObjectProtocol?
    private var knownSerials: Set<String> = []
    private var started = false

    private init() {
        // Deliberately empty. PKPassLibrary touches the PassKit
        // subsystem which can stall or fail under some
        // configurations; defer to start() called explicitly from a
        // view's .task / .onAppear.
    }

    /// Begin observing the user's Wallet for new boarding passes.
    /// Safe to call multiple times; subsequent calls are no-ops.
    func start() {
        guard !started else { return }
        started = true
        let lib = PKPassLibrary()
        self.library = lib
        // Seed with currently-installed passes so we don't spam on
        // first launch — we only want to prompt for *new* passes.
        for p in lib.passes() {
            knownSerials.insert(p.serialNumber)
        }
        startObserving(lib)
    }

    private func startObserving(_ library: PKPassLibrary) {
        // PKPassLibraryDidChangeNotification is posted whenever the
        // user adds/removes a pass. We diff the library against our
        // seen set to find the new one.
        // The PKPassLibrary notification name isn't exposed as a typed
        // constant on the class — fall back to the raw string the
        // framework posts.
        token = NotificationCenter.default.addObserver(
            forName: Notification.Name("PKPassLibraryDidChangeNotification"),
            object: library,
            queue: .main
        ) { [weak self] note in
            Task { @MainActor [weak self] in
                self?.diff()
            }
        }
    }

    private func diff() {
        guard let library else { return }
        let current = library.passes()
        for pass in current {
            if knownSerials.contains(pass.serialNumber) { continue }
            knownSerials.insert(pass.serialNumber)
            if let parsed = Self.parse(pass) {
                pendingPass = parsed
                return
            }
        }
    }

    /// Clear the published pending pass once the UI has consumed it.
    func clearPending() {
        pendingPass = nil
    }

    // MARK: - Parsing
    //
    // A pkpass JSON manifests includes a "boardingPass" object with
    // `transitType: PKTransitTypeAir`, then a soup of structured
    // fields. The standard names used by most airlines:
    //   primaryFields[0].key = "depart" or "origin"
    //   primaryFields[1].key = "destination"
    //   auxiliaryFields[] includes seat / gate / flight#
    // We don't have direct access to the JSON — only to PKPass's
    // typed API (`localizedValue(forFieldKey:)`).

    private static func parse(_ pass: PKPass) -> ParsedPass? {
        // PKPass doesn't expose pass-style (boarding/coupon/event/etc.)
        // via a typed property — we infer it from the presence of
        // boarding-pass-style field keys below.

        // Common field keys across airlines.
        let originKey = ["origin", "depart", "from", "departing"]
            .first { pass.localizedValue(forFieldKey: $0) != nil }
        let destKey = ["destination", "arrive", "to", "arriving"]
            .first { pass.localizedValue(forFieldKey: $0) != nil }
        let flightKey = ["flight", "flightNumber", "flightNo"]
            .first { pass.localizedValue(forFieldKey: $0) != nil }
        let seatKey = ["seat", "seatNumber"]
            .first { pass.localizedValue(forFieldKey: $0) != nil }

        let origin = originKey.flatMap { pass.localizedValue(forFieldKey: $0) as? String }
        let dest   = destKey.flatMap   { pass.localizedValue(forFieldKey: $0) as? String }
        let flight = flightKey.flatMap { pass.localizedValue(forFieldKey: $0) as? String }
        let seat   = seatKey.flatMap   { pass.localizedValue(forFieldKey: $0) as? String }

        // Try to split the flight into carrier + number. Boarding pass
        // values are typically formatted like "WN 7" or "AA2178".
        var carrier: String?
        var number: String?
        if let flight, let m = flight.range(of: "([A-Z]{2,3})\\s*([0-9]{1,4})", options: .regularExpression) {
            let s = String(flight[m])
            let scanner = Scanner(string: s)
            scanner.charactersToBeSkipped = .whitespaces
            var letters: NSString?
            var digits: NSString?
            scanner.scanCharacters(from: .uppercaseLetters, into: &letters)
            scanner.scanCharacters(from: .decimalDigits, into: &digits)
            carrier = letters as String?
            number = digits as String?
        }

        // The relevant date is pass.relevantDate (when the pass should
        // appear on the lock screen). For a boarding pass, that's
        // typically the departure time.
        let flightDate = pass.relevantDate ?? Date()

        return ParsedPass(
            flightDate: flightDate,
            carrierIATA: carrier,
            flightNumber: number,
            departureIATA: origin?.uppercased(),
            arrivalIATA: dest?.uppercased(),
            seat: seat,
            serialNumber: pass.serialNumber
        )
    }
}
