//! Transactional first-login outfit copy and application.

use crate::appearance::{CurrentOutfitFolder, InitialOutfitInitialOutfitPhase as Phase};
use crate::{
    Error, GridClient, InventoryBase, InventoryFolder, InventoryNode, InventorySortOrder,
    LoginStatus,
};
use libremetaverse_types::compat::{
    CancellationToken, CancellationTokenSource, IProgress, Subscription,
};
use libremetaverse_types::{FolderType, UUID};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};

const MAX_FOLDERS: usize = 256;

#[derive(Clone, Debug)]
pub struct InitialOutfitProgress {
    current_item_name: String,
    items_copied: i32,
    message: String,
    phase: Phase,
    total_items: i32,
}

impl InitialOutfitProgress {
    #[allow(clippy::unnecessary_wraps)] // The generated constructor uses the crate Result model.
    pub(crate) fn native_new() -> Result<Self, Error> {
        Ok(Self {
            current_item_name: String::new(),
            items_copied: 0,
            message: String::new(),
            phase: Phase::Counting,
            total_items: 0,
        })
    }
    pub(crate) fn native_current_item_name(&self) -> String {
        self.current_item_name.clone()
    }
    pub(crate) fn native_set_current_item_name(&mut self, value: String) {
        self.current_item_name = value;
    }
    pub(crate) const fn native_items_copied(&self) -> i32 {
        self.items_copied
    }
    pub(crate) fn native_set_items_copied(&mut self, value: i32) {
        self.items_copied = value;
    }
    pub(crate) fn native_message(&self) -> String {
        self.message.clone()
    }
    pub(crate) fn native_set_message(&mut self, value: String) {
        self.message = value;
    }
    pub(crate) const fn native_phase(&self) -> Phase {
        self.phase
    }
    pub(crate) fn native_set_phase(&mut self, value: Phase) {
        self.phase = value;
    }
    pub(crate) const fn native_total_items(&self) -> i32 {
        self.total_items
    }
    pub(crate) fn native_set_total_items(&mut self, value: i32) {
        self.total_items = value;
    }
}

#[derive(Clone)]
pub struct InitialOutfit {
    client: GridClient,
    cof: CurrentOutfitFolder,
}

struct FolderPlan {
    source: InventoryFolder,
    children: Vec<InventoryFolder>,
    items: Vec<InventoryBase>,
}

impl InitialOutfit {
    #[allow(clippy::unnecessary_wraps)] // The generated constructor uses the crate Result model.
    pub(crate) fn native_new(client: GridClient, cof: CurrentOutfitFolder) -> Result<Self, Error> {
        Ok(Self { client, cof })
    }

    #[allow(clippy::needless_pass_by_value)] // Mirrors the public String signature.
    pub(crate) fn native_find_node_by_name(
        root: InventoryNode,
        name: String,
    ) -> Result<Option<InventoryNode>, Error> {
        if name.is_empty() {
            return Ok(None);
        }
        let mut queue = VecDeque::from([root]);
        let mut visited = HashSet::new();
        let mut traversed = 0_usize;
        while let Some(node) = queue.pop_front() {
            traversed += 1;
            if traversed > MAX_FOLDERS {
                return Err(Error::InvalidOperation);
            }
            let identity = node.data().map(|value| value.inventory_base().uuid());
            if let Some(identity) = identity
                && !visited.insert(identity)
            {
                continue;
            }
            if visited.len() > MAX_FOLDERS {
                return Err(Error::InvalidOperation);
            }
            if node
                .data()
                .is_some_and(|value| value.inventory_base().name() == name)
            {
                return Ok(Some(node));
            }
            queue.extend(node.nodes().values());
        }
        Ok(None)
    }

    #[allow(clippy::unused_async)] // Preserves the Task-returning compatibility API.
    pub(crate) async fn native_create_folder(
        &self,
        parent: UUID,
        name: String,
        folder_type: FolderType,
        cancellation_token: Option<CancellationToken>,
    ) -> Result<UUID, Error> {
        let token = cancellation_token.unwrap_or_default();
        token.throw_if_cancellation_requested()?;
        let id = self
            .client
            .native_inventory()?
            .native_create_folder(parent, name, folder_type)?;
        token.throw_if_cancellation_requested()?;
        if id == UUID::zero() {
            Err(Error::InvalidOperation)
        } else {
            Ok(id)
        }
    }

