//! Bounded native inventory browsing, statistics, search, and text export.

use clap::Parser;
use clap::error::ErrorKind;
use libremetaverse::structured_data::{OSD, OSDParser};
use libremetaverse::types::compat::{CancellationTokenSource, Subscription};
use libremetaverse::types::{AssetType, UUID};
use libremetaverse::{
    DisconnectedEventArgs, GridClient, Inventory, InventoryAISClient, InventoryFolder,
    InventoryManager, InventoryObjectClass, LoginProgressEventArgs, NetworkManager,
};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
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_INVENTORY_TIMEOUT_SECONDS: u64 = 30;
const DEFAULT_MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
const DEFAULT_MAX_EXPORT_BYTES: usize = 16 * 1024 * 1024;
const DEFAULT_MAX_ENTRIES: usize = 100_000;
const MAX_FIXTURE_BYTES: u64 = 8 * 1024 * 1024;
const MAX_FIXTURE_ALLOC_BYTES: usize = 8 * 1024 * 1024;
const EVENT_QUEUE_CAPACITY: usize = 256;

#[derive(Parser)]
#[command(
    name = "inventory-explorer",
    version,
    about = "Explore, search, summarize, and export native LibreMetaverse inventory",
    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>,

    /// Search folders and items by case-insensitive name substring.
    #[arg(long, value_name = "TERM")]
    search: Option<String>,

    /// Restrict search results to this item asset type.
    #[arg(long = "type", value_name = "TYPE", value_parser = parse_asset_type)]
    asset_type: Option<AssetType>,

    /// Write a deterministic hierarchical inventory export.
    #[arg(long, value_name = "FILE")]
    export: Option<PathBuf>,

    /// Print inventory statistics.
    #[arg(long)]
    stats: bool,

    /// Parse a bounded AIS LLSD JSON response instead of logging in.
    #[arg(long, value_name = "FILE")]
    fake_ais: Option<PathBuf>,

    /// 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,

    /// Maximum time to wait for a populated inventory root.
    #[arg(long, default_value_t = DEFAULT_INVENTORY_TIMEOUT_SECONDS, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
    inventory_timeout_seconds: u64,

    /// Maximum search results to display.
    #[arg(long, default_value_t = 50, value_name = "COUNT")]
    max_results: usize,

    /// Maximum hierarchy entries to traverse.
    #[arg(long, default_value_t = DEFAULT_MAX_ENTRIES, value_name = "COUNT")]
    max_entries: usize,

    /// Maximum folder depth to traverse.
    #[arg(long, default_value_t = 64, value_name = "DEPTH")]
    max_depth: usize,

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

    /// Refuse to write a larger export file.
    #[arg(long, default_value_t = DEFAULT_MAX_EXPORT_BYTES, value_name = "BYTES")]
    max_export_bytes: usize,
}

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

struct OperationOptions {
    stats: bool,
    search: Option<String>,
    asset_type: Option<AssetType>,
    export: Option<PathBuf>,
    max_results: usize,
    max_entries: usize,
    max_depth: usize,
    max_export_bytes: usize,
}

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

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::InventoryUnavailable
            | Self::Disconnected
            | Self::Cancelled
            | Self::HierarchyCycle(_)
            | Self::HierarchyDepth
            | Self::EntryLimit => EXIT_CLIENT,
            Self::Output(_) | Self::OutputLimit | Self::Export { .. } | Self::ExportLimit => {
                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::Export { action, source } => {
                write!(formatter, "{action}: {source}")
            }
            Self::InvalidFixture(reason) => write!(formatter, "invalid fake AIS 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::InventoryUnavailable => {
                formatter.write_str("inventory root did not become available")
            }
            Self::Disconnected => formatter.write_str("grid disconnected while loading inventory"),
            Self::Cancelled => formatter.write_str("inventory operation cancelled"),
            Self::HierarchyCycle(uuid) => {
                write!(formatter, "inventory folder cycle detected at {uuid}")
            }
            Self::HierarchyDepth => {
                formatter.write_str("inventory exceeds the hierarchy depth limit")
            }
            Self::EntryLimit => formatter.write_str("inventory exceeds the entry traversal limit"),
            Self::Output(source) => write!(formatter, "writing standard output: {source}"),
            Self::OutputLimit => formatter.write_str("inventory output exceeds the byte limit"),
            Self::ExportLimit => formatter.write_str("inventory export exceeds the byte limit"),
        }
    }
}

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 = safe_text(value.as_ref());
        let required = value
            .len()
            .checked_add(1)
            .ok_or(ProgramError::OutputLimit)?;
        if self
            .bytes
            .len()
            .checked_add(required)
            .is_none_or(|total| total > 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 stdout = io::stdout().lock();
        stdout
            .write_all(&self.bytes)
            .map_err(ProgramError::Output)?;
        stdout.flush().map_err(ProgramError::Output)
    }
}

