import SwiftUI
import MapKit
import CoreLocation

struct LiveFlightsView: View {
    let openSky: OpenSkyClient
    let fr24: FR24Client
    let routeExplorer: RouteExplorerClient
    let database: AirportDatabase

    // MARK: - Map state

    @State private var position: MapCameraPosition = .automatic
    @State private var visibleRegion: MKCoordinateRegion?

    // MARK: - Data state

    @State private var aircraft: [LiveAircraft] = []
    @State private var lastFetchAt: Date?
    @State private var isLoading = false
    @State private var error: String?

    // MARK: - Filters

    @State private var searchText: String = ""
    @State private var selectedAirlineICAO: Set<String> = []
    @State private var selectedTypeCodes: Set<String> = []
    @State private var selectedAltitudeBand: AltitudeBand? = nil
    @State private var hideOnGround: Bool = false

    /// Altitude bands the user can filter by. Derived from data we always
    /// have from OpenSky (baroAltitude / geoAltitude) — unlike `category`,
    /// which the anonymous tier returns as null for most aircraft.
    enum AltitudeBand: String, CaseIterable, Identifiable, Hashable {
        case lowLevel    = "Below 10k ft"
        case midLevel    = "10k – 25k ft"
        case cruiseLevel = "25k – 40k ft"
        case highLevel   = "Above 40k ft"
        var id: String { rawValue }
        func contains(_ ft: Int) -> Bool {
            switch self {
            case .lowLevel:    return ft < 10_000
            case .midLevel:    return ft >= 10_000 && ft < 25_000
            case .cruiseLevel: return ft >= 25_000 && ft < 40_000
            case .highLevel:   return ft >= 40_000
            }
        }
    }

    // MARK: - Selection & sheets
    //
    // A single `activeSheet` is set both for tap-on-aircraft and tap-on-gear,
    // so SwiftUI only ever sees one .sheet modifier on this view. Two
    // stacked .sheet modifiers fight each other and produce the "tap-the-
    // plane-freezes-the-app" symptom.

    @State private var activeSheet: ActiveSheet?
    @State private var selectedTrack: AircraftTrack?

    // Cached filter-menu items — recomputed only when `aircraft` changes.
    // Computing them on every body re-render caused the menu-tap freeze
    // (each refresh fed the airlines registry an N×M lookup on the main
    // thread).
    @State private var cachedAirlineItems: [AirlineFilterItem] = []
    @State private var cachedTypeItems: [TypeFilterItem] = []

    /// Pre-filtered aircraft snapshot. Recomputed only when `aircraft` or
    /// any filter state changes. Caching here avoids running the filter
    /// loop on every body re-render (e.g. when a sheet animates in).
    @State private var cachedFilteredAircraft: [LiveAircraft] = []

