// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0

//! Data structures common between LSP and previewer

use i_slint_compiler::diagnostics::{BuildDiagnostics, SourceFile};
use i_slint_compiler::object_tree::Document;
use i_slint_compiler::parser::{TextSize, syntax_nodes};
use i_slint_compiler::typeloader::TypeLoader;
use i_slint_compiler::typeregister::TypeRegister;
use i_slint_live_preview::protocol::SourceFileVersion;
use lsp_types::Url;

use std::{
    cell::RefCell,
    collections::HashMap,
    future::Future,
    path::{Path, PathBuf},
    pin::Pin,
    rc::Rc,
};

use crate::common::{ElementRcNode, Result, file_to_uri, uri_to_file};
use std::collections::HashSet;

pub type SourceFileVersionMap = HashMap<PathBuf, SourceFileVersion>;

fn default_cc() -> i_slint_compiler::CompilerConfiguration {
    i_slint_compiler::CompilerConfiguration::new(
        i_slint_compiler::generator::OutputFormat::Interpreter,
    )
}

/// This is i_slint_compiler::OpenImportCallback with version information
pub type OpenImportCallback = Rc<
    dyn Fn(
        String,
    )
        -> Pin<Box<dyn Future<Output = Option<std::io::Result<(SourceFileVersion, String)>>>>>,
>;

pub struct CompilerConfiguration {
    pub include_paths: Vec<std::path::PathBuf>,
    pub library_paths: HashMap<String, std::path::PathBuf>,
    pub style: Option<String>,
    pub open_import_callback: Option<OpenImportCallback>,
    pub resource_url_mapper: Option<i_slint_compiler::ResourceUrlMapper>,
    pub format: super::ByteFormat,
    /// Whether to enable experimental features.
    /// Note that the i_slint_compiler::CompilerConfiguration still reads the environment variable
    /// in native build, so this is used to transmit the value when compiled to WASM.
    pub enable_experimental: bool,
}

impl Default for CompilerConfiguration {
    fn default() -> Self {
        let mut cc = default_cc();

        Self {
            include_paths: std::mem::take(&mut cc.include_paths),
            library_paths: std::mem::take(&mut cc.library_paths),
            style: std::mem::take(&mut cc.style),
            open_import_callback: None,
            resource_url_mapper: std::mem::take(&mut cc.resource_url_mapper),
            format: super::ByteFormat::Utf8,
            enable_experimental: cc.enable_experimental,
        }
    }
}

impl CompilerConfiguration {
    fn build(mut self) -> (i_slint_compiler::CompilerConfiguration, Option<OpenImportCallback>) {
        let mut result = default_cc();
        result.include_paths = std::mem::take(&mut self.include_paths);
        result.library_paths = std::mem::take(&mut self.library_paths);
        result.style = std::mem::take(&mut self.style);
        result.resource_url_mapper = std::mem::take(&mut self.resource_url_mapper);
        result.enable_experimental |= self.enable_experimental;

        (result, self.open_import_callback)
    }
}

/// A cache of loaded documents
pub struct DocumentCache {
    type_loader: TypeLoader,
    open_import_callback: Option<OpenImportCallback>,
    source_file_versions: Rc<RefCell<SourceFileVersionMap>>,
    pub format: super::ByteFormat,
}

#[cfg(feature = "preview-engine")]
pub fn document_cache_parts_setup(
    compiler_config: &mut i_slint_compiler::CompilerConfiguration,
    open_import_callback: Option<OpenImportCallback>,
    initial_file_versions: SourceFileVersionMap,
) -> (Option<OpenImportCallback>, Rc<RefCell<SourceFileVersionMap>>) {
    let source_file_versions = Rc::new(RefCell::new(initial_file_versions));
    DocumentCache::wire_up_import_fallback(
        compiler_config,
        open_import_callback,
        source_file_versions,
    )
}

