//! Store chunks in the local file system.

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

use crate::{
    blobs::{BlobRepository, BlobsError},
    chunk::{Chunk, ChunkError, Label, Metadata},
    cipher::Engine,
    client::ClientChunk,
    credential::Credential,
    id::Id,
};

/// A backup repository in a local directory.
#[derive(Debug)]
pub struct BackupRepository {
    blobs: BlobRepository,
}

impl BackupRepository {
    /// Has a directory been initialized as a backup repository?
    pub fn is_init(dir: &Path) -> bool {
        BlobRepository::is_init(dir)
    }

    /// Initialize a directory for storing chunks. The directory must exist.
    pub fn init(dir: &Path) -> Result<Self, RepositoryError> {
        Ok(Self {
            blobs: BlobRepository::init(dir)?,
        })
    }

    /// Open a backup repository directory. This fails if the backup repository
    /// hasn't been initialized.
    pub fn open(dir: &Path) -> Result<Self, RepositoryError> {
        Ok(Self {
            blobs: BlobRepository::open(dir)?,
        })
    }

    /// Return metadata for all chunks in the backup repository.
    pub fn all_chunks(&self) -> Result<Vec<Metadata>, RepositoryError> {
        Ok(self.blobs.all_blobs()?)
    }

    /// Store a chunk in the backup repository.
    pub fn add_chunk(&self, chunk: &Chunk) -> Result<(), RepositoryError> {
        let blob = chunk.serialize().map_err(RepositoryError::SerializeChunk)?;
        self.blobs.add_blob(&blob, chunk.metadata())?;
        Ok(())
    }

    // Get any kind of chunk from the backup repository.
    fn get_chunk(&self, id: &Id) -> Result<Chunk, RepositoryError> {
        let (blob, _) = self.blobs.get_blob(id)?;
        let filename = self.chunk_filename(id);
        Chunk::parse(&blob).map_err(|err| RepositoryError::ParseChunk(id.clone(), filename, err))
    }

    /// Get a data chunk from the backup repository.
    pub fn get_data_chunk(&self, id: &Id) -> Result<Chunk, RepositoryError> {
        let chunk = self.get_chunk(id)?;
        if let Chunk::Data(_) = &chunk {
            Ok(chunk)
        } else {
            Err(RepositoryError::NotData(id.clone()))
        }
    }

    /// Get a client chunk from the backup repository.
    pub fn get_client_chunk(&self, id: &Id) -> Result<Chunk, RepositoryError> {
        let chunk = self.get_chunk(id)?;
        if let Chunk::Client(_) = &chunk {
            Ok(chunk)
        } else {
            Err(RepositoryError::NotClient(id.clone()))
        }
    }

