//! Native primitive discovery and inspection for live and deterministic fake grids.

use clap::Parser;
use clap::error::ErrorKind;
use libremetaverse::structured_data::{OSD, OSDParser};
use libremetaverse::types::compat::{CancellationTokenSource, Subscription};
use libremetaverse::types::{SaleType, UUID, Vector3};
use libremetaverse::{
    DisconnectedEventArgs, GridClient, LoginProgressEventArgs, NetworkManager,
    ObjectPropertiesEventArgs, Primitive, PrimitiveObjectProperties, SimChangedEventArgs,
    Simulator,
};
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
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 = 30;
const DEFAULT_DISCOVERY_DELAY_MS: u64 = 2_000;
const DEFAULT_PROPERTY_TIMEOUT_MS: u64 = 3_000;
const DEFAULT_MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
const MAX_FIXTURE_BYTES: u64 = 8 * 1024 * 1024;
const MAX_FIXTURE_ALLOC_BYTES: usize = 8 * 1024 * 1024;
const MAX_OBJECTS: usize = 100_000;
const MAX_PROPERTY_REQUESTS: usize = 1_024;
const EVENT_QUEUE_CAPACITY: usize = 2_048;

#[derive(Parser)]
#[command(
    name = "prim-inspector",
    version,
    about = "Discover and inspect the nearest primitives on a native LibreMetaverse grid",
    long_about = None,
    arg_required_else_help = true
)]
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>,

    /// Case-insensitive object-name substring.
    #[arg(value_name = "SEARCH_TERM")]
    search_term: Option<String>,

    /// Case-insensitive object-name substring for `--fake-grid` mode.
    #[arg(long = "search", value_name = "SEARCH_TERM")]
    fake_search: Option<String>,

    /// Inspect a bounded LLSD JSON fixture without accessing a live grid.
    #[arg(long, value_name = "FILE")]
    fake_grid: Option<PathBuf>,

    /// Maximum number of nearest matching objects to print.
    #[arg(long, default_value_t = 10, value_name = "COUNT")]
    limit: 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,

    /// Time to collect object updates after login.
    #[arg(long, default_value_t = DEFAULT_DISCOVERY_DELAY_MS, value_name = "MILLISECONDS", value_parser = clap::value_parser!(u64).range(0..=300_000))]
    discovery_delay_ms: u64,

    /// Shared deadline for outstanding object-property requests.
    #[arg(long, default_value_t = DEFAULT_PROPERTY_TIMEOUT_MS, value_name = "MILLISECONDS", value_parser = clap::value_parser!(u64).range(1..=300_000))]
    property_timeout_ms: u64,

    /// Refuse to emit more than this many bytes.
    #[arg(long, default_value_t = DEFAULT_MAX_OUTPUT_BYTES, value_name = "BYTES")]
    max_output_bytes: usize,
}

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

