//! Credential-safe native port of the `WebRtcTest` validation program.

#![allow(clippy::cast_possible_truncation)] // The bounded test tone fits exactly in i16.
#![allow(clippy::cast_precision_loss)] // Tone synthesis intentionally uses f32 sample positions.
#![allow(clippy::struct_excessive_bools)] // Independent CLI safety gates are clearer as flags.

use clap::Parser;
use libremetaverse::types::compat::{CancellationToken, Uri};
use libremetaverse::{GridClient, NetworkManager, Simulator};
use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser};
use libremetaverse_voice_webrtc::{
    CloseRequest, LoopbackSignaling, ProvisionRequest, ProvisionResponse, SignalingCompleteRequest,
    VoiceEvent, VoiceSecret, VoiceSessionConfig, VoiceSignaling, WebRtcError, WebRtcVoiceSession,
    audio_devices, encode_pcm_48k_mono,
};
use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::io::{self, BufRead, IsTerminal, Write};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncBufReadExt as _;

pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_INPUT: u8 = 3;
pub const EXIT_SERVICE: u8 = 4;
const LIVE_CONFIRMATION: &str = "LOGIN";
const MAX_CAPABILITY_BYTES: usize = 1024 * 1024;

#[derive(Parser)]
#[command(
    name = "webrtc-test",
    version,
    about = "Validate native LibreMetaverse WebRTC voice, Opus audio, and SLData behavior",
    arg_required_else_help = true,
    after_help = "CI/offline: --fake runs a real in-process ICE/DTLS/SRTP/SCTP peer with virtual 48 kHz audio. Live grid login requires --allow-live-login --confirm-live-login LOGIN; joining voice additionally requires --allow-session-audio. Live credentials use GRID_USER, GRID_PASSWORD, and GRID_LOGIN_URL from the environment or workspace .env. Real hardware enumeration requires building with --features real-audio and remains opt-in through --input-device/--output-device. Native libopus development files are required. Prefer GRID_PASSWORD over a positional password."
)]
struct Cli {
    /// Avatar first name; `GRID_FIRST_NAME` is the fallback.
    #[arg(value_name = "FIRSTNAME")]
    first_name: Option<String>,
    /// Avatar last name; `GRID_LAST_NAME` is the fallback.
    #[arg(value_name = "LASTNAME")]
    last_name: Option<String>,
    /// Avatar password; prefer `GRID_PASSWORD` to avoid process-list exposure.
    #[arg(value_name = "PASSWORD")]
    password: Option<String>,

    /// Run the full deterministic loopback signaling, peer, media, and data-channel flow.
    #[arg(long)]
    fake: bool,
    /// List selectable virtual and compiled-in real audio endpoints.
    #[arg(long)]
    list_devices: bool,
    /// Explicitly permit a live grid login.
    #[arg(long)]
    allow_live_login: bool,
    /// Required literal confirmation for live login.
    #[arg(long, value_name = "LOGIN")]
    confirm_live_login: Option<String>,
    /// Join live parcel voice and transmit/receive media.
    #[arg(long, requires = "allow_live_login")]
    allow_session_audio: bool,
    /// Override the grid login endpoint; `GRID_LOGIN_URL` is the fallback.
    #[arg(long, value_name = "URL")]
    login_uri: Option<String>,
    /// Concrete local interface address advertised as the host ICE candidate.
    #[arg(long, value_name = "IP")]
    bind_ip: Option<IpAddr>,
    /// Optional parcel local ID included in WebRTC provisioning.
    #[arg(long, value_name = "ID")]
    parcel_local_id: Option<i32>,
    /// Selected capture endpoint ID.
    #[arg(long, default_value = "virtual:microphone")]
    input_device: String,
    /// Selected playback endpoint ID.
    #[arg(long, default_value = "virtual:speaker")]
    output_device: String,
    /// WAV file to resample, Opus-encode, and send as microphone input.
    #[arg(long, value_name = "FILE")]
    wav: Option<PathBuf>,
    /// Read interactive commands from a file instead of stdin.
    #[arg(long, value_name = "FILE")]
    commands: Option<PathBuf>,
    /// Maximum duration for login, capabilities, connection, and teardown.
    #[arg(long, default_value_t = 45, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
    timeout_seconds: u64,
}