impl DocumentCache {
    fn wire_up_import_fallback(
        compiler_config: &mut i_slint_compiler::CompilerConfiguration,
        open_import_callback: Option<OpenImportCallback>,
        source_file_versions: Rc<RefCell<SourceFileVersionMap>>,
    ) -> (Option<OpenImportCallback>, Rc<RefCell<SourceFileVersionMap>>) {
        let source_versions = source_file_versions.clone();
        if let Some(open_import_callback) = open_import_callback.clone() {
            compiler_config.open_import_callback = Some(Rc::new(move |file_name: String| {
                let open_import = open_import_callback(file_name.clone());
                let source_versions = source_versions.clone();
                Box::pin(async move {
                    open_import.await.map(|r| {
                        let path = PathBuf::from(file_name);
                        match r {
                            Ok((v, c)) => {
                                source_versions.borrow_mut().insert(path, v);
                                Ok(c)
                            }
                            Err(e) => {
                                source_versions.borrow_mut().remove(&path);
                                Err(e)
                            }
                        }
                    })
                })
            }))
        }

        (open_import_callback, source_file_versions)
    }

    pub fn new(config: CompilerConfiguration) -> Self {
        let format = config.format;
        let (mut compiler_config, open_import_callback) = config.build();

        let (open_import_callback, source_file_versions) = Self::wire_up_import_fallback(
            &mut compiler_config,
            open_import_callback,
            Rc::new(RefCell::new(SourceFileVersionMap::default())),
        );

        Self {
            type_loader: TypeLoader::new(compiler_config, &mut BuildDiagnostics::default()),
            open_import_callback,
            source_file_versions,
            format,
        }
    }

    pub fn new_from_raw_parts(
        mut type_loader: TypeLoader,
        open_import_callback: Option<OpenImportCallback>,
        source_file_versions: Rc<RefCell<SourceFileVersionMap>>,
        format: super::ByteFormat,
    ) -> Self {
        let (open_import_callback, source_file_versions) = Self::wire_up_import_fallback(
            &mut type_loader.compiler_config,
            open_import_callback,
            source_file_versions,
        );

        Self { type_loader, open_import_callback, source_file_versions, format }
    }

    pub fn snapshot(&self) -> Option<Self> {
        let open_import_callback = self.open_import_callback.clone();
        let source_file_versions =
            Rc::new(RefCell::new(self.source_file_versions.borrow().clone()));
        i_slint_compiler::typeloader::snapshot(&self.type_loader).map(|tl| {
            Self::new_from_raw_parts(tl, open_import_callback, source_file_versions, self.format)
        })
    }

    pub fn resolve_import_path(
        &self,
        import_token: Option<&i_slint_compiler::parser::NodeOrToken>,
        maybe_relative_path_or_url: &str,
    ) -> Option<(PathBuf, Option<&'static [u8]>)> {
        self.type_loader.resolve_import_path(import_token, maybe_relative_path_or_url)
    }

    pub fn document_version(&self, target_uri: &Url) -> SourceFileVersion {
        self.document_version_by_path(&uri_to_file(target_uri).unwrap_or_default())
    }

    pub fn document_version_by_path(&self, path: &Path) -> SourceFileVersion {
        self.source_file_versions.borrow().get(path).and_then(|v| *v)
    }

    pub fn get_document<'a>(&'a self, url: &'_ Url) -> Option<&'a Document> {
        let path = uri_to_file(url)?;
        self.type_loader.get_document(&path)
    }

