// SPDX-FileCopyrightText: 2020 Robin Krahl <robin.krahl@ireas.org>
// SPDX-License-Identifier: MIT

//! Handles documentation sources, for example local directories.

use std::fs;
use std::path;

use anyhow::anyhow;

use crate::doc;
use crate::index;
use crate::parser::html;

/// Documentation source, for example a local directory.
pub trait Source {
    fn find_doc(
        &self,
        name: &doc::Fqn,
        ty: Option<doc::ItemType>,
    ) -> anyhow::Result<Option<doc::Doc>>;
    fn load_index(&self) -> anyhow::Result<Option<index::Index>>;
    fn list_crates(&self) -> Vec<String>;
}

/// A collection of sources.
pub struct Sources(Vec<Box<dyn Source>>);

/// Local directory containing documentation data.
///
/// The directory must contain documentation for one or more crates in subdirectories.  Suitable
/// directories are the `doc` directory generated by `cargo doc` or the root directory of the Rust
/// documentation.
#[derive(Clone, Debug, PartialEq)]
pub struct DirSource {
    path: path::PathBuf,
}

impl Sources {
    pub fn new(sources: Vec<Box<dyn Source>>) -> Sources {
        Sources(sources)
    }

    /// List all crate names available across all sources, sorted and deduplicated.
    pub fn list_crates(&self) -> Vec<String> {
        let mut crates: Vec<String> = self.0.iter().flat_map(|s| s.list_crates()).collect();
        crates.sort();
        crates.dedup();
        crates
    }

    /// Find the documentation for an item with the given name (exact matches only).
    pub fn find(
        &self,
        name: &doc::Name,
        ty: Option<doc::ItemType>,
    ) -> anyhow::Result<Option<doc::Doc>> {
        let fqn = name.clone().into();
        for source in &self.0 {
            if let Some(doc) = source.find_doc(&fqn, ty)? {
                return Ok(Some(doc));
            }
        }
        log::info!("Could not find item '{}'", fqn);
        Ok(None)
    }

    /// Use the search index to find an item that partially matches the given keyword.
    pub fn search(&self, name: &doc::Name) -> anyhow::Result<Vec<index::IndexItem>> {
        let indexes = self
            .0
            .iter()
            .filter_map(|s| s.load_index().transpose())
            .collect::<anyhow::Result<Vec<_>>>()?;
        let mut items = indexes
            .iter()
            .map(|i| i.find(name))
            .collect::<Vec<_>>()
            .concat();
        items.sort_unstable();
        items.dedup();
        Ok(items)
    }
}

impl DirSource {
    fn new(path: path::PathBuf) -> Self {
        log::info!("Created directory source at '{}'", path.display());
        Self { path }
    }

    fn find_doc_html(
        &self,
        path: &path::Path,
        name: &doc::Fqn,
        ty: Option<doc::ItemType>,
    ) -> anyhow::Result<Option<doc::Doc>> {
        if let Some(ty) = ty {
            match ty {
                doc::ItemType::Module => self.get_module(path, name),
                doc::ItemType::StructField
                | doc::ItemType::Variant
                | doc::ItemType::AssocType
                | doc::ItemType::AssocConst
                | doc::ItemType::Method => self.get_member(path, name),
                _ => self.get_item(path, name),
            }
        } else {
            self.get_item(path, name)
                .transpose()
                .or_else(|| self.get_module(path, name).transpose())
                .or_else(|| self.get_member(path, name).transpose())
                .transpose()
        }
    }

    fn get_crate(&self, name: &str) -> Option<path::PathBuf> {
        log::info!(
            "Searching crate '{}' in dir source '{}'",
            name,
            self.path.display()
        );
        let crate_path = self.path.join(name.replace('-', "_"));
        if crate_path.join("all.html").is_file() {
            log::info!("Found crate '{}': '{}'", name, crate_path.display());
            Some(crate_path)
        } else {
            log::info!("Did not find crate '{}' in '{}'", name, self.path.display());
            None
        }
    }