    /// Tracks the last bounding box we fetched against. Used to throttle
    /// the on-pan refresh so that micro-camera-settlements (which happen
    /// every time annotations re-render) don't fire fresh OpenSky calls.
    @State private var lastFetchedBoundingBox: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)?

    /// Total matches against the active filters (pre-cap). The map only
    /// renders `cachedFilteredAircraft`; the footer uses this to surface
    /// the "Showing N of M" message when the zoom cap clips the list.
    @State private var filteredTotal: Int = 0

    /// Set after the first camera change that meaningfully moves away
    /// from the initial region. While this is false, an incoming location
    /// fix can re-center the map on the user; after it flips true, the
    /// user has expressed intent and we leave their pan alone.
    @State private var userHasInteracted: Bool = false
    @State private var initialRegionCenter: CLLocationCoordinate2D?

    enum ActiveSheet: Identifiable {
        case aircraft(LiveAircraft)
        case settings
        case airlinePicker
        case typePicker
        var id: String {
            switch self {
            case .aircraft(let a): return "ac-\(a.icao24)"
            case .settings:        return "settings"
            case .airlinePicker:   return "airline"
            case .typePicker:      return "type"
            }
        }
    }

    /// Aircraft currently selected (if any), unwrapped from `activeSheet`.
    private var selectedAircraft: LiveAircraft? {
        if case .aircraft(let a) = activeSheet { return a }
        return nil
    }

    // Refresh interval — paired with the rate-limit guard. Anonymous OpenSky
    // is 100/day so we keep the auto-refresh tab-conservative.
    private static let refreshInterval: TimeInterval = 15

    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        mapLayer
            .safeAreaInset(edge: .top, spacing: 0) { topFilterBar }
            .safeAreaInset(edge: .bottom, spacing: 0) { bottomStatusBar }
            .task { await autoRefreshLoop() }
            .task(id: selectedAircraft?.icao24) {
                await loadTrackForSelection()
            }
            // Force an immediate fetch when the app returns to foreground —
            // the 15s autoloop would otherwise leave up to 15s of stale data
            // visible on resume. Background → active transition counts; we
            // don't fire on .inactive (transient — e.g. notification
            // drawer pulled down) to avoid wasted requests.
            .onChange(of: scenePhase) { old, new in
                if new == .active && old == .background {
                    Task { await refreshNow() }
                }
            }
            .onChange(of: aircraft) { _, _ in
                rebuildFilterItems()
                rebuildFilteredAircraft()
            }
            .onChange(of: selectedAirlineICAO) { _, _ in rebuildFilteredAircraft() }
            .onChange(of: selectedTypeCodes) { _, _ in rebuildFilteredAircraft() }
            .onChange(of: selectedAltitudeBand) { _, _ in rebuildFilteredAircraft() }
            .onChange(of: hideOnGround) { _, _ in rebuildFilteredAircraft() }
            .onChange(of: searchText) { _, _ in rebuildFilteredAircraft() }
            .sheet(item: $activeSheet) { sheet in
                switch sheet {
                case .aircraft(let ac):
                    LiveFlightDetailSheet(
                        aircraft: ac,
                        openSky: openSky,
                        routeExplorer: routeExplorer,
                        database: database
                    )
                    .presentationDetents([.medium, .large])
                    .presentationDragIndicator(.visible)
                case .settings:
                    OpenSkySettingsView()
                case .airlinePicker:
                    LiveFilterPicker(
                        title: "Airline",
                        items: cachedAirlineItems.map { .init(id: $0.icao, label: $0.name, count: $0.count) },
                        selection: $selectedAirlineICAO
                    )
                case .typePicker:
                    LiveFilterPicker(
                        title: "Aircraft Type",
                        items: cachedTypeItems.map { .init(id: $0.code, label: $0.label, count: $0.count) },
                        selection: $selectedTypeCodes
                    )
                }
            }
    }

    // MARK: - Map

    private var mapLayer: some View {
        Map(position: $position) {
            // Trail polyline for the currently selected aircraft.
            if let track = selectedTrack {
                let coords = track.path.map {
                    CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
                }
                MapPolyline(coordinates: coords)
                    .stroke(FlightTheme.accent, lineWidth: 3)
            }

            ForEach(filteredAircraft) { ac in
                // Pre-compute the pin's inputs outside the closure so the
                // SwiftUI builder doesn't re-derive them on every diff.
                let tint = aircraftTint(for: ac)
                let rotation = Double(ac.heading ?? 0) - 45  // SF airplane symbol points up-right
                let selected = selectedAircraft?.id == ac.id
                Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) {
                    AircraftPin(tint: tint, headingMinus45: rotation, isSelected: selected)
                        .onTapGesture {
                            activeSheet = .aircraft(ac)
                        }
                }
                .annotationTitles(.hidden)
            }
        }
        .mapStyle(.standard(elevation: .flat))
        .onMapCameraChange(frequency: .onEnd) { context in
            visibleRegion = context.region
            if let initial = initialRegionCenter {
                let dLat = abs(context.region.center.latitude - initial.latitude)
                let dLon = abs(context.region.center.longitude - initial.longitude)
                if dLat > 0.1 || dLon > 0.1 {
                    userHasInteracted = true
                }
            }
            Self.saveRegion(context.region)
            // Span change shifts the cap, so the visible set might need
            // to grow/shrink even when the underlying aircraft list is
            // unchanged.
            rebuildFilteredAircraft()
            Task { await refreshIfRegionChanged() }
        }
    }

    /// Fetches the trail for whichever aircraft is currently selected.
    /// Cleared automatically on deselection. Race-guarded.
    private func loadTrackForSelection() async {
        guard let selected = selectedAircraft else {
            selectedTrack = nil
            return
        }
        let track = await openSky.track(icao24: selected.icao24)
        if selectedAircraft?.icao24 == selected.icao24 {
            selectedTrack = track
        }
    }

    // MARK: - Top filter bar (lives inside the safe-area inset, never under
    //         the nav title or the tab bar)

    private var topFilterBar: some View {
        VStack(spacing: 8) {
            HStack(spacing: 8) {
                Image(systemName: "magnifyingglass")
                    .foregroundStyle(FlightTheme.textSecondary)
                TextField("Search callsign or flight (e.g. AA2178)", text: $searchText)
                    .autocorrectionDisabled()
                    .textInputAutocapitalization(.characters)
                    .onSubmit { centerOnSearchMatch() }

                if !searchText.isEmpty {
                    Button { searchText = "" } label: {
                        Image(systemName: "xmark.circle.fill")
                            .foregroundStyle(FlightTheme.textSecondary)
                    }
                    .buttonStyle(.plain)
                }
            }
            .padding(.horizontal, 14)
            .padding(.vertical, 10)
            .background(FlightTheme.cardBackground)
            .clipShape(Capsule())
            .shadow(color: FlightTheme.cardShadow, radius: 6, y: 2)

            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 8) {
                    FilterChip(
                        label: hideOnGround ? "Airborne only" : "Include ground",
                        systemImage: hideOnGround ? "airplane" : "airplane.circle",
                        isActive: hideOnGround
                    ) { hideOnGround.toggle() }

                    Button { activeSheet = .airlinePicker } label: {
                        FilterChipLabel(
                            label: selectedAirlineICAO.isEmpty
                                ? "Airline"
                                : "Airline · \(selectedAirlineICAO.count)",
                            systemImage: "building.2",
                            isActive: !selectedAirlineICAO.isEmpty
                        )
                    }
                    .buttonStyle(.plain)

                    Button { activeSheet = .typePicker } label: {
                        FilterChipLabel(
                            label: selectedTypeCodes.isEmpty
                                ? "Type"
                                : "Type · \(selectedTypeCodes.count)",
                            systemImage: "airplane.departure",
                            isActive: !selectedTypeCodes.isEmpty
                        )
                    }
                    .buttonStyle(.plain)

                    Menu {
                        let counts = altitudeBandCounts
                        ForEach(AltitudeBand.allCases) { band in
                            let count = counts[band] ?? 0
                            Button {
                                selectedAltitudeBand = (selectedAltitudeBand == band) ? nil : band
                            } label: {
                                let label = "\(band.rawValue) (\(count))"
                                if selectedAltitudeBand == band {
                                    Label(label, systemImage: "checkmark")
                                } else {
                                    Text(label)
                                }
                            }
                        }
                        if selectedAltitudeBand != nil {
                            Button("Clear", role: .destructive) { selectedAltitudeBand = nil }
                        }
                    } label: {
                        FilterChipLabel(
                            label: selectedAltitudeBand?.rawValue ?? "Altitude",
                            systemImage: "arrow.up.and.down",
                            isActive: selectedAltitudeBand != nil
                        )
                    }

                    if !selectedAirlineICAO.isEmpty || !selectedTypeCodes.isEmpty || selectedAltitudeBand != nil || hideOnGround {
                        Button {
                            selectedAirlineICAO.removeAll()
                            selectedTypeCodes.removeAll()
                            selectedAltitudeBand = nil
                            hideOnGround = false
                        } label: {
                            FilterChipLabel(label: "Reset", systemImage: "arrow.counterclockwise", isActive: false)
                        }
                    }
                }
                .padding(.horizontal, 12)
            }
        }
        .padding(.horizontal, 12)
        .padding(.top, 8)
        .padding(.bottom, 8)
        .background(.ultraThinMaterial)
    }

    // MARK: - Bottom status bar (count, refresh, gear)

    private var bottomStatusBar: some View {
        VStack(spacing: 6) {
            if let error {
                Text(error)
                    .font(.caption)
                    .foregroundStyle(.white)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 6)
                    .background(FlightTheme.cancelled, in: Capsule())
            }

            HStack(spacing: 12) {
                if isLoading {
                    ProgressView().tint(.white)
                } else {
                    Image(systemName: "antenna.radiowaves.left.and.right")
                        .foregroundStyle(.white)
                }
                Text(countLabel)
                    .font(.subheadline.weight(.semibold))
                    .foregroundStyle(.white)
                if let last = lastFetchAt {
                    Text("· updated \(relativeTime(last))")
                        .font(.caption)
                        .foregroundStyle(.white.opacity(0.8))
                }
                Spacer()
                Button {
                    Task { await refreshNow() }
                } label: {
                    Image(systemName: "arrow.clockwise")
                        .foregroundStyle(.white)
                }
                .disabled(isLoading)
                .opacity(isLoading ? 0.3 : 1)

                Button {
                    activeSheet = .settings
                } label: {
                    Image(systemName: "gearshape")
                        .foregroundStyle(.white)
                }
            }
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .background(Color.black.opacity(0.7), in: Capsule())
        }
        .padding(.horizontal, 12)
        .padding(.bottom, 8)
    }

    // MARK: - Derived

    /// Quick read accessor used by the rendering layer. Always returns the
    /// cached snapshot; rebuilds happen via the .onChange handlers above.
    private var filteredAircraft: [LiveAircraft] { cachedFilteredAircraft }

    /// Footer text. Renders "Showing N of M" when the zoom cap is
    /// clipping the visible set, else "N aircraft".
    private var countLabel: String {
        let shown = cachedFilteredAircraft.count
        if filteredTotal > shown {
            return "Showing \(shown) of \(filteredTotal)"
        }
        return "\(shown) aircraft"
    }

    private func rebuildFilteredAircraft() {
        let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
        let airlines = selectedAirlineICAO
        let types = selectedTypeCodes
        let band = selectedAltitudeBand
        let hideGround = hideOnGround

        let filtered = aircraft.filter { ac in
            if hideGround && ac.onGround { return false }
            if !airlines.isEmpty {
                guard let code = ac.airlineICAO, airlines.contains(code) else { return false }
            }
            if !types.isEmpty {
                guard let tc = ac.typeCode, types.contains(tc) else { return false }
            }
            if let band {
                guard let alt = ac.altitudeFeet, band.contains(alt) else { return false }
            }
            if !s.isEmpty {
                let cs = ac.trimmedCallsign?.uppercased() ?? ""
                if !cs.contains(s) { return false }
            }
            return true
        }

        filteredTotal = filtered.count

        // Cap policy: any active filter bypasses the cap so the user
        // always sees every match for their query. With no filter, we
        // tie the visible count to the zoom level so a zoomed-out view
        // doesn't spew 800+ pins onto the map.
        let hasFilter = !airlines.isEmpty || !types.isEmpty || band != nil || hideGround || !s.isEmpty
        let cap = Self.capForSpan(visibleRegion?.span)
        if hasFilter || filtered.count <= cap {
            cachedFilteredAircraft = filtered
            return
        }
        // Cap by distance to map center — most users care about what's
        // overhead first. Squared distance is fine for sorting.
        if let center = visibleRegion?.center {
            cachedFilteredAircraft = filtered
                .map { ($0, Self.squaredDistance($0.coordinate, to: center)) }
                .sorted { $0.1 < $1.1 }
                .prefix(cap)
                .map { $0.0 }
        } else {
            cachedFilteredAircraft = Array(filtered.prefix(cap))
        }
    }

    /// Target visible count for a given map span. Empirically tuned so
    /// the map doesn't feel sparse when zoomed out yet stays smooth.
    /// Below ~2° (≈city/metro) we don't cap at all.
    private static func capForSpan(_ span: MKCoordinateSpan?) -> Int {
        guard let span else { return 150 }
        let maxDelta = max(span.latitudeDelta, span.longitudeDelta)
        switch maxDelta {
        case ..<2:    return .max
        case ..<8:    return 100
        case ..<25:   return 150
        default:      return 200
        }
    }

    private static func squaredDistance(_ a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
        let dLat = a.latitude - b.latitude
        let dLon = a.longitude - b.longitude
        return dLat * dLat + dLon * dLon
    }

    // MARK: - Region persistence

    private struct SavedRegion: Codable {
        let lat: Double
        let lon: Double
        let latDelta: Double
        let lonDelta: Double
    }

    private static let savedRegionKey = "live_flights.saved_region"

    private static func loadSavedRegion() -> MKCoordinateRegion? {
        guard let data = UserDefaults.standard.data(forKey: savedRegionKey),
              let saved = try? JSONDecoder().decode(SavedRegion.self, from: data)
        else { return nil }
        return MKCoordinateRegion(
            center: CLLocationCoordinate2D(latitude: saved.lat, longitude: saved.lon),
            span: MKCoordinateSpan(latitudeDelta: saved.latDelta, longitudeDelta: saved.lonDelta)
        )
    }

    private static func saveRegion(_ r: MKCoordinateRegion) {
        let payload = SavedRegion(
            lat: r.center.latitude,
            lon: r.center.longitude,
            latDelta: r.span.latitudeDelta,
            lonDelta: r.span.longitudeDelta
        )
        if let data = try? JSONEncoder().encode(payload) {
            UserDefaults.standard.set(data, forKey: savedRegionKey)
        }
    }

    struct AirlineFilterItem: Hashable {
        let icao: String
        let name: String
        let count: Int
        var label: String { "\(name) (\(count))" }
    }

    struct TypeFilterItem: Hashable {
        let code: String
        let label: String   // e.g. "Boeing 737-800 · B738"
        let count: Int
    }

    /// Rebuilds the cached filter items. Called from a .task tied to the
    /// aircraft array so it doesn't run on every body re-render.
    private func rebuildFilterItems() {
        var airlines: [String: Int] = [:]
        var types: [String: Int] = [:]
        for ac in aircraft {
            if let code = ac.airlineICAO {
                airlines[code, default: 0] += 1
            }
            if let tc = ac.typeCode {
                types[tc, default: 0] += 1
            }
        }
        cachedAirlineItems = airlines.map { (icao, count) in
            AirlineFilterItem(
                icao: icao,
                name: AircraftRegistry.shared.displayName(icao: icao),
                count: count
            )
        }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }

        cachedTypeItems = types.map { (code, count) in
            let friendly = AircraftDatabase.shared.displayName(forTypeCode: code)
            // If the friendly name differs from the raw code, show both;
            // otherwise just the code so we don't render "B738 · B738".
            let label = friendly == code ? code : "\(friendly) · \(code)"
            return TypeFilterItem(code: code, label: label, count: count)
        }.sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending }
    }

    /// Counts of how many aircraft fall in each altitude band — drives the
    /// altitude filter menu labels.
    private var altitudeBandCounts: [AltitudeBand: Int] {
        var counts: [AltitudeBand: Int] = [:]
        for ac in aircraft {
            guard let ft = ac.altitudeFeet else { continue }
            for band in AltitudeBand.allCases where band.contains(ft) {
                counts[band, default: 0] += 1
            }
        }
        return counts
    }

    // MARK: - Fetch

    /// Single long-lived auto-refresh loop. Runs for the lifetime of the
    /// view (cancelled by SwiftUI when the tab disappears). Replaces the
    /// old .task(id:) cascade, which restarted the timer every time
    /// `isLoading` flipped and produced the "constantly refreshing"
    /// symptom.
    private func autoRefreshLoop() async {
        if visibleRegion == nil {
            // Initial region cascade:
            //   1. Restore the last region the user saw, if we have one
            //   2. Otherwise fall back to a continental US view
            // Either way we kick off a one-shot location request in
            // parallel. If the user grants location *and* hasn't panned
            // by the time the fix lands, we animate to a city-level
            // view centered on them.
            let initial = Self.loadSavedRegion() ?? MKCoordinateRegion(
                center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0),
                span: MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 50)
            )
            position = .region(initial)
            visibleRegion = initial
            initialRegionCenter = initial.center
            Task {
                if let coord = await LocationService.shared.requestOneShotLocation(),
                   !userHasInteracted {
                    let userRegion = MKCoordinateRegion(
                        center: coord,
                        span: MKCoordinateSpan(latitudeDelta: 0.6, longitudeDelta: 0.6)
                    )
                    withAnimation(.easeInOut(duration: 0.6)) {
                        position = .region(userRegion)
                    }
                    visibleRegion = userRegion
                    initialRegionCenter = userRegion.center
                    Self.saveRegion(userRegion)
                }
            }
        }
        await refreshNow()
        while !Task.isCancelled {
            try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000))
            await refreshNow()
        }
    }

    /// Called on map camera change. Only fires a fresh fetch if the new
    /// bounding box actually moved by a meaningful amount — micro-camera
    /// settlements caused by annotation re-renders would otherwise
    /// hammer OpenSky.
    private func refreshIfRegionChanged() async {
        guard let r = visibleRegion else { return }
        let bb = boundingBox(of: r)
        if let last = lastFetchedBoundingBox {
            let centerDelta = max(
                abs((bb.latMin + bb.latMax) / 2 - (last.latMin + last.latMax) / 2),
                abs((bb.lonMin + bb.lonMax) / 2 - (last.lonMin + last.lonMax) / 2)
            )
            let widthRatio = (bb.lonMax - bb.lonMin) / max(0.001, last.lonMax - last.lonMin)
            // Center moved less than 15% of the box width, AND box didn't
            // zoom by more than 20% → skip.
            let halfWidth = (last.lonMax - last.lonMin) / 2
            if centerDelta < halfWidth * 0.15, widthRatio > 0.8, widthRatio < 1.2 {
                return
            }
        }
        await refreshNow()
    }

    private func refreshNow() async {
        guard !isLoading, let r = visibleRegion else { return }
        isLoading = true
        defer { isLoading = false }

        let bb = boundingBox(of: r)

        // Primary: FR24. Their feed includes ASDE-X + MLAT and reliably
        // returns ground aircraft at major airports — OpenSky's free tier
        // does not, which was the root cause of "no SWA jets at DAL".
        // We fall through to OpenSky only when FR24 hard-errors (rare).
        if let results = try? await fr24.states(
            latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
        ) {
            commitResults(results, bb: bb)
            return
        }

        // Fallback: OpenSky. Same shape, missing the inline route data
        // FR24 carries (departure/arrival/flight#), so the detail sheet
        // route-resolver picks up the slack.
        do {
            let results = try await openSky.states(
                latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
            )
            commitResults(results, bb: bb)
        } catch {
            self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
                ?? error.localizedDescription
            if case OpenSkyClient.ClientError.throttled = error {
                try? await Task.sleep(nanoseconds: 60 * 1_000_000_000)
            }
        }
    }

    /// Commit a fresh aircraft list to state. Suppresses SwiftUI's
    /// implicit crossfade when annotations swap so the map doesn't
    /// flicker every 15 seconds.
    private func commitResults(_ results: [LiveAircraft], bb: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)) {
        var tx = Transaction()
        tx.disablesAnimations = true
        withTransaction(tx) {
            aircraft = results
        }
        lastFetchAt = Date()
        lastFetchedBoundingBox = bb
        error = nil
    }

    private func boundingBox(of r: MKCoordinateRegion) -> (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) {
        let lat = r.center.latitude
        let lon = r.center.longitude
        let dLat = r.span.latitudeDelta / 2
        let dLon = r.span.longitudeDelta / 2
        return (
            latMin: max(-90,  lat - dLat),
            lonMin: max(-180, lon - dLon),
            latMax: min( 90,  lat + dLat),
            lonMax: min( 180, lon + dLon)
        )
    }

    // MARK: - Search / selection helpers

    private func centerOnSearchMatch() {
        let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
        guard let match = aircraft.first(where: {
            ($0.trimmedCallsign?.uppercased() ?? "").contains(s)
        }) else { return }
        activeSheet = .aircraft(match)
        position = .region(MKCoordinateRegion(
            center: match.coordinate,
            span: MKCoordinateSpan(latitudeDelta: 4, longitudeDelta: 6)
        ))
    }

    private func toggle<T: Hashable>(_ set: inout Set<T>, _ value: T) {
        if set.contains(value) { set.remove(value) } else { set.insert(value) }
    }

    private func relativeTime(_ d: Date) -> String {
        let secs = Int(Date().timeIntervalSince(d))
        // Snap to coarse buckets so the footer text doesn't tick every
        // second (which would force the footer subtree to re-render
        // every body pass that happens to land on a different second).
        if secs < 5 { return "just now" }
        if secs < 30 { return "<30s ago" }
        if secs < 60 { return "<1m ago" }
        return "\(secs / 60)m ago"
    }
}

