//! Storage-owned, locked password navigation for native mobile frontends.

use std::{error::Error, fmt};

use crate::{
    config::{Config, ConfigError},
    read::{ReadError, TreeNode, TreeNodeKind, hidden_path, list_tree},
    repository::{DirectoryPath, Repository, RepositoryError},
};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobilePasswordRowKind {
    Directory,
    Entry,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobilePasswordRow {
    id: String,
    path: String,
    title: String,
    detail: String,
    system_image: String,
    kind: MobilePasswordRowKind,
}

impl MobilePasswordRow {
    pub fn id(&self) -> &str {
        &self.id
    }

    pub fn path(&self) -> &str {
        &self.path
    }

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn detail(&self) -> &str {
        &self.detail
    }

    pub fn system_image(&self) -> &str {
        &self.system_image
    }

    pub fn kind(&self) -> MobilePasswordRowKind {
        self.kind
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobilePasswordPage {
    id: String,
    path: String,
    title: String,
    rows: Vec<MobilePasswordRow>,
}

impl MobilePasswordPage {
    pub fn load(path: Option<&str>) -> Result<Self, MobilePasswordError> {
        let config = Config::load(None).map_err(MobilePasswordError::from_config)?;
        let repository =
            Repository::open(config.vault()).map_err(MobilePasswordError::repository)?;
        Self::from_repository(&repository, path)
    }

    pub fn search(query: &str) -> Result<Self, MobilePasswordError> {
        let config = Config::load(None).map_err(MobilePasswordError::from_config)?;
        let repository =
            Repository::open(config.vault()).map_err(MobilePasswordError::repository)?;
        Self::search_repository(&repository, query)
    }

    pub fn id(&self) -> &str {
        &self.id
    }

    pub fn path(&self) -> &str {
        &self.path
    }

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn rows(&self) -> &[MobilePasswordRow] {
        &self.rows
    }

    fn from_repository(
        repository: &Repository,
        path: Option<&str>,
    ) -> Result<Self, MobilePasswordError> {
        let directory = match path {
            Some(path) => DirectoryPath::parse(path).map_err(MobilePasswordError::repository)?,
            None => DirectoryPath::root(),
        };
        let tree = list_tree(repository, &directory).map_err(MobilePasswordError::from_read)?;
        let path = directory
            .as_path()
            .to_str()
            .ok_or_else(MobilePasswordError::invalid_path)?
            .to_owned();
        let title = directory
            .as_path()
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("Passwords")
            .to_owned();
        Ok(Self {
            id: format!("directory:{path}"),
            path,
            title,
            rows: tree.children().iter().map(mobile_row).collect(),
        })
    }

    fn search_repository(
        repository: &Repository,
        query: &str,
    ) -> Result<Self, MobilePasswordError> {
        let query = query.trim();
        let folded = query.to_lowercase();
        let snapshot = repository
            .snapshot()
            .map_err(MobilePasswordError::repository)?;
        let rows = if folded.is_empty() {
            Vec::new()
        } else {
            snapshot
                .entries()
                .filter(|entry| !hidden_path(entry.path().as_path()))
                .filter_map(|entry| {
                    let path = entry.path().as_path().to_str()?;
                    path.to_lowercase()
                        .contains(&folded)
                        .then(|| search_row(entry.path(), path))
                })
                .collect()
        };
        Ok(Self {
            id: format!("search:{query}"),
            path: String::new(),
            title: "Search".to_owned(),
            rows,
        })
    }
}

fn search_row(path: &crate::repository::EntryPath, text: &str) -> MobilePasswordRow {
    let title = path
        .as_path()
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(text)
        .to_owned();
    let detail = path
        .as_path()
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .and_then(|parent| parent.to_str())
        .unwrap_or("Password Store")
        .to_owned();
    MobilePasswordRow {
        id: format!("entry:{text}"),
        path: text.to_owned(),
        title,
        detail,
        system_image: "key.fill".to_owned(),
        kind: MobilePasswordRowKind::Entry,
    }
}

fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
    let path = node.path().to_owned();
    let (kind, prefix, detail, system_image) = match node.kind() {
        TreeNodeKind::Directory => (
            MobilePasswordRowKind::Directory,
            "directory",
            "Folder",
            "folder",
        ),
        TreeNodeKind::Entry => (
            MobilePasswordRowKind::Entry,
            "entry",
            "Locked password",
            "key.fill",
        ),
    };
    MobilePasswordRow {
        id: format!("{prefix}:{path}"),
        path,
        title: node.name().to_owned(),
        detail: detail.to_owned(),
        system_image: system_image.to_owned(),
        kind,
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobilePasswordErrorKind {
    MissingConfiguration,
    Configuration,
    InvalidPath,
    DirectoryMissing,
    Repository,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobilePasswordError {
    kind: MobilePasswordErrorKind,
    title: String,
    detail: String,
}

impl MobilePasswordError {
    pub fn kind(&self) -> MobilePasswordErrorKind {
        self.kind
    }

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn detail(&self) -> &str {
        &self.detail
    }

    fn from_config(error: ConfigError) -> Self {
        let kind = if matches!(error, ConfigError::NotFound { .. }) {
            MobilePasswordErrorKind::MissingConfiguration
        } else {
            MobilePasswordErrorKind::Configuration
        };
        Self {
            kind,
            title: "Passwords Are Unavailable".to_owned(),
            detail: error.to_string(),
        }
    }

    fn repository(error: RepositoryError) -> Self {
        let kind = match error {
            RepositoryError::InvalidPath { .. } => MobilePasswordErrorKind::InvalidPath,
            RepositoryError::NotFound { .. } => MobilePasswordErrorKind::DirectoryMissing,
            _ => MobilePasswordErrorKind::Repository,
        };
        Self {
            kind,
            title: match kind {
                MobilePasswordErrorKind::DirectoryMissing => "Folder No Longer Exists",
                MobilePasswordErrorKind::InvalidPath => "Invalid Password Folder",
                _ => "Passwords Are Unavailable",
            }
            .to_owned(),
            detail: error.to_string(),
        }
    }

    fn from_read(error: ReadError) -> Self {
        match error {
            ReadError::Repository(error) => Self::repository(error),
            _ => Self {
                kind: MobilePasswordErrorKind::Repository,
                title: "Passwords Are Unavailable".to_owned(),
                detail: error.to_string(),
            },
        }
    }

    fn invalid_path() -> Self {
        Self {
            kind: MobilePasswordErrorKind::InvalidPath,
            title: "Invalid Password Folder".to_owned(),
            detail: "The password-store path is not valid UTF-8.".to_owned(),
        }
    }
}

impl fmt::Display for MobilePasswordError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}: {}", self.title, self.detail)
    }
}

impl Error for MobilePasswordError {}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::tempdir;

    use super::{MobilePasswordErrorKind, MobilePasswordPage, MobilePasswordRowKind};
    use crate::repository::Repository;

    #[test]
    fn locked_pages_preserve_storage_identity_across_nested_refreshes()
    -> Result<(), Box<dyn std::error::Error>> {
        let temporary = tempdir()?;
        fs::create_dir_all(temporary.path().join("Personal/Finance/Very Deep/Empty"))?;
        fs::write(
            temporary.path().join("Personal/Finance/bank.gpg"),
            b"ciphertext",
        )?;
        fs::write(
            temporary
                .path()
                .join("Personal/a very long password entry name.gpg"),
            b"ciphertext",
        )?;
        let repository = Repository::open(temporary.path())?;

        let root = MobilePasswordPage::from_repository(&repository, None)?;
        assert_eq!(root.title(), "Passwords");
        assert_eq!(root.rows()[0].id(), "directory:Personal");
        assert_eq!(root.rows()[0].kind(), MobilePasswordRowKind::Directory);

        let finance = MobilePasswordPage::from_repository(&repository, Some("Personal/Finance"))?;
        assert_eq!(finance.id(), "directory:Personal/Finance");
        assert_eq!(finance.title(), "Finance");
        assert_eq!(
            finance.rows()[0].id(),
            "directory:Personal/Finance/Very Deep"
        );
        assert_eq!(finance.rows()[1].id(), "entry:Personal/Finance/bank");
        assert_eq!(finance.rows()[1].detail(), "Locked password");

        let refreshed = MobilePasswordPage::from_repository(&repository, Some("Personal/Finance"))?;
        assert_eq!(finance, refreshed);
        let empty = MobilePasswordPage::from_repository(
            &repository,
            Some("Personal/Finance/Very Deep/Empty"),
        )?;
        assert!(empty.rows().is_empty());

        fs::remove_dir(temporary.path().join("Personal/Finance/Very Deep/Empty"))?;
        let error = MobilePasswordPage::from_repository(
            &repository,
            Some("Personal/Finance/Very Deep/Empty"),
        )
        .unwrap_err();
        assert_eq!(error.kind(), MobilePasswordErrorKind::DirectoryMissing);
        Ok(())
    }

    #[test]
    fn search_matches_entry_and_folder_names_and_preserves_location()
    -> Result<(), Box<dyn std::error::Error>> {
        let temporary = tempdir()?;
        fs::create_dir_all(temporary.path().join("Personal/Finance"))?;
        fs::create_dir_all(temporary.path().join("Work/Finance"))?;
        fs::create_dir_all(temporary.path().join(".extensions"))?;
        fs::write(
            temporary.path().join("Personal/Finance/bank.gpg"),
            b"ciphertext",
        )?;
        fs::write(
            temporary.path().join("Work/Finance/bank.gpg"),
            b"ciphertext",
        )?;
        fs::write(temporary.path().join("Personal/mail.gpg"), b"ciphertext")?;
        fs::write(
            temporary.path().join(".extensions/leaked.gpg"),
            b"ciphertext",
        )?;
        let repository = Repository::open(temporary.path())?;

        let folder = MobilePasswordPage::search_repository(&repository, "FINANCE")?;
        assert_eq!(
            folder
                .rows()
                .iter()
                .map(|row| (row.title(), row.detail(), row.path()))
                .collect::<Vec<_>>(),
            vec![
                ("bank", "Personal/Finance", "Personal/Finance/bank"),
                ("bank", "Work/Finance", "Work/Finance/bank"),
            ]
        );
        assert!(
            MobilePasswordPage::search_repository(&repository, "mail")?
                .rows()
                .iter()
                .any(|row| row.path() == "Personal/mail")
        );
        assert!(
            MobilePasswordPage::search_repository(&repository, "  ")?
                .rows()
                .is_empty()
        );
        assert!(
            MobilePasswordPage::search_repository(&repository, "leaked")?
                .rows()
                .is_empty()
        );
        Ok(())
    }
}