#[derive(Debug)]
enum ProgramError {
    Usage(String),
    Input { action: String, source: io::Error },
    InvalidFixture(&'static str),
    Client(&'static str),
    LoginFailed,
    LoginTimedOut,
    Cancelled,
    SimulatorChanged,
    Disconnected,
    Output(io::Error),
    OutputLimit,
}

impl ProgramError {
    const fn exit_code(&self) -> u8 {
        match self {
            Self::Usage(_) => EXIT_USAGE,
            Self::Input { .. } | Self::InvalidFixture(_) => EXIT_INPUT,
            Self::Client(_)
            | Self::LoginFailed
            | Self::LoginTimedOut
            | Self::Cancelled
            | Self::SimulatorChanged
            | Self::Disconnected => 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 } => write!(formatter, "{action}: {source}"),
            Self::InvalidFixture(reason) => {
                write!(formatter, "invalid fake-grid fixture: {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::Cancelled => formatter.write_str("inspection cancelled"),
            Self::SimulatorChanged => formatter.write_str("simulator changed during inspection"),
            Self::Disconnected => formatter.write_str("grid disconnected during inspection"),
            Self::Output(source) => write!(formatter, "writing standard output: {source}"),
            Self::OutputLimit => formatter.write_str("inspection reached the output byte limit"),
        }
    }
}

#[derive(Default)]
struct BoundedOutput {
    bytes: Vec<u8>,
    maximum: usize,
}

impl BoundedOutput {
    fn new(maximum: usize) -> Self {
        Self {
            bytes: Vec::new(),
            maximum,
        }
    }

    fn line(&mut self, value: impl AsRef<str>) -> Result<(), ProgramError> {
        let value = sanitize(value.as_ref());
        let required = value
            .len()
            .checked_add(1)
            .ok_or(ProgramError::OutputLimit)?;
        if self
            .bytes
            .len()
            .checked_add(required)
            .is_none_or(|size| size > self.maximum)
        {
            return Err(ProgramError::OutputLimit);
        }
        self.bytes.extend_from_slice(value.as_bytes());
        self.bytes.push(b'\n');
        Ok(())
    }

    fn flush(self) -> Result<(), ProgramError> {
        let mut output = io::stdout().lock();
        output
            .write_all(&self.bytes)
            .map_err(ProgramError::Output)?;
        output.flush().map_err(ProgramError::Output)
    }
}

enum LiveEvent {
    Status(String),
    Properties(Box<PrimitiveObjectProperties>),
    Disconnected,
    SimulatorChanged { had_previous: bool },
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PropertyState {
    Ready,
    Delayed,
    Missing,
}

struct FixtureObject {
    primitive: Primitive,
    pending_properties: Option<PrimitiveObjectProperties>,
    state: PropertyState,
}

struct Fixture {
    region: String,
    agent_position: Vector3,
    objects: Vec<FixtureObject>,
}

#[must_use]
pub fn main_entry() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("prim-inspector: {error}");
            ExitCode::from(error.exit_code())
        }
    }
}

fn run() -> Result<(), ProgramError> {
    let cli = match Cli::try_parse() {
        Ok(cli) => cli,
        Err(error)
            if matches!(
                error.kind(),
                ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
            ) =>
        {
            error.print().map_err(ProgramError::Output)?;
            return Ok(());
        }
        Err(error) => return Err(ProgramError::Usage(error.to_string())),
    };
    if !(1..=100).contains(&cli.limit) {
        return Err(ProgramError::Usage(
            "--limit must be between 1 and 100".into(),
        ));
    }
    if cli.max_output_bytes == 0 {
        return Err(ProgramError::Usage(
            "--max-output-bytes must be greater than zero".into(),
        ));
    }
    validate_search(cli.search_term.as_deref())?;
    validate_search(cli.fake_search.as_deref())?;
    let mut output = BoundedOutput::new(cli.max_output_bytes);
    let runtime =
        tokio::runtime::Runtime::new().map_err(|_| ProgramError::Client("start runtime"))?;
    if let Some(path) = &cli.fake_grid {
        if cli.first_name.is_some()
            || cli.last_name.is_some()
            || cli.password.is_some()
            || cli.login_uri.is_some()
        {
            return Err(ProgramError::Usage(
                "live credentials and --login-uri cannot be combined with --fake-grid".into(),
            ));
        }
        if cli.search_term.is_some() && cli.fake_search.is_some() {
            return Err(ProgramError::Usage(
                "positional SEARCH_TERM and --search cannot be combined".into(),
            ));
        }
        run_fixture(
            path,
            cli.fake_search.as_deref().or(cli.search_term.as_deref()),
            cli.limit,
            cli.property_timeout_ms,
            &mut output,
        )?;
    } else {
        let live = resolve_live_arguments(&cli)?;
        runtime.block_on(run_live(
            live,
            cli.limit,
            Duration::from_millis(cli.discovery_delay_ms),
            Duration::from_millis(cli.property_timeout_ms),
            &mut output,
        ))?;
    }
    output.flush()
}

fn validate_search(search: Option<&str>) -> Result<(), ProgramError> {
    if search.is_some_and(|value| value.chars().count() > 256) {
        return Err(ProgramError::Usage(
            "SEARCH_TERM must contain at most 256 characters".into(),
        ));
    }
    Ok(())
}

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")?,
        search_term: cli.search_term.clone(),
        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,
    limit: usize,
    discovery_delay: Duration,
    property_timeout: Duration,
    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 objects = client.objects();
    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),
            "PrimInspector".into(),
            env!("CARGO_PKG_VERSION").into(),
        )
        .map_err(|_| ProgramError::Client("build login parameters"))?;
    if let Some(uri) = arguments.login_uri.take() {
        login.uri = uri;
    }
    let (sender, mut receiver) = mpsc::channel(EVENT_QUEUE_CAPACITY);
    let subscriptions = install_subscriptions(&network, &objects, &sender);
    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_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_err(|_| ProgramError::Cancelled)?;
            Err(ProgramError::Cancelled)
        }
    };
    let result = async {
        if !login_result? {
            return Err(ProgramError::LoginFailed);
        }
        let simulator = network
            .current_sim()
            .ok_or(ProgramError::Client("resolve current simulator"))?;
        drain_login_events(&mut receiver, output)?;
        let position = client.self_().sim_position();
        output.line(format!("Region: {}", simulator.name))?;
        output.line(format!("Agent position: {}", vector3(position)))?;
        output.line(format!(
            "Collecting object updates for {} ms...",
            discovery_delay.as_millis()
        ))?;
        wait_duration(discovery_delay, &mut receiver, output).await?;
        let mut primitives = snapshot(&simulator)?;
        output.line(format!("Found {} primitives", primitives.len()))?;
        hydrate_live_properties(
            &objects,
            &simulator,
            &mut primitives,
            property_timeout,
            &mut receiver,
            output,
        )
        .await?;
        inspect(
            primitives,
            position,
            arguments.search_term.as_deref(),
            limit,
            output,
        )
    }
    .await;
    shutdown(&client, &network, subscriptions);
    result
}