    pub(crate) async fn native_check_system_folders(
        &self,
        cancellation_token: Option<CancellationToken>,
    ) -> Result<(), Error> {
        let token = cancellation_token.unwrap_or_default();
        token.throw_if_cancellation_requested()?;
        let inventory = self.client.native_inventory()?;
        let store = inventory.native_store().ok_or(Error::InvalidOperation)?;
        let root = store
            .root_folder()
            .ok_or(Error::InvalidOperation)?
            .base
            .uuid();
        for (folder_type, name) in [
            (FolderType::Clothing, "Clothing"),
            (FolderType::Trash, "Trash"),
        ] {
            token.throw_if_cancellation_requested()?;
            if inventory.native_find_folder_for_type(folder_type) == root {
                self.native_create_folder(root, name.to_owned(), folder_type, Some(token.clone()))
                    .await?;
            }
        }
        Ok(())
    }

    async fn plan_copy(
        &self,
        root: InventoryFolder,
        token: &CancellationToken,
    ) -> Result<(Vec<FolderPlan>, i32), Error> {
        let inventory = self.client.native_inventory()?;
        let mut queue = VecDeque::from([root]);
        let mut visited = HashSet::new();
        let mut plans = Vec::new();
        let mut total = 0_i32;
        while let Some(folder) = queue.pop_front() {
            token.throw_if_cancellation_requested()?;
            if !visited.insert(folder.base.uuid()) {
                continue;
            }
            if visited.len() > MAX_FOLDERS {
                return Err(Error::InvalidOperation);
            }
            let contents = inventory
                .native_folder_contents(
                    folder.base.uuid(),
                    folder.base.owner_id(),
                    true,
                    true,
                    InventorySortOrder::BY_NAME,
                    Some(token.clone()),
                    false,
                )
                .await?;
            let mut children = Vec::new();
            let mut items = Vec::new();
            for value in contents {
                if let Some(child) = value.as_any().downcast_ref::<InventoryFolder>() {
                    children.push(child.clone());
                    queue.push_back(child.clone());
                } else {
                    total = total.checked_add(1).ok_or(Error::Argument)?;
                    items.push(value.inventory_base().clone());
                }
            }
            plans.push(FolderPlan {
                source: folder,
                children,
                items,
            });
        }
        Ok((plans, total))
    }

    pub(crate) async fn native_copy_folder(
        &self,
        folder: InventoryFolder,
        destination: UUID,
        cancellation_token: Option<CancellationToken>,
        progress: Option<Box<dyn IProgress<InitialOutfitProgress>>>,
    ) -> Result<UUID, Error> {
        self.native_copy_folder_impl(folder, destination, cancellation_token, progress, true)
            .await
            .map(|(folder, _)| folder)
    }

    async fn native_copy_folder_impl(
        &self,
        folder: InventoryFolder,
        destination: UUID,
        cancellation_token: Option<CancellationToken>,
        progress: Option<Box<dyn IProgress<InitialOutfitProgress>>>,
        report_complete: bool,
    ) -> Result<(UUID, i32), Error> {
        let token = cancellation_token.unwrap_or_default();
        let (plans, total) = self.plan_copy(folder.clone(), &token).await?;
        report(
            progress.as_deref(),
            Phase::Counting,
            total,
            0,
            "",
            "Inventory counted",
        )?;
        let root_id = self
            .native_create_folder(
                destination,
                folder.base.name(),
                folder.preferred_type(),
                Some(token.clone()),
            )
            .await?;
        let result = self
            .execute_copy(&plans, root_id, total, &token, progress.as_deref())
            .await;
        if let Err(error) = result {
            self.cleanup_folder(root_id).await;
            return Err(error);
        }
        if report_complete {
            report(
                progress.as_deref(),
                Phase::Complete,
                total,
                total,
                "",
                "Folder copy complete",
            )?;
        }
        Ok((root_id, total))
    }

    async fn cleanup_folder(&self, root_id: UUID) {
        // Compensation deliberately ignores the cancelled caller token.
        let Ok(inventory) = self.client.native_inventory() else {
            return;
        };
        let _ = inventory
            .native_remove_inventory_objects(Vec::new(), vec![root_id], None)
            .await;
        if let Some(store) = inventory.native_store()
            && let Ok(Some(folder)) =
                store.get_value_or_default_with_uuid_a7c63fbe::<InventoryFolder>(root_id)
        {
            let _ = store.remove_node_for(&folder);
        }
    }