enum LiveEvent {
    Status(String),
    Disconnected,
}

#[derive(Clone)]
struct SnapshotEntry {
    uuid: UUID,
    parent_uuid: UUID,
    parent_name: String,
    name: String,
    kind: EntryKind,
}

#[derive(Clone, Copy)]
enum EntryKind {
    Folder,
    Item {
        asset_type: AssetType,
        target: Option<UUID>,
    },
    Other,
}

impl SnapshotEntry {
    const fn is_folder(&self) -> bool {
        matches!(self.kind, EntryKind::Folder)
    }
}

struct InventorySnapshot {
    root: InventoryFolder,
    entries: Vec<SnapshotEntry>,
}

#[must_use]
pub fn main_entry() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("inventory-explorer: {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())),
    };
    validate_cli(&cli)?;
    let options = OperationOptions {
        stats: cli.stats,
        search: cli.search.clone(),
        asset_type: cli.asset_type,
        export: cli.export.clone(),
        max_results: cli.max_results,
        max_entries: cli.max_entries,
        max_depth: cli.max_depth,
        max_export_bytes: cli.max_export_bytes,
    };
    let mut output = BoundedOutput::new(cli.max_output_bytes);
    if let Some(path) = &cli.fake_ais {
        run_fake(path, &options, &mut output)?;
    } else {
        let arguments = resolve_live_arguments(&cli)?;
        let runtime =
            tokio::runtime::Runtime::new().map_err(|_| ProgramError::Client("start runtime"))?;
        runtime.block_on(run_live(arguments, &options, &mut output))?;
    }
    output.flush()
}

fn validate_cli(cli: &Cli) -> Result<(), ProgramError> {
    if cli
        .search
        .as_ref()
        .is_some_and(|value| value.is_empty() || value.chars().count() > 256)
    {
        return Err(ProgramError::Usage(
            "--search must contain 1 to 256 characters".into(),
        ));
    }
    if cli.fake_ais.is_some()
        && (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-ais".into(),
        ));
    }
    if !(1..=1_000).contains(&cli.max_results) {
        return Err(ProgramError::Usage(
            "--max-results must be between 1 and 1000".into(),
        ));
    }
    if !(1..=1_000_000).contains(&cli.max_entries) {
        return Err(ProgramError::Usage(
            "--max-entries must be between 1 and 1000000".into(),
        ));
    }
    if !(1..=512).contains(&cli.max_depth) {
        return Err(ProgramError::Usage(
            "--max-depth must be between 1 and 512".into(),
        ));
    }
    if cli.max_output_bytes == 0 || cli.max_export_bytes == 0 {
        return Err(ProgramError::Usage(
            "output byte limits must be greater than zero".into(),
        ));
    }
    if cli.asset_type.is_some() && cli.search.is_none() {
        return Err(ProgramError::Usage("--type requires --search".into()));
    }
    if let Some(path) = &cli.export {
        validate_export_path(path)?;
    }
    Ok(())
}