#[derive(Debug)]
enum ProgramError {
    Usage(&'static str),
    Input(String),
    Grid(&'static str),
    Voice(WebRtcError),
    Io(io::Error),
    Timeout(&'static str),
}

impl ProgramError {
    const fn exit_code(&self) -> u8 {
        match self {
            Self::Usage(_) => EXIT_USAGE,
            Self::Input(_) | Self::Io(_) => EXIT_INPUT,
            Self::Grid(_) | Self::Voice(_) | Self::Timeout(_) => EXIT_SERVICE,
        }
    }
}

impl fmt::Display for ProgramError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Usage(message) | Self::Grid(message) => formatter.write_str(message),
            Self::Input(message) => write!(formatter, "invalid input: {message}"),
            Self::Voice(error) => error.fmt(formatter),
            Self::Io(error) => error.fmt(formatter),
            Self::Timeout(action) => write!(formatter, "{action} timed out"),
        }
    }
}

impl From<WebRtcError> for ProgramError {
    fn from(error: WebRtcError) -> Self {
        Self::Voice(error)
    }
}

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

/// Runs the `WebRtcTest` command.
#[must_use]
pub fn main_entry() -> ExitCode {
    let cli = Cli::parse();
    let Ok(runtime) = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
    else {
        eprintln!("webrtc-test: could not initialize the async runtime");
        return ExitCode::from(EXIT_SERVICE);
    };
    match runtime.block_on(run(cli)) {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("webrtc-test: {error}");
            ExitCode::from(error.exit_code())
        }
    }
}

async fn run(cli: Cli) -> Result<(), ProgramError> {
    if cli.list_devices {
        if cli.fake
            || cli.first_name.is_some()
            || cli.last_name.is_some()
            || cli.password.is_some()
            || cli.allow_live_login
        {
            return Err(ProgramError::Usage(
                "--list-devices cannot be combined with fake or live mode",
            ));
        }
        print_devices()?;
        return Ok(());
    }
    if cli.fake {
        if cli.first_name.is_some()
            || cli.last_name.is_some()
            || cli.password.is_some()
            || cli.allow_live_login
            || cli.confirm_live_login.is_some()
            || cli.allow_session_audio
            || cli.login_uri.is_some()
            || cli.commands.is_some()
        {
            return Err(ProgramError::Usage(
                "credentials and live/interactive options cannot be combined with --fake",
            ));
        }
        return run_fake(Duration::from_secs(cli.timeout_seconds)).await;
    }
    run_live(cli).await
}

fn print_devices() -> Result<(), ProgramError> {
    for device in audio_devices()? {
        println!(
            "{}\t{}\t{}{}",
            if device.input { "input" } else { "output" },
            device.id,
            device.name,
            if device.is_default { " [default]" } else { "" }
        );
    }
    Ok(())
}

