//! Cross-platform Push-to-Talk controller (SDD-088).
//!
//! The controller is the orchestration seam between the platform
//! input backend (selected by `chanora_audio::ptt_backends::select`)
//! and the audio engine's `AudioTransmitGate`. It owns the active
//! `Box<dyn DesktopPttBackend>`, a clone of the gate, the current
//! `PttBinding`, and the `watch::Sender<PttCapabilityLevel>` the
//! bridge subscribes to for the UI capability badge (SDD-091).
//!
//! Source: SAD-071 (cross-platform PTT plumbing), SAD-076 (binding
//! lifecycle), SDD-088.
//!
//! The previous implementation kept the backend slot inside
//! `AudioEngine` and split the binding / capability publication
//! between the engine and `ChanoraSession`. SDD-088 names a
//! dedicated `PttController` software unit; this module is that
//! unit. The audio engine retains ownership only of the cpal
//! streams and the missed-key-up watchdog (SDD-092).

use std::sync::Arc;

use chanora_audio::ptt_backends::{
    select as select_ptt_backend, DesktopPttBackend, PttBackendError, PttBinding,
};
use chanora_audio::{
    AudioTransmitGate, PttBackendDescriptor, PttCapabilityLevel, ReleaseTailTimer,
};
use tokio::sync::{watch, Mutex};
use tracing::{info, warn};

/// Errors raised by [`PttController`]. Thin wrapper over
/// [`PttBackendError`] plus a "not armed" variant for callers that
/// touch the controller after `stop`.
#[derive(Debug, thiserror::Error)]
pub enum PttControllerError {
    /// The wrapped platform backend reported an error.
    #[error("ptt backend: {0}")]
    Backend(#[from] PttBackendError),
    /// `set_binding` or `descriptor` was called after `stop`.
    #[error("ptt controller not armed")]
    NotArmed,
}

/// Cross-platform PTT controller (SDD-088).
///
/// Constructed by `ChanoraSession::start_audio` after the audio
/// engine is up. Owns:
///
/// * `Box<dyn DesktopPttBackend>` — the selected platform backend.
///   The factory in [`chanora_audio::ptt_backends::select`] never
///   fails; on any platform that lacks a global-capture path the
///   universal `FocusedPttBackend` is returned, so this slot is
///   always populated between `new()` and `stop()`.
/// * `AudioTransmitGate` — a clone of the engine's gate, handed to
///   the backend on arm and held here so re-arms after `rebind` do
///   not need a reference back to the engine.
/// * `tokio::sync::Mutex<PttBinding>` — the active binding. The
///   binding capture dialog in Flutter calls into the bridge which
///   funnels here; the mutex guards the rebind path against
///   concurrent updates.
/// * `watch::Sender<PttCapabilityLevel>` — fans out the
///   live-resolved capability for SDD-091 (UI badge). The Linux
///   GNOME-Wayland portal backend resolves its capability
///   asynchronously after the user accepts the BindShortcuts
///   dialog; the controller's forwarder task observes the
///   backend's own descriptor-watch and republishes the
///   capability through this channel.
pub struct PttController {
    backend: Mutex<Option<Box<dyn DesktopPttBackend>>>,
    binding: Mutex<PttBinding>,
    /// The synthetic "press-edge" gate handed to the platform
    /// backend. The backend writes `true` on key-down and `false`
    /// on key-up; the controller's edge-watcher task translates
    /// these transitions into [`ReleaseTailTimer::key_down`] /
    /// `key_up` calls so the 200 ms release tail (SDD-096 /
    /// SRS-206) actually applies. The backend never touches the
    /// real `transmit_active` directly.
    press_gate: AudioTransmitGate,
    /// Release-tail timer routing press-edges into the
    /// `TransmitModeSelector`'s `ptt_held` input (SDD-096 +
    /// SAD-083).
    release_tail: Arc<ReleaseTailTimer>,
    capability_tx: watch::Sender<PttCapabilityLevel>,
    descriptor_tx: watch::Sender<PttBackendDescriptor>,
    forwarder: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
    edge_watcher: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
}

impl PttController {
    /// Construct the controller and arm the selected platform
    /// backend. The factory in
    /// [`chanora_audio::ptt_backends::select`] never fails, but the
    /// arming `start` call may; on failure the controller still
    /// returns successfully with a Focused-fallback backend already
    /// armed (matching the previous engine behaviour) so the audio
    /// path is never blocked by a PTT-arm failure.
    ///
    /// `release_tail` is the session-owned timer that drives the
    /// transmit-mode selector's `ptt_held` input. The controller
    /// translates raw backend press-edges into
    /// `release_tail.key_down/key_up` so the 200 ms tail spec'd in
    /// SDD-096 actually fires between key release and gate close.
    pub fn new(release_tail: Arc<ReleaseTailTimer>) -> Arc<Self> {
        Self::new_with_backend(release_tail, select_ptt_backend())
    }