fn validate_export_path(path: &Path) -> Result<(), ProgramError> {
    if path.as_os_str().is_empty() || path.to_string_lossy().chars().count() > 4_096 {
        return Err(ProgramError::Usage(
            "--export path must contain 1 to 4096 characters".into(),
        ));
    }
    if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
        return Err(ProgramError::Usage(
            "--export refuses to follow a symbolic link".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")?,
        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),
        inventory_timeout: Duration::from_secs(cli.inventory_timeout_seconds),
    })
}

async fn run_live(
    mut arguments: LiveArguments,
    options: &OperationOptions,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let client = GridClient::new().map_err(|_| ProgramError::Client("construct GridClient"))?;
    let network = client.network();
    let inventory = client.inventory();
    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),
            "InventoryExplorer".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, &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);
        }
        output.line("Logged in successfully")?;
        output.line("Downloading inventory...")?;
        let store = wait_for_inventory(
            &inventory,
            arguments.inventory_timeout,
            &mut receiver,
            output,
        )
        .await?;
        output.line(format!("Inventory loaded: {} entries", store.count()))?;
        process_store(&store, options, output)
    }
    .await;
    shutdown(&client, &network, subscriptions);
    result
}

fn install_subscriptions(
    network: &NetworkManager,
    sender: &mpsc::Sender<LiveEvent>,
) -> Vec<Subscription> {
    let status = sender.clone();
    let disconnected = sender.clone();
    vec![
        network.subscribe_login_progress(Arc::new(move |event: LoginProgressEventArgs| {
            let _ = status.try_send(LiveEvent::Status(format!(
                "Login {:?}: {}",
                event.status(),
                event.message()
            )));
        })),
        network.subscribe_disconnected(Arc::new(move |_event: DisconnectedEventArgs| {
            let _ = disconnected.try_send(LiveEvent::Disconnected);
        })),
    ]
}

async fn wait_for_inventory(
    manager: &InventoryManager,
    timeout: Duration,
    receiver: &mut mpsc::Receiver<LiveEvent>,
    output: &mut BoundedOutput,
) -> Result<Inventory, ProgramError> {
    let deadline = tokio::time::Instant::now() + timeout;
    loop {
        if let Some(store) = manager.store()
            && store.count() > 0
            && store.root_folder().is_some()
        {
            return Ok(store);
        }
        tokio::select! {
            () = tokio::time::sleep(Duration::from_millis(100)) => {},
            () = tokio::time::sleep_until(deadline) => return Err(ProgramError::InventoryUnavailable),
            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),
                None => return Err(ProgramError::Client("inventory event queue closed")),
            }
        }
    }
}

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

