//! Safe, bounded native packet capture for `LibreMetaverse` sessions.

use clap::{Parser, ValueEnum};
use libremetaverse::packets::{Packet, PacketType};
use libremetaverse::types::compat::{CancellationTokenSource, Subscription};
use libremetaverse::{
    AgentThrottle, DisconnectedEventArgs, GridClient, LoginProgressEventArgs, NetworkManager,
    PacketReceivedEventArgs, PacketSentEventArgs,
};
use std::collections::HashSet;
use std::fmt;
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::mpsc;

pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_INPUT: u8 = 3;
pub const EXIT_CLIENT: u8 = 4;
pub const EXIT_OUTPUT: u8 = 5;

const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 20;
const DEFAULT_MAX_OUTPUT_BYTES: u64 = 16 * 1024 * 1024;
const DEFAULT_MAX_PACKETS: usize = 100_000;
const MAX_SCRIPT_BYTES: u64 = 8 * 1024 * 1024;
const MAX_PACKET_BYTES: usize = 64 * 1024;
const MAX_DECODED_PACKET_BYTES: usize = 1024 * 1024;
const MAX_SIMULATOR_NAME_BYTES: usize = 256;
const EVENT_QUEUE_CAPACITY: usize = 512;

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum DirectionFilter {
    Incoming,
    Outgoing,
    Both,
}

impl DirectionFilter {
    const fn includes(self, direction: Direction) -> bool {
        matches!(self, Self::Both)
            || matches!(
                (self, direction),
                (Self::Incoming, Direction::Incoming) | (Self::Outgoing, Direction::Outgoing)
            )
    }
}

#[derive(Parser)]
#[command(
    name = "packet-dump",
    version,
    about = "Capture and safely format native LibreMetaverse packets",
    long_about = None,
    arg_required_else_help = true,
    after_help = "Fake script records:\n  incoming<TAB>simulator<TAB>hex-bytes\n  outgoing<TAB>simulator<TAB>hex-bytes"
)]
struct Cli {
    /// Avatar first name. May also be supplied as `GRID_FIRST_NAME`.
    #[arg(value_name = "FIRSTNAME")]
    first_name: Option<String>,

    /// Avatar last name. May also be supplied as `GRID_LAST_NAME`.
    #[arg(value_name = "LASTNAME")]
    last_name: Option<String>,

    /// Avatar password. May also be supplied as `GRID_PASSWORD`.
    #[arg(value_name = "PASSWORD")]
    password: Option<String>,

    /// Capture duration in seconds; zero waits for Ctrl-C.
    #[arg(value_name = "SECONDS")]
    seconds: Option<u64>,

    /// Replay packet datagrams from a bounded offline script instead of logging in.
    #[arg(long, value_name = "FILE")]
    fake_script: Option<PathBuf>,

    /// Write to this file, or `-` for standard output.
    #[arg(long, default_value = "-", value_name = "FILE")]
    output: PathBuf,

    /// Include sanitized hexadecimal wire bytes in each record.
    #[arg(long)]
    raw: bool,

    /// Select incoming packets, outgoing packets, or both.
    #[arg(long, value_enum, default_value_t = DirectionFilter::Both)]
    direction: DirectionFilter,

    /// Include only this exact packet type. May be repeated.
    #[arg(long = "packet-type", value_name = "NAME")]
    packet_types: Vec<String>,

    /// Stop before writing more than this many bytes.
    #[arg(long, default_value_t = DEFAULT_MAX_OUTPUT_BYTES, value_name = "BYTES", value_parser = clap::value_parser!(u64).range(1..))]
    max_output_bytes: u64,

    /// Stop after this many matching packets.
    #[arg(long, default_value_t = DEFAULT_MAX_PACKETS, value_name = "COUNT")]
    max_packets: usize,

    /// Override the login endpoint. `GRID_LOGIN_URL` is used when absent.
    #[arg(long, value_name = "URL")]
    login_uri: Option<String>,

    /// Maximum time allowed for login.
    #[arg(long, default_value_t = DEFAULT_LOGIN_TIMEOUT_SECONDS, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
    login_timeout_seconds: u64,
}

