//! Manage backup clients and their credentials in a backup repository.

use std::{collections::HashMap, path::PathBuf};

use crate::{
    chunk::{Chunk, ChunkError, MetadataFactory},
    cipher::Engine,
    client::{ClientChunk, ClientChunkError},
    credential::Credential,
    id::Id,
    label::{Label, LabelError},
    repoxface::{BackupRepository, RepositoryError},
};

const CLIENT_LABEL: &str = "client";
const CREDENTIAL_LABEL: &str = "credential";

/// Manage backup clients in a repository.
#[derive(Debug)]
pub struct ClientRepository<R: BackupRepository> {
    repo: R,
}

#[allow(unused_variables)]
impl<R: BackupRepository> ClientRepository<R> {
    /// Create a new [`ClientRepository`].
    pub fn new(repo: R) -> Self {
        Self { repo }
    }

    /// Return underlying repository.
    pub fn repo(&self) -> &R {
        &self.repo
    }

    /// Put a new credential in the repository, returning the ID of the new chunk.
    pub fn put_credential(&self, credential: Credential) -> Result<Id, ClientRepositoryError> {
        let mut factory = MetadataFactory::default();
        let metadata = factory.from_label(credential_label()?);
        let id = metadata.id().clone();

        let chunk = Chunk::credential(metadata, credential);
        self.repo
            .put(&chunk)
            .map_err(|err| ClientRepositoryError::PutCredential(err.into()))?;

        Ok(id)
    }

    /// Get a credential chunk from the repository.
    pub fn get_credential_chunk(&self, id: &Id) -> Result<Credential, ClientRepositoryError> {
        let chunk = self
            .repo
            .get(id)
            .map_err(|err| ClientRepositoryError::NoSuchCredential(id.clone(), err.into()))?;
        if let Chunk::Credential { credential, .. } = chunk {
            Ok(credential)
        } else {
            Err(ClientRepositoryError::NotCredential(id.clone()))
        }
    }

    /// Remove a credential.
    pub fn delete_credential(&self, id: &Id) -> Result<(), ClientRepositoryError> {
        self.repo
            .delete(id)
            .map_err(|err| ClientRepositoryError::NoSuchCredential(id.clone(), err.into()))?;
        Ok(())
    }

    /// Find IDs of all credential chunks.
    pub fn find_credential_chunks(&self) -> Result<Vec<Id>, ClientRepositoryError> {
        self.repo
            .find(&credential_label()?)
            .map_err(|err| ClientRepositoryError::NoCredentials(err.into()))
    }

    /// Find all credentials as credentials, not as chunks.
    pub fn find_credentials(&self) -> Result<Vec<Credential>, ClientRepositoryError> {
        let ids = self
            .repo
            .find(&credential_label()?)
            .map_err(|err| ClientRepositoryError::NoCredentials(err.into()))?;

        ids.iter().map(|id| self.get_credential_chunk(id)).collect()
    }

    /// Put a new client chunk into repository.
    pub fn put_client(&self, id: &Id, chunk: Chunk) -> Result<(), ClientRepositoryError> {
        let mut factory = MetadataFactory::default();
        let metadata = factory.from_label(client_label()?);
        let id = metadata.id().clone();

        self.repo
            .put(&chunk)
            .map_err(|err| ClientRepositoryError::PutClient(err.into()))?;

        Ok(())
    }

    /// Get a client chunk from repository.
    pub fn get_client_chunk(&self, id: &Id) -> Result<Chunk, ClientRepositoryError> {
        let chunk = self
            .repo
            .get(id)
            .map_err(|err| ClientRepositoryError::NoSuchClient(id.clone(), err.into()))?;
        if let Chunk::Client { .. } = chunk {
            Ok(chunk)
        } else {
            Err(ClientRepositoryError::NotClient(id.clone()))
        }
    }

    /// Get a client, not as a chunk.
    pub fn get_client(
        &self,
        id: &Id,
        engine: &Engine,
    ) -> Result<ClientChunk, ClientRepositoryError> {
        let chunk = self.get_client_chunk(id)?;

        let plaintext = match chunk {
            Chunk::Client(data) => data
                .decrypt(engine)
                .map_err(ClientRepositoryError::Decrypt)?,
            _ => return Err(ClientRepositoryError::NotClient(id.clone())),
        };

        ClientChunk::try_from(&plaintext)
            .map_err(|err| ClientRepositoryError::ParseClientChunk(id.clone(), err))
    }