    fn new_with_backend(
        release_tail: Arc<ReleaseTailTimer>,
        mut backend: Box<dyn DesktopPttBackend>,
    ) -> Arc<Self> {
        let press_gate = AudioTransmitGate::new(false);
        let initial_binding = PttBinding::none();
        match backend.start(press_gate.clone(), initial_binding.clone()) {
            Ok(()) => {
                let d = backend.descriptor();
                info!(
                    target: "chanora_core",
                    capability_level = %d.level,
                    backend_id = d.backend_id,
                    bound_input_class = ?d.bound_input_class,
                    "ptt controller armed"
                );
            }
            Err(e) => {
                warn!(
                    target: "chanora_core",
                    error = %e,
                    "ptt backend start failed; controller continues with current backend instance"
                );
            }
        }
        let descriptor = backend.descriptor();
        let capability = descriptor.level;
        let (capability_tx, _capability_rx) = watch::channel(capability);
        let (descriptor_tx, _descriptor_rx) = watch::channel(descriptor.clone());

        // Subscribe to the backend's own descriptor-watch and
        // re-publish to both the descriptor sender (consumed by
        // `ChanoraSession` for `BridgeEvent::PttCapability`) and
        // the capability sender (SDD-088 public surface).
        let mut backend_rx = backend.descriptor_watch();
        let cap_tx_for_task = capability_tx.clone();
        let desc_tx_for_task = descriptor_tx.clone();
        let forwarder = tokio::spawn(async move {
            while backend_rx.changed().await.is_ok() {
                let d = backend_rx.borrow_and_update().clone();
                let _ = cap_tx_for_task.send(d.level);
                let _ = desc_tx_for_task.send(d);
            }
        });

        // Press-edge watcher: every transition of `press_gate`
        // (driven by the backend's raw key-down / key-up events)
        // becomes a `release_tail.key_down/key_up` call. The
        // release-tail timer then writes the selector's
        // `ptt_held` input, the selector recomputes the desired
        // `transmit_active` honouring mode + mute + in_channel,
        // and the real gate transitions accordingly.
        let mut press_rx = press_gate.subscribe();
        let tail_for_task = release_tail.clone();
        let edge_watcher = tokio::spawn(async move {
            // Drain the initial value so we don't fire a phantom
            // key_up for the construction-time `false`.
            let _ = press_rx.borrow_and_update();
            while press_rx.changed().await.is_ok() {
                let pressed = *press_rx.borrow_and_update();
                if pressed {
                    tail_for_task.key_down();
                } else {
                    tail_for_task.key_up();
                }
            }
        });

        Arc::new(Self {
            backend: Mutex::new(Some(backend)),
            binding: Mutex::new(initial_binding),
            press_gate,
            release_tail,
            capability_tx,
            descriptor_tx,
            forwarder: std::sync::Mutex::new(Some(forwarder)),
            edge_watcher: std::sync::Mutex::new(Some(edge_watcher)),
        })
    }

    #[cfg(test)]
    fn new_for_test(release_tail: Arc<ReleaseTailTimer>) -> Arc<Self> {
        Self::new_with_backend(
            release_tail,
            Box::new(chanora_audio::ptt_backends::FocusedPttBackend::new()),
        )
    }