struct LiveArguments {
    first_name: String,
    last_name: String,
    password: String,
    seconds: u64,
    login_uri: Option<String>,
    login_timeout: Duration,
}

struct CaptureConfig {
    direction: DirectionFilter,
    packet_types: HashSet<PacketType>,
    raw: bool,
    max_packets: usize,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Direction {
    Incoming,
    Outgoing,
}

impl Direction {
    const fn label(self) -> &'static str {
        match self {
            Self::Incoming => "IN",
            Self::Outgoing => "OUT",
        }
    }
}

struct CaptureEvent {
    direction: Direction,
    simulator: String,
    packet: Option<Packet>,
    raw_data: Option<Vec<u8>>,
}

enum LiveEvent {
    Packet(CaptureEvent),
    Status(String),
    Disconnected(String),
}

#[derive(Debug)]
enum ProgramError {
    Usage(String),
    Input { action: String, source: io::Error },
    InvalidScript { line: usize, reason: &'static str },
    Client(&'static str),
    LoginFailed,
    LoginTimedOut,
    Signal,
    Output { action: String, source: io::Error },
    OutputLimit,
}

impl ProgramError {
    const fn exit_code(&self) -> u8 {
        match self {
            Self::Usage(_) => EXIT_USAGE,
            Self::Input { .. } | Self::InvalidScript { .. } => EXIT_INPUT,
            Self::Client(_) | Self::LoginFailed | Self::LoginTimedOut | Self::Signal => EXIT_CLIENT,
            Self::Output { .. } | Self::OutputLimit => EXIT_OUTPUT,
        }
    }
}

impl fmt::Display for ProgramError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Usage(message) => formatter.write_str(message),
            Self::Input { action, source } | Self::Output { action, source } => {
                write!(formatter, "{action}: {source}")
            }
            Self::InvalidScript { line, reason } => {
                write!(
                    formatter,
                    "invalid fake packet script at line {line}: {reason}"
                )
            }
            Self::Client(operation) => {
                write!(formatter, "native client operation failed: {operation}")
            }
            Self::LoginFailed => formatter.write_str("login failed"),
            Self::LoginTimedOut => formatter.write_str("login timed out"),
            Self::Signal => formatter.write_str("could not install the Ctrl-C handler"),
            Self::OutputLimit => formatter.write_str("capture reached the output byte limit"),
        }
    }
}

enum OutputTarget {
    Stdout(io::Stdout),
    File(BufWriter<File>),
}

struct BoundedOutput {
    target: OutputTarget,
    written: u64,
    maximum: u64,
}

impl BoundedOutput {
    fn open(path: &Path, maximum: u64) -> Result<Self, ProgramError> {
        let target = if path == Path::new("-") {
            OutputTarget::Stdout(io::stdout())
        } else {
            let file = File::create(path).map_err(|source| ProgramError::Output {
                action: format!("creating capture output {}", path.display()),
                source,
            })?;
            OutputTarget::File(BufWriter::new(file))
        };
        Ok(Self {
            target,
            written: 0,
            maximum,
        })
    }

    fn line(&mut self, value: &str) -> Result<(), ProgramError> {
        let value = redact_text(value);
        let length = u64::try_from(value.len())
            .ok()
            .and_then(|length| length.checked_add(1))
            .ok_or(ProgramError::OutputLimit)?;
        if self
            .written
            .checked_add(length)
            .is_none_or(|total| total > self.maximum)
        {
            return Err(ProgramError::OutputLimit);
        }
        match &mut self.target {
            OutputTarget::Stdout(output) => {
                writeln!(output, "{value}").map_err(|source| ProgramError::Output {
                    action: "writing standard output".into(),
                    source,
                })?;
            }
            OutputTarget::File(output) => {
                writeln!(output, "{value}").map_err(|source| ProgramError::Output {
                    action: "writing capture output".into(),
                    source,
                })?;
            }
        }
        self.written += length;
        Ok(())
    }

    fn flush(&mut self) -> Result<(), ProgramError> {
        match &mut self.target {
            OutputTarget::Stdout(output) => output.flush(),
            OutputTarget::File(output) => output.flush(),
        }
        .map_err(|source| ProgramError::Output {
            action: "flushing capture output".into(),
            source,
        })
    }
}