fn run_fake(
    path: &Path,
    options: &OperationOptions,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let root = read_fixture(path)?;
    let client =
        Arc::new(GridClient::new().map_err(|_| ProgramError::Client("construct fake GridClient"))?);
    let result = (|| {
        let manager = client.inventory();
        let mut store = manager
            .store()
            .ok_or(ProgramError::Client("resolve fake inventory store"))?;
        let ais = InventoryAISClient::new(Some(Arc::clone(&client)))
            .map_err(|_| ProgramError::Client("construct AIS parser"))?;
        let root_id = match &root {
            OSD::Map(map) => map.get("root_id").and_then(|value| value.as_uuid().ok()),
            _ => None,
        }
        .filter(|uuid| *uuid != UUID::zero())
        .ok_or(ProgramError::InvalidFixture(
            "root_id must be a non-zero UUID",
        ))?;
        let mut folders = None;
        let mut items = None;
        let mut links = None;
        ais.parse_embedded(root, &mut folders, &mut items, &mut links)
            .map_err(|_| ProgramError::InvalidFixture("AIS embedded inventory is invalid"))?;
        let folders = folders.unwrap_or_default();
        let items = items.unwrap_or_default();
        let links = links.unwrap_or_default();
        if folders
            .len()
            .saturating_add(items.len())
            .saturating_add(links.len())
            > options.max_entries
        {
            return Err(ProgramError::EntryLimit);
        }
        for folder in &folders {
            store
                .update_node_for(folder)
                .map_err(|_| ProgramError::InvalidFixture("could not add AIS folder to store"))?;
        }
        for item in items.iter().chain(&links) {
            store
                .update_node_for(item)
                .map_err(|_| ProgramError::InvalidFixture("could not add AIS item to store"))?;
        }
        let root_folder = folders
            .iter()
            .find(|folder| folder.base.uuid() == root_id)
            .cloned()
            .ok_or(ProgramError::InvalidFixture(
                "root_id does not identify a parsed folder",
            ))?;
        store.set_root_folder(Some(root_folder));
        output.line(format!("CALL AIS fetch root={root_id}"))?;
        output.line(format!(
            "Fake AIS inventory loaded: {} entries",
            store.count()
        ))?;
        process_store(&store, options, output)
    })();
    let _ = client.dispose_with_method();
    result?;
    output.line("Fake grid logout complete; active_tasks=0 open_sockets=0")
}

fn read_fixture(path: &Path) -> Result<OSD, 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(64, 1_000_000, MAX_FIXTURE_ALLOC_BYTES)
        .map_err(|_| ProgramError::InvalidFixture("LLSD limits exceeded"))?;
    if !matches!(root, OSD::Map(_)) {
        return Err(ProgramError::InvalidFixture("root must be a map"));
    }
    Ok(root)
}

fn process_store(
    store: &Inventory,
    options: &OperationOptions,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let snapshot = snapshot(store, options.max_entries, options.max_depth)?;
    let no_explicit_operation =
        !options.stats && options.search.is_none() && options.export.is_none();
    if options.stats {
        show_statistics(store, &snapshot, output)?;
    }
    if let Some(search) = &options.search {
        show_search(
            &snapshot,
            search,
            options.asset_type,
            options.max_results,
            output,
        )?;
    }
    if let Some(path) = &options.export {
        export_inventory(
            &snapshot,
            path,
            options.max_export_bytes,
            options.max_depth,
            output,
        )?;
    }
    if no_explicit_operation {
        show_top_level(store, &snapshot.root, output)?;
    }
    Ok(())
}

fn snapshot(
    store: &Inventory,
    max_entries: usize,
    max_depth: usize,
) -> Result<InventorySnapshot, ProgramError> {
    let root = store
        .root_folder()
        .ok_or(ProgramError::InventoryUnavailable)?;
    let mut entries = Vec::new();
    let mut visited = HashSet::new();
    let mut stack = vec![(root.clone(), 0_usize)];
    while let Some((folder, depth)) = stack.pop() {
        let folder_id = folder.base.uuid();
        if !visited.insert(folder_id) {
            return Err(ProgramError::HierarchyCycle(folder_id));
        }
        if depth > max_depth {
            return Err(ProgramError::HierarchyDepth);
        }
        let mut contents = store
            .get_contents_with_uuid(folder_id)
            .map_err(|_| ProgramError::Client("read inventory folder contents"))?;
        contents.sort_by(|left, right| compare_objects(left.as_ref(), right.as_ref()));
        for object in contents.into_iter().rev() {
            if entries.len() >= max_entries {
                return Err(ProgramError::EntryLimit);
            }
            let entry = snapshot_entry(object.as_ref(), &folder.base.name());
            if entry.is_folder() {
                let child = object
                    .as_any()
                    .downcast_ref::<InventoryFolder>()
                    .cloned()
                    .ok_or(ProgramError::Client("downcast inventory folder"))?;
                stack.push((child, depth + 1));
            }
            entries.push(entry);
        }
    }
    Ok(InventorySnapshot { root, entries })
}

