//! Native async client for the Vivox SDK control protocol.
//!
//! This module connects to an already-running Vivox control service. It never
//! locates, starts, or bundles the proprietary Vivox daemon or SDK.

use roxmltree::{Document, Node};
use std::collections::{HashSet, VecDeque};
use std::fmt;
use std::io;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::time::timeout;

const REQUEST_TERMINATOR: &str = "\n\n\n";
const MAX_FRAME_BYTES: usize = 1024 * 1024;
const MAX_QUEUED_EVENTS: usize = 256;

/// A handle or URI that must not accidentally appear in diagnostics.
#[derive(Clone, Eq, Hash, PartialEq)]
pub struct VivoxSecret(String);

impl VivoxSecret {
    fn new(value: String) -> Result<Self, VivoxError> {
        if value.is_empty() || value.len() > 16 * 1024 || value.chars().any(char::is_control) {
            return Err(VivoxError::Protocol(
                "invalid empty or oversized Vivox secret",
            ));
        }
        Ok(Self(value))
    }

    /// Exposes this value for use in another authenticated protocol operation.
    /// Callers must not log or persist the returned string.
    #[must_use]
    pub fn expose_secret(&self) -> &str {
        &self.0
    }
}

impl fmt::Debug for VivoxSecret {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("VivoxSecret(<redacted>)")
    }
}

impl fmt::Display for VivoxSecret {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("<redacted>")
    }
}

/// A successful Vivox request response.
pub struct VivoxResponse {
    /// Request ID copied through the daemon response.
    pub request_id: u64,
    /// Vivox action, for example `Connector.Create.1`.
    pub action: String,
    /// Vivox return code.
    pub return_code: i32,
    /// Action-specific status code.
    pub status_code: i32,
    /// Action-specific, credential-redacted diagnostic.
    pub status: String,
    document: String,
}

impl fmt::Debug for VivoxResponse {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("VivoxResponse")
            .field("request_id", &self.request_id)
            .field("action", &self.action)
            .field("return_code", &self.return_code)
            .field("status_code", &self.status_code)
            .field("status", &self.status)
            .finish_non_exhaustive()
    }
}

impl VivoxResponse {
    fn ensure_success(self) -> Result<Self, VivoxError> {
        if self.return_code == 0 && self.status_code == 0 {
            Ok(self)
        } else {
            Err(VivoxError::Rejected {
                action: self.action,
                return_code: self.return_code,
                status_code: self.status_code,
                status: self.status,
            })
        }
    }

    fn field(&self, name: &str) -> Result<Option<String>, VivoxError> {
        let document = parse_document(&self.document)?;
        Ok(descendant_text(document.root_element(), name))
    }
}

/// One capture or render device enumeration.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VivoxDevices {
    /// Device selected by the daemon.
    pub current: Option<String>,
    /// All devices reported by the daemon.
    pub available: Vec<String>,
}

/// A parsed asynchronous Vivox control event.
#[derive(Clone)]
pub struct VivoxEvent {
    /// Event type, such as `ParticipantStateChangeEvent`.
    pub kind: String,
    /// Event status code.
    pub status_code: i32,
    /// Credential-redacted event status.
    pub status: String,
    /// Numeric Vivox state value, when supplied.
    pub state: Option<i32>,
    /// Session handle, kept opaque in diagnostics.
    pub session_handle: Option<VivoxSecret>,
    /// Participant URI, kept opaque in diagnostics.
    pub participant_uri: Option<VivoxSecret>,
    /// Participant account name, when supplied.
    pub account_name: Option<String>,
    /// Participant display name, when supplied.
    pub display_name: Option<String>,
    /// Whether the participant is speaking, when supplied.
    pub is_speaking: Option<bool>,
    /// Participant volume, when supplied.
    pub volume: Option<i32>,
    /// Participant audio energy, when supplied.
    pub energy: Option<f32>,
}

impl fmt::Debug for VivoxEvent {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("VivoxEvent")
            .field("kind", &self.kind)
            .field("status_code", &self.status_code)
            .field("status", &self.status)
            .field("state", &self.state)
            .field("session_handle", &self.session_handle)
            .field("participant_uri", &self.participant_uri)
            .field("account_name", &self.account_name)
            .field("display_name", &self.display_name)
            .field("is_speaking", &self.is_speaking)
            .field("volume", &self.volume)
            .field("energy", &self.energy)
            .finish()
    }
}