async fn run_fake(timeout: Duration) -> Result<(), ProgramError> {
    let peer =
        libremetaverse::types::UUID::new_with_string("11111111-2222-4333-8444-555555555555".into())
            .map_err(|_| ProgramError::Grid("could not build deterministic peer ID"))?;
    let signaling = LoopbackSignaling::new(peer);
    let mut session = WebRtcVoiceSession::connect(
        signaling.clone(),
        VoiceSessionConfig {
            timeout,
            ..VoiceSessionConfig::default()
        },
    )
    .await?;
    let mut saw_connected = false;
    let mut saw_channel = false;
    let mut saw_audio_state = false;
    let mut saw_position = false;
    let mut saw_mute = false;
    let mut saw_gain = false;
    tokio::time::timeout(timeout, async {
        while !(saw_connected
            && saw_channel
            && saw_audio_state
            && saw_position
            && saw_mute
            && saw_gain)
        {
            match session.next_event().await {
                Some(VoiceEvent::Connected) => saw_connected = true,
                Some(VoiceEvent::DataChannelReady) => saw_channel = true,
                Some(VoiceEvent::PeerAudio(id, _)) if id == peer => saw_audio_state = true,
                Some(VoiceEvent::PeerPosition(id, _)) if id == peer => saw_position = true,
                Some(VoiceEvent::MuteMap(map)) if map.contains_key(&peer) => saw_mute = true,
                Some(VoiceEvent::GainMap(map)) if map.contains_key(&peer) => saw_gain = true,
                Some(VoiceEvent::Diagnostic(message)) => {
                    return Err(ProgramError::Input(message));
                }
                None | Some(VoiceEvent::Closed) => {
                    return Err(ProgramError::Grid("fake WebRTC peer closed prematurely"));
                }
                _ => {}
            }
        }
        Ok(())
    })
    .await
    .map_err(|_| ProgramError::Timeout("fake WebRTC connection"))??;

    session.set_peer_mute(peer, true).await?;
    session.set_peer_gain(peer, 500).await?;
    session
        .send_position(
            [1.0, 2.0, 3.0],
            [0.0, 0.0, 0.0, 1.0],
            [4.0, 5.0, 6.0],
            [0.0, 0.0, 0.0, 1.0],
        )
        .await?;
    let pcm: Vec<i16> = (0..4 * 960)
        .map(|index| {
            ((index as f32 / 48_000.0 * 440.0 * std::f32::consts::TAU).sin() * 8_000.0) as i16
        })
        .collect();
    session
        .play_frames(encode_pcm_48k_mono(&pcm)?, false)
        .await?;
    tokio::time::timeout(timeout, async {
        while session.snapshot().received_audio_frames < 4 {
            if session.next_event().await.is_none() {
                return Err(ProgramError::Grid("fake audio echo stopped prematurely"));
            }
        }
        Ok(())
    })
    .await
    .map_err(|_| ProgramError::Timeout("virtual audio echo"))??;
    session.shutdown().await?;
    signaling.wait_closed(timeout).await?;
    let snapshot = session.snapshot();
    let sent = signaling.received_messages();
    if snapshot.active_tasks != 0
        || signaling.active_tasks() != 0
        || !sent.iter().any(|value| value.contains("\"m\""))
        || !sent
            .iter()
            .any(|value| value.contains("\"ug\"") && value.contains("220"))
        || !sent.iter().any(|value| value.contains("\"sp\""))
        || !sent.iter().any(|value| value.contains("\"pong\""))
    {
        return Err(ProgramError::Grid(
            "fake peer did not observe every required SLData action or clean teardown",
        ));
    }
    println!(
        "Fake WebRTC validation complete; ice=connected dtls=connected srtp=opus sctp=SLData peer-events=4 sent-audio={} received-audio={} active-peer-tasks=0 active-audio-tasks=0",
        snapshot.sent_audio_frames, snapshot.received_audio_frames
    );
    Ok(())
}