    /// Remove a client chunk from repository.
    pub fn delete_client(&self, id: &Id) -> Result<(), ClientRepositoryError> {
        self.repo
            .delete(id)
            .map_err(|err| ClientRepositoryError::NoSuchClient(id.clone(), err.into()))?;
        Ok(())
    }

    /// Find IDs of all client chunks.
    pub fn find_client_chunks(&self) -> Result<Vec<Id>, ClientRepositoryError> {
        self.repo
            .find(&client_label()?)
            .map_err(|err| ClientRepositoryError::NoClients(err.into()))
    }

    /// Finds all client chunks we can use.
    pub fn find_our_client_chunks(
        &self,
        engine: &Engine,
    ) -> Result<Vec<(Id, Chunk)>, ClientRepositoryError> {
        let ids = self.find_client_chunks()?;
        ids.iter()
            .map(|id| self.get_client_chunk(id).map(|chunk| (id.clone(), chunk)))
            .collect()
    }

    /// Find a client chunk by name and open it.
    pub fn open_client(
        &self,
        engine: &Engine,
        wanted: &str,
    ) -> Result<(Id, ClientChunk), ClientRepositoryError> {
        fn is_old(id: &Id, map: &HashMap<Id, ClientChunk>) -> bool {
            for client in map.values() {
                if client.old_versions().contains(id) {
                    return true;
                }
            }

            false
        }

        let ids = self.find_client_chunks()?;

        let mut map: HashMap<Id, ClientChunk> = HashMap::new();
        for id in ids {
            let client = self.get_client(&id, engine)?;
            map.insert(id, client);
        }

        let mut roots: Vec<&Id> = map.keys().collect();
        loop {
            if roots.is_empty() {
                return Err(ClientRepositoryError::CannotFindClient(wanted.to_string()));
            }

            let mut found = None;

            for (i, id) in roots.iter().enumerate() {
                if is_old(id, &map) {
                    found = Some(i);

                    break;
                }
            }

            if let Some(i) = found {
                roots.remove(i);

                if roots.len() == 1 {
                    break;
                }
            } else {
                break;
            }
        }

        match roots.len() {
            0 => Err(ClientRepositoryError::CannotFindClient(wanted.to_string())),

            1 => {
                let id = roots
                    .first()
                    .ok_or(ClientRepositoryError::CannotFindClient(wanted.to_string()))?;

                let c = map
                    .get(id)
                    .ok_or(ClientRepositoryError::CannotFindClient(wanted.to_string()))?;

                Ok(((*id).clone(), c.clone()))
            }

            _ => Err(ClientRepositoryError::TooManyClients(wanted.to_string())),
        }
    }
}

fn credential_label() -> Result<Label, ClientRepositoryError> {
    Label::try_from(CREDENTIAL_LABEL).map_err(ClientRepositoryError::Label)
}

fn client_label() -> Result<Label, ClientRepositoryError> {
    Label::try_from(CLIENT_LABEL).map_err(ClientRepositoryError::Label)
}

