import AVFoundation
import os
import VoiceCatCore

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

/// iOS audio routing layer — the sole owner of `AVAudioSession` on iOS. On iOS the core never
/// opens a hardware (miniaudio) device: a single `AVAudioEngine` (`IOSAudioEngine`) drives both
/// capture and playback and the core runs fully external (see docs/voice.md §8). This class just
/// configures the *route* — category / mode / options, preferred input, data source, polar
/// pattern, stereo capsule — and `IOSAudioEngine` binds to whatever route is established. After
/// any change here the engine is rebuilt via `IOSAudioEngine.reconfigure()` (a deterministic
/// Swift-only stop → reconfigure → start); there is no second (miniaudio) audio path to hand off
/// to, so a change cannot leave one direction dropped.
///
/// (The core's iOS `ma_context` is still configured with `sessionCategory = none` +
/// `noAudioSessionActivate/Deactivate` in `AudioEngine::make_context_config` so that, should the
/// core ever open a device, miniaudio would not reset the category — but on iOS it does not.)
///
/// The three user-facing choices:
///   1. **Input port** — which physical input (built-in mic, Bluetooth HFP, headset,
///      USB, AirPlay). For the built-in mic, a sub-selection of **data source**
///      (orientation: front/back/top/bottom) and **polar pattern**
///      (omni/cardioid/subcardioid/bidirectional).
///   2. **Bluetooth mode** — how Bluetooth headsets are handled:
///      - "BT HFP voice" (`.allowBluetoothHFP` + `.allowBluetoothA2DP`): both profiles
///        allowed, iOS picks HFP for two-way mic or A2DP for output-only. Mono, AEC on.
///      - "Built-in Mic + BT A2DP stereo" (`.allowBluetoothA2DP` only): stereo output,
///        built-in mic, no HFP processing.
///      - "Built-in Mic + Speaker" (neither): no Bluetooth at all.
///   3. **Mic processing mode** — Standard (`.voiceChat`: AEC/AGC/HPF on) or
///      Raw/Studio (`.measurement`: all processing off). Raw mode is allowed always
///      but shows a warning when the output route is the speaker (echo risk, no AEC).
///
/// Additionally, **stereo capture** (2-channel built-in mic) is enabled by switching the
/// built-in mic's data source to the `.stereo` polar pattern. The recipe is:
/// `setPreferredDataSource(.stereo source)` + `setPreferredPolarPattern(.stereo)` +
/// `setPreferredInput(built-in mic)` + `setInputDataSource(stereo source)`. The channel
/// count itself must NOT be requested via `setPreferredInputNumberOfChannels(2)` — that
/// session-level call collapses the A2DP output route. Instead the core is told to open the
/// device with 2 channels via `vc_set_capture_channels(streamId, 2)`, and the AVAudioSession
/// input anchor (`setPreferredInput` + `setInputDataSource`) keeps the route stable during
/// the HFP→A2DP and mono→stereo reconfigurations.
///
/// Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center
/// for `.voiceChat` apps — surfaced as a hint, not a programmatic toggle.
///
/// All choices are persisted in `UserDefaults` and re-applied on route changes.
@MainActor
final class IOSAudioRouter: ObservableObject {

    static let shared = IOSAudioRouter()

    // MARK: - Published state (drives SettingsView)