async fn run_live(mut cli: Cli) -> Result<(), ProgramError> {
    if !cli.allow_live_login || cli.confirm_live_login.as_deref() != Some(LIVE_CONFIRMATION) {
        return Err(ProgramError::Usage(
            "live mode requires --allow-live-login --confirm-live-login LOGIN",
        ));
    }
    let (first_name, last_name) = grid_names(cli.first_name.take(), cli.last_name.take())?;
    let password = required(cli.password.take(), "GRID_PASSWORD", "PASSWORD is required")?;
    let timeout = Duration::from_secs(cli.timeout_seconds);
    let client =
        GridClient::new().map_err(|_| ProgramError::Grid("could not construct GridClient"))?;
    let network = client.network();
    let mut login = network
        .default_login_params(
            first_name,
            last_name,
            password,
            "WebRtcTest".into(),
            env!("CARGO_PKG_VERSION").into(),
        )
        .map_err(|_| ProgramError::Grid("could not build grid login parameters"))?;
    login.login_location = "WebRTC Voice 1/128/128/50".into();
    if let Some(uri) = cli
        .login_uri
        .take()
        .or_else(|| credential("GRID_LOGIN_URL"))
    {
        login.uri = uri;
    }
    println!("Logging into the grid with redacted credentials...");
    let logged_in = tokio::time::timeout(
        timeout,
        network.login_with_login_params_cancellation_token(login, None),
    )
    .await
    .map_err(|_| ProgramError::Timeout("grid login"))?
    .unwrap_or(false);
    if !logged_in {
        return Err(ProgramError::Grid("grid login was rejected"));
    }
    let operation = async {
        let simulator = wait_for_event_queue(&network, timeout).await?;
        let provision_uri = capability(&simulator, "ProvisionVoiceAccountRequest")?;
        let signaling_uri = capability(&simulator, "VoiceSignalingRequest")?;
        println!("WebRTC voice capabilities are present; capability URLs are redacted.");
        if !cli.allow_session_audio {
            println!("Live voice not joined; pass --allow-session-audio to create a peer and transmit/receive audio.");
            return Ok(());
        }
        let bind_ip = cli.bind_ip.unwrap_or_else(discover_bind_ip);
        let signaling: Arc<dyn VoiceSignaling> = Arc::new(GridCapabilitySignaling {
            client: client.clone(),
            provision_uri,
            signaling_uri,
            timeout,
        });
        let mut session = WebRtcVoiceSession::connect(
            signaling,
            VoiceSessionConfig {
                bind_ip,
                parcel_local_id: cli.parcel_local_id,
                timeout,
                input_device: cli.input_device,
                output_device: cli.output_device,
            },
        )
        .await?;
        let session_result = async {
            wait_ready(&mut session, timeout).await?;
            println!("WebRTC voice connected; ICE/DTLS/SRTP and ordered SLData are ready.");
            if let Some(wav) = cli.wav.as_deref() {
                session.play_wav(wav, true).await?;
                println!("WAV microphone playback started (48 kHz mono Opus).");
            }
            run_commands(&mut session, cli.commands.as_deref()).await
        }
        .await;
        let shutdown_result = session.shutdown().await;
        if session.snapshot().active_tasks != 0 {
            return Err(ProgramError::Grid("peer/audio tasks remained after shutdown"));
        }
        session_result?;
        shutdown_result?;
        println!("WebRTC voice disconnected; peer, audio, and signaling resources closed.");
        Ok(())
    }
    .await;
    let _ = network.logout_with_method();
    operation
}

fn required(
    value: Option<String>,
    environment: &str,
    message: &'static str,
) -> Result<String, ProgramError> {
    value
        .or_else(|| credential(environment))
        .ok_or(ProgramError::Usage(message))
}

fn credential(name: &str) -> Option<String> {
    std::env::var(name)
        .ok()
        .filter(|value| !value.trim().is_empty())
        .or_else(|| {
            let dotenv = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env");
            let contents = std::fs::read_to_string(dotenv).ok()?;
            contents.lines().find_map(|line| {
                let line = line.trim().strip_prefix("export ").unwrap_or(line.trim());
                let (key, value) = line.split_once('=')?;
                (key.trim() == name)
                    .then(|| value.trim().trim_matches(['\'', '"']).to_owned())
                    .filter(|value| !value.is_empty())
            })
        })
}

fn grid_names(
    first_name: Option<String>,
    last_name: Option<String>,
) -> Result<(String, String), ProgramError> {
    let first_name = first_name.or_else(|| credential("GRID_FIRST_NAME"));
    let last_name = last_name.or_else(|| credential("GRID_LAST_NAME"));
    match (first_name, last_name) {
        (Some(first), Some(last)) if !first.trim().is_empty() && !last.trim().is_empty() => {
            Ok((first, last))
        }
        (Some(_), None) | (None, Some(_)) => Err(ProgramError::Usage(
            "FIRSTNAME and LASTNAME must be provided together",
        )),
        _ => split_grid_user(&credential("GRID_USER").ok_or(ProgramError::Usage(
            "FIRSTNAME/LASTNAME or GRID_USER is required",
        ))?),
    }
}