/// Failure from the native Vivox control protocol.
#[derive(Debug)]
#[non_exhaustive]
pub enum VivoxError {
    /// TCP or stream I/O failed.
    Io(io::Error),
    /// A bounded operation exceeded its deadline.
    Timeout(&'static str),
    /// The daemon disconnected before a complete response.
    Disconnected,
    /// The daemon returned malformed or inconsistent XML.
    Protocol(&'static str),
    /// The daemon rejected an action.
    Rejected {
        /// Rejected Vivox action.
        action: String,
        /// Vivox return code.
        return_code: i32,
        /// Action-specific status code.
        status_code: i32,
        /// Redacted status text.
        status: String,
    },
    /// The operation is not valid in the client's current state.
    InvalidState(&'static str),
}

impl fmt::Display for VivoxError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io(error) => write!(formatter, "Vivox control I/O failed: {error}"),
            Self::Timeout(action) => write!(formatter, "Vivox {action} timed out"),
            Self::Disconnected => formatter.write_str("Vivox control service disconnected"),
            Self::Protocol(reason) => write!(formatter, "invalid Vivox control frame: {reason}"),
            Self::Rejected {
                action,
                return_code,
                status_code,
                status,
            } => write!(
                formatter,
                "Vivox action {action} failed (return {return_code}, status {status_code}): {status}"
            ),
            Self::InvalidState(reason) => write!(formatter, "invalid Vivox client state: {reason}"),
        }
    }
}

impl std::error::Error for VivoxError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io(error) => Some(error),
            _ => None,
        }
    }
}

impl From<io::Error> for VivoxError {
    fn from(error: io::Error) -> Self {
        Self::Io(error)
    }
}

/// Stateful native client for an already-running Vivox SDK control service.
pub struct VivoxControlClient {
    reader: BufReader<OwnedReadHalf>,
    writer: OwnedWriteHalf,
    timeout: Duration,
    next_request_id: u64,
    events: VecDeque<VivoxEvent>,
    connector: Option<VivoxSecret>,
    account: Option<VivoxSecret>,
    sessions: HashSet<VivoxSecret>,
    closed: bool,
}

impl fmt::Debug for VivoxControlClient {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("VivoxControlClient")
            .field("timeout", &self.timeout)
            .field("next_request_id", &self.next_request_id)
            .field("queued_events", &self.events.len())
            .field("has_connector", &self.connector.is_some())
            .field("has_account", &self.account.is_some())
            .field("session_count", &self.sessions.len())
            .field("closed", &self.closed)
            .finish_non_exhaustive()
    }
}

impl VivoxControlClient {
    /// Connects to an already-running Vivox control service.
    ///
    /// # Errors
    ///
    /// Returns an error if the timeout is invalid or the TCP connection fails.
    pub async fn connect(
        endpoint: SocketAddr,
        operation_timeout: Duration,
    ) -> Result<Self, VivoxError> {
        if operation_timeout.is_zero() {
            return Err(VivoxError::InvalidState(
                "operation timeout must be positive",
            ));
        }
        let stream = timeout(operation_timeout, TcpStream::connect(endpoint))
            .await
            .map_err(|_| VivoxError::Timeout("connect"))??;
        stream.set_nodelay(true)?;
        let (reader, writer) = stream.into_split();
        Ok(Self {
            reader: BufReader::new(reader),
            writer,
            timeout: operation_timeout,
            next_request_id: 0,
            events: VecDeque::new(),
            connector: None,
            account: None,
            sessions: HashSet::new(),
            closed: false,
        })
    }

    /// Returns the active connector handle without exposing it in formatting.
    #[must_use]
    pub fn connector_handle(&self) -> Option<&VivoxSecret> {
        self.connector.as_ref()
    }

    /// Returns the active account handle without exposing it in formatting.
    #[must_use]
    pub fn account_handle(&self) -> Option<&VivoxSecret> {
        self.account.as_ref()
    }

