//! Inventory, appearance, and asset command groups for the native `TestClient`.

// The command registry and backend adapters intentionally keep the upstream command surface
// together, and the explicit lifetimes keep the object-safe backend futures unambiguous.
#![allow(
    clippy::elidable_lifetime_names,
    clippy::format_push_string,
    clippy::needless_pass_by_value,
    clippy::too_many_lines
)]

use super::{ClientManager, FakeBackend, InventoryFuture as BackendFuture, LiveBackend, lock};
use libremetaverse::types::compat::CancellationToken;
use libremetaverse::types::{AssetType, FolderType, InventoryType, UUID};
use libremetaverse::{PermissionMask, Permissions};
use libremetaverse_imaging::{
    J2kCodec, J2kCompression, J2kDecodeOptions, J2kEncodeOptions, ManagedImage,
    ManagedImageImageChannels,
};
use libremetaverse_imaging_skia::SkiaTextureCodec;
use std::collections::{HashMap, HashSet};
use std::fs::{self, File};
use std::io::{Cursor, Read, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;

const MAX_ASSET_BYTES: u64 = 64 * 1024 * 1024;
const MAX_BACKUP_FILES: usize = 10_000;
const MAX_BACKUP_BYTES: u64 = 512 * 1024 * 1024;
const MAX_TREE_ENTRIES: usize = 100_000;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum Command {
    Appearance,
    Attachments,
    AvatarInfo,
    Clone,
    Wear,
    BackupText,
    Balance,
    Cd,
    CreateNotecard,
    DeleteFolder,
    Download,
    DumpOutfit,
    EmptyLostAndFound,
    EmptyTrash,
    GiveAll,
    Give,
    Inventory,
    Ls,
    ObjectInventory,
    Script,
    TaskRunning,
    UploadImage,
    UploadScript,
    ViewNote,
    Xfer,
}

pub(super) fn commands() -> Vec<super::CommandEntry> {
    use super::{CommandCategory, CommandHandler};
    use Command as C;
    vec![
        (
            "appearance",
            "Set current appearance from the saved outfit. Usage: appearance [rebake] --confirm",
            CommandCategory::Appearance,
            CommandHandler::Inventory(C::Appearance),
        ),
        (
            "attachments",
            "Print currently known agent attachments",
            CommandCategory::Appearance,
            CommandHandler::Inventory(C::Attachments),
        ),
        (
            "avatarinfo",
            "Print nearby avatar textures. Usage: avatarinfo [firstname] [lastname]",
            CommandCategory::Appearance,
            CommandHandler::Inventory(C::AvatarInfo),
        ),
        (
            "clone",
            "Clone a cached nearby avatar appearance. Usage: clone [name] --confirm",
            CommandCategory::Appearance,
            CommandHandler::Inventory(C::Clone),
        ),
        (
            "wear",
            "Wear an inventory outfit. Usage: wear [path] [--confirm]",
            CommandCategory::Appearance,
            CommandHandler::Inventory(C::Wear),
        ),
        (
            "backuptext",
            "Back up scripts and notecards. Usage: backuptext to [directory] | status | abort",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::BackupText),
        ),
        (
            "balance",
            "Request and show the current L$ balance",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::Balance),
        ),
        (
            "cd",
            "Change inventory folder. Usage: cd [path]",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::Cd),
        ),
        (
            "createnotecard",
            "Create a notecard from a local file. Usage: createnotecard [file] [embedded-item] --confirm",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::CreateNotecard),
        ),
        (
            "deletefolder",
            "Move a folder to Trash. Usage: deletefolder [path] --confirm",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::DeleteFolder),
        ),
        (
            "download",
            "Download an asset. Usage: download [asset-uuid] [asset-type] [output]",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::Download),
        ),
        (
            "dumpoutfit",
            "Download avatar outfit textures. Usage: dumpoutfit [avatar-uuid] [directory]",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::DumpOutfit),
        ),
        (
            "emptylostandfound",
            "Empty Lost And Found. Usage: emptylostandfound --confirm",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::EmptyLostAndFound),
        ),
        (
            "emptytrash",
            "Empty Trash. Usage: emptytrash --confirm",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::EmptyTrash),
        ),
        (
            "giveall",
            "Transfer the current balance to the requesting avatar. Usage: giveall --confirm",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::GiveAll),
        ),
        (
            "give",
            "Give an inventory item. Usage: give [avatar-uuid] [item] --confirm",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::Give),
        ),
        (
            "i",
            "Print the complete inventory tree",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::Inventory),
        ),
        (
            "ls",
            "List current inventory folder. Usage: ls [-l]",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::Ls),
        ),
        (
            "objectinventory",
            "List task inventory. Usage: objectinventory [object-uuid]",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::ObjectInventory),
        ),
        (
            "script",
            "Execute bounded TestClient commands from a file. Usage: script [file]",
            CommandCategory::TestClient,
            CommandHandler::Inventory(C::Script),
        ),
        (
            "taskrunning",
            "Query or set task scripts. Usage: taskrunning [object-uuid] [[name] true|false] [--confirm]",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::TaskRunning),
        ),
        (
            "uploadimage",
            "Upload an image. Usage: uploadimage [name] [timeout-ms] [file] --confirm",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::UploadImage),
        ),
        (
            "uploadscript",
            "Upload a local LSL script. Usage: uploadscript [file] --confirm",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::UploadScript),
        ),
        (
            "viewnote",
            "Download and display a notecard. Usage: viewnote [item-uuid]",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::ViewNote),
        ),
        (
            "xfer",
            "Download an object asset with Xfer. Usage: xfer [asset-uuid] [output]",
            CommandCategory::Inventory,
            CommandHandler::Inventory(C::Xfer),
        ),
    ]
}

#[derive(Clone, Copy, Default)]
pub(super) struct MutationPolicy(u8);

impl MutationPolicy {
    const MUTATIONS: u8 = 1;
    const SPENDING: u8 = 1 << 1;
    const ESTATE: u8 = 1 << 2;

    pub(super) const fn new(mutations: bool, spending: bool, estate: bool) -> Self {
        Self((mutations as u8) | ((spending as u8) << 1) | ((estate as u8) << 2))
    }

    pub(super) const fn mutations(self) -> bool {
        self.0 & Self::MUTATIONS != 0
    }

    pub(super) const fn spending(self) -> bool {
        self.0 & Self::SPENDING != 0
    }

    pub(super) const fn estate(self) -> bool {
        self.0 & Self::ESTATE != 0
    }
}

pub(super) struct State {
    policy: MutationPolicy,
    current_directory: Option<UUID>,
    fake: FakeGrid,
    live_appearances: HashMap<UUID, CachedAppearance>,
    clone_serial: u32,
    backup: BackupProgress,
}

impl State {
    pub(super) fn new(policy: MutationPolicy) -> Self {
        Self {
            policy,
            current_directory: None,
            fake: FakeGrid::default(),
            live_appearances: HashMap::new(),
            clone_serial: 0,
            backup: BackupProgress::default(),
        }
    }
}

#[derive(Clone, Default)]
struct BackupProgress {
    running: bool,
    abort_requested: bool,
    found: usize,
    transferred: usize,
    errors: usize,
    bytes: u64,
}

#[derive(Clone, Default)]
struct CachedAppearance {
    texture_entry: Vec<u8>,
    visual_params: Vec<u8>,
}

pub(super) fn capture_appearance(backend: &LiveBackend, bytes: &[u8]) {
    use libremetaverse::packets::AvatarAppearancePacket;
    let Ok(mut packet) = AvatarAppearancePacket::new_with_constructor() else {
        return;
    };
    let Ok(mut packet_end) = i32::try_from(bytes.len()) else {
        return;
    };
    packet_end -= 1;
    let mut offset = 0;
    let mut zero_buffer = vec![0_u8; 64 * 1024];
    if packet
        .from_bytes_with_bytes_int32_int32_bytes(
            bytes.to_vec(),
            &mut offset,
            &mut packet_end,
            Some(&mut zero_buffer),
        )
        .is_err()
    {
        return;
    }
    let appearance = CachedAppearance {
        texture_entry: packet.object_data.texture_entry,
        visual_params: packet
            .visual_param
            .into_iter()
            .map(|block| block.param_value)
            .collect(),
    };
    lock(&backend.inventory_state)
        .live_appearances
        .insert(packet.sender.id, appearance);
}

#[derive(Clone)]
pub(super) struct Entry {
    id: UUID,
    parent: UUID,
    name: String,
    description: String,
    creator: UUID,
    owner: UUID,
    last_owner: UUID,
    group: UUID,
    kind: EntryKind,
}

#[derive(Clone, Copy)]
enum EntryKind {
    Folder(FolderType),
    Item {
        asset_id: UUID,
        asset_type: AssetType,
        inventory_type: InventoryType,
        permissions: Permissions,
    },
}

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

    const fn item(&self) -> Option<(UUID, AssetType, InventoryType, Permissions)> {
        match self.kind {
            EntryKind::Item {
                asset_id,
                asset_type,
                inventory_type,
                permissions,
            } => Some((asset_id, asset_type, inventory_type, permissions)),
            EntryKind::Folder(_) => None,
        }
    }
}

#[derive(Clone)]
pub(super) struct Snapshot {
    root: UUID,
    entries: Vec<Entry>,
}

#[derive(Clone)]
pub(super) struct AttachmentView {
    point: String,
    local_id: u32,
    id: UUID,
    offset: String,
}

#[derive(Clone)]
pub(super) struct AvatarView {
    id: UUID,
    name: String,
    textures: Vec<(String, UUID)>,
}

#[derive(Clone)]
struct TaskItemView {
    id: UUID,
    name: String,
    description: String,
    asset_type: AssetType,
    running: Option<bool>,
}

#[derive(Clone)]
pub(super) struct TaskInventoryView {
    local_id: u32,
    items: Vec<TaskItemView>,
}

#[derive(Clone)]
pub(super) enum Mutation {
    MoveFolderToTrash {
        folder: UUID,
    },
    EmptySystemFolder(FolderType),
    GiveItem {
        item: Entry,
        recipient: UUID,
    },
    GiveMoney {
        recipient: UUID,
        amount: i32,
    },
    CreateAsset {
        name: String,
        description: String,
        data: Vec<u8>,
        asset_type: AssetType,
        inventory_type: InventoryType,
    },
    Wear {
        items: Vec<Entry>,
    },
    RequestAppearance {
        rebake: bool,
    },
    CloneAppearance {
        avatar: UUID,
    },
    SetScriptRunning {
        object: UUID,
        script: UUID,
        running: bool,
    },
}

#[derive(Clone, Copy, Default)]
pub(super) struct MutationResult {
    item_id: UUID,
    asset_id: UUID,
}