// MARK: - Aircraft pin

/// Per-aircraft map pin. Kept deliberately minimal — every annotation is
/// a SwiftUI view in the map's content tree, so view-tree depth × N pins
/// directly affects scroll/pan performance. Equatable so SwiftUI can
/// skip diffing identical pins on re-renders.
private struct AircraftPin: View, Equatable {
    let tint: Color
    let headingMinus45: Double
    let isSelected: Bool

    var body: some View {
        Image(systemName: "airplane")
            .font(.system(size: 14, weight: .bold))
            .foregroundStyle(.white)
            .padding(6)
            .background(Circle().fill(tint))
            .overlay(
                Circle()
                    .stroke(.white, lineWidth: isSelected ? 2.5 : 0)
            )
            .rotationEffect(.degrees(headingMinus45))
            .scaleEffect(isSelected ? 1.3 : 1)
            .animation(nil, value: tint)
            .contentShape(Rectangle())
    }

    static func == (lhs: AircraftPin, rhs: AircraftPin) -> Bool {
        lhs.tint == rhs.tint
            && lhs.headingMinus45 == rhs.headingMinus45
            && lhs.isSelected == rhs.isSelected
    }
}

private func aircraftTint(for ac: LiveAircraft) -> Color {
    if ac.onGround { return FlightTheme.textTertiary }
    switch ac.verticalState {
    case .climbing:   return FlightTheme.onTime
    case .descending: return FlightTheme.delayed
    case .level:      return FlightTheme.accent
    }
}

// MARK: - Filter chips

private struct FilterChip: View {
    let label: String
    let systemImage: String
    let isActive: Bool
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            FilterChipLabel(label: label, systemImage: systemImage, isActive: isActive)
        }
        .buttonStyle(.plain)
    }
}

private struct FilterChipLabel: View {
    let label: String
    let systemImage: String
    let isActive: Bool

    var body: some View {
        HStack(spacing: 6) {
            Image(systemName: systemImage)
                .font(.caption)
            Text(label)
                .font(.caption.weight(.semibold))
        }
        .foregroundStyle(isActive ? .white : FlightTheme.textPrimary)
        .padding(.horizontal, 12)
        .padding(.vertical, 8)
        .background(
            Capsule()
                .fill(isActive ? FlightTheme.accent : FlightTheme.cardBackground)
        )
        .shadow(color: FlightTheme.cardShadow, radius: 4, y: 1)
    }
}
