//! Session management for GuruConnect
//!
//! Manages active remote desktop sessions, tracking which agents
//! are connected and which viewers are watching them.

use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, RwLock};
use uuid::Uuid;

/// Unique identifier for a session
pub type SessionId = Uuid;

/// Unique identifier for an agent
pub type AgentId = String;

/// Unique identifier for a viewer
pub type ViewerId = String;

/// Information about a connected viewer/technician
#[derive(Debug, Clone)]
pub struct ViewerInfo {
    pub id: ViewerId,
    pub name: String,
    pub connected_at: chrono::DateTime<chrono::Utc>,
}

/// Attended-consent state for a session (Task 5).
///
/// Mirrors the `connect_sessions.consent_state` column
/// (`not_required` | `pending` | `granted` | `denied`). For an ATTENDED
/// (support-code) session the end user must accept a consent prompt before the
/// technician's session goes live: the session starts `Pending` and a viewer
/// cannot join (and the agent is not asked to stream) until it becomes
/// `Granted`. Managed/unattended sessions are `NotRequired` and join freely.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsentState {
    /// Managed/unattended session — no consent prompt (Phase-1 default policy).
    NotRequired,
    /// Attended session awaiting the end user's decision. Viewers are blocked.
    Pending,
    /// End user accepted — the session may proceed (viewer join / streaming).
    Granted,
    /// End user declined, or the prompt timed out — the session is torn down.
    Denied,
}

impl ConsentState {
    /// Database/wire string form (matches the `consent_state` column CHECK).
    pub fn as_db_str(self) -> &'static str {
        match self {
            ConsentState::NotRequired => "not_required",
            ConsentState::Pending => "pending",
            ConsentState::Granted => "granted",
            ConsentState::Denied => "denied",
        }
    }

    /// True if a viewer is allowed to join a session in this consent state.
    /// Only `NotRequired` (managed) and `Granted` (consented attended) admit a
    /// viewer; `Pending` and `Denied` block the join.
    pub fn allows_viewer(self) -> bool {
        matches!(self, ConsentState::NotRequired | ConsentState::Granted)
    }
}

/// Heartbeat timeout (90 seconds - 3x the agent's 30 second interval)
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
const HEARTBEAT_TIMEOUT_SECS: u64 = 90;

/// Session state
#[derive(Debug, Clone)]
pub struct Session {
    pub id: SessionId,
    pub agent_id: AgentId,
    pub agent_name: String,
    pub started_at: chrono::DateTime<chrono::Utc>,
    pub viewer_count: usize,
    pub viewers: Vec<ViewerInfo>, // List of connected technicians
    pub is_streaming: bool,
    pub is_online: bool,     // Whether agent is currently connected
    pub is_persistent: bool, // Persistent agent (no support code) vs support session
    /// Attended-consent state (Task 5). Gates viewer join for attended sessions.
    pub consent_state: ConsentState,
    pub last_heartbeat: chrono::DateTime<chrono::Utc>,
    // Agent status info
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub uptime_secs: i64,
    pub display_count: i32,
    pub agent_version: Option<String>, // Agent software version
    pub organization: Option<String>,  // Company/organization name
    pub site: Option<String>,          // Site/location name
    pub tags: Vec<String>,             // Tags for categorization
    /// Whether the agent advertised a hardware H.264 encoder (Task 7). Set from
    /// `AgentStatus.supports_h264`; drives codec negotiation in `select_video_codec`.
    pub supports_h264: bool,
}

/// Default codec-negotiation policy (Task 7).
///
/// `false` means: even when an agent advertises hardware H.264 support, the
/// server still negotiates RAW. H.264 is compile-verified only and not yet
/// validated on real hardware (plan Task 8), so we deliberately do NOT ship it
/// as the default — raw+Zstd stays the guaranteed working path. Flip this to
/// `true` once H.264 is live-validated, or make it per-tenant policy later.
pub const DEFAULT_PREFER_H264: bool = false;

/// Per-agent opt-in tag for validating the hardware H.264 path (Task 8).
///
/// An agent reports this tag via `AgentStatus.tags`. When present, codec
/// negotiation prefers H.264 for THAT agent only — letting us live-validate the
/// HW-H.264 pipeline on a handful of tagged test machines before flipping
/// [`DEFAULT_PREFER_H264`] globally. Clients without the tag are unaffected and
/// continue to negotiate raw (the guaranteed working path). The
/// `supports_h264 &&` guard in [`select_video_codec`] still forces raw for an
/// agent with no hardware encoder even when it carries this tag.
pub const H264_TEST_TAG: &str = "h264-test";