    /// Find a client chunk by name and open it.
    pub fn open_client(
        &self,
        engine: &Engine,
        wanted: &str,
    ) -> Result<(Id, ClientChunk), RepositoryError> {
        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 metas = self.find_client_chunks()?;

        let mut map: HashMap<Id, ClientChunk> = HashMap::new();
        for x in metas {
            let chunk = self.get_client_chunk(x.id()).unwrap();
            let plaintext = match chunk {
                Chunk::Client(data) => data.decrypt(engine).unwrap(),
                _ => return Err(RepositoryError::NotClient(x.id().clone())),
            };
            let client = ClientChunk::try_from(&plaintext).unwrap();
            map.insert(x.id().clone(), client);
        }

        let mut roots: Vec<&Id> = map.keys().collect();
        loop {
            if roots.is_empty() {
                return Err(RepositoryError::NoSuchClient(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(RepositoryError::NoSuchClient(wanted.to_string())),
            1 => {
                let id = roots
                    .first()
                    .ok_or(RepositoryError::NoSuchClient(wanted.to_string()))?;
                let c = map
                    .get(id)
                    .ok_or(RepositoryError::NoSuchClient(wanted.to_string()))?;
                Ok(((*id).clone(), c.clone()))
            }
            _ => Err(RepositoryError::TooManyClients(wanted.to_string())),
        }
    }

    /// Get a credential chunk from the backup repository.
    pub fn get_credential_chunk(&self, id: &Id) -> Result<Credential, RepositoryError> {
        let chunk = self.get_chunk(id)?;
        if let Chunk::Credential { credential, .. } = chunk {
            Ok(credential)
        } else {
            Err(RepositoryError::NotCredential(id.clone()))
        }
    }

    /// Remove a chunk from the backup repository.
    pub fn remove_chunk(&self, id: &Id) -> Result<(), RepositoryError> {
        self.blobs.remove_blob(id)?;
        Ok(())
    }

    /// Find chunks that have a given label.
    pub fn find_chunks(&self, label: &Label) -> Result<Vec<Metadata>, RepositoryError> {
        Ok(self.blobs.find_blobs(label)?)
    }

    /// Find client chunks.
    pub fn find_client_chunks(&self) -> Result<Vec<Metadata>, RepositoryError> {
        let label = Label::from("client");
        self.find_chunks(&label)
    }

    /// Find client chunks that we can open.
    pub fn find_our_client_chunks(
        &self,
        engine: &Engine,
    ) -> Result<Vec<ClientChunk>, RepositoryError> {
        let clients = self.find_client_chunks()?;

        let our_clients: Vec<ClientChunk> = clients
            .iter()
            .filter_map(|meta| self.get_client_chunk(meta.id()).ok())
            .filter_map(|chunk| match chunk {
                Chunk::Client(data) => Some(data),
                _ => None,
            })
            .filter_map(|chunk| chunk.decrypt(engine).ok())
            .filter_map(|plaintext| ClientChunk::try_from(&plaintext).ok())
            .collect();

        Ok(our_clients)
    }

    /// Find credential chunks.
    pub fn find_credential_chunks(&self) -> Result<Vec<Metadata>, RepositoryError> {
        let label = Label::from("credential");
        self.find_chunks(&label)
    }

    /// Get all credential chunks.
    pub fn get_credential_chunks(&self) -> Result<Vec<Credential>, RepositoryError> {
        let credentials = self.find_credential_chunks()?;

        let mut creds = vec![];
        for meta in credentials.iter() {
            creds.push(self.get_credential_chunk(meta.id())?);
        }

        Ok(creds)
    }

    /// Return filename where chunk is stored, assuming it exists in the backup repository.
    pub fn chunk_filename(&self, id: &Id) -> PathBuf {
        self.blobs.blob_filename(id)
    }
}

/// All potential errors from backup repository.
#[derive(Debug, thiserror::Error)]
pub enum RepositoryError {
    /// Blob repository error.
    #[error(transparent)]
    Blobs(#[from] BlobsError),

    /// 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("no client found named {0}")]
    NoSuchClient(String),

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

#[cfg(test)]
mod test {
    use crate::{
        chunk::{DataChunk, MetadataFactory},
        cipher::{Engine, EngineKind, Key},
        client::ClientChunk,
        credential::CredentialMethod,
        plaintext::Plaintext,
        sop::Sop,
    };

    use super::*;

    #[test]
    fn has_no_chunks_initially() {
        let dir = tempfile::tempdir().unwrap();
        BackupRepository::init(dir.path()).unwrap();
        let repo = BackupRepository::open(dir.path()).unwrap();
        assert_eq!(repo.all_chunks().unwrap(), vec![]);
    }

    fn create_chunk(data: &'static str, label: &'static str) -> Chunk {
        let engine = Engine::new(EngineKind::Aead, Key::default());
        let plaintext = Plaintext::uncompressed(data.as_bytes());
        let mut factory = MetadataFactory::default();
        let meta = factory.from_label(label).unwrap();
        Chunk::data(DataChunk::encrypt(&engine, &plaintext, meta.clone()).unwrap())
    }

    #[test]
    fn adds_chunk() {
        let chunk = create_chunk("hello, world", "data-chunk");

        let dir = tempfile::tempdir().unwrap();
        BackupRepository::init(dir.path()).unwrap();
        let repo = BackupRepository::open(dir.path()).unwrap();
        repo.add_chunk(&chunk).unwrap();
        assert_eq!(repo.all_chunks().unwrap(), vec![chunk.metadata().clone()]);

        let gotten = repo.get_chunk(chunk.metadata().id()).unwrap();
        assert_eq!(
            serde_json::to_vec(&chunk).unwrap(),
            serde_json::to_vec(&gotten).unwrap()
        );
    }

    #[test]
    fn removes_chunk() {
        let chunk = create_chunk("hello, world", "data-chunk");

        let dir = tempfile::tempdir().unwrap();
        BackupRepository::init(dir.path()).unwrap();
        let repo = BackupRepository::open(dir.path()).unwrap();
        repo.add_chunk(&chunk).unwrap();
        repo.remove_chunk(chunk.metadata().id()).unwrap();
        assert_eq!(repo.all_chunks().unwrap(), vec![]);
    }

    #[test]
    fn finds_client_chunk() {
        let client = ClientChunk::new("xyzzy");
        let plaintext = client.to_plaintext().unwrap();
        let mut factory = MetadataFactory::default();
        let metadata = factory.from_label("client").unwrap();
        let client_key = Key::default();
        let engine = Engine::new(EngineKind::Aead, client_key);
        let data = DataChunk::encrypt(&engine, &plaintext, metadata.clone()).unwrap();
        let chunk = Chunk::Client(data);
        let dir = tempfile::tempdir().unwrap();
        BackupRepository::init(dir.path()).unwrap();
        let repo = BackupRepository::open(dir.path()).unwrap();
        repo.add_chunk(&chunk).unwrap();
        assert_eq!(repo.find_client_chunks().unwrap(), vec![metadata]);
    }

    #[test]
    fn finds_credential_chunk() {
        let client_key = Key::default();
        let sop = Sop::default();
        let openpgp_key = sop.generate_key("alice@example.com").unwrap();
        let method = CredentialMethod::openpgp_soft(sop, openpgp_key);

        let credential = Credential::new(&method, &client_key).unwrap();
        let mut factory = MetadataFactory::default();
        let metadata = factory.from_label("credential").unwrap();
        let chunk = Chunk::credential(metadata.clone(), credential.clone());

        let dir = tempfile::tempdir().unwrap();
        BackupRepository::init(dir.path()).unwrap();
        let repo = BackupRepository::open(dir.path()).unwrap();
        repo.add_chunk(&chunk).unwrap();

        assert_eq!(repo.find_credential_chunks().unwrap(), vec![metadata]);

        assert_eq!(repo.get_credential_chunks().unwrap(), vec![credential]);
    }

    #[test]
    fn finds_no_clients_when_there_are_none() {
        let client_key = Key::default();
        let engine = Engine::new(EngineKind::Aead, client_key);

        let dir = tempfile::tempdir().unwrap();
        BackupRepository::init(dir.path()).unwrap();
        let repo = BackupRepository::open(dir.path()).unwrap();
        assert_eq!(repo.find_our_client_chunks(&engine).unwrap(), vec![]);
    }

    #[test]
    fn opens_client_chunk() {
        let client = ClientChunk::new("xyzzy");
        let plaintext = client.to_plaintext().unwrap();
        let mut factory = MetadataFactory::default();
        let metadata = factory.from_label("client").unwrap();
        let client_key = Key::default();
        let engine = Engine::new(EngineKind::Aead, client_key);
        let data = DataChunk::encrypt(&engine, &plaintext, metadata.clone()).unwrap();
        let chunk = Chunk::Client(data);
        let dir = tempfile::tempdir().unwrap();
        BackupRepository::init(dir.path()).unwrap();
        let repo = BackupRepository::open(dir.path()).unwrap();
        repo.add_chunk(&chunk).unwrap();
        assert_eq!(repo.find_client_chunks().unwrap(), vec![metadata]);

        let (_, opened) = repo.open_client(&engine, "xyzzy").unwrap();
        assert_eq!(opened.name(), "xyzzy");
    }
}
