//! # Session store + command channel (slice-4 §4.3)
//!
//! `AppState` no longer owns sessions directly. The dedicated media thread
//! (see `media_thread::MediaThread`) owns `HashMap<ChannelId, RtcSession>`
//! exclusively; axum routes interact via a cold-path command channel
//! (`MediaCmd`).
//!
//! This replaces the slice-3 `DashMap<ChannelId, SessionEntry>` + tokio
//! poll task with the slice-4 dedicated `std::thread` architecture. The
//! command channel is the only cross-thread state; handlers never lock a
//! session directly.

use rutster_call_model::ChannelId;
use tokio::sync::{mpsc, oneshot};
use tracing::debug;

use crate::media_thread::{MediaCmd, MediaThread};

/// Application state shared across axum handlers.
///
/// `cmd_tx` is the command channel to the dedicated media thread. All
/// session mutation flows through `MediaCmd` cold-path commands.
#[derive(Clone)]
pub struct AppState {
    pub cmd_tx: mpsc::Sender<MediaCmd>,
    pub default_tap_url: url::Url,
    /// Public base URL the CPaaS calls back on
    /// (`RUTSTER_TWILIO_WEBHOOK_BASE`). `None` = trunk unconfigured — the
    /// webhook answers 503 instead of minting a TwiML Stream URL nobody
    /// can dial (deploy slice A §5.3).
    pub trunk_webhook_base: Option<url::Url>,
    /// CIDR list of edge peers whose forwarded headers are believed
    /// (`RUTSTER_TRUSTED_PROXIES`; empty = ignored, fail-closed). No
    /// route consumes this yet — it is the declared access point for the
    /// trunk slice's signature validation and slice C's TLS listener, so
    /// that work is an implementation, not a plumbing refactor.
    pub trusted_proxies: Vec<ipnet::IpNet>,
}

impl AppState {
    /// Build state with a command-channel sender and default tap URL.
    ///
    /// Callers usually pass a placeholder sender; `spawn_media_thread`
    /// consumes the state and returns it with the real thread sender
    /// installed so the command channel terminates in a running media
    /// thread.
    pub fn new(cmd_tx: mpsc::Sender<MediaCmd>, default_tap_url: url::Url) -> Self {
        Self {
            cmd_tx,
            default_tap_url,
            trunk_webhook_base: None,
            trusted_proxies: Vec::new(),
        }
    }

    /// Thread the operator's public webhook base (from
    /// `config::twilio_credentials`) into the routes. Builder-style so the
    /// two-arg `new` keeps its many existing call sites.
    pub fn with_trunk_webhook_base(mut self, base: Option<url::Url>) -> Self {
        self.trunk_webhook_base = base;
        self
    }

    /// Thread the configured trusted-proxy CIDR list into the routes.
    /// Builder-style; empty list is the default fail-closed posture
    /// (deploy spec §5.7).
    pub fn with_trusted_proxies(mut self, nets: Vec<ipnet::IpNet>) -> Self {
        self.trusted_proxies = nets;
        self
    }

    /// Ask the media thread to create a fresh `RtcSession`, returning its id.
    pub async fn create_session(
        &self,
        tap_url_override: Option<url::Url>,
    ) -> Result<ChannelId, String> {
        let tap_url = tap_url_override.unwrap_or_else(|| self.default_tap_url.clone());
        let (reply, rx) = oneshot::channel();
        self.cmd_tx
            .send(MediaCmd::Register { tap_url, reply })
            .await
            .map_err(|e| format!("media thread gone: {e}"))?;
        let id = rx
            .await
            .map_err(|e| format!("media thread reply dropped: {e}"))??;
        debug!(%id, "session registered via media thread");
        Ok(id)
    }

    /// Ask the media thread to accept a browser SDP offer, returning the SDP answer.
    pub async fn accept_offer(&self, id: ChannelId, sdp: String) -> Result<String, String> {
        let (reply, rx) = oneshot::channel();
        self.cmd_tx
            .send(MediaCmd::AcceptOffer { id, sdp, reply })
            .await
            .map_err(|e| format!("media thread gone: {e}"))?;
        rx.await
            .map_err(|e| format!("media thread reply dropped: {e}"))?
    }

    /// Ask the media thread to tear down a session.
    ///
    /// Fire-and-forget on the command channel: if the thread is gone, the
    /// session is already torn down. This is why the method returns `()`
    /// rather than a `Result`.
    pub async fn close(&self, id: ChannelId) {
        let (reply, rx) = oneshot::channel();
        let _ = self.cmd_tx.send(MediaCmd::Delete { id, reply }).await;
        let _ = rx.await;
    }

    /// Spawn the dedicated media thread with default options.
    ///
    /// Test-facing convenience: most tests don't care about addressing,
    /// admission caps, or event sinks, so this skips straight to
    /// `MediaThreadOpts::default()`. Production always goes through
    /// [`AppState::spawn_media_thread_with`] instead, so that env-resolved
    /// config actually reaches the thread. Returns the state with the real
    /// command-channel sender wired in, plus the `MediaThread` handle;
    /// routes on the returned `AppState` will send `MediaCmd` to the
    /// running media thread.
    pub fn spawn_media_thread(
        self,
        tokio_handle: tokio::runtime::Handle,
    ) -> Result<(Self, MediaThread), std::io::Error> {
        self.spawn_media_thread_with(
            crate::media_thread::MediaThreadOpts::default(),
            tokio_handle,
        )
    }

    /// Spawn the dedicated media thread with explicit options — the
    /// production path.
    ///
    /// Unlike [`AppState::spawn_media_thread`], this threads a caller-built
    /// `MediaThreadOpts` (media addressing, admission cap, event sink) all
    /// the way to the media thread, so `main` can resolve those from env
    /// vars and have them actually take effect. Returns the state with the
    /// real command-channel sender installed, plus the `MediaThread`
    /// handle.
    pub fn spawn_media_thread_with(
        mut self,
        opts: crate::media_thread::MediaThreadOpts,
        tokio_handle: tokio::runtime::Handle,
    ) -> Result<(Self, MediaThread), std::io::Error> {
        let thread = MediaThread::spawn(self.default_tap_url.clone(), opts, tokio_handle)?;
        self.cmd_tx = thread.cmd_tx();
        Ok((self, thread))
    }
}

impl Default for AppState {
    fn default() -> Self {
        // Closed placeholder sender — kept for tests that construct an
        // `AppState` without a running media thread. Production code now
        // builds real `AppState` values with the thread's `cmd_tx` inside
        // `media_thread::run_media_thread` so tap-engine tools can tear down
        // their own sessions.
        let (tx, _rx) = mpsc::channel(1);
        Self::new(
            tx,
            url::Url::parse("ws://127.0.0.1:8081/echo").expect("valid default tap URL"),
        )
    }
}