    /// Enumerates daemon capture devices.
    ///
    /// # Errors
    ///
    /// Returns an error for I/O, timeout, malformed, or rejected responses.
    pub async fn capture_devices(&mut self) -> Result<VivoxDevices, VivoxError> {
        let response = self.request("Aux.GetCaptureDevices.1", &[]).await?;
        devices_from_response(&response, "CaptureDevice", "CurrentCaptureDevice")
    }

    /// Enumerates daemon render devices.
    ///
    /// # Errors
    ///
    /// Returns an error for I/O, timeout, malformed, or rejected responses.
    pub async fn render_devices(&mut self) -> Result<VivoxDevices, VivoxError> {
        let response = self.request("Aux.GetRenderDevices.1", &[]).await?;
        devices_from_response(&response, "RenderDevice", "CurrentRenderDevice")
    }

    /// Creates the single connector used by subsequent account requests.
    ///
    /// # Errors
    ///
    /// Returns an error if a connector exists or the request fails.
    pub async fn create_connector(
        &mut self,
        account_management_server: &str,
    ) -> Result<VivoxSecret, VivoxError> {
        if self.connector.is_some() {
            return Err(VivoxError::InvalidState("connector already exists"));
        }
        validate_text(account_management_server)?;
        let response = self
            .request(
                "Connector.Create.1",
                &[
                    ("ClientName", "MetaCrate VivoxTest"),
                    ("AccountManagementServer", account_management_server),
                ],
            )
            .await?;
        let handle = required_secret_field(&response, "ConnectorHandle")?;
        self.connector = Some(handle.clone());
        Ok(handle)
    }

    /// Logs a provisioned voice account into the active connector.
    ///
    /// # Errors
    ///
    /// Returns an error if connector state is invalid or login fails.
    pub async fn login(
        &mut self,
        account_name: &str,
        account_password: &str,
    ) -> Result<VivoxSecret, VivoxError> {
        if self.account.is_some() {
            return Err(VivoxError::InvalidState("account already logged in"));
        }
        validate_text(account_name)?;
        validate_text(account_password)?;
        let connector = self
            .connector
            .as_ref()
            .ok_or(VivoxError::InvalidState("create a connector before login"))?
            .expose_secret()
            .to_owned();
        let response = self
            .request(
                "Account.Login.1",
                &[
                    ("ConnectorHandle", &connector),
                    ("AccountName", account_name),
                    ("AccountPassword", account_password),
                    ("AudioSessionAnswerMode", "VerifyAnswer"),
                    ("AccountURI", ""),
                    ("ParticipantPropertyFrequency", "10"),
                    ("EnableBuddiesAndPresence", "false"),
                    ("BuddyManagementMode", "Application"),
                ],
            )
            .await?;
        let handle = required_secret_field(&response, "AccountHandle")?;
        self.account = Some(handle.clone());
        Ok(handle)
    }

    /// Creates and joins a voice session.
    ///
    /// # Errors
    ///
    /// Returns an error if account state or input is invalid, or creation fails.
    pub async fn create_session(
        &mut self,
        channel_uri: &str,
        name: &str,
        password: Option<&str>,
    ) -> Result<VivoxSecret, VivoxError> {
        validate_text(channel_uri)?;
        validate_text(name)?;
        if let Some(password) = password {
            validate_text(password)?;
        }
        let account = self
            .account
            .as_ref()
            .ok_or(VivoxError::InvalidState("log in before creating a session"))?
            .expose_secret()
            .to_owned();
        let mut fields = vec![
            ("AccountHandle", account.as_str()),
            ("URI", channel_uri),
            ("Name", name),
        ];
        if let Some(password) = password {
            fields.push(("Password", password));
            fields.push(("PasswordHashAlgorithm", "ClearText"));
        }
        fields.extend([
            ("ConnectAudio", "true"),
            ("ConnectText", "false"),
            ("JoinAudio", "true"),
            ("JoinText", "false"),
            ("VoiceFontID", "0"),
        ]);
        let response = self.request("Session.Create.1", &fields).await?;
        let handle = required_secret_field(&response, "SessionHandle")?;
        self.sessions.insert(handle.clone());
        Ok(handle)
    }