pub(super) trait Backend {
    fn inventory_state(&self) -> &Mutex<State>;
    fn snapshot<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Snapshot, String>>;
    fn fetch_asset<'a>(
        &'a self,
        id: UUID,
        asset_type: AssetType,
        xfer: bool,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Vec<u8>, String>>;
    fn balance<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<i32, String>>;
    fn mutate<'a>(
        &'a self,
        mutation: Mutation,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<MutationResult, String>>;
    fn attachments<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Vec<AttachmentView>, String>>;
    fn avatars<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Vec<AvatarView>, String>>;
    fn task_inventory<'a>(
        &'a self,
        object: UUID,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<TaskInventoryView, String>>;
    fn resolve_inventory_avatar<'a>(
        &'a self,
        _name: &'a str,
        _cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Option<UUID>, String>> {
        Box::pin(async { Ok(None) })
    }
    fn apply_fake_fixture(&self, _fields: &[&str]) -> Result<(), &'static str> {
        Err("inventory fixture requires fake-grid mode")
    }
}

#[derive(Default)]
struct FakeGrid {
    root: UUID,
    balance: i32,
    entries: HashMap<UUID, Entry>,
    assets: HashMap<(UUID, i8), Vec<u8>>,
    avatars: HashMap<UUID, AvatarView>,
    attachments: Vec<AttachmentView>,
    tasks: HashMap<UUID, TaskInventoryView>,
    cached_appearances: HashSet<UUID>,
    next_id: u64,
}

pub(super) async fn execute<B: Backend + ?Sized>(
    backend: &B,
    command: Command,
    args: &[String],
    from: UUID,
    cancellation: CancellationToken,
) -> String {
    match execute_inner(backend, command, args, from, cancellation).await {
        Ok(output) => output,
        Err(error) => error,
    }
}

async fn execute_inner<B: Backend + ?Sized>(
    backend: &B,
    command: Command,
    args: &[String],
    from: UUID,
    cancellation: CancellationToken,
) -> Result<String, String> {
    match command {
        Command::Appearance => appearance(backend, args, cancellation).await,
        Command::Attachments => attachments(backend, args, cancellation).await,
        Command::AvatarInfo => avatar_info(backend, args, cancellation).await,
        Command::Clone => clone_appearance(backend, args, cancellation).await,
        Command::Wear => wear(backend, args, cancellation).await,
        Command::BackupText => backup_text(backend, args, cancellation).await,
        Command::Balance => balance(backend, args, cancellation).await,
        Command::Cd => change_directory(backend, args, cancellation).await,
        Command::CreateNotecard => create_notecard(backend, args, cancellation).await,
        Command::DeleteFolder => delete_folder(backend, args, cancellation).await,
        Command::Download => download(backend, args, false, cancellation).await,
        Command::DumpOutfit => dump_outfit(backend, args, cancellation).await,
        Command::EmptyLostAndFound => {
            empty_system_folder(backend, args, FolderType::LostAndFound, cancellation).await
        }
        Command::EmptyTrash => {
            empty_system_folder(backend, args, FolderType::Trash, cancellation).await
        }
        Command::GiveAll => give_all(backend, args, from, cancellation).await,
        Command::Give => give_item(backend, args, cancellation).await,
        Command::Inventory => inventory_tree(backend, args, cancellation).await,
        Command::Ls => list_contents(backend, args, cancellation).await,
        Command::ObjectInventory => object_inventory(backend, args, cancellation).await,
        Command::Script => Ok("Command is handled by the client manager".into()),
        Command::TaskRunning => task_running(backend, args, cancellation).await,
        Command::UploadImage => upload_image(backend, args, cancellation).await,
        Command::UploadScript => upload_script(backend, args, cancellation).await,
        Command::ViewNote => view_notecard(backend, args, cancellation).await,
        Command::Xfer => download(backend, args, true, cancellation).await,
    }
}

fn require_confirmation<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    spending: bool,
) -> Result<Vec<String>, String> {
    let policy = lock(backend.inventory_state()).policy;
    if !policy.mutations() {
        return Err("Live mutation blocked; restart with --allow-live-mutations".into());
    }
    if spending && !policy.spending() {
        return Err("L$ transfer or upload blocked; restart with --allow-spending".into());
    }
    if !args.iter().any(|arg| arg == "--confirm") {
        return Err("Operation requires an explicit --confirm argument".into());
    }
    Ok(args
        .iter()
        .filter(|arg| arg.as_str() != "--confirm")
        .cloned()
        .collect())
}

async fn appearance<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    let args = require_confirmation(backend, args, false)?;
    if args.len() > 1 || args.first().is_some_and(|arg| arg != "rebake") {
        return Err("Usage: appearance [rebake] --confirm".into());
    }
    backend
        .mutate(
            Mutation::RequestAppearance {
                rebake: args.first().is_some_and(|arg| arg == "rebake"),
            },
            cancellation,
        )
        .await?;
    Ok("Appearance sequence started".into())
}

async fn attachments<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    if !args.is_empty() {
        return Err("Usage: attachments".into());
    }
    let attachments = backend.attachments(cancellation).await?;
    let mut output = String::new();
    for attachment in &attachments {
        output.push_str(&format!(
            "[Attachment @ {}] LocalID: {} UUID: {} Offset: {}\n",
            attachment.point, attachment.local_id, attachment.id, attachment.offset
        ));
    }
    output.push_str(&format!("Found {} attachments", attachments.len()));
    Ok(output)
}

async fn avatar_info<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    if args.len() != 2 {
        return Err("Usage: avatarinfo [firstname] [lastname]".into());
    }
    let target = format!("{} {}", args[0], args[1]);
    let avatars = backend.avatars(cancellation.clone()).await?;
    let resolved = avatars
        .iter()
        .find(|avatar| avatar.name == target)
        .map(|avatar| avatar.id);
    let resolved = match resolved {
        Some(id) => Some(id),
        None => {
            backend
                .resolve_inventory_avatar(&target, cancellation)
                .await?
        }
    };
    let Some(mut avatar) =
        resolved.and_then(|id| avatars.into_iter().find(|avatar| avatar.id == id))
    else {
        return Ok(format!("No nearby avatar named {target}"));
    };
    avatar.name = target;
    let mut output = format!("{} ({})\n", avatar.name, avatar.id);
    for (texture_type, id) in avatar.textures {
        output.push_str(&format!("{texture_type}: {id}\n"));
    }
    Ok(output.trim_end().into())
}

async fn clone_appearance<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    let args = require_confirmation(backend, args, false)?;
    if args.is_empty() {
        return Err("Usage: clone [name] --confirm".into());
    }
    let target = args.join(" ");
    let avatars = backend.avatars(cancellation.clone()).await?;
    let resolved = avatars
        .iter()
        .find(|avatar| avatar.name.eq_ignore_ascii_case(&target))
        .map(|avatar| avatar.id)
        .or_else(|| parse_uuid(&target));
    let resolved = match resolved {
        Some(id) => Some(id),
        None => {
            backend
                .resolve_inventory_avatar(&target, cancellation.clone())
                .await?
        }
    };
    let Some(id) = resolved else {
        return Ok(format!("Could not find {target}"));
    };
    let avatar = avatars
        .into_iter()
        .find(|avatar| avatar.id == id)
        .unwrap_or(AvatarView {
            id,
            name: target.clone(),
            textures: Vec::new(),
        });
    backend
        .mutate(
            Mutation::CloneAppearance { avatar: avatar.id },
            cancellation,
        )
        .await?;
    Ok(format!("Cloned {} ({})", avatar.name, avatar.id))
}

async fn wear<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    let args = require_confirmation(backend, args, false)?;
    if args.is_empty() {
        return Err("Usage: wear [outfit name] --confirm".into());
    }
    let target = args.join(" ");
    let snapshot = backend.snapshot(cancellation.clone()).await?;
    let folder = find_path(&snapshot, snapshot.root, &target)
        .ok_or_else(|| format!("Outfit path {target} not found"))?;
    if !folder.is_folder() {
        return Err(format!("Outfit path {target} is not a folder"));
    }
    let items: Vec<_> = children(&snapshot, folder.id)
        .into_iter()
        .filter(|entry| entry.item().is_some())
        .cloned()
        .collect();
    backend
        .mutate(Mutation::Wear { items }, cancellation)
        .await?;
    Ok(format!("Starting to change outfit to {target}"))
}

async fn balance<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    if !args.is_empty() {
        return Err("Usage: balance".into());
    }
    Ok(format!(
        "Balance is L$: {}",
        backend.balance(cancellation).await?
    ))
}

async fn change_directory<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    if args.len() > 1 {
        return Err("Usage: cd [path-to-folder]".into());
    }
    let snapshot = backend.snapshot(cancellation).await?;
    let current = lock(backend.inventory_state())
        .current_directory
        .unwrap_or(snapshot.root);
    let target = args.first().map_or("", String::as_str);
    let folder = if target.is_empty() {
        snapshot.entries.iter().find(|entry| entry.id == current)
    } else {
        find_path(&snapshot, current, target)
    }
    .ok_or_else(|| format!("{target} not found"))?;
    if !folder.is_folder() {
        return Err(format!("{} is not a folder.", folder.name));
    }
    lock(backend.inventory_state()).current_directory = Some(folder.id);
    Ok(format!("Current folder: {}", folder.name))
}

async fn list_contents<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    if args.len() > 1 || args.first().is_some_and(|arg| arg != "-l") {
        return Err("Usage: ls [-l]".into());
    }
    let long = !args.is_empty();
    let snapshot = backend.snapshot(cancellation).await?;
    let current = lock(backend.inventory_state())
        .current_directory
        .unwrap_or(snapshot.root);
    let mut output = String::new();
    for entry in children(&snapshot, current) {
        if !long {
            output.push_str(&entry.name);
            output.push('\n');
            continue;
        }
        match entry.kind {
            EntryKind::Folder(_) => {
                output.push_str(&format!("d--------- {} {}\n", entry.id, entry.name));
            }
            EntryKind::Item {
                asset_id,
                permissions,
                ..
            } => {
                output.push_str(&format!(
                    "-{} {} {}\n  AssetID: {}\n",
                    permission_string(permissions),
                    entry.id,
                    entry.name,
                    asset_id
                ));
            }
        }
    }
    Ok(output.trim_end().into())
}

async fn inventory_tree<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    if !args.is_empty() {
        return Err("Usage: i".into());
    }
    let snapshot = backend.snapshot(cancellation).await?;
    let mut output = String::new();
    let mut visited = HashSet::new();
    let mut count = 0;
    render_tree(
        &snapshot,
        snapshot.root,
        0,
        &mut visited,
        &mut count,
        &mut output,
    )?;
    output.push_str(&format!("Returned {count} items."));
    Ok(output)
}

fn render_tree(
    snapshot: &Snapshot,
    folder: UUID,
    depth: usize,
    visited: &mut HashSet<UUID>,
    count: &mut usize,
    output: &mut String,
) -> Result<(), String> {
    if !visited.insert(folder) {
        return Err(format!("Inventory cycle detected at {folder}"));
    }
    for entry in children(snapshot, folder) {
        *count += 1;
        if *count > MAX_TREE_ENTRIES {
            return Err("Inventory exceeds 100,000 entries".into());
        }
        output.push_str(&format!(
            "{}{} ({})\n",
            "  ".repeat(depth),
            entry.name,
            entry.id
        ));
        if entry.is_folder() {
            render_tree(snapshot, entry.id, depth + 1, visited, count, output)?;
        }
    }
    visited.remove(&folder);
    Ok(())
}