    @Published var inputPorts: [IOSAudioInputPort] = []
    @Published var outputRoutes: [IOSAudioOutputRoute] = []
    @Published var bluetoothMode: BluetoothMode = .btHfpVoice
    /// User-requested speaker fallback: when on, route to the built-in speaker instead of the
    /// earpiece (receiver) when no headphones/Bluetooth are connected. Orthogonal to the
    /// bluetooth mode and presets. Default off — current behavior is unchanged for existing users.
    @Published var forceSpeaker: Bool = false
    @Published var micMode: MicMode = .standard
    @Published var captureChannels: CaptureChannels = .mono
    @Published var selectedInputPortId: String?
    @Published var selectedDataSourceId: String?
    @Published var selectedPolarPattern: String?
    /// Master voice-processing switch (Apple VPIO: AEC + noise suppression bundled together).
    /// iOS exposes no per-stage toggle, so this is the finest "echo cancellation / noise
    /// reduction" control available. Only takes effect on a VPIO-capable config (mono + standard
    /// + not A2DP); stereo / A2DP configs can't use VPIO regardless. Default on.
    @Published var voiceProcessingEnabled: Bool = true
    /// VPIO automatic gain control — the one VPIO sub-stage iOS lets us toggle independently.
    /// Only meaningful when voice processing is active. Default on.
    @Published var agcEnabled: Bool = true
    @Published var showsRawModeSpeakerWarning: Bool = false
    @Published var showsA2dpNoAecWarning: Bool = false
    @Published var hasBluetoothDevice: Bool = false
    @Published var hasWiredHeadset: Bool = false

    /// Audio presets — the four scenarios from the product spec. Pick a preset for a quick start,
    /// then fine-tune individual settings under "Advanced". HFP / wired headsets are not separate
    /// presets: Voice Chat lets the system route to them, and Advanced exposes manual selection.
    enum AudioPreset: String, CaseIterable, Identifiable {
        /// Voice chat: Apple VPIO does real AEC + noise suppression + AGC. Mono. The system picks
        /// the best route (Bluetooth HFP / wired / speaker / earpiece). Always available.
        case voiceChat = "Voice Chat"
        /// Internal **stereo** built-in mic regardless of the output route. A2DP output when a
        /// Bluetooth headset is connected, else built-in speaker / wired. No VPIO (stereo can't
        /// use it). Always available.
        case stereoMic = "Stereo Mic"
        /// Internal **mono** built-in mic regardless of the output route. A2DP output when a
        /// Bluetooth headset is connected, else built-in speaker / wired. No VPIO. Always available.
        case monoMic = "Mono Mic"
        /// Everything manual — input port, mic orientation / polar pattern, mono/stereo, Bluetooth
        /// mode, raw vs standard, and the VPIO / AGC toggles. Also the display state when the
        /// individual settings don't match a named preset.
        case advanced = "Advanced"

        var id: String { rawValue }

        var bluetoothMode: BluetoothMode {
            switch self {
            case .voiceChat: return .btHfpVoice
            // Internal-mic presets: A2DP output when BT is connected; speaker/wired when not.
            case .stereoMic, .monoMic: return .builtInMicBtA2dp
            case .advanced: return .builtInMicSpeaker  // placeholder; Advanced sets it manually
            }
        }

        var captureChannels: CaptureChannels {
            self == .stereoMic ? .stereo : .mono
        }

        var micMode: MicMode { .standard }

        /// Whether this preset explicitly pins the built-in mic port (the internal-mic presets).
        var usesBuiltInMic: Bool {
            switch self {
            case .stereoMic, .monoMic: return true
            default: return false
            }
        }
    }

    enum BluetoothMode: String, CaseIterable, Identifiable {
        case btHfpVoice = "BT HFP Voice"
        case builtInMicBtA2dp = "Built-in Mic + BT A2DP"
        case builtInMicSpeaker = "Built-in Mic + Speaker"
        var id: String { rawValue }
    }

    enum MicMode: String, CaseIterable, Identifiable {
        case standard = "Standard"
        case raw = "Raw / Studio"
        var id: String { rawValue }
    }

    enum CaptureChannels: String, CaseIterable, Identifiable {
        case mono = "Mono"
        case stereo = "Stereo"
        var id: String { rawValue }
        var channelCount: UInt32 { self == .stereo ? 2 : 1 }
    }

    // MARK: - UserDefaults keys

    private let kBluetoothMode = "cat.voice.audio.bluetoothMode"
    private let kMicMode = "cat.voice.audio.micMode"
    private let kCaptureChannels = "cat.voice.audio.captureChannels"
    private let kInputPortId = "cat.voice.audio.inputPortId"
    private let kDataSourceId = "cat.voice.audio.dataSourceId"
    private let kPolarPattern = "cat.voice.audio.polarPattern"
    private let kPreset = "cat.voice.audio.preset"
    private let kForceSpeaker = "cat.voice.audio.forceSpeaker"
    private let kVoiceProcessing = "cat.voice.audio.voiceProcessing"
    private let kAgc = "cat.voice.audio.agc"