    /// Accepts/connects an existing session.
    ///
    /// # Errors
    ///
    /// Returns an error if the session is unknown or the request fails.
    pub async fn connect_session(&mut self, session: &VivoxSecret) -> Result<(), VivoxError> {
        if !self.sessions.contains(session) {
            return Err(VivoxError::InvalidState(
                "session is not owned by this client",
            ));
        }
        self.request(
            "Session.Connect.1",
            &[
                ("SessionHandle", session.expose_secret()),
                ("AudioMedia", "default"),
            ],
        )
        .await?;
        Ok(())
    }

    /// Sets the local volume for one participant in an active session.
    ///
    /// # Errors
    ///
    /// Returns an error for an unknown session, invalid volume, or failed request.
    pub async fn set_participant_volume(
        &mut self,
        session: &VivoxSecret,
        participant_uri: &VivoxSecret,
        volume: i32,
    ) -> Result<(), VivoxError> {
        if !(-100..=100).contains(&volume) {
            return Err(VivoxError::InvalidState(
                "participant volume must be -100..=100",
            ));
        }
        if !self.sessions.contains(session) {
            return Err(VivoxError::InvalidState(
                "session is not owned by this client",
            ));
        }
        let volume = volume.to_string();
        self.request(
            "Session.SetParticipantVolumeForMe.1",
            &[
                ("SessionHandle", session.expose_secret()),
                ("ParticipantURI", participant_uri.expose_secret()),
                ("Volume", &volume),
            ],
        )
        .await?;
        Ok(())
    }

    /// Waits for the next asynchronous daemon event.
    ///
    /// # Errors
    ///
    /// Returns an error if the stream fails or an unsolicited response arrives.
    pub async fn next_event(&mut self) -> Result<VivoxEvent, VivoxError> {
        if let Some(event) = self.events.pop_front() {
            return Ok(event);
        }
        match self.read_parsed_frame().await? {
            ParsedFrame::Event(event) => Ok(event),
            ParsedFrame::Response(_) => Err(VivoxError::Protocol("unsolicited response")),
        }
    }

    /// Terminates one active session.
    ///
    /// # Errors
    ///
    /// Returns an error if the termination request fails.
    pub async fn terminate_session(&mut self, session: &VivoxSecret) -> Result<(), VivoxError> {
        if !self.sessions.contains(session) {
            return Ok(());
        }
        self.request(
            "Session.Terminate.1",
            &[("SessionHandle", session.expose_secret())],
        )
        .await?;
        self.sessions.remove(session);
        Ok(())
    }

    /// Logs out the active account.
    ///
    /// # Errors
    ///
    /// Returns an error if sessions remain active or the request fails.
    pub async fn logout(&mut self) -> Result<(), VivoxError> {
        let Some(account) = self.account.clone() else {
            return Ok(());
        };
        if !self.sessions.is_empty() {
            return Err(VivoxError::InvalidState("terminate sessions before logout"));
        }
        self.request(
            "Account.Logout.1",
            &[("AccountHandle", account.expose_secret())],
        )
        .await?;
        self.account = None;
        Ok(())
    }

    /// Reverses all active state and closes the TCP pipe. This method is idempotent.
    ///
    /// # Errors
    ///
    /// Returns the first teardown error after attempting every cleanup stage.
    pub async fn shutdown(&mut self) -> Result<(), VivoxError> {
        if self.closed {
            return Ok(());
        }
        let mut first_error = None;
        for session in self.sessions.clone() {
            if let Err(error) = self.terminate_session(&session).await {
                first_error.get_or_insert(error);
            }
        }
        self.sessions.clear();
        if let Err(error) = self.logout().await {
            first_error.get_or_insert(error);
            self.account = None;
        }
        if let Some(connector) = self.connector.clone() {
            if let Err(error) = self
                .request(
                    "Connector.InitiateShutdown.1",
                    &[("ConnectorHandle", connector.expose_secret())],
                )
                .await
            {
                first_error.get_or_insert(error);
            }
            self.connector = None;
        }
        if let Err(error) = self.writer.shutdown().await {
            first_error.get_or_insert(VivoxError::Io(error));
        }
        self.closed = true;
        first_error.map_or(Ok(()), Err)
    }