fn children(snapshot: &Snapshot, parent: UUID) -> Vec<&Entry> {
    let mut entries: Vec<_> = snapshot
        .entries
        .iter()
        .filter(|entry| entry.parent == parent)
        .collect();
    entries.sort_by(|left, right| {
        right
            .is_folder()
            .cmp(&left.is_folder())
            .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
            .then_with(|| left.id.cmp(&right.id))
    });
    entries
}

fn find_path<'a>(snapshot: &'a Snapshot, current: UUID, path: &str) -> Option<&'a Entry> {
    let mut folder = if path.starts_with('/') {
        snapshot.root
    } else {
        current
    };
    let mut found = snapshot.entries.iter().find(|entry| entry.id == folder)?;
    for part in path.split('/') {
        if part.is_empty() || part == "." {
            continue;
        }
        if part == ".." {
            if found.id != snapshot.root {
                folder = found.parent;
                found = snapshot.entries.iter().find(|entry| entry.id == folder)?;
            }
            continue;
        }
        found = children(snapshot, folder)
            .into_iter()
            .find(|entry| entry.name == part || entry.id.to_string() == part)?;
        folder = found.id;
    }
    Some(found)
}

fn permission_string(permissions: Permissions) -> String {
    let mask = permissions.owner_mask.0;
    let mut output = String::with_capacity(3);
    output.push(if mask & PermissionMask::COPY.0 != 0 {
        'C'
    } else {
        '-'
    });
    output.push(if mask & PermissionMask::MODIFY.0 != 0 {
        'M'
    } else {
        '-'
    });
    output.push(if mask & PermissionMask::TRANSFER.0 != 0 {
        'T'
    } else {
        '-'
    });
    output
}

async fn delete_folder<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    let args = require_confirmation(backend, args, false)?;
    if args.is_empty() {
        return Err("Usage: deletefolder [path] --confirm".into());
    }
    let target = args.join(" ");
    let snapshot = backend.snapshot(cancellation.clone()).await?;
    let folder = find_path(&snapshot, snapshot.root, &target)
        .filter(|entry| entry.is_folder())
        .ok_or_else(|| format!("Folder {target} not found"))?;
    if folder.id == snapshot.root {
        return Err("The inventory root cannot be moved to Trash".into());
    }
    if matches!(folder.kind, EntryKind::Folder(FolderType::Trash)) {
        return Err("The Trash folder cannot be moved into itself".into());
    }
    let name = folder.name.clone();
    backend
        .mutate(
            Mutation::MoveFolderToTrash { folder: folder.id },
            cancellation,
        )
        .await?;
    Ok(format!("Moved folder {name} to Trash"))
}

async fn empty_system_folder<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    folder: FolderType,
    cancellation: CancellationToken,
) -> Result<String, String> {
    let args = require_confirmation(backend, args, false)?;
    if !args.is_empty() {
        return Err(match folder {
            FolderType::Trash => "Usage: emptytrash --confirm",
            _ => "Usage: emptylostandfound --confirm",
        }
        .into());
    }
    backend
        .mutate(Mutation::EmptySystemFolder(folder), cancellation)
        .await?;
    Ok(match folder {
        FolderType::Trash => "Trash Emptied",
        _ => "Lost And Found Emptied",
    }
    .into())
}

async fn give_item<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    let args = require_confirmation(backend, args, false)?;
    if args.len() < 2 {
        return Err("Usage: give <agent uuid> itemname --confirm".into());
    }
    let recipient = parse_uuid(&args[0]).ok_or("First argument expected agent UUID.")?;
    let target = args[1..].join(" ");
    let snapshot = backend.snapshot(cancellation.clone()).await?;
    let current = lock(backend.inventory_state())
        .current_directory
        .unwrap_or(snapshot.root);
    let matches: Vec<_> = children(&snapshot, current)
        .into_iter()
        .filter(|entry| entry.name == target || entry.id.to_string() == target)
        .collect();
    if matches.is_empty() {
        return Ok(format!("No inventory item named {target} found."));
    }
    let mut output = String::new();
    for entry in matches {
        let Some((_, asset_type, _, permissions)) = entry.item() else {
            output.push_str(&format!("Unable to give folder {}\n", entry.name));
            continue;
        };
        if permissions.owner_mask.0 & PermissionMask::TRANSFER.0 == 0 {
            output.push_str(&format!(
                "Unable to give non-transferable item {}\n",
                entry.name
            ));
            continue;
        }
        backend
            .mutate(
                Mutation::GiveItem {
                    item: entry.clone(),
                    recipient,
                },
                cancellation.clone(),
            )
            .await?;
        output.push_str(&format!("Gave {} ({asset_type:?})\n", entry.name));
    }
    Ok(output.trim_end().into())
}

async fn give_all<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    from: UUID,
    cancellation: CancellationToken,
) -> Result<String, String> {
    let args = require_confirmation(backend, args, true)?;
    if !args.is_empty() {
        return Err("Usage: giveall --confirm (through an authorized IM)".into());
    }
    if from == UUID::zero() {
        return Ok("Unable to send money to console. This command only works when IMed.".into());
    }
    let amount = backend.balance(cancellation.clone()).await?;
    if amount <= 0 {
        return Ok("No L$ available to transfer".into());
    }
    backend
        .mutate(
            Mutation::GiveMoney {
                recipient: from,
                amount,
            },
            cancellation,
        )
        .await?;
    Ok(format!("Gave ${amount} to {from}"))
}

async fn download<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    xfer: bool,
    cancellation: CancellationToken,
) -> Result<String, String> {
    let (id, asset_type, path) = if xfer {
        if args.is_empty() || args.len() > 2 {
            return Err("Usage: xfer [uuid] [output]".into());
        }
        let id = parse_uuid(&args[0]).ok_or("Usage: xfer [uuid] [output]")?;
        let path = args
            .get(1)
            .map_or_else(|| PathBuf::from(format!("{id}.asset")), PathBuf::from);
        (id, AssetType::Object, path)
    } else {
        if args.len() < 2 || args.len() > 3 {
            return Err("Usage: download [uuid] [assetType] [output]".into());
        }
        let id = parse_uuid(&args[0]).ok_or("Usage: download [uuid] [assetType] [output]")?;
        let asset_type =
            parse_asset_type(&args[1]).ok_or("Usage: download [uuid] [assetType] [output]")?;
        let path = args.get(2).map_or_else(
            || PathBuf::from(format!("{id}.{}", asset_extension(asset_type))),
            PathBuf::from,
        );
        (id, asset_type, path)
    };
    let bytes = backend
        .fetch_asset(id, asset_type, xfer, cancellation)
        .await?;
    write_bounded(&path, &bytes)?;
    Ok(if xfer {
        format!("Saved asset {}", path.display())
    } else {
        format!("Saved {}", path.display())
    })
}

async fn object_inventory<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    if args.len() != 1 {
        return Err("Usage: objectinventory [objectID]".into());
    }
    let object = parse_uuid(&args[0]).ok_or("Usage: objectinventory [objectID]")?;
    let view = backend.task_inventory(object, cancellation).await?;
    let mut output = String::new();
    for item in view.items {
        output.push_str(&format!(
            "[Item] Name: {} Desc: {} Type: {:?}\n",
            item.name, item.description, item.asset_type
        ));
    }
    if output.is_empty() {
        output = format!("Task inventory for {} is empty", view.local_id);
    }
    Ok(output.trim_end().into())
}

async fn task_running<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    let changing = args
        .iter()
        .any(|arg| matches!(arg.as_str(), "true" | "false"));
    let args = if changing {
        require_confirmation(backend, args, false)?
    } else {
        args.to_vec()
    };
    if args.is_empty() || args.len() > 3 {
        return Err("Usage: taskrunning objectID [[scriptName] true|false] [--confirm]".into());
    }
    let object = parse_uuid(&args[0])
        .ok_or("Usage: taskrunning objectID [[scriptName] true|false] [--confirm]")?;
    let (matching, set_to) = match args.as_slice() {
        [_] => (None, None),
        [_, value] if value == "true" || value == "false" => (Some(""), Some(value == "true")),
        [_, name, value] if value == "true" || value == "false" => {
            (Some(name.as_str()), Some(value == "true"))
        }
        _ => {
            return Err("Usage: taskrunning objectID [[scriptName] true|false] [--confirm]".into());
        }
    };
    let mut view = backend.task_inventory(object, cancellation.clone()).await?;
    let mut output = String::new();
    for item in &mut view.items {
        output.push_str(&format!(
            "[Item] Name: {} Desc: {} Type: {:?}",
            item.name, item.description, item.asset_type
        ));
        if matches!(item.asset_type, AssetType::LSLText | AssetType::LSLBytecode) {
            let old = item.running.unwrap_or(false);
            output.push_str(&format!(" IsRunning: {old}"));
            if let (Some(pattern), Some(running)) = (matching, set_to)
                && item.name.contains(pattern)
                && old != running
            {
                backend
                    .mutate(
                        Mutation::SetScriptRunning {
                            object,
                            script: item.id,
                            running,
                        },
                        cancellation.clone(),
                    )
                    .await?;
                output.push_str(&format!(" Setting {old} => {running}"));
            }
        }
        output.push('\n');
    }
    Ok(output.trim_end().into())
}