fn split_grid_user(value: &str) -> Result<(String, String), ProgramError> {
    let mut names = value.split_whitespace();
    let (Some(first), Some(last), None) = (names.next(), names.next(), names.next()) else {
        return Err(ProgramError::Usage(
            "GRID_USER must contain exactly FIRSTNAME LASTNAME",
        ));
    };
    Ok((first.to_owned(), last.to_owned()))
}

async fn wait_for_event_queue(
    network: &NetworkManager,
    timeout: Duration,
) -> Result<Simulator, ProgramError> {
    tokio::time::timeout(timeout, async {
        loop {
            if let Some(simulator) = network.current_sim()
                && simulator
                    .is_event_queue_running(Some(false))
                    .unwrap_or(false)
            {
                return simulator;
            }
            tokio::time::sleep(Duration::from_millis(100)).await;
        }
    })
    .await
    .map_err(|_| ProgramError::Timeout("EventQueueRunning"))
}

fn capability(simulator: &Simulator, name: &'static str) -> Result<Uri, ProgramError> {
    simulator
        .native_capability_uri(name)
        .map_err(|_| ProgramError::Grid("voice capability lookup failed"))?
        .ok_or(ProgramError::Grid(
            "required WebRTC voice capability is missing",
        ))
}

fn discover_bind_ip() -> IpAddr {
    let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0));
    if let Ok(socket) = socket
        && socket
            .connect(SocketAddr::from(([192, 0, 2, 1], 9)))
            .is_ok()
        && let Ok(address) = socket.local_addr()
        && !address.ip().is_unspecified()
    {
        return address.ip();
    }
    IpAddr::V4(Ipv4Addr::LOCALHOST)
}

async fn wait_ready(
    session: &mut WebRtcVoiceSession,
    timeout: Duration,
) -> Result<(), ProgramError> {
    tokio::time::timeout(timeout, async {
        while !session.snapshot().data_channel_ready {
            match session.next_event().await {
                Some(VoiceEvent::Diagnostic(message)) => {
                    return Err(ProgramError::Input(message));
                }
                Some(VoiceEvent::Closed) | None => {
                    return Err(ProgramError::Grid(
                        "WebRTC peer closed before SLData opened",
                    ));
                }
                Some(event) => print_event(&event),
            }
        }
        Ok(())
    })
    .await
    .map_err(|_| ProgramError::Timeout("WebRTC peer connection"))?
}

fn print_event(event: &VoiceEvent) {
    match event {
        VoiceEvent::PeerAudio(id, state) => println!(
            "[Voice] PeerAudio {id} Power={:?} VAD={:?} JoinedPrimary={:?} Left={}",
            state.power, state.voice_active, state.joined_primary, state.left
        ),
        VoiceEvent::PeerPosition(id, position) => {
            println!(
                "[Voice] PeerPosition {id} sender={:?}",
                position.sender_position
            );
        }
        VoiceEvent::MuteMap(map) => {
            for (id, value) in map {
                println!("[Voice] Mute {id} = {value}");
            }
        }
        VoiceEvent::GainMap(map) => {
            for (id, value) in map {
                println!("[Voice] Gain {id} = {value}");
            }
        }
        _ => {}
    }
}

async fn run_commands(
    session: &mut WebRtcVoiceSession,
    path: Option<&Path>,
) -> Result<(), ProgramError> {
    if let Some(path) = path {
        let file = io::BufReader::new(std::fs::File::open(path)?);
        for line in file.lines().take(1_024) {
            if !execute_command(session, &line?).await? {
                break;
            }
        }
        return Ok(());
    }
    if !io::stdin().is_terminal() {
        return Ok(());
    }
    println!(
        "Commands: peers, mute <uuid> <bool>, gain <uuid> <0-220>, playwav <path>, stopwav, quit"
    );
    let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
    loop {
        print!("> ");
        io::stdout().flush()?;
        tokio::select! {
            signal = tokio::signal::ctrl_c() => {
                signal?;
                println!("\nCancellation requested; closing WebRTC voice.");
                break;
            }
            line = lines.next_line() => {
                let Some(line) = line? else { break };
                if !execute_command(session, &line).await? {
                    break;
                }
            }
        }
    }
    Ok(())
}