    /// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change
    /// notifications synchronously on the same thread. Without this guard,
    /// handleRouteChange → applyConfiguration → setCategory → route-change notification
    /// → handleRouteChange → applyConfiguration → ... creates an infinite loop that
    /// burns CPU and cycles the audio session on/off (the "glitching" bug).
    private var isApplyingConfiguration = false

    private init() {}

    // MARK: - Load / refresh from AVAudioSession

    /// Refresh the published input port list and output route list from the current
    /// AVAudioSession state. Call after any route change or when the settings view appears.
    func refreshRoutes() {
        let session = AVAudioSession.sharedInstance()
        let currentInput = session.preferredInput
        let currentDataSource = currentInput?.preferredDataSource?.dataSourceID ?? nil
        let currentPolarPattern = currentInput?.preferredDataSource?.preferredPolarPattern?.rawValue

        inputPorts = (session.availableInputs ?? []).map { port in
            let dataSources = port.dataSources?.map { ds in
                IOSAudioDataSource(
                    id: String(describing: ds.dataSourceID),
                    name: ds.dataSourceName,
                    polarPatterns: ds.supportedPolarPatterns?.map { $0.rawValue },
                    isSelected: currentDataSource == ds.dataSourceID,
                    selectedPolarPattern: currentPolarPattern
                )
            }
            return IOSAudioInputPort(
                id: port.uid,
                name: port.portName,
                portType: port.portType.rawValue,
                dataSources: dataSources,
                isSelected: currentInput?.uid == port.uid
            )
        }

        outputRoutes = session.currentRoute.outputs.map { port in
            IOSAudioOutputRoute(
                id: port.uid,
                name: port.portName,
                portType: port.portType.rawValue
            )
        }

        if selectedInputPortId == nil {
            selectedInputPortId = currentInput?.uid ?? inputPorts.first?.id
        }
        if selectedDataSourceId == nil {
            selectedDataSourceId = currentDataSource.map { String(describing: $0) }
        }
        if selectedPolarPattern == nil {
            selectedPolarPattern = currentPolarPattern
        }

        updateWarnings()
        detectAudioDevices()
    }

    /// Detect connected audio devices — Bluetooth (A2DP/HFP) and wired (headphones,
    /// headset mic, USB audio). Drives which presets are shown: BT presets only appear
    /// when a BT device is connected, wired presets only when a wired device is connected.
    /// This avoids confusing users with irrelevant options.
    private func detectAudioDevices() {
        let session = AVAudioSession.sharedInstance()
        let route = session.currentRoute
        let inputs = session.availableInputs ?? []

        // Bluetooth: check current route + available inputs
        let hasBTOutput = route.outputs.contains {
            $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP
        }
        let hasBTInput = route.inputs.contains { $0.portType == .bluetoothHFP }
        let hasBTAvailable = inputs.contains {
            $0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP
        }
        let wasBT = hasBluetoothDevice
        hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable
        if hasBluetoothDevice != wasBT {
            logger.info("bluetooth device \(self.hasBluetoothDevice ? "connected" : "disconnected")")
        }

        // Wired: headphones, headset mic, USB audio (earpods, Lightning/USB-C headsets)
        let hasWiredOutput = route.outputs.contains {
            $0.portType == .headphones || $0.portType == .usbAudio
        }
        let hasWiredInput = route.inputs.contains {
            $0.portType == .headsetMic || $0.portType == .usbAudio
        }
        let hasWiredAvailable = inputs.contains {
            $0.portType == .headphones || $0.portType == .headsetMic || $0.portType == .usbAudio
        }
        let wasWired = hasWiredHeadset
        hasWiredHeadset = hasWiredOutput || hasWiredInput || hasWiredAvailable
        if hasWiredHeadset != wasWired {
            logger.info("wired headset \(self.hasWiredHeadset ? "connected" : "disconnected")")
        }
    }