async fn backup_text<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    if args == ["status"] {
        let progress = lock(backend.inventory_state()).backup.clone();
        return Ok(format!(
            "{} backup: found {}, transferred {}, errors {}, bytes {}",
            if progress.running { "Current" } else { "Last" },
            progress.found,
            progress.transferred,
            progress.errors,
            progress.bytes
        ));
    }
    if args == ["abort"] {
        let mut state = lock(backend.inventory_state());
        if state.backup.running {
            state.backup.abort_requested = true;
            return Ok("Backup abort requested".into());
        }
        return Ok("No backup is currently running".into());
    }
    if args.len() != 2 || args[0] != "to" {
        return Err("Usage: backuptext to [directory] | abort | status".into());
    }
    let destination = PathBuf::from(&args[1]);
    validate_output_path(&destination)?;
    fs::create_dir_all(&destination)
        .map_err(|error| format!("creating {}: {error}", destination.display()))?;
    let snapshot = backend.snapshot(cancellation.clone()).await?;
    let mut candidates = Vec::new();
    collect_text_assets(
        &snapshot,
        snapshot.root,
        PathBuf::new(),
        &mut HashSet::new(),
        &mut candidates,
    )?;
    if candidates.len() > MAX_BACKUP_FILES {
        return Err("Backup exceeds 10,000 text assets".into());
    }
    lock(backend.inventory_state()).backup = BackupProgress {
        running: true,
        found: candidates.len(),
        ..BackupProgress::default()
    };
    let mut transferred = 0_usize;
    let mut errors = 0_usize;
    let mut total = 0_u64;
    let result: Result<String, String> = async {
        for (entry, relative) in candidates {
            if cancellation.is_cancellation_requested()
                || lock(backend.inventory_state()).backup.abort_requested
            {
                return Err("Backup cancelled".into());
            }
            let Some((asset_id, asset_type, _, _)) = entry.item() else {
                continue;
            };
            match backend
                .fetch_asset(asset_id, asset_type, false, cancellation.clone())
                .await
            {
                Ok(bytes) => {
                    total = total
                        .checked_add(bytes.len() as u64)
                        .ok_or("Backup byte count overflow")?;
                    if total > MAX_BACKUP_BYTES {
                        return Err("Backup exceeds 512 MiB".into());
                    }
                    let path = destination.join(relative);
                    if let Some(parent) = path.parent() {
                        fs::create_dir_all(parent)
                            .map_err(|error| format!("creating {}: {error}", parent.display()))?;
                    }
                    if write_bounded(&path, &bytes).is_ok() {
                        transferred += 1;
                    } else {
                        errors += 1;
                    }
                }
                Err(_) => errors += 1,
            }
            let mut state = lock(backend.inventory_state());
            state.backup.transferred = transferred;
            state.backup.errors = errors;
            state.backup.bytes = total;
        }
        Ok(format!(
            "backuptext completed: found {}, transferred {transferred}, errors {errors}, bytes {total}",
            transferred + errors
        ))
    }
    .await;
    lock(backend.inventory_state()).backup.running = false;
    result
}

fn collect_text_assets<'a>(
    snapshot: &'a Snapshot,
    folder: UUID,
    relative: PathBuf,
    visited: &mut HashSet<UUID>,
    output: &mut Vec<(&'a Entry, PathBuf)>,
) -> Result<(), String> {
    if !visited.insert(folder) {
        return Err(format!("Inventory cycle detected at {folder}"));
    }
    for entry in children(snapshot, folder) {
        match entry.kind {
            EntryKind::Folder(_) => collect_text_assets(
                snapshot,
                entry.id,
                relative.join(safe_component(&entry.name)),
                visited,
                output,
            )?,
            EntryKind::Item { asset_type, .. }
                if matches!(asset_type, AssetType::LSLText | AssetType::Notecard) =>
            {
                let extension = if asset_type == AssetType::LSLText {
                    "lsl"
                } else {
                    "txt"
                };
                output.push((
                    entry,
                    relative.join(format!("{}.{}", safe_component(&entry.name), extension)),
                ));
            }
            EntryKind::Item { .. } => {}
        }
    }
    visited.remove(&folder);
    Ok(())
}

async fn create_notecard<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    let args = require_confirmation(backend, args, true)?;
    if args.is_empty() || args.len() > 2 {
        return Err("Usage: createnotecard filename.txt [itemid] --confirm".into());
    }
    let path = Path::new(&args[0]);
    let body = String::from_utf8(read_bounded(path)?)
        .map_err(|_| format!("{} is not UTF-8 text", path.display()))?;
    let snapshot = backend.snapshot(cancellation.clone()).await?;
    let embedded = if let Some(id) = args.get(1) {
        let id = parse_uuid(id).ok_or("Embedded item UUID is invalid")?;
        Some(
            snapshot
                .entries
                .iter()
                .find(|entry| entry.id == id && entry.item().is_some())
                .ok_or_else(|| format!("Failed to fetch inventory item {id}"))?,
        )
    } else {
        None
    };
    let data = encode_notecard(&body, embedded);
    let name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("notecard")
        .to_owned();
    let result = backend
        .mutate(
            Mutation::CreateAsset {
                name: name.clone(),
                description: format!("{name} created by native TestClient"),
                data,
                asset_type: AssetType::Notecard,
                inventory_type: InventoryType::NOTECARD,
            },
            cancellation,
        )
        .await?;
    Ok(format!(
        "Notecard successfully created, ItemID {} AssetID {}",
        result.item_id, result.asset_id
    ))
}

fn encode_notecard(body: &str, embedded: Option<&Entry>) -> Vec<u8> {
    let mut output = String::from("Linden text version 2\n{\nLLEmbeddedItems version 1\n{\n");
    if let Some(entry) = embedded {
        let (asset_id, asset_type, inventory_type, permissions) =
            entry.item().expect("embedded entry is an item");
        output.push_str("count 1\n{\next char index 0\n\tinv_item\t0\n\t{\n");
        output.push_str(&format!(
            "\t\titem_id\t{}\n\t\tparent_id\t{}\n\tpermissions 0\n\t{{\n\t\tbase_mask\t{:08x}\n\t\towner_mask\t{:08x}\n\t\tgroup_mask\t{:08x}\n\t\teveryone_mask\t{:08x}\n\t\tnext_owner_mask\t{:08x}\n\t\tcreator_id\t{}\n\t\towner_id\t{}\n\t\tlast_owner_id\t{}\n\t\tgroup_id\t{}\n\t}}\n\t\tasset_id\t{asset_id}\n\t\ttype\t{}\n\t\tinv_type\t{}\n\t\tflags\t00000000\n\tsale_info\t0\n\t{{\n\t\tsale_type\tnot\n\t\tsale_price\t0\n\t}}\n\t\tname\t{}|\n\t\tdesc\t{}|\n\t\tcreation_date\t0\n\t}}\n}}\n",
            entry.id,
            entry.parent,
            permissions.base_mask.0,
            permissions.owner_mask.0,
            permissions.group_mask.0,
            permissions.everyone_mask.0,
            permissions.next_owner_mask.0,
            entry.creator,
            entry.owner,
            entry.last_owner,
            entry.group,
            asset_type_name(asset_type),
            inventory_type_name(inventory_type),
            entry.name.replace('|', "_"),
            entry.description.replace('|', "_")
        ));
    } else {
        output.push_str("count 0\n");
    }
    output.push_str("}\n");
    output.push_str(&format!("Text length {}\n{}}}\n", body.len(), body));
    output.into_bytes()
}

async fn upload_script<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    let args = require_confirmation(backend, args, true)?;
    if args.is_empty() {
        return Err("Usage: uploadscript filename.lsl --confirm".into());
    }
    let path = PathBuf::from(args.join(" "));
    let data = read_bounded(&path)?;
    let name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("script.lsl")
        .to_owned();
    let result = backend
        .mutate(
            Mutation::CreateAsset {
                name: name.clone(),
                description: format!("{name} created by native TestClient"),
                data,
                asset_type: AssetType::LSLText,
                inventory_type: InventoryType::LSL,
            },
            cancellation,
        )
        .await?;
    Ok(format!(
        "Filename: {} successfully uploaded, ItemID {} AssetID {} compilation requested",
        path.display(),
        result.item_id,
        result.asset_id
    ))
}

async fn upload_image<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    let args = require_confirmation(backend, args, true)?;
    if args.len() != 3 {
        return Err("Usage: uploadimage [inventoryname] [timeout] [filename] --confirm".into());
    }
    let timeout_ms = args[1]
        .parse::<u64>()
        .ok()
        .filter(|timeout| (1..=300_000).contains(timeout))
        .ok_or("Upload timeout must be between 1 and 300000 milliseconds")?;
    let path = Path::new(&args[2]);
    let data = image_to_j2k(path)?;
    let future = backend.mutate(
        Mutation::CreateAsset {
            name: args[0].clone(),
            description: "Uploaded with native TestClient".into(),
            data,
            asset_type: AssetType::Texture,
            inventory_type: InventoryType::TEXTURE,
        },
        cancellation,
    );
    let result = tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), future)
        .await
        .map_err(|_| "Texture upload timed out".to_owned())??;
    Ok(format!("Texture upload succeeded: {}", result.asset_id))
}

fn image_to_j2k(path: &Path) -> Result<Vec<u8>, String> {
    let bytes = read_bounded(path)?;
    let extension = path
        .extension()
        .and_then(|value| value.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();
    if matches!(extension.as_str(), "jp2" | "j2c" | "j2k") {
        J2kCodec::decode_bytes(&bytes, J2kDecodeOptions::default())
            .map_err(|_| "Invalid JPEG 2000 image".to_owned())?;
        return Ok(bytes);
    }
    let mut image = if matches!(extension.as_str(), "tga" | "targa") {
        decode_tga(&bytes)?
    } else {
        SkiaTextureCodec::new()
            .map_err(|_| "Could not initialize the image decoder".to_owned())?
            .decode(Box::new(Cursor::new(bytes)))
            .map_err(|_| "Failed to decode image".to_owned())?
    };
    let mut width = image.width;
    let mut height = image.height;
    if !is_power_of_two(width) || !is_power_of_two(height) {
        image
            .resize_bilinear(256, 256)
            .map_err(|_| "Failed to resize image to 256x256".to_owned())?;
        width = 256;
        height = 256;
    }
    if width > 1024 || height > 1024 {
        image
            .resize_bilinear(width.min(1024), height.min(1024))
            .map_err(|_| "Failed to limit image dimensions".to_owned())?;
    }
    J2kCodec::encode(
        &image,
        J2kEncodeOptions::default().with_compression(J2kCompression::Lossy {
            compression_ratio: 10.0,
        }),
    )
    .map_err(|_| "Failed to compress image to JPEG2000".to_owned())
}

const fn is_power_of_two(value: i32) -> bool {
    value > 0 && value.cast_unsigned().is_power_of_two()
}

async fn view_notecard<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    if args.len() != 1 {
        return Err("Usage: viewnote [notecard item uuid]".into());
    }
    let item_id = parse_uuid(&args[0]).ok_or("First argument expected item UUID.")?;
    let snapshot = backend.snapshot(cancellation.clone()).await?;
    let item = snapshot
        .entries
        .iter()
        .find(|entry| entry.id == item_id)
        .ok_or("Cannot find item in inventory store, use 'i' to populate store")?;
    let (asset_id, asset_type, _, _) = item.item().ok_or("Inventory UUID is a folder")?;
    if asset_type != AssetType::Notecard {
        return Err("Inventory item is not a notecard".into());
    }
    let bytes = backend
        .fetch_asset(asset_id, asset_type, false, cancellation)
        .await?;
    let body = decode_notecard_body(&bytes)?;
    Ok(format!("Raw Notecard Data:\n {body}"))
}

fn decode_notecard_body(bytes: &[u8]) -> Result<String, String> {
    let text = std::str::from_utf8(bytes).map_err(|_| "Notecard is not UTF-8")?;
    let marker = "Text length ";
    let start = text.find(marker).ok_or("Notecard text length is missing")? + marker.len();
    let line_end = text[start..]
        .find('\n')
        .map(|offset| start + offset)
        .ok_or("Notecard text length is malformed")?;
    let length = text[start..line_end]
        .trim()
        .parse::<usize>()
        .map_err(|_| "Notecard text length is invalid")?;
    let body_start = line_end + 1;
    let body_end = body_start
        .checked_add(length)
        .ok_or("Notecard text length overflow")?;
    text.get(body_start..body_end)
        .map(str::to_owned)
        .ok_or_else(|| "Notecard body is truncated".into())
}