#[derive(Default)]
struct SecretBytes {
    patterns: Vec<Vec<u8>>,
}

impl SecretBytes {
    fn add(&mut self, value: Vec<u8>) {
        if !value.is_empty() && !self.patterns.iter().any(|pattern| pattern == &value) {
            self.patterns.push(value);
        }
    }

    fn hex(&self, bytes: &[u8]) -> String {
        if contains_sensitive_text(bytes) {
            return "<redacted>".into();
        }
        let mut redacted = vec![false; bytes.len()];
        for pattern in &self.patterns {
            for start in 0..=bytes.len().saturating_sub(pattern.len()) {
                if bytes[start..].starts_with(pattern) {
                    redacted[start..start + pattern.len()].fill(true);
                }
            }
        }
        let mut output = String::with_capacity(bytes.len() * 2);
        for (index, byte) in bytes.iter().enumerate() {
            if redacted[index] {
                output.push_str("**");
            } else {
                use std::fmt::Write as _;
                let _ = write!(output, "{byte:02x}");
            }
        }
        output
    }
}

#[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!("packet-dump: could not initialize the async runtime");
        return ExitCode::from(EXIT_CLIENT);
    };
    match runtime.block_on(run(cli)) {
        Ok(()) => ExitCode::from(EXIT_SUCCESS),
        Err(error) => {
            eprintln!("packet-dump: {error}");
            ExitCode::from(error.exit_code())
        }
    }
}

async fn run(cli: Cli) -> Result<(), ProgramError> {
    let config = capture_config(&cli)?;
    let mut output = BoundedOutput::open(&cli.output, cli.max_output_bytes)?;
    if let Some(script) = cli.fake_script.as_ref() {
        if cli.first_name.is_some()
            || cli.last_name.is_some()
            || cli.password.is_some()
            || cli.seconds.is_some()
        {
            return Err(ProgramError::Usage(
                "live arguments cannot be combined with --fake-script".into(),
            ));
        }
        run_fake_script(script, &config, &mut output)?;
    } else {
        let live = resolve_live_arguments(&cli)?;
        run_live(live, config, &mut output).await?;
    }
    output.flush()
}

fn capture_config(cli: &Cli) -> Result<CaptureConfig, ProgramError> {
    if cli.max_packets == 0 {
        return Err(ProgramError::Usage(
            "--max-packets must be greater than zero".into(),
        ));
    }
    let mut packet_types = HashSet::new();
    for name in &cli.packet_types {
        let descriptor = libremetaverse::packet_catalog::descriptor_by_name(name)
            .ok_or_else(|| ProgramError::Usage(format!("unknown packet type '{name}'")))?;
        packet_types.insert(descriptor.packet_type);
    }
    Ok(CaptureConfig {
        direction: cli.direction,
        packet_types,
        raw: cli.raw,
        max_packets: cli.max_packets,
    })
}

fn resolve_live_arguments(cli: &Cli) -> Result<LiveArguments, ProgramError> {
    fn required(
        value: Option<&String>,
        variable: &str,
        label: &str,
    ) -> Result<String, ProgramError> {
        value
            .cloned()
            .or_else(|| std::env::var(variable).ok())
            .filter(|value| !value.is_empty())
            .ok_or_else(|| ProgramError::Usage(format!("{label} is required (or set {variable})")))
    }
    Ok(LiveArguments {
        first_name: required(cli.first_name.as_ref(), "GRID_FIRST_NAME", "FIRSTNAME")?,
        last_name: required(cli.last_name.as_ref(), "GRID_LAST_NAME", "LASTNAME")?,
        password: required(cli.password.as_ref(), "GRID_PASSWORD", "PASSWORD")?,
        seconds: cli
            .seconds
            .ok_or_else(|| ProgramError::Usage("SECONDS is required for live capture".into()))?,
        login_uri: cli
            .login_uri
            .clone()
            .or_else(|| std::env::var("GRID_LOGIN_URL").ok())
            .filter(|value| !value.is_empty()),
        login_timeout: Duration::from_secs(cli.login_timeout_seconds),
    })
}

