//! Narrow public LSL generation and delivery workflow.

#![allow(clippy::missing_errors_doc)]

use crate::backend::{AuthorizedToolBackend, BackendError, BackendFuture};
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, compat::CancellationToken};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

pub const SCRIPT_DELIVERY_TOOL: &str = "deliver_generated_lsl";
const MAX_NAME_BYTES: usize = 96;
const MAX_DESCRIPTION_BYTES: usize = 512;
const MAX_SOURCE_BYTES_HARD: usize = 64 * 1024;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ScriptDeliverySettings {
    pub max_source_bytes: usize,
    pub max_per_resident_per_window: u32,
    pub max_global_per_window: u32,
    pub rate_window: Duration,
    pub max_outstanding: usize,
}

impl Default for ScriptDeliverySettings {
    fn default() -> Self {
        Self {
            max_source_bytes: 32 * 1024,
            max_per_resident_per_window: 2,
            max_global_per_window: 16,
            rate_window: Duration::from_mins(10),
            max_outstanding: 4,
        }
    }
}

impl ScriptDeliverySettings {
    fn valid(self) -> bool {
        (256..=MAX_SOURCE_BYTES_HARD).contains(&self.max_source_bytes)
            && (1..=100).contains(&self.max_per_resident_per_window)
            && self.max_global_per_window >= self.max_per_resident_per_window
            && self.max_global_per_window <= 1_000
            && !self.rate_window.is_zero()
            && self.rate_window <= Duration::from_hours(24)
            && (1..=64).contains(&self.max_outstanding)
    }
}

#[derive(Clone, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct GeneratedScript {
    pub name: String,
    pub description: String,
    pub source: String,
}

impl fmt::Debug for GeneratedScript {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("GeneratedScript")
            .field("name_bytes", &self.name.len())
            .field("description_bytes", &self.description.len())
            .field("source_bytes", &self.source.len())
            .finish()
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScriptInventoryReceipt {
    pub item_id: UUID,
    pub item_name: String,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScriptDeliveryOutcome {
    Delivered,
    RetainedForRecovery,
    Rejected,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ScriptDeliveryError {
    UnsafeLimits,
    InvalidArguments,
    InvalidName,
    InvalidDescription,
    OversizedSource,
    MalformedSource,
    SecretBearingSource,
    DisallowedSource,
    RateLimited,
    Busy,
    Cancelled,
    InventoryCreate,
    TransferAmbiguous,
    RecoveryFailed,
}

impl fmt::Display for ScriptDeliveryError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Self::UnsafeLimits => "unsafe script-delivery limits",
            Self::InvalidArguments => "invalid script result schema",
            Self::InvalidName => "script name is invalid",
            Self::InvalidDescription => "script description is invalid",
            Self::OversizedSource => "script source is too large",
            Self::MalformedSource => "script source did not pass syntax validation",
            Self::SecretBearingSource => "script source may contain a secret or capability URL",
            Self::DisallowedSource => "script requests a policy-disallowed in-world capability",
            Self::RateLimited => "script request rate limit reached",
            Self::Busy => "too many script deliveries are active",
            Self::Cancelled => "script delivery was cancelled",
            Self::InventoryCreate => "script inventory creation failed",
            Self::TransferAmbiguous => {
                "script transfer outcome is ambiguous; the local item was retained for recovery"
            }
            Self::RecoveryFailed => "script transfer failed and recovery could not be confirmed",
        })
    }
}
impl std::error::Error for ScriptDeliveryError {}

pub type ScriptInventoryFuture<'a, T> =
    Pin<Box<dyn Future<Output = Result<T, ScriptDeliveryError>> + Send + 'a>>;

pub trait ScriptInventory: Send + Sync + 'static {
    fn create_full_permission(
        &self,
        script: GeneratedScript,
        cancellation: CancellationToken,
    ) -> ScriptInventoryFuture<'_, ScriptInventoryReceipt>;
    fn give_to(
        &self,
        receipt: ScriptInventoryReceipt,
        recipient: UUID,
        cancellation: CancellationToken,
    ) -> ScriptInventoryFuture<'_, ()>;
    fn retain_for_recovery(
        &self,
        receipt: ScriptInventoryReceipt,
        cancellation: CancellationToken,
    ) -> ScriptInventoryFuture<'_, ()>;
}