fn snapshot_entry(object: &dyn InventoryObjectClass, parent_name: &str) -> SnapshotEntry {
    let base = object.inventory_base();
    let kind = if object.as_any().is::<InventoryFolder>() {
        EntryKind::Folder
    } else if let Some(item) = object.inventory_item() {
        let target = matches!(item.asset_type(), AssetType::Link | AssetType::LinkFolder)
            .then(|| item.asset_uuid());
        EntryKind::Item {
            asset_type: item.asset_type(),
            target,
        }
    } else {
        EntryKind::Other
    };
    SnapshotEntry {
        uuid: base.uuid(),
        parent_uuid: base.parent_uuid(),
        parent_name: parent_name.into(),
        name: base.name(),
        kind,
    }
}

fn compare_objects(left: &dyn InventoryObjectClass, right: &dyn InventoryObjectClass) -> Ordering {
    let left_folder = left.as_any().is::<InventoryFolder>();
    let right_folder = right.as_any().is::<InventoryFolder>();
    right_folder
        .cmp(&left_folder)
        .then_with(|| {
            left.inventory_base()
                .name()
                .to_lowercase()
                .cmp(&right.inventory_base().name().to_lowercase())
        })
        .then_with(|| {
            left.inventory_base()
                .uuid()
                .cmp(&right.inventory_base().uuid())
        })
}

fn compare_entries(left: &SnapshotEntry, right: &SnapshotEntry) -> Ordering {
    right
        .is_folder()
        .cmp(&left.is_folder())
        .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
        .then_with(|| left.uuid.cmp(&right.uuid))
}

fn show_statistics(
    store: &Inventory,
    snapshot: &InventorySnapshot,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let item_count = snapshot
        .entries
        .iter()
        .filter(|entry| matches!(entry.kind, EntryKind::Item { .. }))
        .count();
    let root_folders = store
        .get_contents_with_uuid(snapshot.root.base.uuid())
        .map_err(|_| ProgramError::Client("read root inventory contents"))?
        .into_iter()
        .filter(|entry| entry.as_any().is::<InventoryFolder>())
        .count();
    output.line("=== Inventory Statistics ===")?;
    output.line(format!("Total Items: {item_count}"))?;
    output.line(format!("Root Folders: {root_folders}"))?;
    output.line("")?;
    let mut counts = HashMap::<AssetType, usize>::new();
    for entry in &snapshot.entries {
        if let EntryKind::Item { asset_type, .. } = entry.kind {
            *counts.entry(asset_type).or_default() += 1;
        }
    }
    let mut counts = counts.into_iter().collect::<Vec<_>>();
    counts.sort_by(|(left_type, left_count), (right_type, right_count)| {
        right_count
            .cmp(left_count)
            .then_with(|| format!("{left_type:?}").cmp(&format!("{right_type:?}")))
    });
    output.line("Top Item Types:")?;
    for (asset_type, count) in counts.into_iter().take(10) {
        output.line(format!("{asset_type:?}: {count} items"))?;
    }
    output.line("")
}

fn show_search(
    snapshot: &InventorySnapshot,
    search: &str,
    filter: Option<AssetType>,
    max_results: usize,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let needle = search.to_lowercase();
    let mut results = snapshot.entries.iter().filter(|entry| entry.name.to_lowercase().contains(&needle))
        .filter(|entry| filter.is_none_or(|filter| matches!(entry.kind, EntryKind::Item { asset_type, .. } if asset_type == filter)))
        .collect::<Vec<_>>();
    results.sort_by(|left, right| {
        left.name
            .to_lowercase()
            .cmp(&right.name.to_lowercase())
            .then_with(|| left.uuid.cmp(&right.uuid))
    });
    output.line(format!("=== Search Results for '{search}' ==="))?;
    output.line(format!("Found {} matching entries", results.len()))?;
    output.line("")?;
    for entry in results.iter().take(max_results) {
        output.line(&entry.name)?;
        match entry.kind {
            EntryKind::Folder => output.line("Type: Folder")?,
            EntryKind::Item { asset_type, target } => {
                output.line(format!("Type: {asset_type:?}"))?;
                if let Some(target) = target {
                    output.line(format!("Link Target: {target}"))?;
                }
            }
            EntryKind::Other => output.line("Type: Unknown")?,
        }
        output.line(format!("Folder: {}", entry.parent_name))?;
        output.line(format!("UUID: {}", entry.uuid))?;
        output.line("")?;
    }
    if results.len() > max_results {
        output.line(format!(
            "... and {} more results",
            results.len() - max_results
        ))?;
    }
    Ok(())
}