fn drain_login_events(
    receiver: &mut mpsc::Receiver<LiveEvent>,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    while let Ok(event) = receiver.try_recv() {
        match event {
            LiveEvent::Status(status) => output.line(status)?,
            LiveEvent::Properties(_)
            | LiveEvent::SimulatorChanged {
                had_previous: false,
            } => {}
            LiveEvent::SimulatorChanged { had_previous: true } => {
                return Err(ProgramError::SimulatorChanged);
            }
            LiveEvent::Disconnected => return Err(ProgramError::Disconnected),
        }
    }
    Ok(())
}

fn install_subscriptions(
    network: &NetworkManager,
    objects: &libremetaverse::ObjectManager,
    sender: &mpsc::Sender<LiveEvent>,
) -> Vec<Subscription> {
    let status = sender.clone();
    let properties = sender.clone();
    let disconnected = sender.clone();
    let changed = sender.clone();
    vec![
        network.subscribe_login_progress(Arc::new(move |event: LoginProgressEventArgs| {
            let _ = status.try_send(LiveEvent::Status(format!(
                "Login {:?}: {}",
                event.status(),
                event.message()
            )));
        })),
        objects.subscribe_object_properties(Arc::new(move |event: ObjectPropertiesEventArgs| {
            let _ = properties.try_send(LiveEvent::Properties(Box::new(event.properties())));
        })),
        network.subscribe_disconnected(Arc::new(move |_event: DisconnectedEventArgs| {
            let _ = disconnected.try_send(LiveEvent::Disconnected);
        })),
        network.subscribe_sim_changed(Arc::new(move |event: SimChangedEventArgs| {
            let _ = changed.try_send(LiveEvent::SimulatorChanged {
                had_previous: event.previous_simulator().is_some(),
            });
        })),
    ]
}

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

fn snapshot(simulator: &Simulator) -> Result<Vec<Primitive>, ProgramError> {
    let values = simulator
        .objects_primitives
        .read()
        .map_err(|_| ProgramError::Client("lock simulator primitives"))?;
    if values.len() > MAX_OBJECTS {
        return Err(ProgramError::Client(
            "primitive snapshot exceeds safety limit",
        ));
    }
    Ok(values.values().cloned().collect())
}

async fn wait_duration(
    duration: Duration,
    receiver: &mut mpsc::Receiver<LiveEvent>,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let deadline = tokio::time::Instant::now() + duration;
    loop {
        tokio::select! {
            () = tokio::time::sleep_until(deadline) => return Ok(()),
            signal = tokio::signal::ctrl_c() => {
                signal.map_err(|_| ProgramError::Cancelled)?;
                return Err(ProgramError::Cancelled);
            }
            event = receiver.recv() => match event {
                Some(LiveEvent::Status(status)) => output.line(status)?,
                Some(LiveEvent::Disconnected) => return Err(ProgramError::Disconnected),
                Some(LiveEvent::SimulatorChanged { had_previous: true }) => return Err(ProgramError::SimulatorChanged),
                Some(LiveEvent::SimulatorChanged { had_previous: false } | LiveEvent::Properties(_)) => {},
                None => return Err(ProgramError::Client("event queue closed")),
            }
        }
    }
}