#[cfg(feature = "live-grid")]
pub struct LibremetaverseScriptInventory {
    manager: libremetaverse::InventoryManager,
    delivery_folder: Mutex<Option<UUID>>,
}

#[cfg(feature = "live-grid")]
impl fmt::Debug for LibremetaverseScriptInventory {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("LibremetaverseScriptInventory")
            .finish_non_exhaustive()
    }
}

#[cfg(feature = "live-grid")]
impl LibremetaverseScriptInventory {
    #[must_use]
    pub fn new(client: &libremetaverse::GridClient) -> Self {
        Self {
            manager: client.inventory(),
            delivery_folder: Mutex::new(None),
        }
    }

    fn folder(&self) -> Result<UUID, ScriptDeliveryError> {
        let mut cached = self
            .delivery_folder
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        if let Some(folder) = *cached {
            return Ok(folder);
        }
        let parent = self
            .manager
            .find_folder_for_type_with_asset_type(libremetaverse_types::AssetType::Script)
            .map_err(|_| ScriptDeliveryError::InventoryCreate)?;
        let folder = self
            .manager
            .create_folder_with_uuid_string(parent, "MetaCrate Generated Scripts".into())
            .map_err(|_| ScriptDeliveryError::InventoryCreate)?;
        *cached = Some(folder);
        Ok(folder)
    }
}

#[cfg(feature = "live-grid")]
impl ScriptInventory for LibremetaverseScriptInventory {
    fn create_full_permission(
        &self,
        script: GeneratedScript,
        cancellation: CancellationToken,
    ) -> ScriptInventoryFuture<'_, ScriptInventoryReceipt> {
        Box::pin(async move {
            let folder = self.folder()?;
            let item_name = script.name;
            let result = self
                .manager
                .create_item_from_asset(
                    script.source.into_bytes(),
                    item_name.clone(),
                    script.description,
                    libremetaverse_types::AssetType::Script,
                    libremetaverse_types::InventoryType::LSL,
                    folder,
                    libremetaverse::Permissions::full_permissions(),
                    Some(cancellation),
                    None,
                )
                .await
                .map_err(|_| ScriptDeliveryError::InventoryCreate)?;
            if !result.success() || result.item_id() == UUID::zero() {
                return Err(ScriptDeliveryError::InventoryCreate);
            }
            Ok(ScriptInventoryReceipt {
                item_id: result.item_id(),
                item_name,
            })
        })
    }

    fn give_to(
        &self,
        receipt: ScriptInventoryReceipt,
        recipient: UUID,
        cancellation: CancellationToken,
    ) -> ScriptInventoryFuture<'_, ()> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err(ScriptDeliveryError::Cancelled);
            }
            self.manager
                .give_item(
                    receipt.item_id,
                    receipt.item_name,
                    libremetaverse_types::AssetType::Script,
                    recipient,
                    true,
                )
                .map_err(|_| ScriptDeliveryError::TransferAmbiguous)
        })
    }

    fn retain_for_recovery(
        &self,
        _receipt: ScriptInventoryReceipt,
        cancellation: CancellationToken,
    ) -> ScriptInventoryFuture<'_, ()> {
        Box::pin(async move {
            if cancellation.is_cancellation_requested() {
                return Err(ScriptDeliveryError::Cancelled);
            }
            // Creation occurs directly in the dedicated generated-scripts
            // folder, which is also the recovery queue. No second mutation or
            // blind transfer retry is needed after an ambiguous offer.
            Ok(())
        })
    }
}

struct RateState {
    started: Instant,
    global: u32,
    residents: HashMap<UUID, u32>,
}