    async fn request(
        &mut self,
        action: &str,
        fields: &[(&str, &str)],
    ) -> Result<VivoxResponse, VivoxError> {
        if self.closed {
            return Err(VivoxError::InvalidState("control pipe is closed"));
        }
        validate_name(action)?;
        let request_id = self.next_request_id;
        self.next_request_id = self
            .next_request_id
            .checked_add(1)
            .ok_or(VivoxError::Protocol("request ID space exhausted"))?;
        let mut xml = format!("<Request requestId=\"{request_id}\" action=\"{action}\">");
        for (name, value) in fields {
            validate_name(name)?;
            validate_text(value)?;
            xml.push('<');
            xml.push_str(name);
            xml.push('>');
            escape_xml_into(value, &mut xml);
            xml.push_str("</");
            xml.push_str(name);
            xml.push('>');
        }
        xml.push_str("</Request>");
        xml.push_str(REQUEST_TERMINATOR);
        if xml.len() > MAX_FRAME_BYTES {
            return Err(VivoxError::Protocol("request exceeds 1 MiB"));
        }
        timeout(self.timeout, self.writer.write_all(xml.as_bytes()))
            .await
            .map_err(|_| VivoxError::Timeout("write"))??;
        loop {
            match self.read_parsed_frame().await? {
                ParsedFrame::Event(event) => {
                    if self.events.len() == MAX_QUEUED_EVENTS {
                        return Err(VivoxError::Protocol("event queue capacity exceeded"));
                    }
                    self.events.push_back(event);
                }
                ParsedFrame::Response(mut response) => {
                    if response.request_id != request_id || response.action != action {
                        return Err(VivoxError::Protocol("response did not match request"));
                    }
                    for (name, value) in fields {
                        if is_sensitive_field(name) && !value.is_empty() {
                            response.status = response.status.replace(value, "<redacted>");
                        }
                    }
                    return response.ensure_success();
                }
            }
        }
    }

    async fn read_parsed_frame(&mut self) -> Result<ParsedFrame, VivoxError> {
        loop {
            let mut line = String::new();
            let read = timeout(self.timeout, self.reader.read_line(&mut line))
                .await
                .map_err(|_| VivoxError::Timeout("response"))??;
            if read == 0 {
                return Err(VivoxError::Disconnected);
            }
            if line.len() > MAX_FRAME_BYTES {
                return Err(VivoxError::Protocol("response exceeds 1 MiB"));
            }
            let line = line.trim_matches(['\r', '\n', '\0']);
            if line.is_empty() {
                continue;
            }
            return parse_frame(line);
        }
    }
}

enum ParsedFrame {
    Response(VivoxResponse),
    Event(VivoxEvent),
}

fn parse_frame(xml: &str) -> Result<ParsedFrame, VivoxError> {
    if xml.contains("<!DOCTYPE") || xml.contains("<!ENTITY") {
        return Err(VivoxError::Protocol(
            "DTD and entity declarations are forbidden",
        ));
    }
    let document = parse_document(xml)?;
    let root = document.root_element();
    match root.tag_name().name() {
        "Response" => {
            let action = root
                .attribute("action")
                .ok_or(VivoxError::Protocol("response action is missing"))?
                .to_owned();
            validate_name(&action)?;
            let request_id = root
                .attribute("requestId")
                .or_else(|| {
                    root.descendants()
                        .find(|node| node.has_tag_name("Request"))
                        .and_then(|node| node.attribute("requestId"))
                })
                .ok_or(VivoxError::Protocol("response request ID is missing"))?
                .parse()
                .map_err(|_| VivoxError::Protocol("response request ID is invalid"))?;
            Ok(ParsedFrame::Response(VivoxResponse {
                request_id,
                action,
                return_code: descendant_i32(root, "ReturnCode")?.unwrap_or(0),
                status_code: descendant_i32(root, "StatusCode")?.unwrap_or(0),
                status: redact_sensitive(
                    &descendant_text(root, "StatusString").unwrap_or_else(|| "OK".into()),
                ),
                document: xml.to_owned(),
            }))
        }
        "Event" => Ok(ParsedFrame::Event(VivoxEvent {
            kind: root
                .attribute("type")
                .ok_or(VivoxError::Protocol("event type is missing"))?
                .to_owned(),
            status_code: descendant_i32(root, "StatusCode")?.unwrap_or(0),
            status: redact_sensitive(
                &descendant_text(root, "StatusString").unwrap_or_else(|| "OK".into()),
            ),
            state: descendant_i32(root, "State")?,
            session_handle: optional_secret_field(root, "SessionHandle")?,
            participant_uri: optional_secret_field(root, "ParticipantURI")?
                .or(optional_secret_field(root, "URI")?),
            account_name: descendant_text(root, "AccountName"),
            display_name: descendant_text(root, "DisplayName"),
            is_speaking: descendant_bool(root, "IsSpeaking")?,
            volume: descendant_i32(root, "Volume")?,
            energy: descendant_text(root, "Energy")
                .map(|value| value.parse())
                .transpose()
                .map_err(|_| VivoxError::Protocol("event energy is invalid"))?,
        })),
        _ => Err(VivoxError::Protocol("expected Response or Event root")),
    }
}