async fn hydrate_live_properties(
    objects: &libremetaverse::ObjectManager,
    simulator: &Simulator,
    primitives: &mut [Primitive],
    timeout: Duration,
    receiver: &mut mpsc::Receiver<LiveEvent>,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let mut pending = HashMap::new();
    for primitive in primitives
        .iter()
        .filter(|primitive| primitive.properties.is_none())
        .take(MAX_PROPERTY_REQUESTS)
    {
        objects
            .select_object_with_simulator_u_int32(simulator.clone(), primitive.local_id)
            .map_err(|_| ProgramError::Client("request object properties"))?;
        pending.insert(primitive.id, primitive.local_id);
    }
    if pending.is_empty() {
        return Ok(());
    }
    output.line(format!(
        "Requested properties for {} primitives",
        pending.len()
    ))?;
    let deadline = tokio::time::Instant::now() + timeout;
    while !pending.is_empty() {
        tokio::select! {
            () = tokio::time::sleep_until(deadline) => break,
            signal = tokio::signal::ctrl_c() => {
                signal.map_err(|_| ProgramError::Cancelled)?;
                return Err(ProgramError::Cancelled);
            }
            event = receiver.recv() => match event {
                Some(LiveEvent::Properties(properties)) => {
                    if pending.remove(&properties.object_id).is_some()
                        && let Some(primitive) = primitives.iter_mut().find(|primitive| primitive.id == properties.object_id)
                    { primitive.properties = Some(*properties); }
                }
                Some(LiveEvent::Status(status)) => output.line(status)?,
                Some(LiveEvent::Disconnected) => return Err(ProgramError::Disconnected),
                Some(LiveEvent::SimulatorChanged { had_previous: true }) => return Err(ProgramError::SimulatorChanged),
                Some(LiveEvent::SimulatorChanged { had_previous: false }) => {},
                None => return Err(ProgramError::Client("event queue closed")),
            }
        }
    }
    if !pending.is_empty() {
        output.line(format!(
            "Property timeout: {} primitives remain unknown",
            pending.len()
        ))?;
    }
    Ok(())
}

fn run_fixture(
    path: &Path,
    search: Option<&str>,
    limit: usize,
    property_timeout_ms: u64,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let fixture = read_fixture(path)?;
    output.line("Fake grid connected")?;
    output.line(format!("Region: {}", fixture.region))?;
    output.line(format!(
        "Agent position: {}",
        vector3(fixture.agent_position)
    ))?;
    output.line(format!("Found {} primitives", fixture.objects.len()))?;
    let mut primitives = Vec::with_capacity(fixture.objects.len());
    let mut missing = 0;
    for mut object in fixture.objects {
        match object.state {
            PropertyState::Ready => {}
            PropertyState::Delayed => {
                output.line(format!(
                    "CALL select-object local_id={}",
                    object.primitive.local_id
                ))?;
                object.primitive.properties = object.pending_properties.take();
                output.line(format!(
                    "PROPERTY received local_id={}",
                    object.primitive.local_id
                ))?;
            }
            PropertyState::Missing => {
                output.line(format!(
                    "CALL select-object local_id={}",
                    object.primitive.local_id
                ))?;
                missing += 1;
            }
        }
        primitives.push(object.primitive);
    }
    if missing != 0 {
        output.line(format!(
            "Property timeout after {property_timeout_ms} ms: {missing} primitives remain unknown"
        ))?;
    }
    inspect(primitives, fixture.agent_position, search, limit, output)?;
    output.line("Fake grid logout complete; active_tasks=0 open_sockets=0")
}