async fn dump_outfit<B: Backend + ?Sized>(
    backend: &B,
    args: &[String],
    cancellation: CancellationToken,
) -> Result<String, String> {
    if args.is_empty() || args.len() > 2 {
        return Err("Usage: dumpoutfit [avatar-uuid] [directory]".into());
    }
    let target = parse_uuid(&args[0]).ok_or("Usage: dumpoutfit [avatar-uuid] [directory]")?;
    let directory = args
        .get(1)
        .map_or_else(|| PathBuf::from("."), PathBuf::from);
    validate_output_path(&directory)?;
    fs::create_dir_all(&directory)
        .map_err(|error| format!("creating {}: {error}", directory.display()))?;
    let avatar = backend
        .avatars(cancellation.clone())
        .await?
        .into_iter()
        .find(|avatar| avatar.id == target)
        .ok_or_else(|| format!("Couldn't find avatar {target}"))?;
    let mut downloaded = Vec::new();
    let mut seen = HashSet::new();
    for (texture_type, texture_id) in avatar.textures {
        if texture_id == UUID::zero() || !seen.insert(texture_id) {
            continue;
        }
        let bytes = backend
            .fetch_asset(texture_id, AssetType::Texture, false, cancellation.clone())
            .await?;
        let jp2 = directory.join(format!("{texture_id}.jp2"));
        write_bounded(&jp2, &bytes)?;
        let image = J2kCodec::decode_bytes(&bytes, J2kDecodeOptions::default())
            .map_err(|_| format!("Failed to decode outfit texture {texture_id}"))?;
        let tga = directory.join(format!("{texture_id}.tga"));
        write_bounded(&tga, &encode_tga(&image)?)?;
        downloaded.push(texture_type);
    }
    Ok(format!("Downloaded {}", downloaded.join(" "))
        .trim_end()
        .into())
}

fn decode_tga(bytes: &[u8]) -> Result<ManagedImage, String> {
    if bytes.len() < 18 {
        return Err("TGA header is truncated".into());
    }
    let id_length = usize::from(bytes[0]);
    if bytes[1] != 0 || !matches!(bytes[2], 2 | 3) {
        return Err("Only uncompressed true-color and grayscale TGA images are supported".into());
    }
    let width = i32::from(u16::from_le_bytes([bytes[12], bytes[13]]));
    let height = i32::from(u16::from_le_bytes([bytes[14], bytes[15]]));
    let depth = bytes[16];
    let grayscale = bytes[2] == 3;
    if (grayscale && depth != 8) || (!grayscale && !matches!(depth, 24 | 32)) {
        return Err("Unsupported TGA pixel depth".into());
    }
    let channels = if grayscale {
        ManagedImageImageChannels::GRAY
    } else if depth == 32 {
        ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA
    } else {
        ManagedImageImageChannels::COLOR
    };
    let mut image = ManagedImage::new(width, height, channels)
        .map_err(|_| "Invalid TGA dimensions".to_owned())?;
    let pixel_count = usize::try_from(width)
        .ok()
        .and_then(|width| {
            usize::try_from(height)
                .ok()
                .and_then(|height| width.checked_mul(height))
        })
        .ok_or("Invalid TGA dimensions")?;
    let stride = usize::from(depth / 8);
    let start = 18_usize
        .checked_add(id_length)
        .ok_or("TGA offset overflow")?;
    let end = start
        .checked_add(pixel_count.checked_mul(stride).ok_or("TGA size overflow")?)
        .ok_or("TGA size overflow")?;
    let pixels = bytes.get(start..end).ok_or("TGA pixel data is truncated")?;
    let top_origin = bytes[17] & 0x20 != 0;
    let right_origin = bytes[17] & 0x10 != 0;
    let width_usize = usize::try_from(width).map_err(|_| "Invalid TGA width")?;
    let height_usize = usize::try_from(height).map_err(|_| "Invalid TGA height")?;
    for source_y in 0..height_usize {
        let target_y = if top_origin {
            source_y
        } else {
            height_usize - source_y - 1
        };
        for source_x in 0..width_usize {
            let target_x = if right_origin {
                width_usize - source_x - 1
            } else {
                source_x
            };
            let source = (source_y * width_usize + source_x) * stride;
            let target = target_y * width_usize + target_x;
            if grayscale {
                image.red[target] = pixels[source];
            } else {
                image.blue[target] = pixels[source];
                image.green[target] = pixels[source + 1];
                image.red[target] = pixels[source + 2];
                if depth == 32 {
                    image.alpha[target] = pixels[source + 3];
                }
            }
        }
    }
    Ok(image)
}

pub(super) fn encode_tga(image: &ManagedImage) -> Result<Vec<u8>, String> {
    image
        .validate()
        .map_err(|_| "Invalid decoded image layout")?;
    let width = u16::try_from(image.width).map_err(|_| "Image is too wide for TGA")?;
    let height = u16::try_from(image.height).map_err(|_| "Image is too tall for TGA")?;
    let pixel_count = usize::from(width) * usize::from(height);
    let mut output = Vec::with_capacity(18 + pixel_count * 4);
    output.extend_from_slice(&[0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
    output.extend_from_slice(&width.to_le_bytes());
    output.extend_from_slice(&height.to_le_bytes());
    output.extend_from_slice(&[32, 0x28]);
    let gray = image.channels.contains(ManagedImageImageChannels::GRAY);
    for index in 0..pixel_count {
        let red = image.red[index];
        let green = if gray { red } else { image.green[index] };
        let blue = if gray { red } else { image.blue[index] };
        let alpha = image.alpha.get(index).copied().unwrap_or(u8::MAX);
        output.extend_from_slice(&[blue, green, red, alpha]);
    }
    Ok(output)
}

fn parse_uuid(value: &str) -> Option<UUID> {
    UUID::parse(value.to_owned()).ok()
}

fn parse_asset_type(value: &str) -> Option<AssetType> {
    let normalized = value.to_ascii_lowercase().replace(['_', '-'], "");
    Some(match normalized.as_str() {
        "texture" | "0" => AssetType::Texture,
        "sound" | "1" => AssetType::Sound,
        "callingcard" | "2" => AssetType::CallingCard,
        "landmark" | "3" => AssetType::Landmark,
        "script" | "4" => AssetType::Script,
        "clothing" | "5" => AssetType::Clothing,
        "object" | "6" => AssetType::Object,
        "notecard" | "7" => AssetType::Notecard,
        "folder" | "8" => AssetType::Folder,
        "lsltext" | "lsl" | "10" => AssetType::LSLText,
        "lslbytecode" | "11" => AssetType::LSLBytecode,
        "texturetga" | "12" => AssetType::TextureTGA,
        "bodypart" | "13" => AssetType::Bodypart,
        "soundwav" | "17" => AssetType::SoundWAV,
        "imagetga" | "18" => AssetType::ImageTGA,
        "imagejpeg" | "jpeg" | "19" => AssetType::ImageJPEG,
        "animation" | "20" => AssetType::Animation,
        "gesture" | "21" => AssetType::Gesture,
        "simstate" | "22" => AssetType::Simstate,
        "link" | "24" => AssetType::Link,
        "linkfolder" | "25" => AssetType::LinkFolder,
        "widget" | "40" => AssetType::Widget,
        "person" | "45" => AssetType::Person,
        "mesh" | "49" => AssetType::Mesh,
        "settings" | "56" => AssetType::Settings,
        "material" | "57" => AssetType::Material,
        _ => return None,
    })
}

fn parse_inventory_type(value: &str) -> Option<InventoryType> {
    let normalized = value.to_ascii_lowercase().replace(['_', '-'], "");
    Some(match normalized.as_str() {
        "texture" | "0" => InventoryType::TEXTURE,
        "sound" | "1" => InventoryType::SOUND,
        "callingcard" | "2" => InventoryType::CALLING_CARD,
        "landmark" | "3" => InventoryType::LANDMARK,
        "object" | "6" => InventoryType::OBJECT,
        "notecard" | "7" => InventoryType::NOTECARD,
        "category" | "folder" | "8" => InventoryType::CATEGORY,
        "rootcategory" | "9" => InventoryType::ROOT_CATEGORY,
        "lsl" | "script" | "10" => InventoryType::LSL,
        "snapshot" | "15" => InventoryType::SNAPSHOT,
        "attachment" | "17" => InventoryType::ATTACHMENT,
        "wearable" | "18" => InventoryType::WEARABLE,
        "animation" | "19" => InventoryType::ANIMATION,
        "gesture" | "20" => InventoryType::GESTURE,
        "mesh" | "22" => InventoryType::MESH,
        "settings" | "25" => InventoryType::SETTINGS,
        "material" | "26" => InventoryType::MATERIAL,
        _ => return None,
    })
}

fn parse_folder_type(value: &str) -> Option<FolderType> {
    Some(
        match value.to_ascii_lowercase().replace(['_', '-'], "").as_str() {
            "none" | "-1" => FolderType::None,
            "root" | "8" => FolderType::Root,
            "texture" | "0" => FolderType::Texture,
            "object" | "6" => FolderType::Object,
            "notecard" | "7" => FolderType::Notecard,
            "lsltext" | "script" | "10" => FolderType::LSLText,
            "trash" | "14" => FolderType::Trash,
            "lostandfound" | "16" => FolderType::LostAndFound,
            "outfit" | "47" => FolderType::Outfit,
            "myoutfits" | "48" => FolderType::MyOutfits,
            _ => return None,
        },
    )
}

const fn asset_extension(asset_type: AssetType) -> &'static str {
    match asset_type {
        AssetType::Texture => "jp2",
        AssetType::Sound | AssetType::SoundWAV => "ogg",
        AssetType::LSLText => "lsl",
        AssetType::Notecard => "txt",
        AssetType::ImageJPEG => "jpg",
        AssetType::ImageTGA | AssetType::TextureTGA => "tga",
        _ => "asset",
    }
}

const fn asset_type_name(value: AssetType) -> &'static str {
    match value {
        AssetType::Texture => "texture",
        AssetType::Sound => "sound",
        AssetType::CallingCard => "callcard",
        AssetType::Landmark => "landmark",
        AssetType::Script | AssetType::LSLText | AssetType::LSLBytecode => "lsltext",
        AssetType::Clothing => "clothing",
        AssetType::Object => "object",
        AssetType::Notecard => "notecard",
        AssetType::Bodypart => "bodypart",
        AssetType::Animation => "animation",
        AssetType::Gesture => "gesture",
        _ => "unknown",
    }
}