/// Whether to prefer H.264 for an agent given its reported tags (Task 8).
///
/// Pure decision function (unit-tested): H.264 is preferred when the global
/// default prefers it OR the agent carries the per-agent test opt-in tag
/// ([`H264_TEST_TAG`]). Tag matching is case-insensitive.
pub fn prefer_h264_for(tags: &[String]) -> bool {
    DEFAULT_PREFER_H264 || tags.iter().any(|t| t.eq_ignore_ascii_case(H264_TEST_TAG))
}

/// Negotiate the video codec for a stream (Task 7).
///
/// Pure decision function (unit-tested): given whether the agent advertised
/// hardware H.264 and whether policy prefers H.264, pick the codec. H.264 is
/// chosen ONLY when both the agent supports it AND policy allows it; otherwise
/// raw — the safe default/fallback. HEVC is intentionally never selected here
/// (future opt-in; TODO).
pub fn select_video_codec(
    agent_supports_h264: bool,
    prefer_h264: bool,
) -> crate::proto::VideoCodec {
    if agent_supports_h264 && prefer_h264 {
        crate::proto::VideoCodec::H264
    } else {
        crate::proto::VideoCodec::Raw
    }
}

/// Channel for sending frames from agent to viewers
pub type FrameSender = broadcast::Sender<Vec<u8>>;
pub type FrameReceiver = broadcast::Receiver<Vec<u8>>;

/// Channel for sending input events from viewer to agent
pub type InputSender = tokio::sync::mpsc::Sender<Vec<u8>>;
pub type InputReceiver = tokio::sync::mpsc::Receiver<Vec<u8>>;

/// Internal session data with channels
struct SessionData {
    info: Session,
    /// Channel for video frames (agent -> viewers)
    frame_tx: FrameSender,
    /// Channel for input events (viewer -> agent)
    input_tx: InputSender,
    #[allow(dead_code)]
    // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
    input_rx: Option<InputReceiver>,
    /// Map of connected viewers (id -> info)
    viewers: HashMap<ViewerId, ViewerInfo>,
    /// Instant for heartbeat tracking
    last_heartbeat_instant: Instant,
}

/// Manages all active sessions
#[derive(Clone)]
pub struct SessionManager {
    sessions: Arc<RwLock<HashMap<SessionId, SessionData>>>,
    agents: Arc<RwLock<HashMap<AgentId, SessionId>>>,
}

impl SessionManager {
    pub fn new() -> Self {
        Self {
            sessions: Arc::new(RwLock::new(HashMap::new())),
            agents: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Register a new agent and create a session
    /// If agent was previously connected (offline session exists), reuse that session
    pub async fn register_agent(
        &self,
        agent_id: AgentId,
        agent_name: String,
        is_persistent: bool,
    ) -> (SessionId, FrameSender, InputReceiver) {
        // Check if this agent already has an offline session (reconnecting)
        {
            let agents = self.agents.read().await;
            if let Some(&existing_session_id) = agents.get(&agent_id) {
                let mut sessions = self.sessions.write().await;
                if let Some(session_data) = sessions.get_mut(&existing_session_id) {
                    if !session_data.info.is_online {
                        // Reuse existing session - mark as online and create new channels
                        tracing::info!(
                            "Agent {} reconnecting to existing session {}",
                            agent_id,
                            existing_session_id
                        );

                        let (frame_tx, _) = broadcast::channel(16);
                        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);

                        session_data.info.is_online = true;
                        session_data.info.last_heartbeat = chrono::Utc::now();
                        session_data.info.agent_name = agent_name; // Update name in case it changed
                        session_data.frame_tx = frame_tx.clone();
                        session_data.input_tx = input_tx;
                        session_data.last_heartbeat_instant = Instant::now();

                        return (existing_session_id, frame_tx, input_rx);
                    }
                }
            }
        }

        // Create new session
        let session_id = Uuid::new_v4();

        // Create channels
        let (frame_tx, _) = broadcast::channel(16); // Buffer 16 frames
        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64); // Buffer 64 input events

        let now = chrono::Utc::now();
        let session = Session {
            id: session_id,
            agent_id: agent_id.clone(),
            agent_name,
            started_at: now,
            viewer_count: 0,
            viewers: Vec::new(),
            is_streaming: false,
            is_online: true,
            is_persistent,
            // Attended (support-code) sessions require consent before a viewer
            // may join; managed/persistent sessions do not (Phase-1 policy).
            consent_state: if is_persistent {
                ConsentState::NotRequired
            } else {
                ConsentState::Pending
            },
            last_heartbeat: now,
            os_version: None,
            is_elevated: false,
            uptime_secs: 0,
            display_count: 1,
            agent_version: None,
            organization: None,
            site: None,
            tags: Vec::new(),
            supports_h264: false,
        };

        let session_data = SessionData {
            info: session,
            frame_tx: frame_tx.clone(),
            input_tx,
            input_rx: None,
            viewers: HashMap::new(),
            last_heartbeat_instant: Instant::now(),
        };

        let mut sessions = self.sessions.write().await;
        sessions.insert(session_id, session_data);

        let mut agents = self.agents.write().await;
        agents.insert(agent_id, session_id);

        (session_id, frame_tx, input_rx)
    }