async fn execute_command(
    session: &mut WebRtcVoiceSession,
    line: &str,
) -> Result<bool, ProgramError> {
    let fields: Vec<_> = line.split_whitespace().collect();
    let Some(command) = fields.first().map(|value| value.to_ascii_lowercase()) else {
        return Ok(true);
    };
    match command.as_str() {
        "quit" | "exit" => return Ok(false),
        "peers" | "list" => {
            for peer in session.snapshot().known_peers {
                println!("{peer}");
            }
        }
        "mute" if fields.len() == 3 => {
            let peer = parse_peer(fields[1])?;
            let mute = fields[2]
                .parse()
                .map_err(|_| ProgramError::Input("mute value must be true or false".into()))?;
            session.set_peer_mute(peer, mute).await?;
        }
        "gain" if fields.len() == 3 => {
            let peer = parse_peer(fields[1])?;
            let gain = fields[2]
                .parse()
                .map_err(|_| ProgramError::Input("gain must be an integer".into()))?;
            session.set_peer_gain(peer, gain).await?;
        }
        "playwav" if fields.len() == 2 => {
            session.play_wav(Path::new(fields[1]), true).await?;
        }
        "stopwav" if fields.len() == 1 => session.stop_wav().await?,
        _ => {
            return Err(ProgramError::Input(
                "unknown command or incorrect command arguments".into(),
            ));
        }
    }
    Ok(true)
}

fn parse_peer(value: &str) -> Result<libremetaverse::types::UUID, ProgramError> {
    libremetaverse::types::UUID::new_with_string(value.to_owned())
        .map_err(|_| ProgramError::Input("peer ID must be a UUID".into()))
}

struct GridCapabilitySignaling {
    client: GridClient,
    provision_uri: Uri,
    signaling_uri: Uri,
    timeout: Duration,
}

impl GridCapabilitySignaling {
    async fn post(&self, uri: &Uri, body: OSD) -> Result<OSD, WebRtcError> {
        let http = self.client.http_caps_client();
        let request = http.post_with_uri_osd_format_osd_cancellation_token_i_progress(
            uri.clone(),
            OSDFormat::Xml,
            body,
            CancellationToken::default(),
            None,
        );
        let (response, bytes) = tokio::time::timeout(self.timeout, request)
            .await
            .map_err(|_| WebRtcError::Timeout("grid voice capability"))?
            .map_err(|_| WebRtcError::Signaling("capability request failed".into()))?;
        if !response.is_success_status_code() || bytes.len() > MAX_CAPABILITY_BYTES {
            return Err(WebRtcError::Signaling(format!(
                "capability returned HTTP {}",
                response.status_code
            )));
        }
        OSDParser::deserialize_with_bytes(bytes)
            .map_err(|_| WebRtcError::Signaling("capability returned malformed LLSD".into()))
    }
}