fn parse_document(xml: &str) -> Result<Document<'_>, VivoxError> {
    Document::parse(xml).map_err(|_| VivoxError::Protocol("XML is malformed"))
}

fn descendant_text(root: Node<'_, '_>, name: &str) -> Option<String> {
    root.descendants()
        .find(|node| node.has_tag_name(name))
        .and_then(|node| node.text())
        .map(str::to_owned)
}

fn descendant_i32(root: Node<'_, '_>, name: &str) -> Result<Option<i32>, VivoxError> {
    descendant_text(root, name)
        .map(|value| {
            value
                .parse()
                .map_err(|_| VivoxError::Protocol("numeric response field is invalid"))
        })
        .transpose()
}

fn descendant_bool(root: Node<'_, '_>, name: &str) -> Result<Option<bool>, VivoxError> {
    descendant_text(root, name)
        .map(|value| match value.as_str() {
            "true" | "1" => Ok(true),
            "false" | "0" => Ok(false),
            _ => Err(VivoxError::Protocol("boolean event field is invalid")),
        })
        .transpose()
}

fn optional_secret_field(
    root: Node<'_, '_>,
    name: &str,
) -> Result<Option<VivoxSecret>, VivoxError> {
    descendant_text(root, name)
        .map(VivoxSecret::new)
        .transpose()
}

fn required_secret_field(response: &VivoxResponse, name: &str) -> Result<VivoxSecret, VivoxError> {
    response
        .field(name)?
        .ok_or(VivoxError::Protocol("required response handle is missing"))
        .and_then(VivoxSecret::new)
}

fn devices_from_response(
    response: &VivoxResponse,
    item_name: &str,
    current_name: &str,
) -> Result<VivoxDevices, VivoxError> {
    let document = parse_document(&response.document)?;
    let root = document.root_element();
    let available = root
        .descendants()
        .filter(|node| node.has_tag_name(item_name))
        .filter_map(|node| {
            node.descendants()
                .find(|child| child.has_tag_name("Device"))
                .and_then(|child| child.text())
                .or_else(|| node.text())
        })
        .map(str::to_owned)
        .collect();
    let current = root
        .descendants()
        .find(|node| node.has_tag_name(current_name))
        .and_then(|node| {
            node.descendants()
                .find(|child| child.has_tag_name("Device"))
                .and_then(|child| child.text())
                .or_else(|| node.text())
        })
        .map(str::to_owned);
    Ok(VivoxDevices { current, available })
}

fn validate_name(value: &str) -> Result<(), VivoxError> {
    if !value.is_empty()
        && value.len() <= 128
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
    {
        Ok(())
    } else {
        Err(VivoxError::Protocol("XML name or action is invalid"))
    }
}

fn is_sensitive_field(name: &str) -> bool {
    matches!(
        name,
        "AccountPassword"
            | "Password"
            | "ConnectorHandle"
            | "AccountHandle"
            | "SessionHandle"
            | "ParticipantURI"
            | "ChannelURI"
            | "URI"
    )
}