const fn inventory_type_name(value: InventoryType) -> &'static str {
    if value.0 == InventoryType::TEXTURE.0 {
        "texture"
    } else if value.0 == InventoryType::SOUND.0 {
        "sound"
    } else if value.0 == InventoryType::CALLING_CARD.0 {
        "callcard"
    } else if value.0 == InventoryType::LANDMARK.0 {
        "landmark"
    } else if value.0 == InventoryType::OBJECT.0 {
        "object"
    } else if value.0 == InventoryType::NOTECARD.0 {
        "notecard"
    } else if value.0 == InventoryType::LSL.0 {
        "lsl"
    } else if value.0 == InventoryType::WEARABLE.0 {
        "wearable"
    } else if value.0 == InventoryType::ANIMATION.0 {
        "animation"
    } else if value.0 == InventoryType::GESTURE.0 {
        "gesture"
    } else {
        "unknown"
    }
}

fn safe_component(value: &str) -> String {
    let value: String = value
        .chars()
        .map(|character| match character {
            '/' | '\\' | '\0' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
            character if character.is_control() => '_',
            character => character,
        })
        .collect();
    let value = value.trim_matches([' ', '.']);
    if value.is_empty() || matches!(value, "." | "..") {
        "unnamed".into()
    } else {
        value.into()
    }
}

fn read_bounded(path: &Path) -> Result<Vec<u8>, String> {
    let file = File::open(path).map_err(|error| format!("opening {}: {error}", path.display()))?;
    let length = file
        .metadata()
        .map_err(|error| format!("reading {} metadata: {error}", path.display()))?
        .len();
    if length > MAX_ASSET_BYTES {
        return Err(format!("{} exceeds 64 MiB", path.display()));
    }
    let mut bytes = Vec::with_capacity(usize::try_from(length).unwrap_or(0));
    file.take(MAX_ASSET_BYTES + 1)
        .read_to_end(&mut bytes)
        .map_err(|error| format!("reading {}: {error}", path.display()))?;
    if bytes.len() as u64 > MAX_ASSET_BYTES {
        return Err(format!("{} exceeds 64 MiB", path.display()));
    }
    Ok(bytes)
}

fn write_bounded(path: &Path, bytes: &[u8]) -> Result<(), String> {
    if bytes.len() as u64 > MAX_ASSET_BYTES {
        return Err("Asset exceeds 64 MiB".into());
    }
    if let Some(parent) = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent)
            .map_err(|error| format!("creating {}: {error}", parent.display()))?;
    }
    validate_output_path(path)?;
    let mut file =
        File::create(path).map_err(|error| format!("creating {}: {error}", path.display()))?;
    file.write_all(bytes)
        .map_err(|error| format!("writing {}: {error}", path.display()))
}

fn validate_output_path(path: &Path) -> Result<(), String> {
    if path
        .components()
        .any(|component| matches!(component, Component::ParentDir))
    {
        Err("Output paths may not contain parent traversal".into())
    } else {
        Ok(())
    }
}

impl Backend for FakeBackend {
    fn inventory_state(&self) -> &Mutex<State> {
        &self.inventory_state
    }

    fn snapshot<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Snapshot, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err("Inventory request cancelled".into());
            }
            let state = lock(&self.inventory_state);
            let mut entries: Vec<_> = state.fake.entries.values().cloned().collect();
            entries.sort_by_key(|entry| entry.id);
            Ok(Snapshot {
                root: state.fake.root,
                entries,
            })
        })
    }

    fn fetch_asset<'a>(
        &'a self,
        id: UUID,
        asset_type: AssetType,
        xfer: bool,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Vec<u8>, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err("Asset request cancelled".into());
            }
            self.record(format!(
                "CALL {} {id} {asset_type:?}",
                if xfer { "xfer" } else { "asset-download" }
            ));
            lock(&self.inventory_state)
                .fake
                .assets
                .get(&(id, asset_type as i8))
                .cloned()
                .ok_or_else(|| format!("Asset {id} ({asset_type:?}) is not in the fake fixture"))
        })
    }

    fn balance<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<i32, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err("Balance request cancelled".into());
            }
            self.record("CALL balance".into());
            Ok(lock(&self.inventory_state).fake.balance)
        })
    }

    fn mutate<'a>(
        &'a self,
        mutation: Mutation,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<MutationResult, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err("Mutation cancelled".into());
            }
            let mut state = lock(&self.inventory_state);
            let grid = &mut state.fake;
            match mutation {
                Mutation::MoveFolderToTrash { folder } => {
                    let trash = grid
                        .entries
                        .values()
                        .find(|entry| matches!(entry.kind, EntryKind::Folder(FolderType::Trash)))
                        .map(|entry| entry.id)
                        .ok_or("Trash fixture is missing")?;
                    grid.entries
                        .get_mut(&folder)
                        .ok_or("Folder fixture is missing")?
                        .parent = trash;
                    drop(state);
                    self.record(format!("CALL inventory-move-folder {folder} {trash}"));
                }
                Mutation::EmptySystemFolder(folder_type) => {
                    let folder = grid.entries.values().find(|entry| matches!(entry.kind, EntryKind::Folder(value) if value == folder_type)).map(|entry| entry.id).ok_or("System folder fixture is missing")?;
                    let mut remove = HashSet::new();
                    let mut stack = vec![folder];
                    while let Some(parent) = stack.pop() {
                        let children: Vec<_> = grid
                            .entries
                            .values()
                            .filter(|entry| entry.parent == parent)
                            .map(|entry| entry.id)
                            .collect();
                        for child in children {
                            remove.insert(child);
                            stack.push(child);
                        }
                    }
                    grid.entries.retain(|id, _| !remove.contains(id));
                    drop(state);
                    self.record(format!("CALL inventory-empty {folder_type:?}"));
                }
                Mutation::GiveItem { item, recipient } => {
                    drop(state);
                    self.record(format!("CALL inventory-give {} {recipient}", item.id));
                }
                Mutation::GiveMoney { recipient, amount } => {
                    if amount <= 0 || grid.balance < amount {
                        return Err("Insufficient fake-grid balance".into());
                    }
                    grid.balance -= amount;
                    drop(state);
                    self.record(format!("CALL give-money {recipient} {amount}"));
                    return Ok(MutationResult::default());
                }
                Mutation::CreateAsset {
                    name,
                    description,
                    data,
                    asset_type,
                    inventory_type,
                } => {
                    grid.next_id = grid.next_id.saturating_add(1);
                    let asset_id = UUID::new_with_u_int64(0xa550_0000_0000_0000 | grid.next_id)
                        .map_err(|_| "creating fake asset UUID")?;
                    grid.next_id = grid.next_id.saturating_add(1);
                    let item_id = UUID::new_with_u_int64(0x1ee0_0000_0000_0000 | grid.next_id)
                        .map_err(|_| "creating fake item UUID")?;
                    let parent = grid.entries.values().find(|entry| matches!(entry.kind, EntryKind::Folder(kind) if folder_for_asset(asset_type) == kind)).map_or(grid.root, |entry| entry.id);
                    grid.assets.insert((asset_id, asset_type as i8), data);
                    grid.entries.insert(
                        item_id,
                        Entry {
                            id: item_id,
                            parent,
                            name: name.clone(),
                            description,
                            creator: self.id,
                            owner: self.id,
                            last_owner: self.id,
                            group: UUID::zero(),
                            kind: EntryKind::Item {
                                asset_id,
                                asset_type,
                                inventory_type,
                                permissions: Permissions::full_permissions(),
                            },
                        },
                    );
                    drop(state);
                    self.record(format!("CALL inventory-upload {asset_type:?} item={item_id} asset={asset_id} name={name}"));
                    return Ok(MutationResult { item_id, asset_id });
                }
                Mutation::Wear { items } => {
                    let ids: Vec<_> = items.iter().map(|item| item.id.to_string()).collect();
                    drop(state);
                    self.record(format!("CALL appearance-wear {}", ids.join(",")));
                }
                Mutation::RequestAppearance { rebake } => {
                    drop(state);
                    self.record(format!("CALL appearance-set rebake={rebake}"));
                }
                Mutation::CloneAppearance { avatar } => {
                    if !grid.cached_appearances.contains(&avatar) {
                        return Err(format!("Don't have an appearance cached for {avatar}"));
                    }
                    drop(state);
                    self.record(format!("CALL appearance-clone {avatar}"));
                }
                Mutation::SetScriptRunning {
                    object,
                    script,
                    running,
                } => {
                    let task = grid
                        .tasks
                        .get_mut(&object)
                        .ok_or("Task fixture is missing")?;
                    let item = task
                        .items
                        .iter_mut()
                        .find(|item| item.id == script)
                        .ok_or("Task script fixture is missing")?;
                    item.running = Some(running);
                    drop(state);
                    self.record(format!(
                        "CALL task-script-running {object} {script} {running}"
                    ));
                }
            }
            Ok(MutationResult::default())
        })
    }

    fn attachments<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Vec<AttachmentView>, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                Err("Attachment request cancelled".into())
            } else {
                Ok(lock(&self.inventory_state).fake.attachments.clone())
            }
        })
    }
    fn avatars<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Vec<AvatarView>, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                Err("Avatar request cancelled".into())
            } else {
                Ok(lock(&self.inventory_state)
                    .fake
                    .avatars
                    .values()
                    .cloned()
                    .collect())
            }
        })
    }
    fn task_inventory<'a>(
        &'a self,
        object: UUID,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<TaskInventoryView, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err("Task inventory request cancelled".into());
            }
            lock(&self.inventory_state)
                .fake
                .tasks
                .get(&object)
                .cloned()
                .ok_or_else(|| format!("Couldn't find object {object}"))
        })
    }
    fn resolve_inventory_avatar<'a>(
        &'a self,
        name: &'a str,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Option<UUID>, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err("Avatar lookup cancelled".into());
            }
            Ok(lock(&self.inventory_state)
                .fake
                .avatars
                .values()
                .find(|avatar| avatar.name.eq_ignore_ascii_case(name))
                .map(|avatar| avatar.id))
        })
    }
    fn apply_fake_fixture(&self, fields: &[&str]) -> Result<(), &'static str> {
        apply_fixture(self, fields)
    }
}

const fn folder_for_asset(asset_type: AssetType) -> FolderType {
    match asset_type {
        AssetType::Texture => FolderType::Texture,
        AssetType::Notecard => FolderType::Notecard,
        AssetType::LSLText | AssetType::LSLBytecode => FolderType::LSLText,
        AssetType::Object => FolderType::Object,
        _ => FolderType::None,
    }
}

impl Backend for LiveBackend {
    fn inventory_state(&self) -> &Mutex<State> {
        &self.inventory_state
    }