pub struct ScriptDeliveryBackend {
    inventory: Arc<dyn ScriptInventory>,
    settings: ScriptDeliverySettings,
    rate: Mutex<RateState>,
    outstanding: Arc<tokio::sync::Semaphore>,
}

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

impl ScriptDeliveryBackend {
    pub fn new(
        inventory: Arc<dyn ScriptInventory>,
        settings: ScriptDeliverySettings,
    ) -> Result<Self, ScriptDeliveryError> {
        if !settings.valid() {
            return Err(ScriptDeliveryError::UnsafeLimits);
        }
        Ok(Self {
            inventory,
            settings,
            rate: Mutex::new(RateState {
                started: Instant::now(),
                global: 0,
                residents: HashMap::new(),
            }),
            outstanding: Arc::new(tokio::sync::Semaphore::new(settings.max_outstanding)),
        })
    }

    fn admit(&self, resident: UUID) -> Result<(), ScriptDeliveryError> {
        let mut rate = self
            .rate
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        if rate.started.elapsed() >= self.settings.rate_window {
            rate.started = Instant::now();
            rate.global = 0;
            rate.residents.clear();
        }
        let used = rate.residents.get(&resident).copied().unwrap_or(0);
        if rate.global >= self.settings.max_global_per_window
            || used >= self.settings.max_per_resident_per_window
        {
            return Err(ScriptDeliveryError::RateLimited);
        }
        rate.global = rate.global.saturating_add(1);
        rate.residents.insert(resident, used.saturating_add(1));
        Ok(())
    }
}

impl AuthorizedToolBackend for ScriptDeliveryBackend {
    fn apply(
        &self,
        action: AuthorizedAction,
        cancellation: CancellationToken,
    ) -> BackendFuture<'_, Result<ToolCallOutcome, BackendError>> {
        Box::pin(async move {
            let call_id = action.call().call_id.clone();
            let result = async {
                if action.call().name.as_str() != SCRIPT_DELIVERY_TOOL {
                    return Err(ScriptDeliveryError::InvalidArguments);
                }
                let recipient = action
                    .authenticated_avatar_id()
                    .ok_or(ScriptDeliveryError::InvalidArguments)?;
                let script: GeneratedScript =
                    serde_json::from_str(action.call().arguments_json.as_str())
                        .map_err(|_| ScriptDeliveryError::InvalidArguments)?;
                validate_script(&script, self.settings.max_source_bytes)?;
                self.admit(recipient)?;
                let _permit = self
                    .outstanding
                    .clone()
                    .try_acquire_owned()
                    .map_err(|_| ScriptDeliveryError::Busy)?;
                if cancellation.is_cancellation_requested() {
                    return Err(ScriptDeliveryError::Cancelled);
                }
                let receipt = self
                    .inventory
                    .create_full_permission(script, cancellation.clone())
                    .await?;
                if self
                    .inventory
                    .give_to(receipt.clone(), recipient, cancellation.clone())
                    .await
                    .is_ok()
                {
                    Ok(ScriptDeliveryOutcome::Delivered)
                } else {
                    self.inventory
                        .retain_for_recovery(receipt, cancellation)
                        .await
                        .map_err(|_| ScriptDeliveryError::RecoveryFailed)?;
                    Ok(ScriptDeliveryOutcome::RetainedForRecovery)
                }
            }
            .await;
            Ok(match result {
                Ok(ScriptDeliveryOutcome::Delivered) => ToolCallOutcome::Completed {
                    call_id,
                    result: BoundedText::<MAX_BODY_BYTES>::new(
                        "script_delivery.result",
                        "The full-permission LSL script was delivered to the requesting avatar.",
                    )
                    .map_err(|_| BackendError::Operation {
                        operation: "script result",
                    })?,
                },
                Ok(ScriptDeliveryOutcome::RetainedForRecovery) => ToolCallOutcome::Rejected {
                    call_id,
                    reason: BoundedText::<MAX_OBSERVABLE_DETAIL_BYTES>::new(
                        "script_delivery.recovery",
                        ScriptDeliveryError::TransferAmbiguous.to_string(),
                    )
                    .map_err(|_| BackendError::Operation {
                        operation: "script recovery result",
                    })?,
                },
                Ok(ScriptDeliveryOutcome::Rejected) => unreachable!(),
                Err(error) => ToolCallOutcome::Rejected {
                    call_id,
                    reason: BoundedText::<MAX_OBSERVABLE_DETAIL_BYTES>::new(
                        "script_delivery.rejection",
                        error.to_string(),
                    )
                    .map_err(|_| BackendError::Operation {
                        operation: "script rejection",
                    })?,
                },
            })
        })
    }
}