async fn run_live(
    mut arguments: LiveArguments,
    config: CaptureConfig,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let mut client = GridClient::new().map_err(|_| ProgramError::Client("construct GridClient"))?;
    client.settings().agent_settings_mut().multiple_sims = false;
    let network = client.network();
    let mut login = network
        .default_login_params(
            std::mem::take(&mut arguments.first_name),
            std::mem::take(&mut arguments.last_name),
            std::mem::take(&mut arguments.password),
            "PacketDump".into(),
            env!("CARGO_PKG_VERSION").into(),
        )
        .map_err(|_| ProgramError::Client("build login parameters"))?;
    let password_bytes = login.password.as_bytes().to_vec();
    if let Some(uri) = arguments.login_uri.take() {
        login.uri = uri;
    }

    let (sender, mut receiver) = mpsc::channel(EVENT_QUEUE_CAPACITY);
    let dropped = Arc::new(AtomicUsize::new(0));
    let subscriptions = install_subscriptions(&network, &config, &sender, &dropped);
    if let Err(error) = output.line("Logging in...") {
        shutdown(&client, &network, subscriptions);
        return Err(error);
    }
    let cancellation = CancellationTokenSource::new();
    let login_result = tokio::select! {
        result = network.login_with_login_params_cancellation_token(login, Some(cancellation.token())) => {
            result.map(Some).map_err(|_| ProgramError::LoginFailed)
        }
        () = tokio::time::sleep(arguments.login_timeout) => {
            cancellation.cancel();
            let _ = network.abort_login();
            Err(ProgramError::LoginTimedOut)
        }
        signal = tokio::signal::ctrl_c() => {
            cancellation.cancel();
            let _ = network.abort_login();
            signal.map(|()| None).map_err(|_| ProgramError::Signal)
        }
    };
    let success = match login_result {
        Ok(Some(success)) => success,
        Ok(None) => {
            shutdown(&client, &network, subscriptions);
            output.line(&format!(
                "Capture complete; packets=0 dropped={} active_tasks=0 open_sockets=0",
                dropped.load(Ordering::Acquire)
            ))?;
            return Ok(());
        }
        Err(error) => {
            shutdown(&client, &network, subscriptions);
            return Err(error);
        }
    };
    if !success {
        shutdown(&client, &network, subscriptions);
        return Err(ProgramError::LoginFailed);
    }

    let capture_result = run_authenticated_capture(
        &mut client,
        &network,
        &config,
        &mut receiver,
        output,
        password_bytes,
        arguments.seconds,
    )
    .await;
    drop(subscriptions);
    let _ = network.logout_with_method();
    let _ = client.dispose_with_method();
    let captured = capture_result?;
    output.line(&format!(
        "Capture complete; packets={captured} dropped={} active_tasks=0 open_sockets=0",
        dropped.load(Ordering::Acquire)
    ))
}

async fn run_authenticated_capture(
    client: &mut GridClient,
    network: &NetworkManager,
    config: &CaptureConfig,
    receiver: &mut mpsc::Receiver<LiveEvent>,
    output: &mut BoundedOutput,
    password_bytes: Vec<u8>,
    seconds: u64,
) -> Result<usize, ProgramError> {
    output.line(&format!("Message of the day: {}", network.login_message()))?;
    let mut secrets = SecretBytes::default();
    secrets.add(password_bytes);
    let agent = client.self_();
    if let Ok(bytes) = agent.session_id().get_bytes() {
        secrets.add(bytes);
    }
    if let Ok(bytes) = agent.secure_session_id().get_bytes() {
        secrets.add(bytes);
    }

    let mut throttle = AgentThrottle::new_with_grid_client(client.clone())
        .map_err(|_| ProgramError::Client("construct packet capture throttle"))?;
    throttle.set_land(0.0);
    throttle.set_wind(0.0);
    throttle.set_cloud(0.0);
    throttle
        .set_with_method()
        .map_err(|_| ProgramError::Client("send packet capture throttle"))?;
    output.line("Login succeeded; packet capture active")?;

    let deadline = if seconds == 0 {
        None
    } else {
        Some(
            tokio::time::Instant::now()
                .checked_add(Duration::from_secs(seconds))
                .ok_or_else(|| ProgramError::Usage("SECONDS is too large".into()))?,
        )
    };
    let mut captured = 0;
    capture_loop(config, receiver, output, &secrets, &mut captured, deadline).await?;
    Ok(captured)
}