/// All potential errors from backup repository.
#[derive(Debug, thiserror::Error)]
pub enum ClientRepositoryError {
    /// Can't serialize a chunk.
    #[error("can't serialize chunk for storing in disk")]
    SerializeChunk(#[source] ChunkError),

    /// Can't parse chunk that has been read from disk.
    #[error("can't de-serialize chunk {0} from {1}")]
    ParseChunk(Id, PathBuf, #[source] ChunkError),

    /// Wanted data chunk, got something else.
    #[error("retrieved chunk, but it's not a data chunk: {0}")]
    NotData(Id),

    /// Wanted client chunk, got something else.
    #[error("retrieved chunk, but it's not a client chunk: {0}")]
    NotClient(Id),

    /// Wanted credential chunk, got something else.
    #[error("retrieved chunk, but it's not a credential chunk: {0}")]
    NotCredential(Id),

    /// FIXME
    #[error("more than one client found named {0}")]
    TooManyClients(String),

    /// Client doesn't exist.
    #[error("client chunk doesn't exist: {0}")]
    NoSuchClient(Id, #[source] RepositoryError),

    /// Credential doesn't exist.
    #[error("credential chunk doesn't exist: {0}")]
    NoSuchCredential(Id, #[source] RepositoryError),

    /// Can't find credentials.
    #[error("failed to find credentials")]
    NoCredentials(#[source] RepositoryError),

    /// Can't find clients.
    #[error("failed to find clients")]
    NoClients(#[source] RepositoryError),

    /// Can't find client in repository.
    #[error("no client found named {0}")]
    CannotFindClient(String),

    /// Can't put a client chunk.
    #[error("failed to add a client chunk to repository")]
    PutClient(#[source] RepositoryError),

    /// Can't put a credential chunk.
    #[error("failed to add a credential chunk to repository")]
    PutCredential(#[source] RepositoryError),

    /// Can't parse client chunk.
    #[error("failed to parse client chunk {0}")]
    ParseClientChunk(Id, #[source] ClientChunkError),

    /// Can't create chunk label.
    #[error(transparent)]
    Label(#[from] LabelError),

    /// Can't decrypt chunk.
    #[error("failed to decrypt chunk")]
    Decrypt(#[source] ChunkError),
}

#[cfg(test)]
mod test {
    use tempfile::TempDir;

    use crate::{
        blobs::BlobRepository,
        chunk::DataChunk,
        cipher::{Engine, EngineKind, Key},
        client::ClientChunk,
        credential::CredentialMethod,
        local_repo::LocalRepository,
        sop::Sop,
    };

    use super::*;

    type AnyError<T> = Result<T, Box<dyn std::error::Error>>;

    fn setup() -> AnyError<(TempDir, ClientRepository<LocalRepository>)> {
        let dir = tempfile::tempdir()?;
        let blobs = BlobRepository::init(dir.path())?;
        let local = LocalRepository::new(blobs);
        Ok((dir, ClientRepository::new(local)))
    }

    fn create_engine() -> Engine {
        let key = Key::default();
        Engine::new(EngineKind::Aead, key)
    }

    fn create_credential() -> AnyError<Credential> {
        let client_key = Key::default();
        let sop = Sop::default();
        let openpgp_key = sop.generate_key("alice@example.com")?;
        let method = CredentialMethod::openpgp_soft(sop, openpgp_key);

        Ok(Credential::new(&method, &client_key)?)
    }

    fn create_client(engine: &Engine) -> AnyError<Chunk> {
        let client = ClientChunk::new("xyzzy");
        let plaintext = client.to_plaintext()?;
        let mut factory = MetadataFactory::default();
        let metadata = factory.from_label(client_label()?);
        let data = DataChunk::encrypt(engine, &plaintext, metadata.clone())?;
        Ok(Chunk::Client(data))
    }

    #[test]
    fn adds_credential() -> AnyError<()> {
        let (_dir, clients) = setup()?;

        let credential = create_credential()?;
        let id = clients.put_credential(credential.clone())?;

        let retrieved = clients.get_credential_chunk(&id)?;
        assert_eq!(credential, retrieved);

        let found = clients.find_credential_chunks()?;
        assert_eq!(found, vec![id]);

        let found = clients.find_credentials()?;
        assert_eq!(found, vec![credential]);

        Ok(())
    }

    #[test]
    fn removes_credential() -> AnyError<()> {
        let (_dir, clients) = setup()?;

        let credential = create_credential()?;
        let id = clients.put_credential(credential.clone())?;
        clients.delete_credential(&id)?;
        assert!(matches!(
            clients.get_credential_chunk(&id),
            Err(ClientRepositoryError::NoSuchCredential(_, _))
        ));

        Ok(())
    }

    #[test]
    fn puts_client() -> AnyError<()> {
        let engine = create_engine();
        let chunk = create_client(&engine)?;

        let (_dir, clients) = setup()?;
        clients.put_client(chunk.metadata().id(), chunk.clone())?;

        let found = clients.get_client_chunk(chunk.metadata().id())?;
        assert_eq!(found.serialize()?, chunk.serialize()?);

        let found = clients.find_client_chunks()?;
        assert_eq!(found, vec![chunk.metadata().id().clone()]);

        let found = clients.find_our_client_chunks(&engine)?;
        assert_eq!(found.len(), 1);
        if let Some((id, _)) = found.first() {
            assert_eq!(id, chunk.metadata().id());
        } else {
            unreachable!()
        };

        Ok(())
    }

    #[test]
    fn removes_client() -> AnyError<()> {
        let engine = create_engine();
        let chunk = create_client(&engine)?;

        let (_dir, clients) = setup()?;
        clients.put_client(chunk.metadata().id(), chunk.clone())?;
        clients.delete_client(chunk.metadata().id())?;
        assert!(matches!(
            clients.get_client_chunk(chunk.metadata().id()),
            Err(ClientRepositoryError::NoSuchClient(_, _))
        ));

        Ok(())
    }
}