fn show_top_level(
    store: &Inventory,
    root: &InventoryFolder,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    output.line("=== Inventory Tree (Top Level) ===")?;
    output.line("")?;
    let mut contents = store
        .get_contents_with_uuid(root.base.uuid())
        .map_err(|_| ProgramError::Client("read root inventory contents"))?;
    contents.sort_by(|left, right| compare_objects(left.as_ref(), right.as_ref()));
    for object in contents {
        let name = object.inventory_base().name();
        if let Some(folder) = object.as_any().downcast_ref::<InventoryFolder>() {
            let count = store
                .get_contents_with_uuid(folder.base.uuid())
                .map_err(|_| ProgramError::Client("read top-level folder contents"))?
                .len();
            output.line(format!("Folder: {name} ({count} entries)"))?;
        } else if let Some(item) = object.inventory_item() {
            if matches!(item.asset_type(), AssetType::Link | AssetType::LinkFolder) {
                output.line(format!(
                    "Link: {name} ({:?} -> {})",
                    item.asset_type(),
                    item.asset_uuid()
                ))?;
            } else {
                output.line(format!("Item: {name} ({:?})", item.asset_type()))?;
            }
        } else {
            output.line(format!("Entry: {name}"))?;
        }
    }
    Ok(())
}

fn export_inventory(
    snapshot: &InventorySnapshot,
    path: &Path,
    maximum: usize,
    max_depth: usize,
    output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
    let mut children = HashMap::<UUID, Vec<&SnapshotEntry>>::new();
    for entry in &snapshot.entries {
        children.entry(entry.parent_uuid).or_default().push(entry);
    }
    for values in children.values_mut() {
        values.sort_by(|left, right| compare_entries(left, right));
    }
    let mut bytes = Vec::new();
    append_export(&mut bytes, "LibreMetaverse Inventory Export\n", maximum)?;
    append_export(&mut bytes, "Format: deterministic-v1\n", maximum)?;
    append_export(
        &mut bytes,
        &format!("Entries: {}\n\n", snapshot.entries.len()),
        maximum,
    )?;
    let mut visited = HashSet::new();
    export_children(
        snapshot.root.base.uuid(),
        0,
        &children,
        &mut visited,
        &mut bytes,
        maximum,
        max_depth,
    )?;
    validate_export_path(path)?;
    fs::write(path, &bytes).map_err(|source| ProgramError::Export {
        action: format!("writing inventory export {}", path.display()),
        source,
    })?;
    output.line(format!(
        "Exported {} entries to {}",
        snapshot.entries.len(),
        path.display()
    ))
}