fn read_fixture(path: &Path) -> Result<Fixture, ProgramError> {
    let metadata = fs::metadata(path).map_err(|source| ProgramError::Input {
        action: format!("reading fixture metadata {}", path.display()),
        source,
    })?;
    if metadata.len() > MAX_FIXTURE_BYTES {
        return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
    }
    let bytes = fs::read(path).map_err(|source| ProgramError::Input {
        action: format!("reading fixture {}", path.display()),
        source,
    })?;
    if bytes.len() > MAX_FIXTURE_ALLOC_BYTES {
        return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
    }
    let text =
        String::from_utf8(bytes).map_err(|_| ProgramError::InvalidFixture("JSON must be UTF-8"))?;
    let root = OSDParser::deserialize_json_with_string(text)
        .map_err(|_| ProgramError::InvalidFixture("could not parse LLSD JSON"))?;
    root.validate_limits(32, 1_000_000, MAX_FIXTURE_ALLOC_BYTES)
        .map_err(|_| ProgramError::InvalidFixture("LLSD limits exceeded"))?;
    let OSD::Map(root) = root else {
        return Err(ProgramError::InvalidFixture("root must be a map"));
    };
    let region = value_string(&root, "region").unwrap_or_else(|| "Fake Region".into());
    let agent_position = root
        .get("agent_position")
        .and_then(|value| value.as_vector3().ok())
        .ok_or(ProgramError::InvalidFixture(
            "agent_position must be a three-number array",
        ))?;
    let Some(OSD::Array(values)) = root.get("objects") else {
        return Err(ProgramError::InvalidFixture("objects must be an array"));
    };
    if values.len() > MAX_OBJECTS {
        return Err(ProgramError::InvalidFixture("too many objects"));
    }
    let mut objects = Vec::with_capacity(values.len());
    for value in values {
        let OSD::Map(map) = value else {
            return Err(ProgramError::InvalidFixture("each object must be a map"));
        };
        let mut primitive = Primitive::from_osd(value.clone())
            .map_err(|_| ProgramError::InvalidFixture("invalid primitive data"))?;
        if primitive.id == UUID::zero() || primitive.local_id == 0 {
            return Err(ProgramError::InvalidFixture(
                "each object needs non-zero id and localid",
            ));
        }
        primitive.text = value_string(map, "hover_text").unwrap_or_default();
        let mut properties = primitive.properties.take().unwrap_or_default();
        properties.object_id = primitive.id;
        properties.owner_id = value_uuid(map, "owner").unwrap_or_default();
        properties.creator_id = value_uuid(map, "creator").unwrap_or_default();
        properties.sale_type = parse_sale_type(value_string(map, "sale_type").as_deref())?;
        properties.sale_price = map
            .get("sale_price")
            .and_then(|value| value.as_integer().ok())
            .unwrap_or_default();
        let state = match value_string(map, "property_state")
            .as_deref()
            .unwrap_or("ready")
        {
            "ready" => PropertyState::Ready,
            "delayed" => PropertyState::Delayed,
            "missing" => PropertyState::Missing,
            _ => {
                return Err(ProgramError::InvalidFixture(
                    "property_state must be ready, delayed, or missing",
                ));
            }
        };
        let (current, pending) = match state {
            PropertyState::Ready => (Some(properties), None),
            PropertyState::Delayed => (None, Some(properties)),
            PropertyState::Missing => (None, None),
        };
        primitive.properties = current;
        objects.push(FixtureObject {
            primitive,
            pending_properties: pending,
            state,
        });
    }
    Ok(Fixture {
        region,
        agent_position,
        objects,
    })
}

fn value_string(map: &HashMap<String, OSD>, key: &str) -> Option<String> {
    map.get(key).and_then(|value| value.as_string().ok())
}

fn value_uuid(map: &HashMap<String, OSD>, key: &str) -> Option<UUID> {
    map.get(key)
        .and_then(|value| value.as_uuid().ok())
        .filter(|value| *value != UUID::zero())
}

fn parse_sale_type(value: Option<&str>) -> Result<SaleType, ProgramError> {
    match value.unwrap_or("not").to_ascii_lowercase().as_str() {
        "not" | "none" => Ok(SaleType::Not),
        "original" => Ok(SaleType::Original),
        "copy" => Ok(SaleType::Copy),
        "contents" => Ok(SaleType::Contents),
        _ => Err(ProgramError::InvalidFixture("unknown sale_type")),
    }
}

fn inspect(
    primitives: Vec<Primitive>,
    agent_position: Vector3,
    search: Option<&str>,
    limit: usize,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let needle = search.map(str::to_lowercase);
    let mut matches = primitives
        .into_iter()
        .filter_map(|primitive| {
            let name = primitive
                .properties
                .as_ref()
                .map_or("Unknown", |properties| properties.name.as_str());
            if needle
                .as_ref()
                .is_some_and(|needle| !name.to_lowercase().contains(needle))
            {
                return None;
            }
            let distance = Vector3::distance(primitive.position, agent_position).ok()?;
            Some((distance, primitive))
        })
        .collect::<Vec<_>>();
    matches.sort_by(|(left_distance, left), (right_distance, right)| {
        left_distance
            .total_cmp(right_distance)
            .then_with(|| left.local_id.cmp(&right.local_id))
            .then_with(|| left.id.to_string().cmp(&right.id.to_string()))
    });
    if matches.is_empty() {
        if let Some(search) = search {
            output.line(format!("No objects matching '{search}'"))?;
        } else {
            output.line("No primitives found")?;
        }
        return Ok(());
    }
    output.line(format!(
        "Inspecting {} nearest matching primitives",
        matches.len().min(limit)
    ))?;
    for (distance, primitive) in matches.into_iter().take(limit) {
        format_primitive(&primitive, distance, output)?;
    }
    Ok(())
}