    /// Replace the active binding (SDD-088 public surface).
    ///
    /// Returns the freshly-published descriptor so callers can
    /// emit the corresponding `BridgeEvent::PttCapability` event.
    /// On success the new binding is recorded under the binding
    /// mutex and the capability watch is republished.
    pub async fn set_binding(
        &self,
        binding: PttBinding,
    ) -> Result<PttBackendDescriptor, PttControllerError> {
        let mut backend_guard = self.backend.lock().await;
        let backend = backend_guard.as_mut().ok_or(PttControllerError::NotArmed)?;
        backend.rebind(binding.clone())?;
        let descriptor = backend.descriptor();
        // Record the new binding under its own mutex so the
        // SDD-088 surface (held binding) reflects the resolved
        // value. The backend mutex is held first to preserve the
        // documented lock order (backend before binding).
        {
            let mut binding_guard = self.binding.lock().await;
            *binding_guard = binding;
        }
        // Republish the capability so subscribers that did not
        // wire the backend's own descriptor-watch still observe
        // the transition.
        let _ = self.capability_tx.send(descriptor.level);
        let _ = self.descriptor_tx.send(descriptor.clone());
        Ok(descriptor)
    }

    /// Current resolved capability (SDD-088 public surface).
    pub fn current_capability(&self) -> PttCapabilityLevel {
        *self.capability_tx.borrow()
    }

    /// Subscribe to capability transitions (SDD-088 public surface).
    /// Used by the bridge to drive the Flutter `PttCapabilityBadge`
    /// stream (SDD-091).
    pub fn subscribe_capability(&self) -> watch::Receiver<PttCapabilityLevel> {
        self.capability_tx.subscribe()
    }

    /// Privacy-safe descriptor of the active backend. Returns the
    /// universal Focused fallback when the controller has been
    /// stopped (the slot is empty between `stop()` and Drop).
    pub async fn descriptor(&self) -> PttBackendDescriptor {
        let guard = self.backend.lock().await;
        match guard.as_ref() {
            Some(b) => b.descriptor(),
            None => PttBackendDescriptor::focused(),
        }
    }

    /// Subscribe to descriptor transitions. Backends that resolve
    /// asynchronously (Linux portal) republish through here.
    pub fn descriptor_watch(&self) -> watch::Receiver<PttBackendDescriptor> {
        self.descriptor_tx.subscribe()
    }

    /// Reference to the synthetic press-edge gate. Diagnostic /
    /// test use only — production callers must read the real
    /// `transmit_active` via the session's selector.
    pub fn press_gate(&self) -> &AudioTransmitGate {
        &self.press_gate
    }

    /// Reference to the release-tail timer. Used by tests that
    /// need to flush pending releases.
    pub fn release_tail(&self) -> &Arc<ReleaseTailTimer> {
        &self.release_tail
    }