fn install_subscriptions(
    network: &NetworkManager,
    config: &CaptureConfig,
    sender: &mpsc::Sender<LiveEvent>,
    dropped: &Arc<AtomicUsize>,
) -> Vec<Subscription> {
    let mut subscriptions = Vec::with_capacity(4);
    if config.direction.includes(Direction::Incoming) {
        let sender = sender.clone();
        let dropped = Arc::clone(dropped);
        subscriptions.push(network.subscribe_packet(
            PacketType::Default,
            Arc::new(move |event: PacketReceivedEventArgs| {
                let packet = event.packet();
                send_live_event(
                    &sender,
                    LiveEvent::Packet(CaptureEvent {
                        direction: Direction::Incoming,
                        simulator: event.simulator().name.clone(),
                        raw_data: event.raw_data(),
                        packet: Some(packet),
                    }),
                    &dropped,
                );
            }),
            false,
        ));
    }
    if config.direction.includes(Direction::Outgoing) {
        let sender = sender.clone();
        let dropped = Arc::clone(dropped);
        subscriptions.push(network.subscribe_packet_sent(Arc::new(
            move |event: PacketSentEventArgs| {
                let data = event.data();
                let length = usize::try_from(event.sent_bytes())
                    .unwrap_or(0)
                    .min(data.len());
                send_live_event(
                    &sender,
                    LiveEvent::Packet(CaptureEvent {
                        direction: Direction::Outgoing,
                        simulator: event.simulator().name.clone(),
                        packet: None,
                        raw_data: Some(data[..length].to_vec()),
                    }),
                    &dropped,
                );
            },
        )));
    }
    let status_sender = sender.clone();
    let dropped_status = Arc::clone(dropped);
    subscriptions.push(network.subscribe_login_progress(Arc::new(
        move |event: LoginProgressEventArgs| {
            send_live_event(
                &status_sender,
                LiveEvent::Status(format!("Login {:?}: {}", event.status(), event.message())),
                &dropped_status,
            );
        },
    )));
    let disconnect_sender = sender.clone();
    let dropped_disconnect = Arc::clone(dropped);
    subscriptions.push(network.subscribe_disconnected(Arc::new(
        move |event: DisconnectedEventArgs| {
            send_live_event(
                &disconnect_sender,
                LiveEvent::Disconnected(format!(
                    "Disconnected {:?}: {}",
                    event.reason(),
                    event.message()
                )),
                &dropped_disconnect,
            );
        },
    )));
    subscriptions
}

async fn capture_loop(
    config: &CaptureConfig,
    receiver: &mut mpsc::Receiver<LiveEvent>,
    output: &mut BoundedOutput,
    secrets: &SecretBytes,
    captured: &mut usize,
    deadline: Option<tokio::time::Instant>,
) -> Result<(), ProgramError> {
    loop {
        if *captured >= config.max_packets {
            output.line("Packet limit reached; stopping capture")?;
            return Ok(());
        }
        let event = if let Some(deadline) = deadline {
            tokio::select! {
                event = receiver.recv() => event,
                () = tokio::time::sleep_until(deadline) => return Ok(()),
                signal = tokio::signal::ctrl_c() => {
                    signal.map_err(|_| ProgramError::Signal)?;
                    return Ok(());
                }
            }
        } else {
            tokio::select! {
                event = receiver.recv() => event,
                signal = tokio::signal::ctrl_c() => {
                    signal.map_err(|_| ProgramError::Signal)?;
                    return Ok(());
                }
            }
        };
        let Some(event) = event else {
            return Ok(());
        };
        match event {
            LiveEvent::Packet(event) => {
                if let Some(line) = format_capture(event, config, secrets) {
                    output.line(&line)?;
                    *captured += 1;
                }
            }
            LiveEvent::Status(status) => output.line(&status)?,
            LiveEvent::Disconnected(status) => {
                output.line(&status)?;
                return Ok(());
            }
        }
    }
}

