//! Bounded, generation-scoped projections of already-observed grid state.

#![allow(clippy::missing_errors_doc)]

use crate::backend::{AuthorizedToolBackend, BackendError, BackendFuture};
use crate::conversation::{ConversationChannel, ConversationStore};
use crate::llm::{ToolDefinition, ToolSchema};
use crate::policy::{
    AllowedOrigins, ApprovalRule, AuthorizedAction, Capability, FixedCost, Idempotency,
    OriginClass, PolicyError, PolicyTool, ResourceCost, Risk,
};
use crate::types::{
    BoundedText, MAX_BODY_BYTES, MAX_IDENTIFIER_BYTES, MAX_OBSERVABLE_DETAIL_BYTES, ToolCallOutcome,
};
use libremetaverse_types::UUID;
use libremetaverse_types::compat::CancellationToken;
use serde::Serialize;
use serde_json::{Map, Value, json};
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::fmt;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::mpsc;

pub const LOCATION_TOOL: &str = "world_location";
pub const AGENT_STATE_TOOL: &str = "agent_state";
pub const NEARBY_AVATARS_TOOL: &str = "nearby_avatars";
pub const VISIBLE_OBJECTS_TOOL: &str = "visible_objects";
pub const PARCEL_ENVIRONMENT_TOOL: &str = "parcel_environment";
pub const INVENTORY_SEARCH_TOOL: &str = "inventory_search";
pub const RECEIVED_LANDMARKS_TOOL: &str = "received_landmarks";
pub const RECENT_PARTICIPANTS_TOOL: &str = "recent_participants";

const SNAPSHOT_TTL: Duration = Duration::from_secs(2);
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_RESULT_BYTES: usize = 32 * 1024;
const MAX_SOURCE_ITEMS: usize = 2_048;
const MAX_PAGE_SIZE: usize = 20;
const MAX_PAGE: usize = 1_000;
const MAX_RADIUS_METERS: f64 = 256.0;
const MAX_QUERY_BYTES: usize = 128;
const MAX_UNTRUSTED_BYTES: usize = 256;

pub type SnapshotFuture<'a> = BackendFuture<'a, Result<WorldSnapshot, PerceptionError>>;

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PerceptionError {
    NotReady,
    Cancelled,
    TimedOut,
    StaleGeneration,
    InvalidSnapshot,
    SourceUnavailable,
    InvalidArguments,
    ResultTooLarge,
}

impl fmt::Display for PerceptionError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::NotReady => "world perception is unavailable before session readiness",
            Self::Cancelled => "world perception was cancelled",
            Self::TimedOut => "world perception timed out",
            Self::StaleGeneration => "world snapshot belongs to a stale session generation",
            Self::InvalidSnapshot => "world snapshot failed boundary validation",
            Self::SourceUnavailable => "world snapshot source is unavailable",
            Self::InvalidArguments => "perception tool arguments are invalid",
            Self::ResultTooLarge => "perception result exceeded its byte budget",
        })
    }
}

impl Error for PerceptionError {}

/// An injected read-only view over caches owned by the native manager graph.
pub trait WorldSnapshotSource: Send + Sync + 'static {
    fn capture(&self, generation: u64, cancellation: CancellationToken) -> SnapshotFuture<'_>;
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
pub struct WorldPosition {
    pub x: f64,
    pub y: f64,
    pub z: f64,
}

impl WorldPosition {
    fn valid(self) -> bool {
        self.x.is_finite()
            && self.y.is_finite()
            && self.z.is_finite()
            && self.x.abs() <= 1_000_000.0
            && self.y.abs() <= 1_000_000.0
            && self.z.abs() <= 1_000_000.0
    }