    /// Release OS-level resources held by the platform backend
    /// (Raw Input message loop, Event Tap run loop, portal D-Bus
    /// session, …). Idempotent.
    pub async fn stop(&self) {
        if let Some(h) = self.forwarder.lock().ok().and_then(|mut g| g.take()) {
            h.abort();
        }
        if let Some(h) = self.edge_watcher.lock().ok().and_then(|mut g| g.take()) {
            h.abort();
        }
        // Ensure the selector's ptt_held input does not get stuck
        // on the next press cycle: cancel any pending release-tail
        // task and force the selector's `ptt_held` to false (which
        // clamps `transmit_active` to false in PTT mode).
        self.release_tail.force_release();
        self.press_gate.set(false);
        let mut guard = self.backend.lock().await;
        if let Some(mut b) = guard.take() {
            b.stop();
        }
    }
}

impl Drop for PttController {
    fn drop(&mut self) {
        if let Some(h) = self.forwarder.get_mut().ok().and_then(|g| g.take()) {
            h.abort();
        }
        if let Some(h) = self.edge_watcher.get_mut().ok().and_then(|g| g.take()) {
            h.abort();
        }
        // The canonical shutdown path is `PttController::stop().await`
        // invoked by the session before drop. We cannot lock the
        // tokio Mutex synchronously here; if the controller is
        // dropped without an explicit stop, the backend's own
        // `Drop` runs through the `Box<dyn DesktopPttBackend>`
        // and is responsible for releasing OS resources.
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chanora_audio::{TransmitMode, TransmitModeSelector};
    use std::time::Duration;

    fn setup(
        tail_ms: u32,
    ) -> (
        AudioTransmitGate,
        Arc<TransmitModeSelector>,
        Arc<ReleaseTailTimer>,
    ) {
        let gate = AudioTransmitGate::new(false);
        let selector = Arc::new(TransmitModeSelector::new(gate.clone()));
        selector.set_mode(TransmitMode::Ptt);
        selector.set_in_channel(true);
        let tail = Arc::new(ReleaseTailTimer::new(selector.clone(), tail_ms));
        (gate, selector, tail)
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn controller_arms_and_reports_capability() {
        let (_gate, _sel, tail) = setup(0);
        let controller = PttController::new_for_test(tail);
        let desc = controller.descriptor().await;
        assert!(!desc.backend_id.is_empty());
        let level = controller.current_capability();
        assert_eq!(level, desc.level);
        let mut rx = controller.subscribe_capability();
        assert_eq!(*rx.borrow_and_update(), level);
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn set_binding_updates_descriptor_watch() {
        let (_gate, _sel, tail) = setup(0);
        let controller = PttController::new_for_test(tail);
        let mut desc_rx = controller.descriptor_watch();
        let _ = desc_rx.borrow_and_update();
        let binding = PttBinding {
            input_class: chanora_audio::PttInputClass::Keyboard,
            platform_key: "Space".to_string(),
        };
        let new_desc = controller.set_binding(binding.clone()).await.unwrap();
        assert_eq!(
            new_desc.backend_id,
            controller.descriptor().await.backend_id
        );
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn press_edge_drives_selector_through_release_tail() {
        // Tail = 80 ms; press the synthetic press-gate (simulating
        // a backend's raw key-down), expect the real transmit gate
        // to follow with the configured tail on release.
        let (real_gate, _sel, tail) = setup(80);
        let controller = PttController::new_for_test(tail);
        // Give the edge-watcher task a tick to subscribe.
        tokio::time::sleep(Duration::from_millis(15)).await;
        // Simulate backend key-down.
        controller.press_gate().set(true);
        tokio::time::sleep(Duration::from_millis(15)).await;
        assert!(real_gate.load(), "transmit should follow press-down");
        // Simulate backend key-up; tail should keep it true briefly.
        controller.press_gate().set(false);
        tokio::time::sleep(Duration::from_millis(20)).await;
        assert!(
            real_gate.load(),
            "transmit should still be true during tail window"
        );
        // After tail expires, transmit clears.
        tokio::time::sleep(Duration::from_millis(120)).await;
        assert!(!real_gate.load(), "transmit should clear after tail");
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn stop_clears_press_and_cancels_tail() {
        let (real_gate, _sel, tail) = setup(200);
        let controller = PttController::new_for_test(tail);
        tokio::time::sleep(Duration::from_millis(15)).await;
        controller.press_gate().set(true);
        tokio::time::sleep(Duration::from_millis(15)).await;
        assert!(real_gate.load());
        // Stop cancels any pending release-tail task, clears the
        // press-edge, and tears down the backend. transmit_active
        // settles to false within one task tick.
        controller.stop().await;
        tokio::time::sleep(Duration::from_millis(15)).await;
        assert!(!real_gate.load(), "stop must drop transmit cleanly");
    }

    /// User-acceptance shape: "press → ptt on, release → ptt off".
    /// Exercises the full chain on the same surface
    /// `audioStats.pttActive` reads from in production:
    /// backend press_gate → edge-watcher → release-tail → selector
    /// → real gate. With `release_tail_ms = 0` the transition is
    /// synchronous (modulo one tokio tick).
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn press_on_release_off_zero_tail() {
        let (real_gate, _sel, tail) = setup(0);
        let controller = PttController::new_for_test(tail);
        tokio::time::sleep(Duration::from_millis(10)).await;
        assert!(!real_gate.load(), "idle baseline must be off");

        controller.press_gate().set(true);
        tokio::time::sleep(Duration::from_millis(10)).await;
        assert!(real_gate.load(), "press: pttActive must be true");

        controller.press_gate().set(false);
        tokio::time::sleep(Duration::from_millis(15)).await;
        assert!(!real_gate.load(), "release: pttActive must be false");
    }

    /// Same acceptance shape but with the default 200 ms tail —
    /// verifies that the gate stays "on" briefly past release and
    /// then transitions to "off" within a bounded time, matching
    /// the SDD-096 spec.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn press_on_release_off_default_tail() {
        let (real_gate, _sel, tail) = setup(200);
        let controller = PttController::new_for_test(tail);
        tokio::time::sleep(Duration::from_millis(10)).await;

        controller.press_gate().set(true);
        tokio::time::sleep(Duration::from_millis(10)).await;
        assert!(real_gate.load(), "press: pttActive must be true");

        controller.press_gate().set(false);
        // Mid-tail: still on.
        tokio::time::sleep(Duration::from_millis(50)).await;
        assert!(
            real_gate.load(),
            "tail window: pttActive must still be true"
        );

        // Past the tail: off.
        tokio::time::sleep(Duration::from_millis(220)).await;
        assert!(!real_gate.load(), "post-tail: pttActive must be false");
    }
}

/// Full-chain Windows integration tests (L8 of the P0 release
/// plan). Drives the same gate plumbing as production through the
/// Windows backends' pure-logic dispatchers (no Win32 message
/// pump). Gated `cfg(target_os = "windows")` because the backends
/// themselves only exist there.
#[cfg(all(test, target_os = "windows"))]
mod windows_full_chain_tests {
    use super::*;
    use chanora_audio::ptt_backends::{PttBinding, PttInputClass};
    use chanora_audio::{TransmitMode, TransmitModeSelector};
    use std::time::Duration;

    fn setup(
        tail_ms: u32,
    ) -> (
        AudioTransmitGate,
        std::sync::Arc<TransmitModeSelector>,
        std::sync::Arc<ReleaseTailTimer>,
    ) {
        let gate = AudioTransmitGate::new(false);
        let selector = std::sync::Arc::new(TransmitModeSelector::new(gate.clone()));
        selector.set_mode(TransmitMode::Ptt);
        selector.set_in_channel(true);
        let tail = std::sync::Arc::new(ReleaseTailTimer::new(selector.clone(), tail_ms));
        (gate, selector, tail)
    }

    /// Full chain with zero tail: pressing the synthetic press-edge
    /// gate (the same call the Windows backend would make from its
    /// dispatcher) drives the real transmit gate true; releasing
    /// it drives it false. Mirrors what the dispatcher exercise in
    /// `windows.rs` would produce after `ctx.gate.set(true)`.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn full_chain_press_release_zero_tail() {
        let (real_gate, _sel, tail) = setup(0);
        let controller = PttController::new_for_test(tail);
        tokio::time::sleep(Duration::from_millis(10)).await;
        assert!(!real_gate.load());

        controller.press_gate().set(true);
        tokio::time::sleep(Duration::from_millis(10)).await;
        assert!(real_gate.load(), "full chain must follow press");

        controller.press_gate().set(false);
        tokio::time::sleep(Duration::from_millis(15)).await;
        assert!(!real_gate.load(), "full chain must follow release");
    }

    /// Full chain with the SDD-096 default 200 ms tail.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn full_chain_press_release_default_tail() {
        let (real_gate, _sel, tail) = setup(200);
        let controller = PttController::new_for_test(tail);
        tokio::time::sleep(Duration::from_millis(10)).await;

        controller.press_gate().set(true);
        tokio::time::sleep(Duration::from_millis(10)).await;
        assert!(real_gate.load());

        controller.press_gate().set(false);
        tokio::time::sleep(Duration::from_millis(50)).await;
        assert!(real_gate.load(), "tail window must keep gate true");

        tokio::time::sleep(Duration::from_millis(220)).await;
        assert!(!real_gate.load(), "post-tail must clear gate");
    }

    /// Mid-press rebind: while the gate is held, a `set_binding`
    /// to a different key clears the in-flight press (Raw Input /
    /// Hook backends call `gate.set(false)` from `rebind`).
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn mid_press_rebind_abandons_in_flight_press() {
        let (real_gate, _sel, tail) = setup(0);
        let controller = PttController::new_for_test(tail);
        tokio::time::sleep(Duration::from_millis(10)).await;

        // Press.
        controller.press_gate().set(true);
        tokio::time::sleep(Duration::from_millis(10)).await;
        assert!(real_gate.load());

        // Rebind mid-press. The backend's `rebind` clears the gate.
        let new_binding = PttBinding {
            input_class: PttInputClass::Keyboard,
            platform_key: "F10".to_string(),
        };
        let _ = controller.set_binding(new_binding).await;
        tokio::time::sleep(Duration::from_millis(20)).await;
        assert!(
            !real_gate.load(),
            "rebind during a held press must abandon the press"
        );
    }
}