fn export_children(
    folder: UUID,
    depth: usize,
    children: &HashMap<UUID, Vec<&SnapshotEntry>>,
    visited: &mut HashSet<UUID>,
    bytes: &mut Vec<u8>,
    maximum: usize,
    max_depth: usize,
) -> Result<(), ProgramError> {
    if depth > max_depth {
        return Err(ProgramError::HierarchyDepth);
    }
    if !visited.insert(folder) {
        return Err(ProgramError::HierarchyCycle(folder));
    }
    if let Some(entries) = children.get(&folder) {
        for entry in entries {
            let indent = "  ".repeat(depth);
            let name = safe_text(&entry.name);
            let line = match entry.kind {
                EntryKind::Folder => format!("{indent}[Folder] {name}\n"),
                EntryKind::Item {
                    asset_type,
                    target: Some(target),
                } => format!(
                    "{indent}{name} ({asset_type:?} -> {target}) - {}\n",
                    entry.uuid
                ),
                EntryKind::Item {
                    asset_type,
                    target: None,
                } => format!("{indent}{name} ({asset_type:?}) - {}\n", entry.uuid),
                EntryKind::Other => format!("{indent}{name} - {}\n", entry.uuid),
            };
            append_export(bytes, &line, maximum)?;
            if entry.is_folder() {
                export_children(
                    entry.uuid,
                    depth + 1,
                    children,
                    visited,
                    bytes,
                    maximum,
                    max_depth,
                )?;
            }
        }
    }
    visited.remove(&folder);
    Ok(())
}

fn append_export(bytes: &mut Vec<u8>, value: &str, maximum: usize) -> Result<(), ProgramError> {
    if bytes
        .len()
        .checked_add(value.len())
        .is_none_or(|total| total > maximum)
    {
        return Err(ProgramError::ExportLimit);
    }
    bytes.extend_from_slice(value.as_bytes());
    Ok(())
}

fn parse_asset_type(value: &str) -> Result<AssetType, String> {
    match value.to_ascii_lowercase().as_str() {
        "texture" => Ok(AssetType::Texture),
        "sound" => Ok(AssetType::Sound),
        "callingcard" | "calling-card" => Ok(AssetType::CallingCard),
        "landmark" => Ok(AssetType::Landmark),
        "script" => Ok(AssetType::Script),
        "clothing" => Ok(AssetType::Clothing),
        "object" => Ok(AssetType::Object),
        "notecard" => Ok(AssetType::Notecard),
        "folder" => Ok(AssetType::Folder),
        "lsltext" | "lsl-text" => Ok(AssetType::LSLText),
        "lslbytecode" | "lsl-bytecode" => Ok(AssetType::LSLBytecode),
        "texturetga" | "texture-tga" => Ok(AssetType::TextureTGA),
        "bodypart" | "body-part" => Ok(AssetType::Bodypart),
        "soundwav" | "sound-wav" => Ok(AssetType::SoundWAV),
        "imagetga" | "image-tga" => Ok(AssetType::ImageTGA),
        "imagejpeg" | "image-jpeg" => Ok(AssetType::ImageJPEG),
        "animation" => Ok(AssetType::Animation),
        "gesture" => Ok(AssetType::Gesture),
        "simstate" => Ok(AssetType::Simstate),
        "link" => Ok(AssetType::Link),
        "linkfolder" | "link-folder" => Ok(AssetType::LinkFolder),
        "mesh" => Ok(AssetType::Mesh),
        "widget" => Ok(AssetType::Widget),
        "person" => Ok(AssetType::Person),
        "settings" => Ok(AssetType::Settings),
        "material" => Ok(AssetType::Material),
        "unknown" => Ok(AssetType::Unknown),
        _ => Err(format!("unknown asset type '{value}'")),
    }
}

fn safe_text(value: &str) -> String {
    value
        .replace(['\r', '\n', '\t'], " ")
        .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=")
                || lower.contains("authorization=")
            {
                "[redacted]"
            } else {
                word
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

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

    #[test]
    fn asset_types_are_case_insensitive_and_reject_unknown_names() {
        assert_eq!(parse_asset_type("LsL-TeXt").unwrap(), AssetType::LSLText);
        assert!(parse_asset_type("money").is_err());
    }

    #[test]
    fn status_and_inventory_text_are_sanitized() {
        assert_eq!(
            safe_text("open https://grid.invalid/cap?token=x now"),
            "open [redacted] now"
        );
    }
}