impl VoiceSignaling for GridCapabilitySignaling {
    fn provision(
        &self,
        request: ProvisionRequest,
    ) -> Pin<Box<dyn Future<Output = Result<ProvisionResponse, WebRtcError>> + Send + '_>> {
        Box::pin(async move {
            let mut jsep = HashMap::new();
            jsep.insert("type".into(), OSD::String(request.jsep.kind));
            jsep.insert("sdp".into(), OSD::String(request.jsep.sdp));
            let mut body = HashMap::new();
            body.insert("jsep".into(), OSD::Map(jsep));
            body.insert("channel_type".into(), OSD::String(request.channel_type));
            body.insert(
                "voice_server_type".into(),
                OSD::String(request.voice_server_type),
            );
            if let Some(id) = request.parcel_local_id {
                body.insert("parcel_local_id".into(), OSD::Integer(id));
            }
            let OSD::Map(response) = self.post(&self.provision_uri, OSD::Map(body)).await? else {
                return Err(WebRtcError::Signaling(
                    "provision response was not a map".into(),
                ));
            };
            let answer_sdp = match response.get("jsep") {
                Some(OSD::Map(jsep)) => jsep
                    .get("sdp")
                    .and_then(|value| value.as_string().ok())
                    .filter(|value| !value.is_empty()),
                _ => None,
            }
            .ok_or_else(|| WebRtcError::Signaling("provision response omitted SDP".into()))?;
            let viewer_session = response
                .get("viewer_session")
                .and_then(|value| value.as_uuid().ok())
                .filter(|value| *value != libremetaverse::types::UUID::zero())
                .ok_or_else(|| {
                    WebRtcError::Signaling("provision response omitted session ID".into())
                })?;
            let channel = response
                .get("channel")
                .and_then(|value| value.as_string().ok())
                .filter(|value| !value.is_empty());
            let credentials = response
                .get("credentials")
                .and_then(|value| value.as_string().ok())
                .filter(|value| !value.is_empty())
                .map(VoiceSecret::new)
                .transpose()?;
            Ok(ProvisionResponse {
                answer_sdp,
                viewer_session,
                channel,
                credentials,
            })
        })
    }

    fn complete(
        &self,
        request: SignalingCompleteRequest,
    ) -> Pin<Box<dyn Future<Output = Result<(), WebRtcError>> + Send + '_>> {
        Box::pin(async move {
            let mut complete = HashMap::new();
            complete.insert(
                "completed".into(),
                OSD::Boolean(request.candidate.completed),
            );
            let mut body = HashMap::new();
            body.insert(
                "voice_server_type".into(),
                OSD::String(request.voice_server_type),
            );
            body.insert("viewer_session".into(), OSD::String(request.viewer_session));
            body.insert("candidate".into(), OSD::Map(complete));
            let _ = self.post(&self.signaling_uri, OSD::Map(body)).await?;
            Ok(())
        })
    }

    fn close(
        &self,
        request: CloseRequest,
    ) -> Pin<Box<dyn Future<Output = Result<(), WebRtcError>> + Send + '_>> {
        Box::pin(async move {
            let mut body = HashMap::new();
            body.insert("logout".into(), OSD::Boolean(request.logout));
            body.insert(
                "voice_server_type".into(),
                OSD::String(request.voice_server_type),
            );
            body.insert("viewer_session".into(), OSD::String(request.viewer_session));
            if let Some(channel) = request.channel {
                body.insert("channel".into(), OSD::String(channel));
            }
            if let Some(credentials) = request.credentials {
                body.insert(
                    "credentials".into(),
                    OSD::String(credentials.expose().to_owned()),
                );
            }
            let _ = self.post(&self.provision_uri, OSD::Map(body)).await?;
            Ok(())
        })
    }
}

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

    #[test]
    fn live_mode_is_doubly_gated_and_does_not_echo_password() {
        let cli = Cli::try_parse_from(["webrtc-test", "First", "Last", "secret"]).unwrap();
        let error = tokio::runtime::Runtime::new()
            .unwrap()
            .block_on(run(cli))
            .unwrap_err();
        assert!(matches!(error, ProgramError::Usage(_)));
        assert!(!error.to_string().contains("secret"));
    }

    #[test]
    fn bind_discovery_returns_a_concrete_address() {
        assert!(!discover_bind_ip().is_unspecified());
    }

    #[test]
    fn grid_user_requires_the_repository_first_last_shape() {
        assert_eq!(
            split_grid_user("First Last").unwrap(),
            ("First".to_owned(), "Last".to_owned())
        );
        assert!(split_grid_user("First").is_err());
        assert!(split_grid_user("First Middle Last").is_err());
    }
}