    /// Update agent status from heartbeat or status message
    #[allow(clippy::too_many_arguments)] // signature mirrors the relay/session protocol contract; refactor into a params struct tracked in docs/specs/native-remote-control/
    pub async fn update_agent_status(
        &self,
        session_id: SessionId,
        os_version: Option<String>,
        is_elevated: bool,
        uptime_secs: i64,
        display_count: i32,
        is_streaming: bool,
        agent_version: Option<String>,
        organization: Option<String>,
        site: Option<String>,
        tags: Vec<String>,
        supports_h264: bool,
    ) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            session_data.info.last_heartbeat = chrono::Utc::now();
            session_data.last_heartbeat_instant = Instant::now();
            session_data.info.is_streaming = is_streaming;
            session_data.info.supports_h264 = supports_h264;
            if let Some(os) = os_version {
                session_data.info.os_version = Some(os);
            }
            session_data.info.is_elevated = is_elevated;
            session_data.info.uptime_secs = uptime_secs;
            session_data.info.display_count = display_count;
            if let Some(version) = agent_version {
                session_data.info.agent_version = Some(version);
            }
            if let Some(org) = organization {
                session_data.info.organization = Some(org);
            }
            if let Some(s) = site {
                session_data.info.site = Some(s);
            }
            if !tags.is_empty() {
                session_data.info.tags = tags;
            }
        }
    }

    /// Update heartbeat timestamp
    pub async fn update_heartbeat(&self, session_id: SessionId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            session_data.info.last_heartbeat = chrono::Utc::now();
            session_data.last_heartbeat_instant = Instant::now();
        }
    }

    /// Set the attended-consent state for a session (Task 5).
    ///
    /// Returns the previous state if the session exists, or `None` if it does
    /// not. Used by the relay to move an attended session through
    /// `Pending → Granted` (proceed) or `Pending → Denied` (tear down).
    pub async fn set_consent_state(
        &self,
        session_id: SessionId,
        state: ConsentState,
    ) -> Option<ConsentState> {
        let mut sessions = self.sessions.write().await;
        let session_data = sessions.get_mut(&session_id)?;
        let previous = session_data.info.consent_state;
        session_data.info.consent_state = state;
        Some(previous)
    }

    /// Get the current attended-consent state for a session, if it exists.
    /// Read accessor for the API/dashboard "awaiting consent" surface; currently
    /// exercised by tests, so allow it to be unused in non-test builds.
    #[cfg_attr(not(test), allow(dead_code))]
    pub async fn get_consent_state(&self, session_id: SessionId) -> Option<ConsentState> {
        let sessions = self.sessions.read().await;
        sessions.get(&session_id).map(|s| s.info.consent_state)
    }

    /// Check if a session has timed out (no heartbeat for too long)
    #[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
    pub async fn is_session_timed_out(&self, session_id: SessionId) -> bool {
        let sessions = self.sessions.read().await;
        if let Some(session_data) = sessions.get(&session_id) {
            session_data.last_heartbeat_instant.elapsed().as_secs() > HEARTBEAT_TIMEOUT_SECS
        } else {
            true // Non-existent sessions are considered timed out
        }
    }

    /// Get sessions that have timed out
    #[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
    pub async fn get_timed_out_sessions(&self) -> Vec<SessionId> {
        let sessions = self.sessions.read().await;
        sessions
            .iter()
            .filter(|(_, data)| {
                data.last_heartbeat_instant.elapsed().as_secs() > HEARTBEAT_TIMEOUT_SECS
            })
            .map(|(id, _)| *id)
            .collect()
    }

    /// Get a session by agent ID
    pub async fn get_session_by_agent(&self, agent_id: &str) -> Option<Session> {
        let agents = self.agents.read().await;
        let session_id = agents.get(agent_id)?;

        let sessions = self.sessions.read().await;
        sessions.get(session_id).map(|s| s.info.clone())
    }

    /// Get a session by session ID
    pub async fn get_session(&self, session_id: SessionId) -> Option<Session> {
        let sessions = self.sessions.read().await;
        sessions.get(&session_id).map(|s| s.info.clone())
    }

    /// Join a session as a viewer, returns channels and sends StartStream to agent
    pub async fn join_session(
        &self,
        session_id: SessionId,
        viewer_id: ViewerId,
        viewer_name: String,
    ) -> Option<(FrameReceiver, InputSender)> {
        let mut sessions = self.sessions.write().await;
        let session_data = sessions.get_mut(&session_id)?;

        // CONSENT GATE (Task 5): an attended session must have the end user's
        // consent before a viewer may join. A `Pending`/`Denied` session is not
        // joinable — the technician's session is not surfaced until `Granted`
        // (or `NotRequired` for managed sessions). This is the enforcement point
        // that keeps a support-code session invisible to the technician until
        // the end user accepts.
        if !session_data.info.consent_state.allows_viewer() {
            tracing::warn!(
                "Viewer join refused for session {}: consent_state = {}",
                session_id,
                session_data.info.consent_state.as_db_str()
            );
            return None;
        }

        let was_empty = session_data.viewers.is_empty();

        // Add viewer info
        let viewer_info = ViewerInfo {
            id: viewer_id.clone(),
            name: viewer_name.clone(),
            connected_at: chrono::Utc::now(),
        };
        session_data.viewers.insert(viewer_id.clone(), viewer_info);

        // Update session info
        session_data.info.viewer_count = session_data.viewers.len();
        session_data.info.viewers = session_data.viewers.values().cloned().collect();

        let frame_rx = session_data.frame_tx.subscribe();
        let input_tx = session_data.input_tx.clone();

        // If this is the first viewer, send StartStream to agent
        if was_empty {
            tracing::info!(
                "Viewer {} ({}) joined session {}, sending StartStream",
                viewer_name,
                viewer_id,
                session_id
            );
            Self::send_start_stream_internal(session_data, &viewer_id).await;
        } else {
            tracing::info!(
                "Viewer {} ({}) joined session {}",
                viewer_name,
                viewer_id,
                session_id
            );
        }

        Some((frame_rx, input_tx))
    }

    /// Internal helper to send StartStream message
    async fn send_start_stream_internal(session_data: &SessionData, viewer_id: &str) {
        use crate::proto;
        use prost::Message;

        // Negotiate the video codec for this stream (Task 7): H.264 only when the
        // agent advertised hardware support AND policy prefers it. The default
        // policy (DEFAULT_PREFER_H264 = false) resolves to RAW, but an agent that
        // carries the per-agent H264_TEST_TAG opts itself into H.264 so the HW path
        // can be live-validated (Task 8) without affecting untagged sessions.
        let prefer_h264 = prefer_h264_for(&session_data.info.tags);
        let codec = select_video_codec(session_data.info.supports_h264, prefer_h264);
        tracing::info!(
            "StartStream codec negotiation: agent_supports_h264={}, prefer_h264={} -> {:?}",
            session_data.info.supports_h264,
            prefer_h264,
            codec
        );

        let start_stream = proto::Message {
            payload: Some(proto::message::Payload::StartStream(proto::StartStream {
                viewer_id: viewer_id.to_string(),
                display_id: 0, // Primary display
                video_codec: codec as i32,
            })),
        };

        let mut buf = Vec::new();
        if start_stream.encode(&mut buf).is_ok() {
            let _ = session_data.input_tx.send(buf).await;
        }
    }

    /// Leave a session as a viewer, sends StopStream if no viewers left
    pub async fn leave_session(&self, session_id: SessionId, viewer_id: &ViewerId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            let viewer_name = session_data.viewers.get(viewer_id).map(|v| v.name.clone());
            session_data.viewers.remove(viewer_id);
            session_data.info.viewer_count = session_data.viewers.len();
            session_data.info.viewers = session_data.viewers.values().cloned().collect();

            // If no more viewers, send StopStream to agent
            if session_data.viewers.is_empty() {
                tracing::info!(
                    "Last viewer {} ({}) left session {}, sending StopStream",
                    viewer_name.as_deref().unwrap_or("unknown"),
                    viewer_id,
                    session_id
                );
                Self::send_stop_stream_internal(session_data, viewer_id).await;
            } else {
                tracing::info!(
                    "Viewer {} ({}) left session {}",
                    viewer_name.as_deref().unwrap_or("unknown"),
                    viewer_id,
                    session_id
                );
            }
        }
    }

    /// Internal helper to send StopStream message
    async fn send_stop_stream_internal(session_data: &SessionData, viewer_id: &str) {
        use crate::proto;
        use prost::Message;

        let stop_stream = proto::Message {
            payload: Some(proto::message::Payload::StopStream(proto::StopStream {
                viewer_id: viewer_id.to_string(),
            })),
        };

        let mut buf = Vec::new();
        if stop_stream.encode(&mut buf).is_ok() {
            let _ = session_data.input_tx.send(buf).await;
        }
    }

    /// Mark agent as disconnected
    /// For persistent agents: keep session but mark as offline
    /// For support sessions: remove session entirely
    pub async fn mark_agent_disconnected(&self, session_id: SessionId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            if session_data.info.is_persistent {
                // Persistent agent - keep session but mark as offline
                tracing::info!(
                    "Persistent agent {} marked offline (session {} preserved)",
                    session_data.info.agent_id,
                    session_id
                );
                session_data.info.is_online = false;
                session_data.info.is_streaming = false;
                session_data.info.viewer_count = 0;
                session_data.info.viewers.clear();
                session_data.viewers.clear();
            } else {
                // Support session - remove entirely
                let agent_id = session_data.info.agent_id.clone();
                sessions.remove(&session_id);
                drop(sessions); // Release sessions lock before acquiring agents lock
                let mut agents = self.agents.write().await;
                agents.remove(&agent_id);
                tracing::info!("Support session {} removed", session_id);
            }
        }
    }

    /// Remove a session entirely (for cleanup)
    pub async fn remove_session(&self, session_id: SessionId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.remove(&session_id) {
            drop(sessions);
            let mut agents = self.agents.write().await;
            agents.remove(&session_data.info.agent_id);
        }
    }

    /// Disconnect a session by sending a disconnect message to the agent
    /// Returns true if the message was sent successfully
    pub async fn disconnect_session(&self, session_id: SessionId, reason: &str) -> bool {
        let sessions = self.sessions.read().await;
        if let Some(session_data) = sessions.get(&session_id) {
            // Create disconnect message
            use crate::proto;
            use prost::Message;

            let disconnect_msg = proto::Message {
                payload: Some(proto::message::Payload::Disconnect(proto::Disconnect {
                    reason: reason.to_string(),
                })),
            };

            let mut buf = Vec::new();
            if disconnect_msg.encode(&mut buf).is_ok() {
                // Send via input channel (will be forwarded to agent's WebSocket)
                if session_data.input_tx.send(buf).await.is_ok() {
                    return true;
                }
            }
        }
        false
    }

    /// List all active sessions
    pub async fn list_sessions(&self) -> Vec<Session> {
        let sessions = self.sessions.read().await;
        sessions.values().map(|s| s.info.clone()).collect()
    }

    /// Send an admin command to an agent (uninstall, restart, etc.)
    /// Returns true if the message was sent successfully
    pub async fn send_admin_command(
        &self,
        session_id: SessionId,
        command: crate::proto::AdminCommandType,
        reason: &str,
    ) -> bool {
        let sessions = self.sessions.read().await;
        if let Some(session_data) = sessions.get(&session_id) {
            if !session_data.info.is_online {
                tracing::warn!("Cannot send admin command to offline agent");
                return false;
            }

            use crate::proto;
            use prost::Message;

            let admin_cmd = proto::Message {
                payload: Some(proto::message::Payload::AdminCommand(proto::AdminCommand {
                    command: command as i32,
                    reason: reason.to_string(),
                })),
            };

            let mut buf = Vec::new();
            if admin_cmd.encode(&mut buf).is_ok() && session_data.input_tx.send(buf).await.is_ok() {
                tracing::info!("Sent admin command {:?} to session {}", command, session_id);
                return true;
            }
        }
        false
    }

    /// Remove an agent/machine from the session manager (for deletion)
    /// Returns the agent_id if found
    pub async fn remove_agent(&self, agent_id: &str) -> Option<SessionId> {
        let agents = self.agents.read().await;
        let session_id = agents.get(agent_id).copied()?;
        drop(agents);

        self.remove_session(session_id).await;
        Some(session_id)
    }
}