pub type ScriptDeliveryTool = PolicyTool;

pub fn script_delivery_policy_tool(
    settings: ScriptDeliverySettings,
) -> Result<PolicyTool, PolicyError> {
    if !settings.valid() {
        return Err(PolicyError::InvalidRegistration);
    }
    let properties = BTreeMap::from([
        ("name".into(), ToolSchema::String),
        ("description".into(), ToolSchema::String),
        ("source".into(), ToolSchema::String),
    ]);
    let cost = ResourceCost {
        tool_calls: 1,
        upload_bytes: u64::try_from(settings.max_source_bytes).unwrap_or(u64::MAX),
        inventory_operations: 2,
        ..ResourceCost::default()
    };
    PolicyTool::new(
        ToolDefinition {
            name: BoundedText::<MAX_IDENTIFIER_BYTES>::new(
                "script.tool.name",
                SCRIPT_DELIVERY_TOOL,
            )?,
            description: BoundedText::new(
                "script.tool.description",
                "Create and deliver one validated full-permission LSL script only to the authenticated requesting avatar",
            )?,
            schema: ToolSchema::Object {
                properties,
                required: BTreeSet::from(["name".into(), "description".into(), "source".into()]),
                additional_properties: false,
            },
            mutating: true,
        },
        Capability::PublicLslRequest,
        Risk::InventoryMutation,
        AllowedOrigins::new([
            OriginClass::PublicChat,
            OriginClass::UnprivilegedIm,
            OriginClass::AuthorizedIm,
        ])?,
        cost,
        Idempotency::NonIdempotent,
        ApprovalRule::Never,
        false,
        Arc::new(FixedCost(cost)),
    )
}

pub(crate) fn validate_script(
    script: &GeneratedScript,
    maximum: usize,
) -> Result<(), ScriptDeliveryError> {
    if script.name.trim().is_empty()
        || script.name.len() > MAX_NAME_BYTES
        || script.name.chars().any(char::is_control)
    {
        return Err(ScriptDeliveryError::InvalidName);
    }
    if script.description.trim().is_empty()
        || script.description.len() > MAX_DESCRIPTION_BYTES
        || script
            .description
            .chars()
            .any(|c| c == '\0' || (c.is_control() && !matches!(c, '\n' | '\t')))
    {
        return Err(ScriptDeliveryError::InvalidDescription);
    }
    if script.source.len() > maximum || script.source.len() > metacrate_lsl_tools::MAX_SOURCE_UNITS
    {
        return Err(ScriptDeliveryError::OversizedSource);
    }
    let lower = script.source.to_ascii_lowercase();
    if [
        "api_key",
        "apikey",
        "authorization:",
        "bearer ",
        "capability_url",
        "secondlife:///app",
    ]
    .iter()
    .any(|v| lower.contains(v))
        || lower.contains("http://")
        || lower.contains("https://")
    {
        return Err(ScriptDeliveryError::SecretBearingSource);
    }
    if [
        "llrez",
        "llteleportagent",
        "llgivemoney",
        "llmanageestateaccess",
        "llejectfromland",
        "llreturnobjectsbyowner",
        "llattachtoavatar",
    ]
    .iter()
    .any(|v| lower.contains(v))
    {
        return Err(ScriptDeliveryError::DisallowedSource);
    }
    metacrate_lsl_tools::validate_lsl_source(&script.source)
        .map_err(|_| ScriptDeliveryError::MalformedSource)
}