    fn snapshot<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Snapshot, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err("Inventory request cancelled".into());
            }
            let manager = lock(&self.client).inventory();
            let inventory = manager.store().ok_or("Inventory store is unavailable")?;
            let root = inventory
                .root_folder()
                .ok_or("Inventory root is unavailable")?;
            let owner = inventory.owner();
            let mut entries = vec![entry_from_folder(&root)];
            let mut pending = vec![root.base.uuid()];
            let mut visited = HashSet::new();
            while let Some(folder) = pending.pop() {
                if !visited.insert(folder) {
                    return Err(format!("Inventory cycle detected at {folder}"));
                }
                let contents = manager
                    .folder_contents(
                        folder,
                        owner,
                        true,
                        true,
                        libremetaverse::InventorySortOrder::BY_NAME,
                        Some(cancellation.clone()),
                        Some(false),
                    )
                    .await
                    .map_err(|_| format!("Fetching inventory folder {folder}"))?;
                for object in contents {
                    if entries.len() >= MAX_TREE_ENTRIES {
                        return Err("Inventory exceeds 100,000 entries".into());
                    }
                    if let Some(item) = object.inventory_item() {
                        entries.push(entry_from_item(item));
                    } else if let Some(child) = object
                        .as_any()
                        .downcast_ref::<libremetaverse::InventoryFolder>()
                    {
                        entries.push(entry_from_folder(child));
                        pending.push(child.base.uuid());
                    }
                }
            }
            Ok(Snapshot {
                root: root.base.uuid(),
                entries,
            })
        })
    }

    fn fetch_asset<'a>(
        &'a self,
        id: UUID,
        asset_type: AssetType,
        xfer: bool,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Vec<u8>, String>> {
        Box::pin(async move {
            if !xfer {
                let manager = lock(&self.client).assets();
                let asset = manager
                    .request_asset_with_uuid_asset_type_boolean_cancellation_token(
                        id,
                        asset_type,
                        false,
                        Some(cancellation),
                    )
                    .await
                    .map_err(|_| format!("Asset request failed for {id}"))?
                    .ok_or_else(|| format!("Asset {id} was not returned"))?;
                return Ok(asset.asset_data);
            }
            let manager = lock(&self.client).assets();
            let (sender, receiver) = tokio::sync::oneshot::channel();
            let sender = Arc::new(Mutex::new(Some(sender)));
            let callback = Arc::clone(&sender);
            let subscription = manager.subscribe_xfer_received(Arc::new(move |event| {
                let xfer = event.xfer();
                if xfer.v_file_id == id
                    && let Some(sender) = lock(&callback).take()
                {
                    let _ = sender.send(xfer.base.asset_data);
                }
            }));
            manager
                .request_asset_xfer(String::new(), false, true, id, asset_type, false)
                .map_err(|_| format!("Could not start Xfer for {id}"))?;
            let result = tokio::select! {
                () = cancellation.cancelled() => Err("Xfer cancelled".into()),
                () = tokio::time::sleep(Duration::from_secs(30)) => Err("Xfer timed out".into()),
                result = receiver => result.map_err(|_| "Xfer completion channel closed".into()),
            };
            drop(subscription);
            result
        })
    }

    fn balance<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<i32, String>> {
        Box::pin(async move {
            let (sender, receiver) = tokio::sync::oneshot::channel();
            let sender = Arc::new(Mutex::new(Some(sender)));
            let callback = Arc::clone(&sender);
            let subscription = self.with_agent(|agent| {
                agent.subscribe_money_balance(Arc::new(move |event| {
                    if let Some(sender) = lock(&callback).take() {
                        let _ = sender.send(event.balance());
                    }
                }))
            });
            self.with_agent(|agent| agent.request_balance())
                .map_err(|_| "Could not request balance")?;
            let result = tokio::select! {
                () = cancellation.cancelled() => Err("Balance request cancelled".into()),
                () = tokio::time::sleep(Duration::from_secs(20)) => Err("Balance request timed out".into()),
                result = receiver => result.map_err(|_| "Balance completion channel closed".into()),
            };
            drop(subscription);
            result
        })
    }

    fn mutate<'a>(
        &'a self,
        mutation: Mutation,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<MutationResult, String>> {
        Box::pin(async move {
            match mutation {
                Mutation::MoveFolderToTrash { folder } => {
                    let manager = lock(&self.client).inventory();
                    let trash = manager
                        .find_folder_for_type_with_folder_type(FolderType::Trash)
                        .map_err(|_| "Trash folder is unavailable")?;
                    manager
                        .move_folder_with_uuid_uuid_cancellation_token_4497f975(
                            folder,
                            trash,
                            Some(cancellation),
                        )
                        .await
                        .map_err(|_| "Moving folder to Trash failed")?;
                }
                Mutation::EmptySystemFolder(folder) => {
                    let manager = lock(&self.client).inventory();
                    match folder {
                        FolderType::Trash => manager.empty_trash(Some(cancellation)).await,
                        FolderType::LostAndFound => {
                            manager.empty_lost_and_found(Some(cancellation)).await
                        }
                        _ => return Err("Unsupported system folder".into()),
                    }
                    .map_err(|_| "Emptying system folder failed")?;
                }
                Mutation::GiveItem { item, recipient } => {
                    let (_, asset_type, _, _) =
                        item.item().ok_or("Folder cannot be given as an item")?;
                    lock(&self.client)
                        .inventory()
                        .give_item(item.id, item.name, asset_type, recipient, true)
                        .map_err(|_| "Giving inventory item failed")?;
                }
                Mutation::GiveMoney { recipient, amount } => {
                    self.with_agent(|agent| {
                        agent.give_money(
                            recipient,
                            amount,
                            "TestClient giveall".into(),
                            libremetaverse::MoneyTransactionType::Gift,
                            libremetaverse::TransactionFlags::NONE,
                        )
                    })
                    .map_err(|_| "L$ transfer failed")?;
                }
                Mutation::CreateAsset {
                    name,
                    description,
                    data,
                    asset_type,
                    inventory_type,
                } => {
                    let transaction =
                        UUID::random().map_err(|_| "Could not create upload transaction")?;
                    let (assets, inventory) = {
                        let client = lock(&self.client);
                        (client.assets(), client.inventory())
                    };
                    let asset_id = assets
                        .request_upload_with_asset_type_bytes_boolean_uuid_cancellation_token(
                            asset_type,
                            data,
                            false,
                            transaction,
                            Some(cancellation.clone()),
                        )
                        .await
                        .map_err(|_| "Asset upload failed")?;
                    let folder = inventory
                        .find_folder_for_type_with_asset_type(asset_type)
                        .map_err(|_| "Upload inventory folder is unavailable")?;
                    let item = inventory.create_item_with_uuid_string_string_asset_type_uuid_inventory_type_permission_mask_cancellation_token(folder, name, description, asset_type, transaction, inventory_type, PermissionMask::ALL, Some(cancellation)).await.map_err(|_| "Creating uploaded inventory item failed")?.ok_or("Upload completed without an inventory item")?;
                    return Ok(MutationResult {
                        item_id: item.base.uuid(),
                        asset_id,
                    });
                }
                Mutation::Wear { items } => {
                    let (appearance, store) = {
                        let client = lock(&self.client);
                        (
                            client.appearance(),
                            client
                                .inventory()
                                .store()
                                .ok_or("Inventory store is unavailable")?,
                        )
                    };
                    let items: Vec<libremetaverse::InventoryItem> = items.into_iter().map(|entry| store.get_value_or_default_with_uuid_a7c63fbe::<libremetaverse::InventoryItem>(entry.id).map_err(|_| "Reading outfit item").and_then(|item| item.ok_or("Outfit item is missing from the store"))).collect::<Result<_, _>>()?;
                    appearance
                        .replace_outfit_with_list(items)
                        .await
                        .map_err(|_| "Replacing outfit failed")?;
                }
                Mutation::RequestAppearance { rebake } => {
                    let appearance = lock(&self.client).appearance();
                    appearance
                        .request_set_appearance(Some(rebake))
                        .await
                        .map_err(|_| "Appearance request failed")?;
                }
                Mutation::CloneAppearance { avatar } => {
                    self.send_cloned_appearance(avatar)?;
                }
                Mutation::SetScriptRunning {
                    object,
                    script,
                    running,
                } => {
                    lock(&self.client)
                        .inventory()
                        .request_set_script_running(object, script, running)
                        .map_err(|_| "Setting task script state failed")?;
                }
            }
            Ok(MutationResult::default())
        })
    }

    fn attachments<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Vec<AttachmentView>, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err("Attachment request cancelled".into());
            }
            let simulator = self.network.current_sim().ok_or("No current simulator")?;
            let avatar_local = simulator
                .global_to_local_id
                .read()
                .map_err(|_| "Locking simulator object index")?
                .get(&self.id)
                .copied();
            let primitives = simulator
                .objects_primitives
                .read()
                .map_err(|_| "Locking simulator primitives")?;
            Ok(primitives
                .values()
                .filter(|primitive| {
                    primitive.is_attachment
                        && (primitive.owner_id == self.id
                            || avatar_local == Some(primitive.parent_id))
                })
                .map(|primitive| AttachmentView {
                    point: format!("{:?}", primitive.prim_data.attachment_point()),
                    local_id: primitive.local_id,
                    id: primitive.id,
                    offset: primitive.position.to_string(),
                })
                .collect())
        })
    }

    fn avatars<'a>(
        &'a self,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Vec<AvatarView>, String>> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err("Avatar request cancelled".into());
            }
            let appearances = lock(&self.inventory_state).live_appearances.clone();
            let mut avatars = Vec::new();
            for (id, appearance) in appearances {
                let mut textures = Vec::new();
                if let Ok(entry) = libremetaverse::PrimitiveTextureEntry::new_with_bytes_int32_int32(
                    appearance.texture_entry.clone(),
                    0,
                    i32::try_from(appearance.texture_entry.len()).unwrap_or(0),
                ) {
                    for index in 0..45_u32 {
                        if let Ok(Some(face)) = entry.get_face(index) {
                            textures.push((format!("Texture{index}"), face.texture_id()));
                        }
                    }
                }
                avatars.push(AvatarView {
                    id,
                    name: id.to_string(),
                    textures,
                });
            }
            Ok(avatars)
        })
    }

    fn task_inventory<'a>(
        &'a self,
        object: UUID,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<TaskInventoryView, String>> {
        Box::pin(async move {
            let simulator = self.network.current_sim().ok_or("No current simulator")?;
            let local_id = simulator
                .global_to_local_id
                .read()
                .map_err(|_| "Locking simulator object index")?
                .get(&object)
                .copied()
                .ok_or_else(|| format!("Couldn't find object {object}"))?;
            let manager = lock(&self.client).inventory();
            let objects = manager
                .get_task_inventory(
                    object,
                    local_id,
                    Some(simulator),
                    Some(cancellation.clone()),
                )
                .await
                .map_err(|_| "Task inventory request failed")?;
            let mut items: Vec<_> = objects
                .into_iter()
                .filter_map(|object| object.inventory_item().cloned())
                .map(|item| TaskItemView {
                    id: item.base.uuid(),
                    name: item.base.name(),
                    description: item.description(),
                    asset_type: item.asset_type(),
                    running: None,
                })
                .collect();
            let expected: HashSet<_> = items
                .iter()
                .filter(|item| {
                    matches!(item.asset_type, AssetType::LSLText | AssetType::LSLBytecode)
                })
                .map(|item| item.id)
                .collect();
            if !expected.is_empty() {
                let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
                let expected_callback = expected.clone();
                let subscription = manager.subscribe_script_running_reply(Arc::new(move |event| {
                    if event.object_id() == object && expected_callback.contains(&event.script_id())
                    {
                        let _ = sender.send((event.script_id(), event.is_running()));
                    }
                }));
                for script in &expected {
                    manager
                        .request_get_script_running(object, *script)
                        .map_err(|_| "Requesting task script state failed")?;
                }
                let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
                let mut states = HashMap::new();
                while states.len() < expected.len() {
                    tokio::select! {
                        () = cancellation.cancelled() => return Err("Task script query cancelled".into()),
                        () = tokio::time::sleep_until(deadline) => return Err("Task script query timed out".into()),
                        reply = receiver.recv() => {
                            let Some((script, running)) = reply else { return Err("Task script reply channel closed".into()); };
                            states.insert(script, running);
                        }
                    }
                }
                drop(subscription);
                for item in &mut items {
                    item.running = states.get(&item.id).copied();
                }
            }
            Ok(TaskInventoryView { local_id, items })
        })
    }

    fn resolve_inventory_avatar<'a>(
        &'a self,
        name: &'a str,
        cancellation: CancellationToken,
    ) -> BackendFuture<'a, Result<Option<UUID>, String>> {
        Box::pin(async move {
            super::ClientBackend::resolve_avatar(self, name, cancellation)
                .await
                .map_err(str::to_owned)
        })
    }
}