    /// The presets the user can pick. All four are always available — the named presets simply
    /// describe what to do "regardless of the output route", and Advanced is always offered.
    var availablePresets: [AudioPreset] { AudioPreset.allCases }

    /// Which named preset matches the current settings, or `.advanced` if nothing matches.
    var activePreset: AudioPreset {
        for preset in [AudioPreset.voiceChat, .stereoMic, .monoMic] {
            if bluetoothMode == preset.bluetoothMode
               && captureChannels == preset.captureChannels
               && micMode == preset.micMode {
                return preset
            }
        }
        return .advanced
    }

    /// Whether the current configuration should engage Apple's Voice-Processing I/O unit (VPIO:
    /// real AEC + noise suppression + AGC, driven by `IOSAudioEngine`). VPIO forces mono and
    /// can't run on an A2DP route, so it is available only for a mono + standard + non-A2DP
    /// config, and then only when the user hasn't disabled it via the Advanced master toggle.
    var currentConfigUsesVoiceProcessing: Bool {
        voiceProcessingEnabled && voiceProcessingAvailable
    }

    /// Whether the current config *could* use VPIO (mono + standard + non-A2DP), independent of
    /// the user's master toggle. Drives whether the Advanced "Voice Processing" switch is shown.
    var voiceProcessingAvailable: Bool {
        captureChannels == .mono && micMode == .standard && bluetoothMode != .builtInMicBtA2dp
    }

    // MARK: - Apply configuration

    /// Apply the full audio configuration to AVAudioSession. Call this before (re)building the
    /// `IOSAudioEngine` graph so the engine binds to the intended route (`applyAndReconfigure`
    /// does both). Re-entrant-safe: if a route-change notification fires synchronously during a
    /// `setCategory`/`setPreferredInput` call, the guard prevents re-entry.
    func applyConfiguration() {
        guard !isApplyingConfiguration else {
            logger.debug("applyConfiguration skipped — already applying (re-entrancy guard)")
            return
        }
        isApplyingConfiguration = true
        defer { isApplyingConfiguration = false }

        let session = AVAudioSession.sharedInstance()

        // 1. Build category options from bluetooth mode.
        //    .mixWithOthers is ALWAYS set — it keeps other audio (notably VoiceOver, which a
        //    blind user needs to operate the phone) audible while our session is active. Never
        //    drop it.
        //    .defaultToSpeaker is set for the speaker preset and, when the user enables the
        //    `forceSpeaker` toggle, for the HFP preset too — it forces output to the built-in
        //    speaker instead of the receiver while still yielding to connected BT/wired output.
        //    It also actively breaks A2DP routing in .playAndRecord, so it must NEVER be set for
        //    the A2DP preset (forceSpeaker is intentionally ignored there).
        //    .allowAirPlay is added to the Bluetooth presets so AirPlay output also works.
        var options: AVAudioSession.CategoryOptions = [.mixWithOthers]
        switch bluetoothMode {
        case .btHfpVoice:
            // Voice Chat: allow BOTH HFP and A2DP, let iOS pick the right profile for the
            // connected device. HFP and A2DP must NOT be made mutually exclusive (HFP-only)
            // — that blocks A2DP headphones from receiving audio. HFP is preferred (the system
            // uses it when a two-way mic path is needed); A2DP stays available for output-only.
            options.insert(.allowBluetoothHFP)
            options.insert(.allowBluetoothA2DP)
            options.insert(.allowAirPlay)
        case .builtInMicBtA2dp:
            // A2DP output only (no HFP). With HFP disabled the Bluetooth device can only be
            // an OUTPUT (A2DP), so the system routes the mic to the built-in mic — exactly
            // what we want for "built-in mic + A2DP output", in either mono OR stereo.
            options.insert(.allowBluetoothA2DP)
            options.insert(.allowAirPlay)
        case .builtInMicSpeaker:
            // Built-in mic + speaker/wired output only. Prefer speaker over the receiver.
            options.insert(.defaultToSpeaker)
        }

        // User-requested speaker fallback: route to the built-in speaker instead of the
        // receiver when no headphones/BT are connected. Skipped for the A2DP mode because
        // .defaultToSpeaker breaks A2DP routing (see note above). Redundant for
        // builtInMicSpeaker, which already sets it.
        if forceSpeaker && bluetoothMode != .builtInMicBtA2dp {
            options.insert(.defaultToSpeaker)
        }

        // 2. Set category + mode, chosen per scenario:
        //    - Stereo capture: .default — .voiceChat (the AEC/VPIO path) forces MONO, so stereo
        //      is only possible in a non-VPIO mode. .default supports multi-capsule stereo AND
        //      keeps the A2DP output route alive.
        //    - Mono raw/studio: .measurement — all system processing off.
        //    - Mono + A2DP output: .videoRecording — keeps A2DP output without VPIO (no AEC).
        //    - Mono standard (HFP or speaker): .voiceChat — hardware AEC/AGC/HPF.
        let mode: AVAudioSession.Mode
        if captureChannels == .stereo {
            mode = .default
        } else if micMode == .raw {
            mode = .measurement
        } else if bluetoothMode == .builtInMicBtA2dp {
            mode = .videoRecording
        } else {
            mode = .voiceChat
        }

        do {
            try session.setCategory(.playAndRecord, mode: mode, options: options)
            logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue), ch=\(self.captureChannels.rawValue), options=\(self.optionsLabel(options))")
        } catch {
            logger.error("setCategory failed: \(error.localizedDescription)")
        }