impl Default for SessionManager {
    fn default() -> Self {
        Self::new()
    }
}

impl SessionManager {
    /// Restore a machine as an offline session (called on startup from database)
    pub async fn restore_offline_machine(&self, agent_id: &str, hostname: &str) -> SessionId {
        let session_id = Uuid::new_v4();
        let now = chrono::Utc::now();

        let session = Session {
            id: session_id,
            agent_id: agent_id.to_string(),
            agent_name: hostname.to_string(),
            started_at: now,
            viewer_count: 0,
            viewers: Vec::new(),
            is_streaming: false,
            is_online: false, // Offline until agent reconnects
            is_persistent: true,
            consent_state: ConsentState::NotRequired, // managed/persistent
            last_heartbeat: now,
            os_version: None,
            is_elevated: false,
            uptime_secs: 0,
            display_count: 1,
            agent_version: None,
            organization: None,
            site: None,
            tags: Vec::new(),
            supports_h264: false,
        };

        // Create placeholder channels (will be replaced on reconnect)
        let (frame_tx, _) = broadcast::channel(16);
        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);

        let session_data = SessionData {
            info: session,
            frame_tx,
            input_tx,
            input_rx: Some(input_rx),
            viewers: HashMap::new(),
            last_heartbeat_instant: Instant::now(),
        };