fn validate_text(value: &str) -> Result<(), VivoxError> {
    if value.len() <= 64 * 1024
        && !value
            .chars()
            .any(|ch| matches!(ch, '\0'..='\x08' | '\x0b' | '\x0c' | '\x0e'..='\x1f'))
    {
        Ok(())
    } else {
        Err(VivoxError::Protocol("XML text is invalid or oversized"))
    }
}

fn escape_xml_into(value: &str, output: &mut String) {
    for character in value.chars() {
        match character {
            '&' => output.push_str("&amp;"),
            '<' => output.push_str("&lt;"),
            '>' => output.push_str("&gt;"),
            '\'' => output.push_str("&apos;"),
            '"' => output.push_str("&quot;"),
            _ => output.push(character),
        }
    }
}

/// Redacts common Vivox secret XML fields, URI values, and URI query strings.
#[must_use]
pub fn redact_sensitive(input: &str) -> String {
    let mut output = input.to_owned();
    for tag in [
        "AccountPassword",
        "Password",
        "ConnectorHandle",
        "AccountHandle",
        "SessionHandle",
        "ParticipantURI",
        "ChannelURI",
        "URI",
    ] {
        let opening = format!("<{tag}>");
        let closing = format!("</{tag}>");
        let mut search_from = 0;
        while let Some(relative_start) = output[search_from..].find(&opening) {
            let value_start = search_from + relative_start + opening.len();
            let Some(relative_end) = output[value_start..].find(&closing) else {
                break;
            };
            let value_end = value_start + relative_end;
            output.replace_range(value_start..value_end, "<redacted>");
            search_from = value_start + "<redacted>".len() + closing.len();
        }
    }
    if output.starts_with("sip:") || output.starts_with("http://") || output.starts_with("https://")
    {
        return "<redacted-uri>".into();
    }
    output
}

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

    #[test]
    fn parses_response_and_participant_event_without_debug_leaks() {
        let response = parse_frame("<Response requestId=\"7\" action=\"Account.Login.1\"><ReturnCode>0</ReturnCode><Results><StatusCode>0</StatusCode><StatusString>OK</StatusString><AccountHandle>account-secret</AccountHandle></Results></Response>").unwrap();
        let ParsedFrame::Response(response) = response else {
            panic!("response")
        };
        assert_eq!(response.request_id, 7);
        assert_eq!(
            required_secret_field(&response, "AccountHandle")
                .unwrap()
                .expose_secret(),
            "account-secret"
        );
        assert!(!format!("{response:?}").contains("account-secret"));

        let event = parse_frame("<Event type=\"ParticipantPropertiesEvent\"><SessionHandle>session-secret</SessionHandle><ParticipantURI>sip:token@example.test</ParticipantURI><DisplayName>Alice</DisplayName><IsSpeaking>true</IsSpeaking><Volume>12</Volume><Energy>0.5</Energy></Event>").unwrap();
        let ParsedFrame::Event(event) = event else {
            panic!("event")
        };
        assert_eq!(event.display_name.as_deref(), Some("Alice"));
        assert_eq!(event.is_speaking, Some(true));
        let debug = format!("{event:?}");
        assert!(!debug.contains("session-secret"));
        assert!(!debug.contains("sip:token"));
    }

    #[test]
    fn xml_escaping_and_redaction_cover_control_secrets() {
        let mut escaped = String::new();
        escape_xml_into("<&>\"'", &mut escaped);
        assert_eq!(escaped, "&lt;&amp;&gt;&quot;&apos;");
        let redacted = redact_sensitive(
            "<AccountPassword>secret</AccountPassword><SessionHandle>handle</SessionHandle>",
        );
        assert!(!redacted.contains("secret"));
        assert!(!redacted.contains("handle"));
        assert_eq!(
            redact_sensitive("sip:user@example.test?token=secret"),
            "<redacted-uri>"
        );
    }

    #[test]
    fn rejects_active_xml_and_invalid_control_fields() {
        assert!(parse_frame("<!DOCTYPE x><Event type=\"x\" />").is_err());
        assert!(validate_name("bad name").is_err());
        assert!(validate_text("bad\0text").is_err());
    }
}