        // 3. Input & mic-capsule configuration.
        if captureChannels == .stereo {
            // Stereo: enable the built-in mic's .stereo polar pattern AND anchor the input
            // route explicitly via setPreferredInput + setInputDataSource. With HFP disabled
            // the system routes input to the built-in mic, but without the explicit
            // preferred-input anchor the route can collapse during the mode switch
            // (.voiceChat → .default) and the output dies. The channel count is carried by the
            // engine's mic tap + vc_set_capture_channels, NOT via
            // setPreferredInputNumberOfChannels(2) — that call collapses the A2DP output route.
            configureStereoCapture(session: session)
        } else if let portId = selectedInputPortId, !portId.isEmpty,
                  let port = session.availableInputs?.first(where: { $0.uid == portId }) {
            // Mono with an explicit input-port selection (advanced settings).
            do {
                try session.setPreferredInput(port)
                logger.info("setPreferredInput ok — \(port.portName)")
            } catch {
                logger.error("setPreferredInput failed: \(error.localizedDescription)")
            }
            configureMonoCapture(session: session, port: port)
        } else {
            // Mono, system-default input. Still clear any leftover .stereo capsule from a
            // prior stereo session so we actually return to mono.
            clearStereoPolarPattern(session: session)
        }

        updateWarnings()
    }

    /// Enable 2-channel capture on the built-in mic. The recipe that achieves stereo mic +
    /// A2DP Bluetooth output simultaneously:
    ///   1. `setPreferredDataSource(stereoSource)` on the built-in mic port
    ///   2. `setPreferredPolarPattern(.stereo)` on that data source
    ///   3. `setPreferredInput(builtIn)` — anchor the input route explicitly. Without this
    ///      anchor the route can collapse during the mode switch (.voiceChat → .default).
    ///   4. `setInputDataSource(stereoSource)` — commit the data source at the session level
    /// The channel count itself is carried by the engine's mic tap (which captures 2 channels)
    /// plus `vc_set_capture_channels(2)` so the core encodes stereo. We must NOT call
    /// `setPreferredInputNumberOfChannels(2)` — that session-level call collapses the A2DP route.
    private func configureStereoCapture(session: AVAudioSession) {
        guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
        else {
            logger.warning("stereo requested but no built-in mic available — staying mono")
            return
        }
        guard let stereoSource = builtIn.dataSources?.first(where: {
            $0.supportedPolarPatterns?.contains(.stereo) == true
        }) else {
            logger.warning("stereo requested but built-in mic has no .stereo data source — staying mono")
            return
        }
        do {
            try builtIn.setPreferredDataSource(stereoSource)
            try stereoSource.setPreferredPolarPattern(.stereo)
            // Anchor the input route explicitly; without it the route can collapse during
            // the mode switch (.voiceChat → .default) and the A2DP output dies.
            try session.setPreferredInput(builtIn)
            // Commit the data source at the session level. setPreferredDataSource alone only
            // sets the port-level preference; setInputDataSource makes it the active source.
            try session.setInputDataSource(stereoSource)
            logger.info("stereo capsule enabled — source=\(stereoSource.dataSourceName), pattern=.stereo, input anchored")
        } catch {
            logger.error("stereo capsule setup failed: \(error.localizedDescription)")
        }
    }

    /// Configure mono capture on an explicitly selected port: apply the user's chosen data source
    /// (orientation) and polar pattern, resetting any prior `.stereo` pattern back to default.
    private func configureMonoCapture(session: AVAudioSession, port: AVAudioSessionPortDescription) {
        guard let dataSourceId = selectedDataSourceId, !dataSourceId.isEmpty,
              let dataSource = port.dataSources?.first(where: {
                  String(describing: $0.dataSourceID) == dataSourceId
              }) else {
            // No explicit capsule choice — make sure we're not stuck on a prior .stereo pattern.
            clearStereoPolarPattern(session: session)
            return
        }
        do {
            try port.setPreferredDataSource(dataSource)
            logger.info("setPreferredDataSource ok — \(dataSource.dataSourceName)")
        } catch {
            logger.error("setPreferredDataSource failed: \(error.localizedDescription)")
        }

        if let polarPattern = selectedPolarPattern, !polarPattern.isEmpty {
            let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
            try? dataSource.setPreferredPolarPattern(pattern)
            logger.info("setPreferredPolarPattern ok — \(polarPattern)")
        } else {
            // Clear any prior .stereo selection so mono capture returns to a mono capsule.
            try? dataSource.setPreferredPolarPattern(nil)
        }
    }

    /// Reset any built-in-mic data source that's currently on the `.stereo` polar pattern back to
    /// the default (mono) pattern. Used when switching from a stereo session back to mono with no
    /// explicit capsule selection, so the prior stereo capsule doesn't linger.
    private func clearStereoPolarPattern(session: AVAudioSession) {
        guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
        else { return }
        for ds in builtIn.dataSources ?? [] where ds.selectedPolarPattern == .stereo {
            try? ds.setPreferredPolarPattern(nil)
        }
    }

    private func modeLabel(_ mode: AVAudioSession.Mode) -> String {
        switch mode {
        case .voiceChat: return "voiceChat"
        case .measurement: return "measurement"
        case .videoRecording: return "videoRecording"
        case .default: return "default"
        default: return "other"
        }
    }

    private func optionsLabel(_ opts: AVAudioSession.CategoryOptions) -> String {
        var parts: [String] = []
        if opts.contains(.defaultToSpeaker) { parts.append("defaultToSpeaker") }
        if opts.contains(.mixWithOthers) { parts.append("mixWithOthers") }
        if opts.contains(.allowBluetoothHFP) { parts.append("allowBluetoothHFP") }
        if opts.contains(.allowBluetoothA2DP) { parts.append("allowBluetoothA2DP") }
        return parts.joined(separator: ",")
    }

    /// Apply stored preferences from UserDefaults. Called at app launch (before any
    /// audio session activation).
    func loadStoredPreferences() {
        if let raw = UserDefaults.standard.string(forKey: kBluetoothMode),
           let mode = BluetoothMode(rawValue: raw) {
            bluetoothMode = mode
        }
        if let raw = UserDefaults.standard.string(forKey: kMicMode),
           let mode = MicMode(rawValue: raw) {
            micMode = mode
        }
        if let raw = UserDefaults.standard.string(forKey: kCaptureChannels),
           let ch = CaptureChannels(rawValue: raw) {
            captureChannels = ch
        }
        selectedInputPortId = UserDefaults.standard.string(forKey: kInputPortId)
        selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
        selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
        forceSpeaker = UserDefaults.standard.bool(forKey: kForceSpeaker)
        // VPIO toggles default ON when never set (object(forKey:) is nil → use true).
        voiceProcessingEnabled = (UserDefaults.standard.object(forKey: kVoiceProcessing) as? Bool) ?? true
        agcEnabled = (UserDefaults.standard.object(forKey: kAgc) as? Bool) ?? true
    }
    /// Persist current selections to UserDefaults.
    func savePreferences() {
        UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode)
        UserDefaults.standard.set(micMode.rawValue, forKey: kMicMode)
        UserDefaults.standard.set(captureChannels.rawValue, forKey: kCaptureChannels)
        UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId)
        UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
        UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
        UserDefaults.standard.set(forceSpeaker, forKey: kForceSpeaker)
        UserDefaults.standard.set(voiceProcessingEnabled, forKey: kVoiceProcessing)
        UserDefaults.standard.set(agcEnabled, forKey: kAgc)
    }

    // MARK: - Selection setters (called from SettingsView pickers)

    /// Shared tail for every setting change: persist, re-apply the AVAudioSession config, refresh
    /// the route lists, re-evaluate the A2DP speaker fallback, and rebind the live engine to the
    /// new route. `IOSAudioEngine.reconfigure()` is a no-op when not connected, so this is safe to
    /// call from Settings whether or not a session is in progress. There is no longer a second
    /// (miniaudio) audio path to hand off to, so one engine rebuild is the whole story.
    private func applyAndReconfigure() {
        savePreferences()
        applyConfiguration()
        if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
        refreshRoutes()
        IOSAudioEngine.shared.reconfigure()
    }

    func selectInputPort(_ portId: String) {
        selectedInputPortId = portId
        selectedDataSourceId = nil
        selectedPolarPattern = nil
        applyAndReconfigure()
    }

    func selectDataSource(_ dataSourceId: String) {
        selectedDataSourceId = dataSourceId
        selectedPolarPattern = nil
        applyAndReconfigure()
    }

    func selectPolarPattern(_ pattern: String) {
        selectedPolarPattern = pattern
        applyAndReconfigure()
    }

    func selectBluetoothMode(_ mode: BluetoothMode) {
        bluetoothMode = mode
        applyAndReconfigure()
    }

    func setForceSpeaker(_ on: Bool) {
        forceSpeaker = on
        applyAndReconfigure()
    }

    func selectMicMode(_ mode: MicMode) {
        micMode = mode
        applyAndReconfigure()
    }

    func setVoiceProcessingEnabled(_ on: Bool) {
        voiceProcessingEnabled = on
        applyAndReconfigure()
    }

    func setAgcEnabled(_ on: Bool) {
        agcEnabled = on
        // No session reconfigure needed — just rebuild the engine so VPIO picks up the AGC flag.
        savePreferences()
        IOSAudioEngine.shared.reconfigure()
    }

    func selectCaptureChannels(_ channels: CaptureChannels) {
        captureChannels = channels
        savePreferences()
        applyConfiguration()
        if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
        refreshRoutes()
        // Push the channel count into the core's MIC stream, then rebuild the engine graph so the
        // mic tap captures the right number of channels. The engine owns the route now, so there's
        // no stereo-vs-A2DP race to sequence around.
        IOSAudioEngine.shared.setCaptureChannels(channels.channelCount)
    }

    // MARK: - Presets

    /// Apply a named preset — set all individual settings to the preset's values, then re-apply
    /// the configuration and rebind the engine. The internal-mic presets pin the built-in mic.
    func applyPreset(_ preset: AudioPreset) {
        guard preset != .advanced else { return }  // Advanced is a display state, not "applied"

        bluetoothMode = preset.bluetoothMode
        micMode = preset.micMode
        captureChannels = preset.captureChannels

        // Voice Chat is a phone-call experience — default to the loud speaker so output doesn't
        // land on the quiet earpiece (receiver). Still yields to connected BT/wired output.
        if preset == .voiceChat { forceSpeaker = true }

        if preset.usesBuiltInMic {
            // Pin the built-in mic. In stereo, iOS uses multiple capsules automatically; in mono
            // the default orientation is fine — so don't force a specific data source / pattern.
            if let builtInMic = (AVAudioSession.sharedInstance().availableInputs ?? []).first(where: {
                $0.portType == .builtInMic
            }) {
                selectedInputPortId = builtInMic.uid
            }
            selectedDataSourceId = nil
            selectedPolarPattern = nil
        } else {
            // Voice Chat: let the system pick the input (Bluetooth HFP / wired / built-in).
            selectedInputPortId = nil
            selectedDataSourceId = nil
            selectedPolarPattern = nil
        }

        UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
        savePreferences()
        applyConfiguration()
        if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
        refreshRoutes()
        // Push the channel count to the core, then rebuild the engine graph (VPIO on/off + tap).
        IOSAudioEngine.shared.setCaptureChannels(preset.captureChannels.channelCount)
        IOSAudioEngine.shared.reconfigure()
        logger.info("applyPreset — \(preset.rawValue)")
    }

    // MARK: - Helpers

    /// Update warning indicators for the Settings UI.
    private func updateWarnings() {
        let session = AVAudioSession.sharedInstance()
        let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
        // Raw/Studio mode + speaker = echo risk (no AEC in .measurement mode)
        showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker)
        // A2DP output runs without hardware AEC (the .voiceChat AEC path isn't available on an
        // A2DP route). Applies to both mono and stereo A2DP. Stereo also has no AEC (it can't
        // use .voiceChat at all), but the message is the same and the warning already shows when
        // the bluetooth mode is A2DP.
        showsA2dpNoAecWarning = (bluetoothMode == .builtInMicBtA2dp)
    }

    /// Route fallback for the A2DP-output presets (Stereo Mic / Studio / BT Headphones + Mono
    /// Mic, all `.builtInMicBtA2dp`). These presets deliberately omit `.defaultToSpeaker` (it
    /// breaks A2DP routing) and skip the `forceSpeaker` override, so when NO external output
    /// (Bluetooth A2DP / wired / AirPlay) is connected `.playAndRecord` pins output to the quiet
    /// built-in receiver (earpiece). This routes to the loud built-in speaker instead via a
    /// post-activation `overrideOutputAudioPort(.speaker)` — the documented "A2DP if connected,
    /// else speaker" behavior. When an external output IS present we clear the override so A2DP /
    /// headphones / AirPlay are honored. No-op outside `.builtInMicBtA2dp` mode (other modes pick
    /// their route via category options). Must be called AFTER the session is active.
    func applyA2dpSpeakerFallback() {
        guard bluetoothMode == .builtInMicBtA2dp else { return }
        let session = AVAudioSession.sharedInstance()
        // Treat the built-in receiver and speaker as "internal"; anything else (A2DP, headphones,
        // USB, AirPlay) is an external output we should defer to.
        let hasExternalOutput = session.currentRoute.outputs.contains {
            $0.portType != .builtInReceiver && $0.portType != .builtInSpeaker
        }
        do {
            if hasExternalOutput {
                try session.overrideOutputAudioPort(.none)
                logger.info("A2DP mode — external output present, clearing speaker override")
            } else {
                try session.overrideOutputAudioPort(.speaker)
                logger.info("A2DP mode — no external output, routing to built-in speaker")
            }
        } catch {
            logger.error("A2DP speaker fallback failed: \(error.localizedDescription)")
        }
    }

    /// The selected input port object, if any.
    var selectedPort: IOSAudioInputPort? {
        inputPorts.first(where: { $0.id == selectedInputPortId })
    }

    /// The data sources of the selected input port, if it's the built-in mic.
    var selectedPortDataSources: [IOSAudioDataSource]? {
        selectedPort?.dataSources
    }

    /// Whether the selected input port is the built-in mic (has data sources / orientation).
    var selectedPortIsBuiltInMic: Bool {
        selectedPort?.portType == AVAudioSession.Port.builtInMic.rawValue
    }
}