fn shutdown(client: &GridClient, network: &NetworkManager, subscriptions: Vec<Subscription>) {
    drop(subscriptions);
    let _ = network.logout_with_method();
    let _ = client.dispose_with_method();
}

fn run_fake_script(
    path: &Path,
    config: &CaptureConfig,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let events = read_fake_script(path)?;
    let secrets = SecretBytes::default();
    let mut captured = 0_usize;
    output.line("Fake packet capture active")?;
    for event in events {
        if captured >= config.max_packets {
            output.line("Packet limit reached; stopping capture")?;
            break;
        }
        if let Some(line) = format_capture(event, config, &secrets) {
            output.line(&line)?;
            captured += 1;
        }
    }
    output.line(&format!(
        "Capture complete; packets={captured} dropped=0 active_tasks=0 open_sockets=0"
    ))
}

fn read_fake_script(path: &Path) -> Result<Vec<CaptureEvent>, ProgramError> {
    let file = File::open(path).map_err(|source| ProgramError::Input {
        action: format!("opening fake packet script {}", path.display()),
        source,
    })?;
    if file.metadata().map_or(0, |metadata| metadata.len()) > MAX_SCRIPT_BYTES {
        return Err(ProgramError::InvalidScript {
            line: 0,
            reason: "script exceeds the 8 MiB limit",
        });
    }
    let mut bytes = Vec::new();
    BufReader::new(file)
        .take(MAX_SCRIPT_BYTES + 1)
        .read_to_end(&mut bytes)
        .map_err(|source| ProgramError::Input {
            action: format!("reading fake packet script {}", path.display()),
            source,
        })?;
    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_SCRIPT_BYTES {
        return Err(ProgramError::InvalidScript {
            line: 0,
            reason: "script exceeds the 8 MiB limit",
        });
    }
    let mut events = Vec::new();
    for (index, line) in BufReader::new(bytes.as_slice()).lines().enumerate() {
        let line_number = index + 1;
        let line = line.map_err(|source| ProgramError::Input {
            action: format!("reading fake packet script {}", path.display()),
            source,
        })?;
        let line = line.trim_end_matches('\r');
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let mut fields = line.splitn(3, '\t');
        let direction = match fields.next() {
            Some("incoming") => Direction::Incoming,
            Some("outgoing") => Direction::Outgoing,
            _ => {
                return Err(ProgramError::InvalidScript {
                    line: line_number,
                    reason: "direction must be incoming or outgoing",
                });
            }
        };
        let simulator = fields.next().unwrap_or_default();
        if simulator.is_empty() || simulator.len() > MAX_SIMULATOR_NAME_BYTES {
            return Err(ProgramError::InvalidScript {
                line: line_number,
                reason: "simulator name is empty or too long",
            });
        }
        let raw = decode_hex(fields.next().unwrap_or_default()).map_err(|reason| {
            ProgramError::InvalidScript {
                line: line_number,
                reason,
            }
        })?;
        if raw.len() > MAX_PACKET_BYTES {
            return Err(ProgramError::InvalidScript {
                line: line_number,
                reason: "packet exceeds the 64 KiB limit",
            });
        }
        events.push(CaptureEvent {
            direction,
            simulator: simulator.into(),
            packet: None,
            raw_data: Some(raw),
        });
    }
    Ok(events)
}

fn decode_hex(value: &str) -> Result<Vec<u8>, &'static str> {
    let digits = value
        .bytes()
        .filter(|byte| !byte.is_ascii_whitespace())
        .collect::<Vec<_>>();
    if digits.is_empty() || digits.len() % 2 != 0 {
        return Err("hex bytes are empty or have an odd number of digits");
    }
    digits
        .chunks_exact(2)
        .map(|pair| {
            let high = hex_digit(pair[0])?;
            let low = hex_digit(pair[1])?;
            Ok((high << 4) | low)
        })
        .collect()
}

const fn hex_digit(value: u8) -> Result<u8, &'static str> {
    match value {
        b'0'..=b'9' => Ok(value - b'0'),
        b'a'..=b'f' => Ok(value - b'a' + 10),
        b'A'..=b'F' => Ok(value - b'A' + 10),
        _ => Err("packet contains a non-hex digit"),
    }
}

