import AVFoundation
import os
import VoiceCatCore

private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")

@MainActor
final class AudioSessionManager {
    static let shared = AudioSessionManager()

    /// Tracks whether WE activated the session. The session must be active whenever the
    /// AudioEngine is running (for capture OR playback), so it is activated when any audio
    /// needs to play (a remote stream started OR the user joins voice) and only deactivated
    /// when disconnecting from the server — not when leaving voice, since the user may still
    /// want to hear remote audio.
    private var isSessionActive = false

    /// Whether the AVAudioSession is currently active (we activated it). Read by `IOSAudioRouter`
    /// to decide whether the post-activation A2DP speaker fallback can be applied.
    var isActive: Bool { isSessionActive }

    func configure() {
        // Load stored audio routing preferences and apply them before any audio session
        // activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
        // miniaudio (the core) does NOT touch AVAudioSession on iOS.
        IOSAudioRouter.shared.loadStoredPreferences()
        IOSAudioRouter.shared.applyConfiguration()
        IOSAudioRouter.shared.refreshRoutes()

        NotificationCenter.default.addObserver(
            self, selector: #selector(handleInterruption),
            name: AVAudioSession.interruptionNotification, object: nil)
        NotificationCenter.default.addObserver(
            self, selector: #selector(handleRouteChange),
            name: AVAudioSession.routeChangeNotification, object: nil)
    }

    /// The single end-to-end audio recovery path, driven by *intent* (`IOSAudioEngine.isConnected`)
    /// — not by session bookkeeping flags that can drift out of sync (e.g. an interruption ended
    /// without `.shouldResume`, which used to leave `isSessionActive` false forever). Safe to call
    /// speculatively: the underlying calls are idempotent (AVAudioSession.setActive(true),
    /// `IOSAudioRouter.applyConfiguration` has a re-entrancy guard, `IOSAudioEngine.reconfigure`
    /// no-ops when not connected). Call this whenever the audio environment changes in a way that
    /// could have stopped the engine — interruption end, route change, AVAudioEngine
    /// configuration-change — and we still want audio back.
    func recoverAudio() {
        guard IOSAudioEngine.shared.isConnected else { return }
        do {
            try ensureSessionActive()
        } catch {
            logger.error("recoverAudio — session activate failed: \(error.localizedDescription)")
        }
        IOSAudioRouter.shared.applyConfiguration()
        if isSessionActive { IOSAudioRouter.shared.applyA2dpSpeakerFallback() }
        IOSAudioEngine.shared.reconfigure()
        logSessionState("after recoverAudio")
    }

    /// Activate the AVAudioSession if not already active. Call before any audio I/O:
    /// when the user joins voice, or when a remote stream starts (so playback works even
    /// before the user has joined voice). Idempotent — safe to call multiple times.
    func ensureSessionActive() throws {
        guard !isSessionActive else {
            logger.debug("ensureSessionActive — already active, skipping")
            return
        }
        IOSAudioRouter.shared.applyConfiguration()
        let session = AVAudioSession.sharedInstance()
        try session.setActive(true, options: [])
        isSessionActive = true
        // For the A2DP output presets, pick the right output once the session is live: defer to
        // a connected A2DP/wired/AirPlay route, but fall back to the loud built-in speaker (not
        // the quiet earpiece) when nothing external is connected. See applyA2dpSpeakerFallback().
        IOSAudioRouter.shared.applyA2dpSpeakerFallback()
        let route = AVAudioSession.sharedInstance().currentRoute
        let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ")
        let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ")
        logger.info("session activated — outputs: [\(outputNames)], inputs: [\(inputNames)]")
        logSessionState("after activate")
    }

    /// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server — not
    /// when leaving voice (the user may still want to hear remote audio).
    func deactivateSession() {
        guard isSessionActive else {
            logger.debug("deactivateSession — not active, skipping")
            return
        }
        try? AVAudioSession.sharedInstance().setActive(false,
            options: .notifyOthersOnDeactivation)
        isSessionActive = false
        logger.info("session deactivated")
    }