fn entry_from_folder(folder: &libremetaverse::InventoryFolder) -> Entry {
    Entry {
        id: folder.base.uuid(),
        parent: folder.base.parent_uuid(),
        name: folder.base.name(),
        description: String::new(),
        creator: UUID::zero(),
        owner: folder.base.owner_id(),
        last_owner: UUID::zero(),
        group: UUID::zero(),
        kind: EntryKind::Folder(folder.preferred_type()),
    }
}

fn entry_from_item(item: &libremetaverse::InventoryItem) -> Entry {
    Entry {
        id: item.base.uuid(),
        parent: item.base.parent_uuid(),
        name: item.base.name(),
        description: item.description(),
        creator: item.creator_id(),
        owner: item.base.owner_id(),
        last_owner: item.last_owner_id(),
        group: item.group_id(),
        kind: EntryKind::Item {
            asset_id: item.asset_uuid(),
            asset_type: item.asset_type(),
            inventory_type: item.inventory_type(),
            permissions: item.permissions(),
        },
    }
}

impl LiveBackend {
    fn send_cloned_appearance(&self, avatar: UUID) -> Result<(), String> {
        use libremetaverse::packets::{
            AgentSetAppearancePacket, AgentSetAppearancePacketVisualParamBlock, PacketType,
        };
        use libremetaverse::types::Vector3;
        let appearance = lock(&self.inventory_state)
            .live_appearances
            .get(&avatar)
            .cloned()
            .ok_or_else(|| format!("Don't have an appearance cached for {avatar}"))?;
        let mut packet = AgentSetAppearancePacket::new_with_constructor()
            .map_err(|_| "Creating appearance packet")?;
        packet.agent_data.agent_id = self.id;
        packet.agent_data.session_id = self.with_agent(|agent| agent.session_id());
        let serial = {
            let mut state = lock(&self.inventory_state);
            state.clone_serial = state.clone_serial.saturating_add(1);
            state.clone_serial
        };
        packet.agent_data.serial_num = serial;
        packet.agent_data.size =
            Vector3::new_with_single(2.0).map_err(|_| "Creating appearance size")?;
        packet.object_data.texture_entry = appearance.texture_entry;
        for value in appearance.visual_params {
            let mut block = AgentSetAppearancePacketVisualParamBlock::new_with_constructor()
                .map_err(|_| "Creating visual parameter")?;
            block.param_value = value;
            packet.visual_param.push(block);
        }
        lock(&self.client)
            .appearance()
            .add_attachments(Vec::new(), true, Some(false))
            .map_err(|_| "Detaching current attachments")?;
        let data = packet
            .to_bytes_with_method()
            .map_err(|_| "Encoding appearance packet")?;
        self.network
            .current_sim()
            .ok_or("No current simulator")?
            .send_packet_data(
                data.clone(),
                i32::try_from(data.len()).map_err(|_| "Appearance packet is too large")?,
                PacketType::AgentSetAppearance,
                true,
            )
            .map_err(|_| "Sending appearance packet".to_owned())
    }
}

const FIXTURE_DIRECTIVES: &[&str] = &[
    "inventory-root",
    "inventory-folder",
    "inventory-item",
    "asset",
    "balance",
    "avatar",
    "appearance-cache",
    "attachment",
    "task",
    "task-item",
];

pub(super) fn apply_fake_directive(
    manager: &mut ClientManager,
    fields: &[&str],
) -> Option<Result<(), &'static str>> {
    if !fields
        .first()
        .is_some_and(|name| FIXTURE_DIRECTIVES.contains(name))
    {
        return None;
    }
    let Some(client_id) = fields.get(1).and_then(|value| parse_uuid(value)) else {
        return Some(Err("inventory fixture client UUID is invalid"));
    };
    let Some(client) = manager.clients.get(&client_id) else {
        return Some(Err("inventory fixture client is not registered"));
    };
    Some(client.backend.apply_fake_fixture(fields))
}

fn apply_fixture(backend: &FakeBackend, fields: &[&str]) -> Result<(), &'static str> {
    let mut state = lock(&backend.inventory_state);
    let grid = &mut state.fake;
    match fields {
        ["inventory-root", _, root, owner, name] => {
            let root = fixture_uuid(root)?;
            grid.root = root;
            let _owner = fixture_uuid(owner)?;
            grid.entries.insert(
                root,
                Entry {
                    id: root,
                    parent: UUID::zero(),
                    name: (*name).into(),
                    description: String::new(),
                    creator: UUID::zero(),
                    owner: backend.id,
                    last_owner: UUID::zero(),
                    group: UUID::zero(),
                    kind: EntryKind::Folder(FolderType::Root),
                },
            );
        }
        ["inventory-folder", _, id, parent, folder_type, name] => {
            let id = fixture_uuid(id)?;
            grid.entries.insert(
                id,
                Entry {
                    id,
                    parent: fixture_uuid(parent)?,
                    name: (*name).into(),
                    description: String::new(),
                    creator: UUID::zero(),
                    owner: backend.id,
                    last_owner: UUID::zero(),
                    group: UUID::zero(),
                    kind: EntryKind::Folder(
                        parse_folder_type(folder_type)
                            .ok_or("inventory fixture folder type is invalid")?,
                    ),
                },
            );
        }
        [
            "inventory-item",
            _,
            id,
            parent,
            asset,
            asset_type,
            inventory_type,
            permissions,
            name,
            description,
        ] => {
            let id = fixture_uuid(id)?;
            let permissions = u32::from_str_radix(permissions.trim_start_matches("0x"), 16)
                .map_err(|_| "inventory fixture permissions are invalid")?;
            grid.entries.insert(
                id,
                Entry {
                    id,
                    parent: fixture_uuid(parent)?,
                    name: (*name).into(),
                    description: (*description).into(),
                    creator: backend.id,
                    owner: backend.id,
                    last_owner: backend.id,
                    group: UUID::zero(),
                    kind: EntryKind::Item {
                        asset_id: fixture_uuid(asset)?,
                        asset_type: parse_asset_type(asset_type)
                            .ok_or("inventory fixture asset type is invalid")?,
                        inventory_type: parse_inventory_type(inventory_type)
                            .ok_or("inventory fixture inventory type is invalid")?,
                        permissions: Permissions::new(permissions, 0, 0, permissions, permissions)
                            .map_err(|_| "inventory fixture permissions are invalid")?,
                    },
                },
            );
        }
        ["asset", _, id, asset_type, hex] => {
            grid.assets.insert(
                (
                    fixture_uuid(id)?,
                    parse_asset_type(asset_type).ok_or("asset fixture type is invalid")? as i8,
                ),
                decode_hex(hex)?,
            );
        }
        ["balance", _, amount] => {
            grid.balance = amount
                .parse()
                .map_err(|_| "balance fixture amount is invalid")?;
        }
        ["avatar", _, id, name, textures] => {
            let id = fixture_uuid(id)?;
            let mut parsed = Vec::new();
            if *textures != "-" {
                for pair in textures.split(',') {
                    let (label, texture) = pair
                        .split_once('=')
                        .ok_or("avatar texture fixture is invalid")?;
                    parsed.push((label.into(), fixture_uuid(texture)?));
                }
            }
            grid.avatars.insert(
                id,
                AvatarView {
                    id,
                    name: (*name).into(),
                    textures: parsed,
                },
            );
        }
        ["appearance-cache", _, avatar] => {
            grid.cached_appearances.insert(fixture_uuid(avatar)?);
        }
        ["attachment", _, point, local_id, id, offset] => {
            grid.attachments.push(AttachmentView {
                point: (*point).into(),
                local_id: local_id
                    .parse()
                    .map_err(|_| "attachment local ID is invalid")?,
                id: fixture_uuid(id)?,
                offset: (*offset).into(),
            });
        }
        ["task", _, object, local_id] => {
            let object = fixture_uuid(object)?;
            grid.tasks.insert(
                object,
                TaskInventoryView {
                    local_id: local_id.parse().map_err(|_| "task local ID is invalid")?,
                    items: Vec::new(),
                },
            );
        }
        [
            "task-item",
            _,
            object,
            item,
            asset_type,
            running,
            name,
            description,
        ] => {
            let running = match *running {
                "true" => Some(true),
                "false" => Some(false),
                "none" => None,
                _ => return Err("task running fixture is invalid"),
            };
            grid.tasks
                .get_mut(&fixture_uuid(object)?)
                .ok_or("task fixture must precede task-item")?
                .items
                .push(TaskItemView {
                    id: fixture_uuid(item)?,
                    name: (*name).into(),
                    description: (*description).into(),
                    asset_type: parse_asset_type(asset_type).ok_or("task asset type is invalid")?,
                    running,
                });
        }
        _ => return Err("invalid inventory fake directive"),
    }
    Ok(())
}

fn fixture_uuid(value: &str) -> Result<UUID, &'static str> {
    parse_uuid(value).ok_or("inventory fixture UUID is invalid")
}

fn decode_hex(value: &str) -> Result<Vec<u8>, &'static str> {
    if !value.len().is_multiple_of(2) || value.len() as u64 / 2 > MAX_ASSET_BYTES {
        return Err("asset fixture hex is invalid or too large");
    }
    value
        .as_bytes()
        .chunks_exact(2)
        .map(|pair| {
            let high = hex_nibble(pair[0]).ok_or("asset fixture hex is invalid")?;
            let low = hex_nibble(pair[1]).ok_or("asset fixture hex is invalid")?;
            Ok((high << 4) | low)
        })
        .collect()
}

const fn hex_nibble(value: u8) -> Option<u8> {
    match value {
        b'0'..=b'9' => Some(value - b'0'),
        b'a'..=b'f' => Some(value - b'a' + 10),
        b'A'..=b'F' => Some(value - b'A' + 10),
        _ => None,
    }
}