    fn get_item(&self, root: &path::Path, name: &doc::Fqn) -> anyhow::Result<Option<doc::Doc>> {
        log::info!(
            "Searching item '{}' in directory '{}'",
            name,
            root.display()
        );
        if let Some(local_name) = name.rest() {
            let parser = html::Parser::from_file(root.join("all.html"))?;
            if let Some(path) = parser.find_item(local_name)? {
                let file_name = path::Path::new(&path)
                    .file_name()
                    .unwrap()
                    .to_str()
                    .unwrap();
                let ty: doc::ItemType = file_name.splitn(2, '.').next().unwrap().parse()?;
                html::Parser::from_file(root.join(path))?
                    .parse_item_doc(name, ty)
                    .map(Some)
            } else {
                Ok(None)
            }
        } else {
            Ok(None)
        }
    }

    fn get_module(&self, root: &path::Path, name: &doc::Fqn) -> anyhow::Result<Option<doc::Doc>> {
        log::info!(
            "Searching module '{}' in directory '{}'",
            name,
            root.display()
        );
        let module_path = if let Some(local_name) = name.rest() {
            local_name
                .split("::")
                .fold(path::PathBuf::new(), |mut p, s| {
                    p.push(s);
                    p
                })
        } else {
            path::PathBuf::new()
        };
        let path = root.join(module_path).join("index.html");
        if path.is_file() {
            html::Parser::from_file(path)?
                .parse_module_doc(name)
                .map(Some)
        } else {
            Ok(None)
        }
    }

    fn get_member(&self, root: &path::Path, name: &doc::Fqn) -> anyhow::Result<Option<doc::Doc>> {
        log::info!(
            "Searching member '{}' in directory '{}'",
            name,
            root.display()
        );
        if let Some(parent) = name.parent() {
            if let Some(rest) = parent.rest() {
                let parser = html::Parser::from_file(root.join("all.html"))?;
                if let Some(path) = parser.find_item(rest)? {
                    let parser = html::Parser::from_file(root.join(path))?;
                    if let Some(ty) = parser.find_member(name)? {
                        return parser.parse_member_doc(name, ty).map(Some);
                    }
                }
            }
        }
        Ok(None)
    }
}

impl Source for DirSource {
    fn find_doc(
        &self,
        name: &doc::Fqn,
        ty: Option<doc::ItemType>,
    ) -> anyhow::Result<Option<doc::Doc>> {
        log::info!(
            "Searching documentation for '{}' in dir source '{}'",
            name,
            self.path.display()
        );
        if let Some(crate_path) = self.get_crate(name.krate()) {
            let doc = self.find_doc_html(&crate_path, name, ty)?;
            if doc.is_some() {
                log::info!(
                    "Found documentation for '{}' in dir source '{}'",
                    name,
                    self.path.display()
                )
            } else {
                log::info!(
                    "Did not find documentation for '{}' in dir source '{}'",
                    name,
                    self.path.display()
                )
            }
            Ok(doc)
        } else {
            log::info!(
                "Did not find crate '{}' in dir source '{}'",
                name.krate(),
                self.path.display()
            );
            Ok(None)
        }
    }

    fn load_index(&self) -> anyhow::Result<Option<index::Index>> {
        log::info!("Searching search index for '{}'", self.path.display());
        // use the first file that matches the pattern search-index*.js
        for entry in fs::read_dir(&self.path)? {
            let entry = entry?;
            if entry.file_type()?.is_file() {
                if let Some(s) = entry.file_name().to_str() {
                    if s.starts_with("search-index") && s.ends_with(".js") {
                        log::info!("Found search index '{}'", &entry.path().display());
                        return index::Index::load(&entry.path());
                    }
                }
            }
        }
        log::info!("Could not find search index for '{}'", self.path.display());
        Ok(None)
    }

    fn list_crates(&self) -> Vec<String> {
        let Ok(entries) = fs::read_dir(&self.path) else {
            return Vec::new();
        };
        let mut crates: Vec<String> = entries
            .filter_map(|e| e.ok())
            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
            .filter(|e| e.path().join("all.html").is_file())
            .filter_map(|e| e.file_name().into_string().ok())
            .collect();
        crates.sort();
        crates
    }
}

pub fn get_source<P: AsRef<path::Path>>(path: P) -> anyhow::Result<Box<dyn Source>> {
    if path.as_ref().is_dir() {
        Ok(Box::new(DirSource::new(path.as_ref().to_path_buf())))
    } else {
        Err(anyhow!(
            "This source is not supported: {}",
            path.as_ref().display()
        ))
    }
}