    /// Iterator over every fully-loaded `object_tree::Document` in the cache.
    pub fn all_documents(&self) -> impl Iterator<Item = &Document> + '_ {
        self.type_loader.all_documents()
    }

    fn uses_widgets_impl(&self, doc_path: PathBuf, dedup: &mut HashSet<PathBuf>) -> bool {
        if dedup.contains(&doc_path) {
            return false;
        }

        if doc_path.starts_with("builtin:/") && doc_path.ends_with("std-widgets.slint") {
            return true;
        }

        let Some(doc) = self.get_document_by_path(&doc_path) else {
            return false;
        };

        dedup.insert(doc_path.to_path_buf());

        for import in doc.imports.iter().map(|i| PathBuf::from(&i.file)) {
            if self.uses_widgets_impl(import, dedup) {
                return true;
            }
        }

        false
    }

    /// Returns true if doc_url uses (possibly indirectly) widgets from "std-widgets.slint"
    pub fn uses_widgets(&self, doc_url: &Url) -> bool {
        let Some(doc_path) = uri_to_file(doc_url) else {
            return false;
        };

        let mut dedup = HashSet::new();

        self.uses_widgets_impl(doc_path, &mut dedup)
    }

    pub fn get_document_by_path<'a>(&'a self, path: &'_ Path) -> Option<&'a Document> {
        self.type_loader.get_document(path)
    }

    pub fn get_document_for_source_file<'a>(
        &'a self,
        source_file: &'_ SourceFile,
    ) -> Option<&'a Document> {
        self.type_loader.get_document(source_file.path())
    }

    pub fn get_document_and_offset<'a>(
        &'a self,
        text_document_uri: &'_ Url,
        pos: &'_ lsp_types::Position,
    ) -> Option<(&'a i_slint_compiler::object_tree::Document, TextSize)> {
        let doc = self.get_document(text_document_uri)?;
        let o = (doc.node.as_ref()?.source_file.offset(
            pos.line as usize + 1,
            pos.character as usize + 1,
            self.format,
        ) as u32)
            .into();
        doc.node.as_ref()?.text_range().contains_inclusive(o).then_some((doc, o))
    }

    pub fn all_url_documents(&self) -> impl Iterator<Item = (Url, &syntax_nodes::Document)> + '_ {
        self.type_loader.all_file_documents().filter_map(|(p, d)| Some((file_to_uri(p)?, d)))
    }

    pub fn all_urls(&self) -> impl Iterator<Item = Url> + '_ {
        self.type_loader.all_files().filter_map(|p| file_to_uri(p))
    }

    pub fn all_urls_to_watch(&self) -> HashSet<Url> {
        self.type_loader
            .all_files_to_watch()
            .into_iter()
            .filter_map(|path| file_to_uri(&path))
            .collect()
    }

    pub fn global_type_registry(&self) -> std::cell::Ref<'_, TypeRegister> {
        self.type_loader.global_type_registry.borrow()
    }

    pub fn revision(&self) -> u64 {
        self.type_loader.revision()
    }

    fn invalidate_everything(&mut self) {
        let all_files = self.type_loader.all_files().cloned().collect::<Vec<_>>();

        for path in all_files {
            self.type_loader.invalidate_document(&path);
        }
    }

    /// Apply a new configuration to the document cache
    ///
    /// This will invalidate and reload all loaded documents.
    ///
    /// Returns the new compiler configuration and the set of paths that were reloaded.
    pub async fn reconfigure(
        &mut self,
        style: Option<String>,
        include_paths: Option<Vec<PathBuf>>,
        library_paths: Option<HashMap<String, PathBuf>>,
        enable_experimental: bool,
        diag: &mut BuildDiagnostics,
    ) -> (CompilerConfiguration, HashSet<lsp_types::Url>) {
        if let Some(s) = style {
            if s.is_empty() {
                self.type_loader.compiler_config.style = None;
            } else {
                self.type_loader.compiler_config.style = Some(s);
            }
        }

        if let Some(ip) = include_paths {
            self.type_loader.compiler_config.include_paths = ip;
        }

        if let Some(lp) = library_paths {
            self.type_loader.compiler_config.library_paths = lp;
        }

        if enable_experimental && !self.type_loader.compiler_config.enable_experimental {
            self.type_loader.compiler_config.enable_experimental = true;
            *self.type_loader.global_type_registry.borrow_mut() = Rc::into_inner(
                TypeRegister::builtin_experimental(&self.type_loader.symbol_counters),
            )
            .unwrap()
            .into_inner();
        }

        self.invalidate_everything();

        self.preload_builtins().await;

        let all_urls = self.all_urls().collect::<HashSet<_>>();
        for url in &all_urls {
            self.reload_cached_file(url, diag).await;
        }

        (self.compiler_configuration(), all_urls)
    }

    pub async fn preload_builtins(&mut self) {
        // Always load the widgets so we can auto-complete them
        let mut diag = BuildDiagnostics::default();
        self.type_loader.import_component("std-widgets.slint", "StyleMetrics", &mut diag).await;
        assert!(!diag.has_errors());
    }

    pub async fn load_url(
        &mut self,
        url: &Url,
        version: SourceFileVersion,
        content: String,
        diag: &mut BuildDiagnostics,
    ) -> Result<()> {
        let path =
            uri_to_file(url).ok_or_else(|| format!("Failed to convert path for loading: {url}"))?;
        self.type_loader.load_file(&path, &path, content, false, diag).await;
        self.source_file_versions.borrow_mut().insert(path, version);
        Ok(())
    }

    pub async fn reload_cached_file(&mut self, url: &Url, diag: &mut BuildDiagnostics) {
        let Some(path) = uri_to_file(url) else { return };
        self.type_loader.reload_cached_file(&path, diag).await;
    }

    /// Drop a document from the cache.
    /// Returns the list of dependencies that were invalidated.
    ///
    /// Compared to [Self::invalidate_url], this actually causes the document to be reloaded from
    /// disk, not just reparse.
    pub fn drop_document(&mut self, url: &Url) -> Result<HashSet<Url>> {
        let Some(path) = uri_to_file(url) else {
            // This isn't fatal, but we might want to learn about paths/schemes to support in the future.
            tracing::error!("Failed to convert path for dropping document: {url}");
            return Ok(Default::default());
        };
        Ok(self
            .type_loader
            .drop_document(&path)?
            .iter()
            .filter_map(|path| file_to_uri(path))
            .collect())
    }

    /// Invalidate a document and all its dependencies.
    /// return the list of dependencies that were invalidated.
    ///
    /// Compared to [Self::drop_document], the CST remains in the cache, and only the type
    /// information is dropped from the cache, which causes the document to be re-analyzed.
    pub fn invalidate_url(&mut self, url: &Url) -> HashSet<Url> {
        let Some(path) = uri_to_file(url) else { return HashSet::new() };
        self.type_loader
            .invalidate_document(&path)
            .into_iter()
            .filter_map(|x| file_to_uri(&x))
            .collect()
    }

    pub fn compiler_configuration(&self) -> CompilerConfiguration {
        CompilerConfiguration {
            include_paths: self.type_loader.compiler_config.include_paths.clone(),
            library_paths: self.type_loader.compiler_config.library_paths.clone(),
            style: self.type_loader.compiler_config.style.clone(),
            open_import_callback: None, // We need to re-generate this anyway
            resource_url_mapper: self.type_loader.compiler_config.resource_url_mapper.clone(),
            format: self.format,
            enable_experimental: self.type_loader.compiler_config.enable_experimental,
        }
    }

    fn element_at_document_and_offset(
        &self,
        document: &i_slint_compiler::object_tree::Document,
        offset: TextSize,
    ) -> Option<ElementRcNode> {
        fn element_contains(
            element: &i_slint_compiler::object_tree::ElementRc,
            offset: TextSize,
        ) -> Option<usize> {
            element
                .borrow()
                .debug
                .iter()
                .position(|n| n.node.parent().is_some_and(|n| n.text_range().contains(offset)))
        }

        for component in &document.inner_components {
            let root_element = component.root_element.clone();
            let Some(root_debug_index) = element_contains(&root_element, offset) else {
                continue;
            };

            let mut element =
                ElementRcNode { element: root_element, debug_index: root_debug_index };
            while element.contains_offset(offset) {
                if let Some((c, i)) = element
                    .element
                    .clone()
                    .borrow()
                    .children
                    .iter()
                    .find_map(|c| element_contains(c, offset).map(|i| (c, i)))
                {
                    element = ElementRcNode { element: c.clone(), debug_index: i };
                } else {
                    return Some(element);
                }
            }
        }
        None
    }

    pub fn element_at_offset(
        &self,
        text_document_uri: &Url,
        offset: TextSize,
    ) -> Option<ElementRcNode> {
        let doc = self.get_document(text_document_uri)?;
        self.element_at_document_and_offset(doc, offset)
    }

    pub fn element_at_position(
        &self,
        text_document_uri: &Url,
        pos: &lsp_types::Position,
    ) -> Option<ElementRcNode> {
        let (doc, offset) = self.get_document_and_offset(text_document_uri, pos)?;
        self.element_at_document_and_offset(doc, offset)
    }

    pub fn all_paths_to_watch(&self) -> HashSet<PathBuf> {
        self.type_loader.all_files_to_watch()
    }
}