        let mut sessions = self.sessions.write().await;
        sessions.insert(session_id, session_data);

        let mut agents = self.agents.write().await;
        agents.insert(agent_id.to_string(), session_id);

        tracing::info!("Restored offline machine: {} ({})", hostname, agent_id);
        session_id
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn consent_state_db_strings_match_column_check() {
        assert_eq!(ConsentState::NotRequired.as_db_str(), "not_required");
        assert_eq!(ConsentState::Pending.as_db_str(), "pending");
        assert_eq!(ConsentState::Granted.as_db_str(), "granted");
        assert_eq!(ConsentState::Denied.as_db_str(), "denied");
    }

    #[test]
    fn only_granted_or_not_required_admit_a_viewer() {
        assert!(ConsentState::NotRequired.allows_viewer());
        assert!(ConsentState::Granted.allows_viewer());
        assert!(!ConsentState::Pending.allows_viewer());
        assert!(!ConsentState::Denied.allows_viewer());
    }

    #[tokio::test]
    async fn attended_session_starts_pending_and_blocks_viewer_until_granted() {
        let mgr = SessionManager::new();

        // Attended (support-code) session: is_persistent = false.
        let (session_id, _frame_tx, _input_rx) = mgr
            .register_agent("agent-att".to_string(), "Attended PC".to_string(), false)
            .await;

        // Starts Pending.
        assert_eq!(
            mgr.get_consent_state(session_id).await,
            Some(ConsentState::Pending)
        );

        // A viewer cannot join while Pending.
        assert!(mgr
            .join_session(session_id, "viewer-1".to_string(), "Tech".to_string())
            .await
            .is_none());

        // Grant consent -> viewer may now join.
        let previous = mgr
            .set_consent_state(session_id, ConsentState::Granted)
            .await;
        assert_eq!(previous, Some(ConsentState::Pending));
        assert!(mgr
            .join_session(session_id, "viewer-2".to_string(), "Tech".to_string())
            .await
            .is_some());
    }