    async fn execute_copy(
        &self,
        plans: &[FolderPlan],
        root_id: UUID,
        total: i32,
        token: &CancellationToken,
        progress: Option<&dyn IProgress<InitialOutfitProgress>>,
    ) -> Result<(), Error> {
        let inventory = self.client.native_inventory()?;
        let root_source = plans.first().ok_or(Error::Argument)?.source.base.uuid();
        let mut destinations = HashMap::from([(root_source, root_id)]);
        let mut copied = 0_i32;
        for plan in plans {
            token.throw_if_cancellation_requested()?;
            let destination = *destinations
                .get(&plan.source.base.uuid())
                .ok_or(Error::InvalidOperation)?;
            for child in &plan.children {
                let id = self
                    .native_create_folder(
                        destination,
                        child.base.name(),
                        child.preferred_type(),
                        Some(token.clone()),
                    )
                    .await?;
                destinations.insert(child.base.uuid(), id);
            }
            for item in &plan.items {
                token.throw_if_cancellation_requested()?;
                report(
                    progress,
                    Phase::Copying,
                    total,
                    copied,
                    &item.name(),
                    "Copying inventory item",
                )?;
                let copied_item = inventory
                    .native_copy_item(
                        item.uuid(),
                        destination,
                        item.name(),
                        item.owner_id(),
                        Some(token.clone()),
                    )
                    .await?;
                if copied_item.is_none() {
                    return Err(Error::InvalidOperation);
                }
                copied += 1;
                report(
                    progress,
                    Phase::Copying,
                    total,
                    copied,
                    &item.name(),
                    "Inventory item copied",
                )?;
            }
        }
        Ok(())
    }

    pub(crate) async fn native_set_initial_outfit(
        &self,
        outfit: String,
        cancellation_token: Option<CancellationToken>,
        progress: Option<Box<dyn IProgress<InitialOutfitProgress>>>,
    ) -> Result<(), Error> {
        if outfit.is_empty() {
            return Err(Error::Argument);
        }
        let token = cancellation_token.unwrap_or_default();
        token.throw_if_cancellation_requested()?;
        let inventory = self.client.native_inventory()?;
        let store = inventory.native_store().ok_or(Error::InvalidOperation)?;
        let node = Self::native_find_node_by_name(store.library_root_node(), outfit)?
            .ok_or(Error::InvalidOperation)?;
        let folder = node
            .data()
            .and_then(|value| value.as_any().downcast_ref::<InventoryFolder>().cloned())
            .ok_or(Error::InvalidOperation)?;
        self.native_check_system_folders(Some(token.clone()))
            .await?;
        let clothing = inventory.native_find_folder_for_type(FolderType::Clothing);
        let wrapper = progress.map(Arc::<dyn IProgress<InitialOutfitProgress>>::from);
        let (copied, total) = self
            .native_copy_folder_impl(
                folder,
                clothing,
                Some(token.clone()),
                wrapper
                    .as_ref()
                    .map(|sink| Box::new(SharedProgress(Arc::clone(sink))) as Box<_>),
                false,
            )
            .await?;
        report(
            wrapper.as_deref(),
            Phase::Applying,
            total,
            total,
            "",
            "Applying initial outfit",
        )?;
        let applied = async {
            token.throw_if_cancellation_requested()?;
            if self
                .cof
                .native_replace_outfit(copied, Some(token.clone()))
                .await?
            {
                Ok(())
            } else {
                Err(Error::InvalidOperation)
            }
        }
        .await;
        if let Err(error) = applied {
            self.cleanup_folder(copied).await;
            return Err(error);
        }
        report(
            wrapper.as_deref(),
            Phase::Complete,
            total,
            total,
            "",
            "Initial outfit applied",
        )
    }
}

#[allow(clippy::unnecessary_wraps)] // Keeps progress reporting composable with fallible stages.
fn report(
    sink: Option<&dyn IProgress<InitialOutfitProgress>>,
    phase: Phase,
    total: i32,
    copied: i32,
    current: &str,
    message: &str,
) -> Result<(), Error> {
    if let Some(sink) = sink {
        sink.report(InitialOutfitProgress {
            current_item_name: current.to_owned(),
            items_copied: copied,
            message: message.to_owned(),
            phase,
            total_items: total,
        });
    }
    Ok(())
}

struct SharedProgress(Arc<dyn IProgress<InitialOutfitProgress>>);
impl IProgress<InitialOutfitProgress> for SharedProgress {
    fn report(&self, value: InitialOutfitProgress) {
        self.0.report(value);
    }
}

pub struct InitialOutfitHandler {
    cancellation: CancellationTokenSource,
    subscription: Mutex<Option<Subscription>>,
}