#[cfg(test)]
mod tests {
    use crate::language::test::complex_document_cache;

    use super::*;

    fn id_at_position(dc: &DocumentCache, url: &Url, line: u32, character: u32) -> Option<String> {
        let result = dc.element_at_position(url, &lsp_types::Position { line, character })?;
        let element = result.element.borrow();
        Some(element.id.to_string())
    }

    fn base_type_at_position(
        dc: &DocumentCache,
        url: &Url,
        line: u32,
        character: u32,
    ) -> Option<String> {
        let result = dc.element_at_position(url, &lsp_types::Position { line, character })?;
        let element = result.element.borrow();
        Some(element.base_type.to_string())
    }

    #[test]
    fn test_element_at_position_no_element() {
        let (dc, url, _) = complex_document_cache();
        assert_eq!(id_at_position(&dc, &url, 0, 10), None);
        // TODO: This is past the end of the line and should thus return None
        assert_eq!(id_at_position(&dc, &url, 42, 90), Some(String::new()));
        assert_eq!(id_at_position(&dc, &url, 1, 0), None);
        assert_eq!(id_at_position(&dc, &url, 55, 1), None);
        assert_eq!(id_at_position(&dc, &url, 56, 5), None);
    }

    #[test]
    fn test_document_version() {
        let (dc, url, _) = complex_document_cache();
        assert_eq!(dc.document_version(&url), Some(42));
    }