    #[tokio::test]
    async fn managed_session_is_not_required_and_joins_immediately() {
        let mgr = SessionManager::new();

        // Managed/persistent session: is_persistent = true.
        let (session_id, _frame_tx, _input_rx) = mgr
            .register_agent("agent-mgd".to_string(), "Managed PC".to_string(), true)
            .await;

        assert_eq!(
            mgr.get_consent_state(session_id).await,
            Some(ConsentState::NotRequired)
        );

        // No consent prompt — a viewer joins immediately.
        assert!(mgr
            .join_session(session_id, "viewer-1".to_string(), "Tech".to_string())
            .await
            .is_some());
    }

    #[test]
    fn codec_negotiation_picks_h264_only_when_supported_and_preferred() {
        use crate::proto::VideoCodec;

        // Agent supports H.264 AND policy prefers it -> H.264.
        assert_eq!(select_video_codec(true, true), VideoCodec::H264);
        // Agent supports it but policy does not prefer it -> raw (the safe default).
        assert_eq!(select_video_codec(true, false), VideoCodec::Raw);
        // Policy prefers H.264 but the agent has no HW encoder -> raw.
        assert_eq!(select_video_codec(false, true), VideoCodec::Raw);
        // Neither -> raw.
        assert_eq!(select_video_codec(false, false), VideoCodec::Raw);
    }