fn format_primitive(
    primitive: &Primitive,
    distance: f32,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let name = primitive
        .properties
        .as_ref()
        .map(|properties| properties.name.as_str())
        .filter(|name| !name.is_empty())
        .unwrap_or("Unknown");
    output.line("")?;
    output.line(format!("Object: {name} (ID: {})", primitive.id))?;
    output.line(format!("  Local ID: {}", primitive.local_id))?;
    output.line(format!("  Type: {:?}", primitive.type_()))?;
    output.line(format!("  Distance: {distance:.2} m"))?;
    output.line(format!("  Position: {}", vector3(primitive.position)))?;
    output.line(format!(
        "  Rotation: <{:.3}, {:.3}, {:.3}, {:.3}>",
        primitive.rotation.x, primitive.rotation.y, primitive.rotation.z, primitive.rotation.w
    ))?;
    output.line(format!("  Scale: {}", vector3(primitive.scale)))?;
    if primitive.parent_id != 0 {
        output.line(format!("  Parent ID: {}", primitive.parent_id))?;
    }
    output.line(format!("  Flags: {:?}", primitive.flags))?;
    output.line(format!("  Material: {:?}", primitive.prim_data.material))?;
    output.line(format!("  PCode: {:?}", primitive.prim_data.p_code))?;
    output.line(format!(
        "  Profile: {:?}",
        primitive.prim_data.profile_curve_with_property()
    ))?;
    output.line(format!("  Path: {:?}", primitive.prim_data.path_curve))?;
    if let Some(sculpt) = &primitive.sculpt {
        output.line(format!("  Sculpt Type: {:?}", sculpt.type_()))?;
        output.line(format!("  Sculpt Texture: {}", sculpt.sculpt_texture))?;
    }
    if let Some(light) = &primitive.light {
        output.line(format!(
            "  Light Color: <{:.3}, {:.3}, {:.3}, {:.3}>",
            light.color.r, light.color.g, light.color.b, light.color.a
        ))?;
        output.line(format!("  Light Intensity: {:.3}", light.intensity))?;
        output.line(format!("  Light Radius: {:.3}", light.radius))?;
    }
    if let Some(flexible) = &primitive.flexible {
        output.line(format!("  Flexible Softness: {}", flexible.softness))?;
        output.line(format!("  Flexible Gravity: {:.3}", flexible.gravity))?;
    }
    if let Some(properties) = &primitive.properties {
        output.line(format!("  Owner: {}", properties.owner_id))?;
        output.line(format!("  Creator: {}", properties.creator_id))?;
        output.line(format!("  Description: {}", properties.description))?;
        if properties.sale_type != SaleType::Not {
            output.line(format!(
                "  For Sale: {:?} for L${}",
                properties.sale_type, properties.sale_price
            ))?;
        }
    } else {
        output.line("  Properties: unavailable")?;
    }
    if !primitive.text.is_empty() {
        output.line(format!("  Hover Text: {}", primitive.text))?;
    }
    Ok(())
}

fn sanitize(value: &str) -> String {
    let value = value.replace(['\r', '\n', '\t'], " ");
    value
        .split_whitespace()
        .map(|word| {
            let lower = word.to_ascii_lowercase();
            if lower.starts_with("http://")
                || lower.starts_with("https://")
                || lower.contains("password=")
                || lower.contains("token=")
            {
                "[redacted]"
            } else {
                word
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn vector3(value: Vector3) -> String {
    format!("<{:.3}, {:.3}, {:.3}>", value.x, value.y, value.z)
}

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

    #[test]
    fn sanitize_removes_urls_and_secret_parameters() {
        assert_eq!(
            sanitize("see https://grid.invalid/?token=secret now"),
            "see [redacted] now"
        );
    }

    #[test]
    fn sale_type_parser_accepts_documented_values() {
        assert_eq!(parse_sale_type(Some("COPY")).unwrap(), SaleType::Copy);
        assert!(parse_sale_type(Some("auction")).is_err());
    }
}