    #[test]
    fn test_element_at_position_no_such_document() {
        let (dc, _, _) = complex_document_cache();
        assert_eq!(id_at_position(&dc, &Url::parse("https://foo.bar/baz").unwrap(), 5, 0), None);
    }

    #[test]
    fn test_element_at_position_root() {
        let (dc, url, _) = complex_document_cache();

        assert_eq!(id_at_position(&dc, &url, 2, 30), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 2, 32), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 2, 42), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 3, 0), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 3, 53), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 4, 19), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 5, 0), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 6, 8), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 6, 15), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 6, 23), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 8, 15), Some("root".to_string()));
        assert_eq!(id_at_position(&dc, &url, 12, 3), Some("root".to_string())); // right before child // TODO: Seems wrong!
        assert_eq!(id_at_position(&dc, &url, 51, 5), Some("root".to_string())); // right after child // TODO: Why does this not work?
        assert_eq!(id_at_position(&dc, &url, 52, 0), Some("root".to_string()));
    }

    #[test]
    fn test_element_at_position_child() {
        let (dc, url, _) = complex_document_cache();

        assert_eq!(base_type_at_position(&dc, &url, 12, 4), Some("VerticalBox".to_string()));
        assert_eq!(base_type_at_position(&dc, &url, 14, 22), Some("HorizontalBox".to_string()));
        assert_eq!(base_type_at_position(&dc, &url, 15, 33), Some("Text".to_string()));
        assert_eq!(base_type_at_position(&dc, &url, 27, 4), Some("VerticalBox".to_string()));
        assert_eq!(base_type_at_position(&dc, &url, 28, 8), Some("Text".to_string()));
        assert_eq!(base_type_at_position(&dc, &url, 51, 4), Some("VerticalBox".to_string()));
    }
}