    #[test]
    fn default_policy_does_not_prefer_h264() {
        // Guardrail: until H.264 is hardware-validated (Task 8) the default policy
        // MUST keep raw as the negotiated codec even for capable agents. We assert
        // the OBSERVABLE behavior (codec selection under the default policy) rather
        // than the constant directly, which keeps the test meaningful if the policy
        // later becomes dynamic.
        let chosen = select_video_codec(true, DEFAULT_PREFER_H264);
        assert_eq!(
            chosen,
            crate::proto::VideoCodec::Raw,
            "default policy must negotiate raw until H.264 is hardware-validated"
        );
    }

    #[test]
    fn prefer_h264_for_untagged_matches_default_policy() {
        // No tags -> falls back to the global default (false today). An untagged
        // session's preference must be byte-for-byte the existing behavior.
        assert_eq!(prefer_h264_for(&[]), DEFAULT_PREFER_H264);
        assert!(!prefer_h264_for(&[]));
        // An unrelated tag must not opt the agent in.
        assert!(!prefer_h264_for(&["other".to_string()]));
    }

    #[test]
    fn prefer_h264_for_tagged_opts_in_case_insensitively() {
        assert!(prefer_h264_for(&["h264-test".to_string()]));
        assert!(prefer_h264_for(&["H264-Test".to_string()]));
        // Present alongside other tags still opts in.
        assert!(prefer_h264_for(&[
            "other".to_string(),
            "h264-test".to_string()
        ]));
    }

    #[test]
    fn tagged_negotiation_respects_hardware_guard() {
        use crate::proto::VideoCodec;

        // Tagged + HW-capable -> H.264 (the live-validation path).
        assert_eq!(
            select_video_codec(true, prefer_h264_for(&["h264-test".to_string()])),
            VideoCodec::H264
        );
        // Tagged but NO hardware encoder -> raw (the no-HW guard still holds).
        assert_eq!(
            select_video_codec(false, prefer_h264_for(&["h264-test".to_string()])),
            VideoCodec::Raw
        );
        // Untagged + HW-capable -> raw (untagged sessions stay on the safe default).
        assert_eq!(
            select_video_codec(true, prefer_h264_for(&[])),
            VideoCodec::Raw
        );
    }

    #[tokio::test]
    async fn agent_status_updates_h264_capability() {
        let mgr = SessionManager::new();
        let (session_id, _frame_tx, _input_rx) = mgr
            .register_agent("agent-cap".to_string(), "Cap PC".to_string(), true)
            .await;

        // Default is false until a status reports capability.
        assert_eq!(
            mgr.get_session(session_id).await.map(|s| s.supports_h264),
            Some(false)
        );

        mgr.update_agent_status(
            session_id,
            Some("Windows".to_string()),
            true,
            10,
            1,
            false,
            Some("0.2.0".to_string()),
            None,
            None,
            Vec::new(),
            true, // supports_h264
        )
        .await;

        assert_eq!(
            mgr.get_session(session_id).await.map(|s| s.supports_h264),
            Some(true)
        );
    }

    #[tokio::test]
    async fn denied_attended_session_keeps_viewer_blocked() {
        let mgr = SessionManager::new();
        let (session_id, _frame_tx, _input_rx) = mgr
            .register_agent("agent-deny".to_string(), "Deny PC".to_string(), false)
            .await;

        mgr.set_consent_state(session_id, ConsentState::Denied)
            .await;
        assert_eq!(
            mgr.get_consent_state(session_id).await,
            Some(ConsentState::Denied)
        );
        assert!(mgr
            .join_session(session_id, "viewer-x".to_string(), "Tech".to_string())
            .await
            .is_none());
    }
}