    fn distance(self, other: Self) -> f64 {
        ((self.x - other.x).powi(2) + (self.y - other.y).powi(2) + (self.z - other.z).powi(2))
            .sqrt()
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct ObservedAvatar {
    pub id: UUID,
    pub name: String,
    pub position: WorldPosition,
}

#[derive(Clone, Debug, PartialEq)]
pub struct ObservedObject {
    pub id: UUID,
    pub local_id: u32,
    pub name: String,
    pub description: String,
    pub hover_text: String,
    pub position: WorldPosition,
    pub scale: WorldPosition,
    pub is_attachment: bool,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InventoryMetadata {
    pub id: UUID,
    pub name: String,
    pub kind: String,
    pub path: String,
    pub modified_unix_millis: Option<u64>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LandmarkMetadata {
    pub id: UUID,
    pub name: String,
    pub region_id: Option<UUID>,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct AgentSnapshot {
    pub id: UUID,
    pub position: Option<WorldPosition>,
    pub health: Option<f32>,
    pub sitting_on_local_id: Option<u32>,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ParcelSnapshot {
    pub name: String,
    pub description: String,
    pub area: Option<i32>,
    pub category: Option<String>,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct EnvironmentSnapshot {
    pub water_height: Option<f32>,
    pub terrain_height: Option<f32>,
    pub sun_phase: Option<f32>,
    pub region_access: Option<String>,
}

/// A bounded source projection. It deliberately has no owner identities,
/// capability URLs, asset bodies, media URLs, permissions, or chat content.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WorldSnapshot {
    pub generation: u64,
    pub observed_unix_millis: u64,
    pub region_id: UUID,
    pub region_name: String,
    pub agent: AgentSnapshot,
    pub avatars: Vec<ObservedAvatar>,
    pub avatars_truncated: bool,
    pub objects: Vec<ObservedObject>,
    pub objects_truncated: bool,
    pub parcel: Option<ParcelSnapshot>,
    pub environment: Option<EnvironmentSnapshot>,
    pub inventory: Vec<InventoryMetadata>,
    pub inventory_truncated: bool,
    pub landmarks: Vec<LandmarkMetadata>,
}

impl WorldSnapshot {
    fn validate(&mut self, generation: u64) -> Result<(), PerceptionError> {
        sanitize_snapshot(self);
        if self.generation != generation {
            return Err(PerceptionError::StaleGeneration);
        }
        if self.region_id == UUID::zero()
            || self.agent.id == UUID::zero()
            || self.observed_unix_millis == 0
            || self.avatars.len() > MAX_SOURCE_ITEMS
            || self.objects.len() > MAX_SOURCE_ITEMS
            || self.inventory.len() > MAX_SOURCE_ITEMS
            || self.landmarks.len() > MAX_SOURCE_ITEMS
            || self
                .agent
                .position
                .is_some_and(|position| !position.valid())
            || self
                .agent
                .health
                .is_some_and(|health| !health.is_finite() || !(0.0..=100.0).contains(&health))
            || self
                .avatars
                .iter()
                .any(|item| item.id == UUID::zero() || !item.position.valid())
            || self.objects.iter().any(|item| {
                item.id == UUID::zero() || !item.position.valid() || !item.scale.valid()
            })
            || self.environment.as_ref().is_some_and(|environment| {
                environment
                    .water_height
                    .is_some_and(|value| !value.is_finite())
                    || environment
                        .terrain_height
                        .is_some_and(|value| !value.is_finite())
                    || environment
                        .sun_phase
                        .is_some_and(|value| !value.is_finite())
            })
        {
            return Err(PerceptionError::InvalidSnapshot);
        }
        Ok(())
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Lifecycle {
    generation: Option<u64>,
    region_id: Option<UUID>,
}

#[derive(Clone)]
struct CachedSnapshot {
    captured: Instant,
    snapshot: Arc<WorldSnapshot>,
}

#[derive(Default)]
struct PerceptionState {
    lifecycle: Option<Lifecycle>,
    cache: Option<CachedSnapshot>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PerceptionOutcome {
    Completed,
    Rejected,
}

/// Content-free timing summary. Tool arguments and results are never retained.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PerceptionObservation {
    pub authorization_id: u64,
    pub call_id: BoundedText<MAX_IDENTIFIER_BYTES>,
    pub tool: BoundedText<MAX_IDENTIFIER_BYTES>,
    pub outcome: PerceptionOutcome,
    pub duration_millis: u64,
    pub result_bytes: usize,
    pub cache_hit: bool,
}

#[derive(Clone)]
pub struct PerceptionIngress {
    state: Arc<Mutex<PerceptionState>>,
}

impl fmt::Debug for PerceptionIngress {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("PerceptionIngress")
            .finish_non_exhaustive()
    }
}

impl PerceptionIngress {
    pub fn connected(&self, generation: u64, region_id: UUID) -> Result<(), PerceptionError> {
        if generation == 0 || region_id == UUID::zero() {
            return Err(PerceptionError::InvalidSnapshot);
        }
        let mut state = lock(&self.state);
        state.lifecycle = Some(Lifecycle {
            generation: Some(generation),
            region_id: Some(region_id),
        });
        state.cache = None;
        Ok(())
    }

    pub fn region_changed(&self, generation: u64, region_id: UUID) -> Result<(), PerceptionError> {
        let mut state = lock(&self.state);
        let Some(lifecycle) = state.lifecycle else {
            return Err(PerceptionError::NotReady);
        };
        if lifecycle.generation != Some(generation) {
            return Err(PerceptionError::StaleGeneration);
        }
        if region_id == UUID::zero() {
            return Err(PerceptionError::InvalidSnapshot);
        }
        state.lifecycle = Some(Lifecycle {
            generation: Some(generation),
            region_id: Some(region_id),
        });
        state.cache = None;
        Ok(())
    }

    pub fn disconnected(&self) {
        let mut state = lock(&self.state);
        state.lifecycle = None;
        state.cache = None;
    }
}

pub struct PerceptionBackend {
    source: Arc<dyn WorldSnapshotSource>,
    conversations: Arc<ConversationStore>,
    state: Arc<Mutex<PerceptionState>>,
    observations: mpsc::Sender<PerceptionObservation>,
    observation_rx: Mutex<Option<mpsc::Receiver<PerceptionObservation>>>,
    capture_lock: tokio::sync::Mutex<()>,
    cache_ttl: Duration,
    snapshot_timeout: Duration,
}

impl fmt::Debug for PerceptionBackend {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("PerceptionBackend")
            .field("cache_ttl", &self.cache_ttl)
            .field("snapshot_timeout", &self.snapshot_timeout)
            .finish_non_exhaustive()
    }
}

impl PerceptionBackend {
    pub fn new(
        source: Arc<dyn WorldSnapshotSource>,
        conversations: Arc<ConversationStore>,
        observation_capacity: usize,
    ) -> Result<Self, PerceptionError> {
        if observation_capacity == 0 {
            return Err(PerceptionError::InvalidSnapshot);
        }
        let (observations, observation_rx) = mpsc::channel(observation_capacity);
        Ok(Self {
            source,
            conversations,
            state: Arc::new(Mutex::new(PerceptionState::default())),
            observations,
            observation_rx: Mutex::new(Some(observation_rx)),
            capture_lock: tokio::sync::Mutex::new(()),
            cache_ttl: SNAPSHOT_TTL,
            snapshot_timeout: SNAPSHOT_TIMEOUT,
        })
    }

    #[cfg(test)]
    pub(crate) fn with_timing(mut self, cache_ttl: Duration, timeout: Duration) -> Self {
        self.cache_ttl = cache_ttl;
        self.snapshot_timeout = timeout;
        self
    }

    #[must_use]
    pub fn ingress(&self) -> PerceptionIngress {
        PerceptionIngress {
            state: Arc::clone(&self.state),
        }
    }

    pub fn take_observations(&self) -> Option<mpsc::Receiver<PerceptionObservation>> {
        lock(&self.observation_rx).take()
    }

    async fn snapshot(
        &self,
        cancellation: CancellationToken,
    ) -> Result<(Arc<WorldSnapshot>, bool), PerceptionError> {
        if cancellation.is_cancellation_requested() {
            return Err(PerceptionError::Cancelled);
        }
        let lifecycle = lock(&self.state)
            .lifecycle
            .ok_or(PerceptionError::NotReady)?;
        let generation = lifecycle.generation.ok_or(PerceptionError::NotReady)?;
        if let Some(cached) = lock(&self.state).cache.clone()
            && cached.captured.elapsed() <= self.cache_ttl
            && cached.snapshot.generation == generation
            && Some(cached.snapshot.region_id) == lifecycle.region_id
        {
            return Ok((cached.snapshot, true));
        }
        let deadline = tokio::time::Instant::now() + self.snapshot_timeout;
        let _capture_guard = tokio::select! {
            () = cancellation.cancelled() => return Err(PerceptionError::Cancelled),
            result = tokio::time::timeout_at(deadline, self.capture_lock.lock()) => {
                result.map_err(|_| PerceptionError::TimedOut)?
            },
        };
        let lifecycle = lock(&self.state)
            .lifecycle
            .ok_or(PerceptionError::NotReady)?;
        if lifecycle.generation != Some(generation) {
            return Err(PerceptionError::StaleGeneration);
        }
        if let Some(cached) = lock(&self.state).cache.clone()
            && cached.captured.elapsed() <= self.cache_ttl
            && cached.snapshot.generation == generation
            && Some(cached.snapshot.region_id) == lifecycle.region_id
        {
            return Ok((cached.snapshot, true));
        }
        let capture = self.source.capture(generation, cancellation.clone());
        let mut snapshot = tokio::select! {
            () = cancellation.cancelled() => return Err(PerceptionError::Cancelled),
            result = tokio::time::timeout_at(deadline, capture) => {
                result.map_err(|_| PerceptionError::TimedOut)??
            }
        };
        snapshot.validate(generation)?;
        let current = lock(&self.state)
            .lifecycle
            .ok_or(PerceptionError::NotReady)?;
        if current.generation != Some(generation) || current.region_id != Some(snapshot.region_id) {
            return Err(PerceptionError::StaleGeneration);
        }
        let snapshot = Arc::new(snapshot);
        lock(&self.state).cache = Some(CachedSnapshot {
            captured: Instant::now(),
            snapshot: Arc::clone(&snapshot),
        });
        Ok((snapshot, false))
    }

    async fn execute(
        &self,
        action: &AuthorizedAction,
        cancellation: CancellationToken,
    ) -> Result<(Value, bool), PerceptionError> {
        let arguments: Value = serde_json::from_str(action.call().arguments_json.as_str())
            .map_err(|_| PerceptionError::InvalidArguments)?;
        let arguments = arguments
            .as_object()
            .ok_or(PerceptionError::InvalidArguments)?;
        let (snapshot, cache_hit) = self.snapshot(cancellation).await?;
        let value = match action.call().name.as_str() {
            LOCATION_TOOL => location_result(&snapshot, cache_hit, arguments)?,
            AGENT_STATE_TOOL => agent_result(&snapshot, cache_hit, arguments)?,
            NEARBY_AVATARS_TOOL => avatars_result(&snapshot, cache_hit, arguments)?,
            VISIBLE_OBJECTS_TOOL => objects_result(&snapshot, cache_hit, arguments)?,
            PARCEL_ENVIRONMENT_TOOL => environment_result(&snapshot, cache_hit, arguments)?,
            INVENTORY_SEARCH_TOOL => inventory_result(&snapshot, cache_hit, arguments)?,
            RECEIVED_LANDMARKS_TOOL => landmarks_result(&snapshot, cache_hit, arguments)?,
            RECENT_PARTICIPANTS_TOOL => {
                participants_result(&snapshot, cache_hit, arguments, &self.conversations)?
            }
            _ => return Err(PerceptionError::InvalidArguments),
        };
        Ok((value, cache_hit))
    }
}

impl AuthorizedToolBackend for PerceptionBackend {
    fn apply(
        &self,
        action: AuthorizedAction,
        cancellation: CancellationToken,
    ) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>> {
        Box::pin(async move {
            let started = Instant::now();
            let authorization_id = action.authorization_id();
            let call_id = action.call().call_id.clone();
            let tool = action.call().name.clone();
            let execution = self.execute(&action, cancellation).await;
            let (outcome, result_bytes, cache_hit, status) = match execution {
                Ok((value, cache_hit)) => {
                    let serialized =
                        serde_json::to_string(&value).map_err(|_| BackendError::Operation {
                            operation: "serialize perception result",
                        })?;
                    if serialized.len() > MAX_RESULT_BYTES {
                        return Err(BackendError::Operation {
                            operation: "bounded perception result",
                        });
                    }
                    let result =
                        BoundedText::<MAX_BODY_BYTES>::new("perception.result", serialized)
                            .map_err(|_| BackendError::Operation {
                                operation: "bounded perception result",
                            })?;
                    let bytes = result.len();
                    (
                        ToolCallOutcome::Completed {
                            call_id: call_id.clone(),
                            result,
                        },
                        bytes,
                        cache_hit,
                        PerceptionOutcome::Completed,
                    )
                }
                Err(error) => {
                    let reason = BoundedText::<MAX_OBSERVABLE_DETAIL_BYTES>::new(
                        "perception.rejection",
                        error.to_string(),
                    )
                    .map_err(|_| BackendError::Operation {
                        operation: "bounded perception rejection",
                    })?;
                    (
                        ToolCallOutcome::Rejected {
                            call_id: call_id.clone(),
                            reason,
                        },
                        0,
                        false,
                        PerceptionOutcome::Rejected,
                    )
                }
            };
            let _ = self.observations.try_send(PerceptionObservation {
                authorization_id,
                call_id,
                tool,
                outcome: status,
                duration_millis: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
                result_bytes,
                cache_hit,
            });
            Ok(outcome)
        })
    }
}

#[allow(clippy::too_many_lines)] // The stable registry table documents all eight schemas together.
pub fn perception_policy_tools() -> Result<Vec<PolicyTool>, PolicyError> {
    let world_origins = || {
        AllowedOrigins::new([
            OriginClass::PublicChat,
            OriginClass::UnprivilegedIm,
            OriginClass::AuthorizedIm,
            OriginClass::LocalOperator,
            OriginClass::InternalScheduler,
        ])
    };
    let private_origins = || {
        AllowedOrigins::new([
            OriginClass::AuthorizedIm,
            OriginClass::LocalOperator,
            OriginClass::InternalScheduler,
        ])
    };
    let empty = ToolSchema::Object {
        properties: BTreeMap::new(),
        required: BTreeSet::new(),
        additional_properties: false,
    };
    let page = paged_schema(false);
    let radius_page = paged_schema(true);
    let inventory = {
        let mut properties = BTreeMap::new();
        properties.insert("query".to_owned(), ToolSchema::String);
        properties.insert("page".to_owned(), ToolSchema::Integer);
        properties.insert("page_size".to_owned(), ToolSchema::Integer);
        ToolSchema::Object {
            properties,
            required: BTreeSet::from(["query".to_owned()]),
            additional_properties: false,
        }
    };
    let specs = [
        (
            LOCATION_TOOL,
            "Read current region and position with freshness metadata.",
            empty.clone(),
            false,
        ),
        (
            AGENT_STATE_TOOL,
            "Read safe current agent state with freshness metadata.",
            empty.clone(),
            false,
        ),
        (
            NEARBY_AVATARS_TOOL,
            "List nearby avatars by bounded radius with deterministic pagination.",
            radius_page.clone(),
            false,
        ),
        (
            VISIBLE_OBJECTS_TOOL,
            "List tracked objects and safe properties with bounded deterministic pagination.",
            radius_page,
            false,
        ),
        (
            PARCEL_ENVIRONMENT_TOOL,
            "Read cached parcel and environment facts without requesting network state.",
            empty,
            false,
        ),
        (
            INVENTORY_SEARCH_TOOL,
            "Search cached inventory metadata; never returns assets, permissions, or URLs.",
            inventory,
            true,
        ),
        (
            RECEIVED_LANDMARKS_TOOL,
            "List cached landmark metadata with deterministic pagination.",
            page.clone(),
            true,
        ),
        (
            RECENT_PARTICIPANTS_TOOL,
            "List recent participant UUIDs and channels without conversation content.",
            page,
            true,
        ),
    ];
    specs
        .into_iter()
        .map(|(name, description, schema, private)| {
            PolicyTool::new(
                ToolDefinition {
                    name: BoundedText::new("perception.tool.name", name)?,
                    description: BoundedText::new("perception.tool.description", description)?,
                    schema,
                    mutating: false,
                },
                Capability::Informational,
                Risk::ReadOnly,
                if private {
                    private_origins()?
                } else {
                    world_origins()?
                },
                ResourceCost::one_call(),
                Idempotency::Idempotent,
                ApprovalRule::Never,
                true,
                Arc::new(FixedCost(ResourceCost::one_call())),
            )
        })
        .collect()
}

fn paged_schema(radius: bool) -> ToolSchema {
    let mut properties = BTreeMap::new();
    properties.insert(
        "page".to_owned(),
        ToolSchema::Nullable(Box::new(ToolSchema::Integer)),
    );
    properties.insert(
        "page_size".to_owned(),
        ToolSchema::Nullable(Box::new(ToolSchema::Integer)),
    );
    if radius {
        properties.insert(
            "radius_meters".to_owned(),
            ToolSchema::Nullable(Box::new(ToolSchema::Number)),
        );
    }
    ToolSchema::Object {
        properties,
        required: BTreeSet::new(),
        additional_properties: false,
    }
}

fn base(snapshot: &WorldSnapshot, cache_hit: bool, tool: &str) -> Map<String, Value> {
    let derived_fields = match tool {
        NEARBY_AVATARS_TOOL => vec!["distance_meters", "direction"],
        VISIBLE_OBJECTS_TOOL => vec!["distance_meters"],
        _ => Vec::new(),
    };
    let mut result = Map::new();
    result.insert("schema_version".to_owned(), json!(1));
    result.insert("tool".to_owned(), json!(tool));
    result.insert("generation".to_owned(), json!(snapshot.generation));
    result.insert(
        "region_id".to_owned(),
        json!(snapshot.region_id.to_string()),
    );
    result.insert(
        "observed_unix_millis".to_owned(),
        json!(snapshot.observed_unix_millis),
    );
    result.insert(
        "freshness".to_owned(),
        json!(if cache_hit { "cached" } else { "observed" }),
    );
    result.insert("trust".to_owned(), json!("untrusted_observed_data"));
    result.insert(
        "provenance".to_owned(),
        json!({
            "facts": if cache_hit { "cached_observation" } else { "current_observation" },
            "derived_fields": derived_fields,
            "model_inference": "none"
        }),
    );
    result
}

fn location_result(
    snapshot: &WorldSnapshot,
    cache_hit: bool,
    arguments: &Map<String, Value>,
) -> Result<Value, PerceptionError> {
    require_empty(arguments)?;
    let mut result = base(snapshot, cache_hit, LOCATION_TOOL);
    result.insert("region_name".to_owned(), json!(snapshot.region_name));
    insert_optional(
        &mut result,
        "position",
        snapshot.agent.position.map(|value| json!(value)),
    );
    Ok(Value::Object(result))
}

fn agent_result(
    snapshot: &WorldSnapshot,
    cache_hit: bool,
    arguments: &Map<String, Value>,
) -> Result<Value, PerceptionError> {
    require_empty(arguments)?;
    let mut result = base(snapshot, cache_hit, AGENT_STATE_TOOL);
    result.insert("agent_id".to_owned(), json!(snapshot.agent.id.to_string()));
    insert_optional(
        &mut result,
        "position",
        snapshot.agent.position.map(|value| json!(value)),
    );
    insert_optional(
        &mut result,
        "health",
        snapshot.agent.health.map(|value| json!(value)),
    );
    insert_optional(
        &mut result,
        "sitting_on_local_id",
        snapshot.agent.sitting_on_local_id.map(|value| json!(value)),
    );
    Ok(Value::Object(result))
}

fn avatars_result(
    snapshot: &WorldSnapshot,
    cache_hit: bool,
    arguments: &Map<String, Value>,
) -> Result<Value, PerceptionError> {
    let (page, page_size) = pagination(arguments)?;
    let radius = radius(arguments)?;
    let origin = snapshot
        .agent
        .position
        .ok_or(PerceptionError::SourceUnavailable)?;
    let mut items = snapshot
        .avatars
        .iter()
        .filter(|avatar| avatar.id != snapshot.agent.id)
        .map(|avatar| (origin.distance(avatar.position), avatar))
        .filter(|(distance, _)| *distance <= radius)
        .collect::<Vec<_>>();
    items.sort_by(|left, right| {
        left.0
            .total_cmp(&right.0)
            .then_with(|| left.1.id.to_string().cmp(&right.1.id.to_string()))
    });
    let total = items.len();
    let values = page_slice(&items, page, page_size)
        .iter()
        .map(|(distance, avatar)| {
            json!({
                "id": avatar.id.to_string(),
                "name": avatar.name,
                "position": avatar.position,
                "distance_meters": round(*distance),
                "direction": direction(origin, avatar.position),
                "trust": "untrusted_observed_data"
            })
        })
        .collect::<Vec<_>>();
    paged_result(
        snapshot,
        cache_hit,
        NEARBY_AVATARS_TOOL,
        PageResult {
            page,
            page_size,
            total,
            items: values,
            source_truncated: snapshot.avatars_truncated,
        },
    )
}

fn objects_result(
    snapshot: &WorldSnapshot,
    cache_hit: bool,
    arguments: &Map<String, Value>,
) -> Result<Value, PerceptionError> {
    let (page, page_size) = pagination(arguments)?;
    let radius = radius(arguments)?;
    let origin = snapshot
        .agent
        .position
        .ok_or(PerceptionError::SourceUnavailable)?;
    let mut items = snapshot
        .objects
        .iter()
        .filter(|object| !object.is_attachment)
        .map(|object| (origin.distance(object.position), object))
        .filter(|(distance, _)| *distance <= radius)
        .collect::<Vec<_>>();
    items.sort_by(|left, right| {
        left.0
            .total_cmp(&right.0)
            .then_with(|| left.1.id.to_string().cmp(&right.1.id.to_string()))
    });
    let total = items.len();
    let values = page_slice(&items, page, page_size)
        .iter()
        .map(|(distance, object)| {
            json!({
                "id": object.id.to_string(),
                "local_id": object.local_id,
                "name": object.name,
                "description": object.description,
                "hover_text": object.hover_text,
                "position": object.position,
                "scale": object.scale,
                "distance_meters": round(*distance),
                "trust": "untrusted_observed_data"
            })
        })
        .collect::<Vec<_>>();
    paged_result(
        snapshot,
        cache_hit,
        VISIBLE_OBJECTS_TOOL,
        PageResult {
            page,
            page_size,
            total,
            items: values,
            source_truncated: snapshot.objects_truncated,
        },
    )
}

fn environment_result(
    snapshot: &WorldSnapshot,
    cache_hit: bool,
    arguments: &Map<String, Value>,
) -> Result<Value, PerceptionError> {
    require_empty(arguments)?;
    let mut result = base(snapshot, cache_hit, PARCEL_ENVIRONMENT_TOOL);
    result.insert(
        "parcel".to_owned(),
        snapshot.parcel.as_ref().map_or_else(
            || unknown("manager_cache_unavailable"),
            |parcel| {
                json!({
                    "status": if cache_hit { "cached" } else { "observed" },
                    "name": parcel.name,
                    "description": parcel.description,
                    "area": optional_fact(parcel.area.map(|value| json!(value))),
                    "category": optional_fact(parcel.category.as_ref().map(|value| json!(value))),
                    "trust": "untrusted_observed_data"
                })
            },
        ),
    );
    result.insert(
        "environment".to_owned(),
        snapshot.environment.as_ref().map_or_else(
            || unknown("manager_cache_unavailable"),
            |environment| {
                json!({
                    "status": if cache_hit { "cached" } else { "observed" },
                    "water_height": optional_fact(environment.water_height.map(|value| json!(value))),
                    "terrain_height": optional_fact(environment.terrain_height.map(|value| json!(value))),
                    "sun_phase": optional_fact(environment.sun_phase.map(|value| json!(value))),
                    "region_access": optional_fact(environment.region_access.as_ref().map(|value| json!(value))),
                    "trust": "untrusted_observed_data"
                })
            },
        ),
    );
    Ok(Value::Object(result))
}

fn inventory_result(
    snapshot: &WorldSnapshot,
    cache_hit: bool,
    arguments: &Map<String, Value>,
) -> Result<Value, PerceptionError> {
    let query = arguments
        .get("query")
        .and_then(Value::as_str)
        .ok_or(PerceptionError::InvalidArguments)?;
    if query.is_empty() || query.len() > MAX_QUERY_BYTES {
        return Err(PerceptionError::InvalidArguments);
    }
    let (page, page_size) = pagination(arguments)?;
    let query = query.to_lowercase();
    let mut items = snapshot
        .inventory
        .iter()
        .filter(|item| {
            item.name.to_lowercase().contains(&query)
                || item.path.to_lowercase().contains(&query)
                || item.kind.to_lowercase().contains(&query)
        })
        .collect::<Vec<_>>();
    items.sort_by(|left, right| {
        left.path
            .to_lowercase()
            .cmp(&right.path.to_lowercase())
            .then_with(|| left.id.to_string().cmp(&right.id.to_string()))
    });
    let total = items.len();
    let values = page_slice(&items, page, page_size)
        .iter()
        .map(|item| {
            json!({
                "id": item.id.to_string(),
                "name": item.name,
                "kind": item.kind,
                "path": item.path,
                "modified_unix_millis": optional_fact(item.modified_unix_millis.map(|value| json!(value))),
                "trust": "untrusted_observed_data"
            })
        })
        .collect::<Vec<_>>();
    paged_result(
        snapshot,
        cache_hit,
        INVENTORY_SEARCH_TOOL,
        PageResult {
            page,
            page_size,
            total,
            items: values,
            source_truncated: snapshot.inventory_truncated,
        },
    )
}

fn landmarks_result(
    snapshot: &WorldSnapshot,
    cache_hit: bool,
    arguments: &Map<String, Value>,
) -> Result<Value, PerceptionError> {
    let (page, page_size) = pagination(arguments)?;
    let mut items = snapshot.landmarks.iter().collect::<Vec<_>>();
    items.sort_by(|left, right| {
        left.name
            .to_lowercase()
            .cmp(&right.name.to_lowercase())
            .then_with(|| left.id.to_string().cmp(&right.id.to_string()))
    });
    let total = items.len();
    let values = page_slice(&items, page, page_size)
        .iter()
        .map(|item| {
            json!({
                "id": item.id.to_string(),
                "name": item.name,
                "region_id": optional_fact(item.region_id.map(|id| json!(id.to_string()))),
                "trust": "untrusted_observed_data"
            })
        })
        .collect::<Vec<_>>();
    paged_result(
        snapshot,
        cache_hit,
        RECEIVED_LANDMARKS_TOOL,
        PageResult {
            page,
            page_size,
            total,
            items: values,
            source_truncated: snapshot.inventory_truncated,
        },
    )
}

fn participants_result(
    snapshot: &WorldSnapshot,
    cache_hit: bool,
    arguments: &Map<String, Value>,
    conversations: &ConversationStore,
) -> Result<Value, PerceptionError> {
    let (page, page_size) = pagination(arguments)?;
    let mut items = conversations.list_metadata();
    items.sort_by(|left, right| {
        right
            .last_active_unix_millis
            .cmp(&left.last_active_unix_millis)
            .then_with(|| left.avatar_id.to_string().cmp(&right.avatar_id.to_string()))
            .then_with(|| left.channel.cmp(&right.channel))
    });
    let source_truncated = items.len() > MAX_SOURCE_ITEMS;
    items.truncate(MAX_SOURCE_ITEMS);
    let total = items.len();
    let values = page_slice(&items, page, page_size)
        .iter()
        .map(|item| {
            json!({
                "avatar_id": item.avatar_id.to_string(),
                "channel": match item.channel {
                    ConversationChannel::PublicChat => "public_chat",
                    ConversationChannel::DirectIm => "direct_im",
                },
                "last_active_unix_millis": item.last_active_unix_millis
            })
        })
        .collect::<Vec<_>>();
    paged_result(
        snapshot,
        cache_hit,
        RECENT_PARTICIPANTS_TOOL,
        PageResult {
            page,
            page_size,
            total,
            items: values,
            source_truncated,
        },
    )
}

struct PageResult {
    page: usize,
    page_size: usize,
    total: usize,
    items: Vec<Value>,
    source_truncated: bool,
}

fn paged_result(
    snapshot: &WorldSnapshot,
    cache_hit: bool,
    tool: &str,
    page: PageResult,
) -> Result<Value, PerceptionError> {
    let mut result = base(snapshot, cache_hit, tool);
    result.insert("page".to_owned(), json!(page.page));
    result.insert("page_size".to_owned(), json!(page.page_size));
    result.insert("total_matches".to_owned(), json!(page.total));
    result.insert("source_truncated".to_owned(), json!(page.source_truncated));
    result.insert(
        "has_more".to_owned(),
        json!(page.page.saturating_add(1).saturating_mul(page.page_size) < page.total),
    );
    result.insert("items".to_owned(), Value::Array(page.items));
    let value = Value::Object(result);
    if serde_json::to_vec(&value).map_or(true, |bytes| bytes.len() > MAX_RESULT_BYTES) {
        return Err(PerceptionError::ResultTooLarge);
    }
    Ok(value)
}

fn pagination(arguments: &Map<String, Value>) -> Result<(usize, usize), PerceptionError> {
    let page = optional_usize(arguments, "page", 0)?;
    let page_size = optional_usize(arguments, "page_size", 20)?;
    if page > MAX_PAGE || page_size == 0 || page_size > MAX_PAGE_SIZE {
        return Err(PerceptionError::InvalidArguments);
    }
    Ok((page, page_size))
}

fn radius(arguments: &Map<String, Value>) -> Result<f64, PerceptionError> {
    let radius = arguments
        .get("radius_meters")
        .filter(|value| !value.is_null())
        .map_or(Ok(96.0), |value| {
            value.as_f64().ok_or(PerceptionError::InvalidArguments)
        })?;
    if !radius.is_finite() || !(0.1..=MAX_RADIUS_METERS).contains(&radius) {
        return Err(PerceptionError::InvalidArguments);
    }
    Ok(radius)
}

fn optional_usize(
    arguments: &Map<String, Value>,
    name: &str,
    default: usize,
) -> Result<usize, PerceptionError> {
    arguments
        .get(name)
        .filter(|value| !value.is_null())
        .map_or(Ok(default), |value| {
            value
                .as_u64()
                .and_then(|value| usize::try_from(value).ok())
                .ok_or(PerceptionError::InvalidArguments)
        })
}

fn page_slice<T>(items: &[T], page: usize, page_size: usize) -> &[T] {
    let start = page.saturating_mul(page_size).min(items.len());
    let end = start.saturating_add(page_size).min(items.len());
    &items[start..end]
}

fn require_empty(arguments: &Map<String, Value>) -> Result<(), PerceptionError> {
    if arguments.is_empty() {
        Ok(())
    } else {
        Err(PerceptionError::InvalidArguments)
    }
}

fn insert_optional(result: &mut Map<String, Value>, name: &str, value: Option<Value>) {
    result.insert(
        name.to_owned(),
        value.unwrap_or_else(|| unknown("manager_cache_unavailable")),
    );
}

fn unknown(reason: &str) -> Value {
    json!({"status": "unknown", "reason": reason})
}

fn optional_fact(value: Option<Value>) -> Value {
    value.unwrap_or_else(|| unknown("manager_cache_unavailable"))
}

fn direction(origin: WorldPosition, target: WorldPosition) -> &'static str {
    let dx = target.x - origin.x;
    let dy = target.y - origin.y;
    if dx.abs() < 0.01 && dy.abs() < 0.01 {
        "here"
    } else if dx.abs() > dy.abs() {
        if dx > 0.0 { "east" } else { "west" }
    } else if dy > 0.0 {
        "north"
    } else {
        "south"
    }
}

fn round(value: f64) -> f64 {
    (value * 100.0).round() / 100.0
}

fn sanitize_snapshot(snapshot: &mut WorldSnapshot) {
    snapshot.region_name = sanitize_untrusted(&snapshot.region_name);
    for avatar in &mut snapshot.avatars {
        avatar.name = sanitize_untrusted(&avatar.name);
    }
    for object in &mut snapshot.objects {
        object.name = sanitize_untrusted(&object.name);
        object.description = sanitize_untrusted(&object.description);
        object.hover_text = sanitize_untrusted(&object.hover_text);
    }
    if let Some(parcel) = &mut snapshot.parcel {
        parcel.name = sanitize_untrusted(&parcel.name);
        parcel.description = sanitize_untrusted(&parcel.description);
        parcel.category = parcel
            .category
            .take()
            .map(|value| sanitize_untrusted(&value));
    }
    if let Some(environment) = &mut snapshot.environment {
        environment.region_access = environment
            .region_access
            .take()
            .map(|value| sanitize_untrusted(&value));
    }
    for item in &mut snapshot.inventory {
        item.name = sanitize_untrusted(&item.name);
        item.kind = sanitize_untrusted(&item.kind);
        item.path = sanitize_untrusted(&item.path);
    }
    for landmark in &mut snapshot.landmarks {
        landmark.name = sanitize_untrusted(&landmark.name);
    }
}

pub(crate) fn sanitize_untrusted(value: &str) -> String {
    let mut result = String::with_capacity(value.len().min(MAX_UNTRUSTED_BYTES));
    for character in value.chars() {
        let character = if character.is_control() {
            ' '
        } else {
            character
        };
        if result.len() + character.len_utf8() > MAX_UNTRUSTED_BYTES {
            break;
        }
        result.push(character);
    }
    result
        .split_whitespace()
        .map(|token| {
            let normalized = token.to_ascii_lowercase();
            if normalized.contains("http://")
                || normalized.contains("https://")
                || normalized.contains("secondlife://")
                || normalized.contains("x-grid-location-info://")
            {
                "[url removed]"
            } else if normalized.starts_with("sk-")
                || normalized.starts_with("bearer")
                || normalized.contains("api_key=")
                || normalized.contains("apikey=")
                || normalized.contains("password=")
                || normalized.contains("token=")
            {
                "[secret removed]"
            } else {
                token
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
    mutex
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
}

#[must_use]
pub fn unix_millis_now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |duration| {
            u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
        })
}