    /// Log the full AVAudioSession state — category, mode, options, and active route.
    /// Useful for diagnosing routing issues, e.g. confirming the session stays
    /// `PlayAndRecord` with `allowBluetoothA2DP` and keeps the A2DP output route even
    /// after the mic engine starts.
    func logSessionState(_ when: String) {
        let s = AVAudioSession.sharedInstance()
        var opts: [String] = []
        let o = s.categoryOptions
        if o.contains(.mixWithOthers)      { opts.append("mixWithOthers") }
        if o.contains(.duckOthers)         { opts.append("duckOthers") }
        if o.contains(.allowBluetoothHFP)  { opts.append("allowBluetoothHFP") }
        if o.contains(.allowBluetoothA2DP) { opts.append("allowBluetoothA2DP") }
        if o.contains(.allowAirPlay)       { opts.append("allowAirPlay") }
        if o.contains(.defaultToSpeaker)   { opts.append("defaultToSpeaker") }
        let outs = s.currentRoute.outputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
            .joined(separator: ", ")
        let ins = s.currentRoute.inputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
            .joined(separator: ", ")
        logger.info("""
            [SESSION @ \(when, privacy: .public)] category=\(s.category.rawValue, privacy: .public) \
            mode=\(s.mode.rawValue, privacy: .public) options=[\(opts.joined(separator: ","), privacy: .public)] \
            inputs=[\(ins, privacy: .public)] outputs=[\(outs, privacy: .public)] \
            inputCh=\(s.inputNumberOfChannels) outputCh=\(s.outputNumberOfChannels)
            """)
    }

    @objc private func handleInterruption(_ notification: Notification) {
        guard let info = notification.userInfo,
              let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
              let type = AVAudioSession.InterruptionType(rawValue: typeValue)
        else { return }

        switch type {
        case .began:
            // The system stops our AVAudioEngine and deactivates the session. Nothing to tear
            // down — `IOSAudioEngine` rebuilds on resume.
            logger.info("interruption began — session suspended by system")
            isSessionActive = false
        case .ended:
            // Always attempt recovery when we have a live session. iOS sometimes ends an
            // interruption without the `.shouldResume` hint (e.g. Siri), and the previous
            // behavior of only reactivating when `.shouldResume` was set left the session
            // permanently dead — audio never came back. `recoverAudio()` is intent-gated on
            // `IOSAudioEngine.isConnected` and idempotent, so speculatively calling it is safe.
            logger.info("interruption ended — recovery requested")
            recoverAudio()
        @unknown default: break
        }
    }

    @objc private func handleRouteChange(_ notification: Notification) {
        guard let info = notification.userInfo,
              let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
              let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
        else {
            logger.warning("routeChange — unknown reason, refreshing + recovery")
            IOSAudioRouter.shared.refreshRoutes()
            NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
            recoverAudio()
            return
        }

        logger.info("routeChange reason=\(self.reasonLabel(reason))")
        IOSAudioRouter.shared.refreshRoutes()
        NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)

        // Recover audio on every externally-initiated route change. `.categoryChange`,
        // `.routeConfigurationChange`, and `.override` are fired by our OWN calls:
        //   - `.categoryChange` / `.routeConfigurationChange` ← applyConfiguration()'s
        //     setCategory / setPreferredInput / ...
        //   - `.override` ← applyA2dpSpeakerFallback()'s overrideOutputAudioPort(.speaker),
        //     which fires on every AirPods disconnect (and reconnect) on an A2DP preset.
        // Acting on any of these would create a tight ping-pong loop with the re-entrancy
        // guard (handleRouteChange → recoverAudio → applyA2dpSpeakerFallback →
        // overrideOutputAudioPort → .override routeChange → recoverAudio → ...). The
        // `.override` skip is what fixes the AirPods-disconnect reinitialize loop: each
        // iteration also calls IOSAudioEngine.reconfigure() → rebuild() (a full
        // stop/restart of AVAudioEngine), which is the audible cycling. IOSAudioRouter's
        // guard is the backstop that bounds it to ONE extra iteration, but skipping these
        // three reasons avoids even that, so we reconfigure only in response to genuine
        // environmental changes.
        //
        // The recovery set below (oldDeviceUnavailable, newDeviceAvailable, wakeFromSleep,
        // noSuitableRouteForCategory, unknown) covers headphone/AirPods/wired unplug-replug
        // — the previously-reported "audio dies when headphones disconnect" bug. If an
        // override ever actually stops the AVAudioEngine, the
        // AVAudioEngineConfigurationChange handler in IOSVoiceProcessingEngine catches it.
        if reason != .categoryChange && reason != .routeConfigurationChange && reason != .override {
            recoverAudio()
        }
        logSessionState("route change (\(reasonLabel(reason)))")
    }

    private func reasonLabel(_ reason: AVAudioSession.RouteChangeReason) -> String {
        switch reason {
        case .oldDeviceUnavailable: return "oldDeviceUnavailable"
        case .newDeviceAvailable: return "newDeviceAvailable"
        case .categoryChange: return "categoryChange"
        case .override: return "override"
        case .wakeFromSleep: return "wakeFromSleep"
        case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
        case .routeConfigurationChange: return "routeConfigurationChange"
        case .unknown: return "unknown"
        @unknown default: return "unknown"
        }
    }
}

extension Notification.Name {
    static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
}