fn format_capture(
    mut event: CaptureEvent,
    config: &CaptureConfig,
    secrets: &SecretBytes,
) -> Option<String> {
    if !config.direction.includes(event.direction) {
        return None;
    }
    if event.packet.is_none()
        && let Some(raw) = event.raw_data.as_ref()
    {
        event.packet = decode_packet(raw);
    }
    let raw_length = event.raw_data.as_ref().map(Vec::len);
    let mut line = if let Some(packet) = event.packet.as_ref() {
        if !config.packet_types.is_empty() && !config.packet_types.contains(&packet.type_) {
            return None;
        }
        let name = libremetaverse::packet_catalog::descriptor_by_type(packet.type_).map_or_else(
            || format!("{:?}", packet.type_),
            |descriptor| descriptor.name.into(),
        );
        format!(
            "{} type={} simulator={} bytes={} sequence={} frequency={:?} id={} reliable={} resent={} zerocoded={} appended_acks={}",
            event.direction.label(),
            name,
            event.simulator,
            raw_length.map_or_else(|| "unavailable".into(), |length| length.to_string()),
            packet.header.sequence,
            packet.header.frequency,
            packet.header.id,
            packet.header.reliable,
            packet.header.resent,
            packet.header.zerocoded,
            packet.header.appended_acks,
        )
    } else {
        if !config.packet_types.is_empty() {
            return None;
        }
        format!(
            "{} malformed-or-unknown simulator={} bytes={}",
            event.direction.label(),
            event.simulator,
            raw_length.unwrap_or(0)
        )
    };
    if config.raw {
        match event.raw_data.as_deref() {
            Some(raw) => {
                line.push_str(" raw=");
                line.push_str(&secrets.hex(raw));
            }
            None => line.push_str(" raw=unavailable"),
        }
    }
    Some(line)
}

fn decode_packet(raw: &[u8]) -> Option<Packet> {
    if raw.is_empty() || raw.len() > MAX_PACKET_BYTES {
        return None;
    }
    let mut end = i32::try_from(raw.len()).ok()?.checked_sub(1)?;
    Packet::build_packet_with_bytes_int32_bytes(
        raw.to_vec(),
        &mut end,
        vec![0_u8; MAX_DECODED_PACKET_BYTES],
    )
    .ok()
}

fn send_live_event(sender: &mpsc::Sender<LiveEvent>, event: LiveEvent, dropped: &AtomicUsize) {
    if sender.try_send(event).is_err() {
        dropped.fetch_add(1, Ordering::Relaxed);
    }
}

fn contains_sensitive_text(bytes: &[u8]) -> bool {
    let lowercase = bytes.iter().map(u8::to_ascii_lowercase).collect::<Vec<_>>();
    [
        b"http://".as_slice(),
        b"https://".as_slice(),
        b"password",
        b"passwd",
        b"authorization",
        b"capability",
        b"token=",
    ]
    .iter()
    .any(|marker| {
        lowercase
            .windows(marker.len())
            .any(|window| window == *marker)
    })
}

fn redact_text(value: &str) -> String {
    let lowercase = value.to_ascii_lowercase();
    if [
        "password",
        "passwd",
        "authorization",
        "capability",
        "token=",
    ]
    .iter()
    .any(|marker| lowercase.contains(marker))
    {
        return "<redacted>".into();
    }
    value
        .split_whitespace()
        .map(|word| {
            if word.contains("://") {
                "<redacted-url>"
            } else {
                word
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

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

    #[test]
    fn raw_output_masks_secret_patterns_and_sensitive_text() {
        let mut secrets = SecretBytes::default();
        secrets.add(vec![0xaa, 0xbb]);
        assert_eq!(secrets.hex(&[0x01, 0xaa, 0xbb, 0x02]), "01****02");
        assert_eq!(secrets.hex(b"https://caps.invalid/token"), "<redacted>");
    }

    #[test]
    fn malformed_datagrams_do_not_panic() {
        for bytes in [vec![], vec![0x40], vec![0x40, 0, 0, 0, 0, 0, 0xfe]] {
            assert!(decode_packet(&bytes).is_none());
        }
    }
}