impl InitialOutfitHandler {
    pub(crate) fn native_new(
        client: GridClient,
        cof: CurrentOutfitFolder,
        progress: Option<Box<dyn IProgress<InitialOutfitProgress>>>,
    ) -> Result<Self, Error> {
        let cancellation = CancellationTokenSource::new();
        let token = cancellation.token();
        let started = Arc::new(AtomicBool::new(false));
        let callback_started = Arc::clone(&started);
        let progress = progress.map(Arc::<dyn IProgress<InitialOutfitProgress>>::from);
        let network = client.native_network()?;
        let subscription = network.native_subscribe_login_progress(Arc::new(move |event| {
            if event.status() != LoginStatus::Success {
                return;
            }
            let Some(login) = client
                .native_network()
                .ok()
                .and_then(|network| network.login_response_data)
            else {
                return;
            };
            if !login.first_login() || login.initial_outfit().is_empty() {
                return;
            }
            if callback_started
                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
                .is_err()
            {
                return;
            }
            let client = client.clone();
            let cof = cof.clone();
            let outfit = login.initial_outfit();
            let token = token.clone();
            let progress = progress.as_ref().map(Arc::clone);
            let _ = std::thread::Builder::new()
                .name("initial-outfit".to_owned())
                .spawn(move || {
                    let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
                        .enable_all()
                        .build()
                    else {
                        return;
                    };
                    let mut client = client;
                    let _ = runtime.block_on(async move {
                        let _ = client
                            .native_self()
                            .native_set_agent_access_async("A".to_owned(), Some(token.clone()))
                            .await;
                        let initial = InitialOutfit { client, cof };
                        initial
                            .native_set_initial_outfit(
                                outfit,
                                Some(token),
                                progress.map(|sink| Box::new(SharedProgress(sink)) as Box<_>),
                            )
                            .await
                    });
                });
        }));
        Ok(Self {
            cancellation,
            subscription: Mutex::new(Some(subscription)),
        })
    }

    #[allow(clippy::unnecessary_wraps)] // IDisposable is represented with the crate Result model.
    pub(crate) fn native_dispose(&self) -> Result<(), Error> {
        self.cancellation.cancel();
        self.subscription
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .take();
        Ok(())
    }
}

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

    #[test]
    fn progress_properties_round_trip_without_shared_mutability() {
        let mut progress = InitialOutfitProgress::native_new().unwrap();
        progress.native_set_phase(Phase::Copying);
        progress.native_set_total_items(7);
        progress.native_set_items_copied(3);
        progress.native_set_current_item_name("shirt".to_owned());
        progress.native_set_message("copying".to_owned());
        assert_eq!(progress.native_phase(), Phase::Copying);
        assert_eq!(progress.native_total_items(), 7);
        assert_eq!(progress.native_items_copied(), 3);
        assert_eq!(progress.native_current_item_name(), "shirt");
        assert_eq!(progress.native_message(), "copying");
    }

    #[test]
    fn find_node_by_name_walks_children_and_is_bounded() {
        let mut root_data = InventoryFolder::new(UUID::random().unwrap()).unwrap();
        root_data.base.set_name("Library".to_owned());
        let root = InventoryNode::new_with_inventory_base(&root_data).unwrap();
        let mut child_data = InventoryFolder::new(UUID::random().unwrap()).unwrap();
        child_data.base.set_name("Starter Outfit".to_owned());
        let child = InventoryNode::new_with_inventory_base(&child_data).unwrap();
        root.nodes().add(child_data.base.uuid(), child).unwrap();
        let found = InitialOutfit::native_find_node_by_name(root, "Starter Outfit".to_owned())
            .unwrap()
            .unwrap();
        assert_eq!(
            found.data().unwrap().inventory_base().name(),
            "Starter Outfit"
        );
    }

    #[tokio::test]
    async fn cancelled_create_does_not_touch_inventory_or_network() {
        let client = GridClient::new().unwrap();
        let cof = CurrentOutfitFolder::native_new(Some(Arc::new(client.clone()))).unwrap();
        let initial = InitialOutfit::native_new(client, cof).unwrap();
        let source = CancellationTokenSource::new();
        source.cancel();
        let result = initial
            .native_create_folder(
                UUID::zero(),
                "Clothing".to_owned(),
                FolderType::Clothing,
                Some(source.token()),
            )
            .await;
        assert_eq!(result, Err(Error::Cancelled));
    }

    #[test]
    fn handler_dispose_is_idempotent_and_cancels_work() {
        let handler = InitialOutfitHandler {
            cancellation: CancellationTokenSource::new(),
            subscription: Mutex::new(None),
        };
        handler.native_dispose().unwrap();
        handler.native_dispose().unwrap();
        assert!(handler.cancellation.token().is_cancellation_requested());
    }
}
