//! Store chunks as blobs in the local file system.
//!
//! A blob is an encrypted chunk. In this module, it is treated as an
//! opaque byte string.

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

use rusqlite::Connection;

use crate::{
    chunk::{ChunkError, Label, Metadata},
    id::Id,
    util::{UtilError, read_file, write_file},
};

/// A blob repository in a local directory.
#[derive(Debug)]
pub struct BlobRepository {
    dir: PathBuf,
    conn: Connection,
}

impl BlobRepository {
    /// Has a directory been initialized as a blob repository?
    pub fn is_init(dir: &Path) -> bool {
        Self::db(dir).exists()
    }

    /// Initialize a directory for storing blobs. The directory must exist.
    pub fn init(dir: &Path) -> Result<Self, BlobsError> {
        if !dir.exists() {
            return Err(BlobsError::NoSuchDir(dir.into()));
        }

        if Self::is_init(dir) {
            return Err(BlobsError::AlreadyInit(dir.into()));
        }

        Self::init_or_open(dir)
    }

    /// Open a blob repository directory. This fails if the blob repository
    /// hasn't been initialized.
    pub fn open(dir: &Path) -> Result<Self, BlobsError> {
        if !dir.exists() {
            return Err(BlobsError::NoSuchDir(dir.into()));
        }

        if !Self::is_init(dir) {
            return Err(BlobsError::NotInit(dir.into()));
        }

        Self::init_or_open(dir)
    }

    fn init_or_open(dir: &Path) -> Result<Self, BlobsError> {
        let db = Self::db(dir);
        let conn = Connection::open(&db).map_err(|err| BlobsError::CreateDb(db.clone(), err))?;
        db::create_tables(&conn).map_err(|err| BlobsError::CreateTables(db.clone(), err))?;

        Ok(Self {
            dir: dir.into(),
            conn,
        })
    }

    /// Return metadata for all blobs in the repository.
    pub fn all_blobs(&self) -> Result<Vec<Metadata>, BlobsError> {
        db::all_blobs(&self.conn)
    }

    /// Store a blob in the repository.
    pub fn add_blob(&self, blob: &[u8], metadata: &Metadata) -> Result<(), BlobsError> {
        let filename = self.blob_filename(metadata.id());
        write_file(&filename, blob).map_err(BlobsError::WriteBlob)?;
        db::insert(&self.conn, metadata)?;
        Ok(())
    }

    /// Get blob from the repository.
    pub fn get_blob(&self, id: &Id) -> Result<(Vec<u8>, Metadata), BlobsError> {
        let meta = db::get(&self.conn, id)?;
        let filename = self.blob_filename(id);
        let data = read_file(&filename).map_err(BlobsError::ReadBlob)?;
        Ok((data, meta))
    }

    /// Remove a blob from the repository.
    pub fn remove_blob(&self, id: &Id) -> Result<(), BlobsError> {
        let filename = self.blob_filename(id);
        std::fs::remove_file(&filename).map_err(|err| BlobsError::RemoveBlobFile(filename, err))?;
        db::delete(&self.conn, id)
    }

    /// Find blobs that have a given label.
    pub fn find_blobs(&self, label: &Label) -> Result<Vec<Metadata>, BlobsError> {
        db::find(&self.conn, label)
    }

    fn db(dir: &Path) -> PathBuf {
        dir.join("index.sqlite")
    }

    /// Return filename where blob is stored, assuming it exists in the repository.
    pub fn blob_filename(&self, id: &Id) -> PathBuf {
        self.dir.join(format!("{id}.chunk"))
    }
}

mod db {
    use super::{BlobsError, Connection, Id, Label, Metadata};

    fn id_param(id: &Id) -> Vec<u8> {
        id.to_string().as_bytes().to_vec()
    }

    fn label_param(label: &Label) -> Vec<u8> {
        label.as_bytes().to_vec()
    }

    pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
        conn.execute(
            "CREATE TABLE IF NOT EXISTS chunks (id BLOB, label BLOB, metadata BLOB)",
            [],
        )?;
        Ok(())
    }

    pub fn insert(conn: &Connection, meta: &Metadata) -> Result<(), BlobsError> {
        let meta_blob = meta.to_bytes().map_err(BlobsError::MetadataToBlob)?;
        conn.execute(
            "INSERT INTO chunks (id, label, metadata) VALUES (?1, ?2, ?3)",
            (id_param(meta.id()), label_param(meta.label()), &meta_blob),
        )
        .map_err(BlobsError::Insert)?;
        Ok(())
    }

    pub fn get(conn: &Connection, id: &Id) -> Result<Metadata, BlobsError> {
        let mut stmt = conn
            .prepare("SELECT metadata FROM chunks WHERE id = ?1")
            .map_err(BlobsError::Prepare)?;
        let iter = stmt
            .query_map(&[(1, &id_param(id))], |row| {
                let data: Result<Vec<u8>, _> = row.get(0);
                Ok(data)
            })
            .map_err(BlobsError::Query)?;
        let mut metas = vec![];
        for meta in iter {
            let meta: Result<Vec<u8>, _> = meta.map_err(BlobsError::Row)?;
            let meta: Vec<u8> = meta.map_err(BlobsError::Row)?;
            let meta = Metadata::from_bytes(&meta).map_err(BlobsError::MetadataFromBlob)?;
            metas.push(meta);
        }
        match &metas[..] {
            [] => Err(BlobsError::NoSuchBlob(id.clone())),
            [meta] => Ok(meta.clone()),
            _ => Err(BlobsError::DuplicateId(id.clone())),
        }
    }

    pub fn delete(conn: &Connection, id: &Id) -> Result<(), BlobsError> {
        conn.execute("DELETE FROM chunks WHERE id = ?1", (id_param(id),))
            .map_err(BlobsError::RemoveFromIndex)?;
        Ok(())
    }

    pub fn all_blobs(conn: &Connection) -> Result<Vec<Metadata>, BlobsError> {
        let mut stmt = conn
            .prepare("SELECT metadata FROM chunks")
            .map_err(BlobsError::Prepare)?;
        let iter = stmt
            .query_map([], |row| {
                let data: Result<Vec<u8>, _> = row.get(0);
                Ok(data)
            })
            .map_err(BlobsError::Query)?;
        let mut metas = vec![];
        for meta in iter {
            let meta: Result<Vec<u8>, _> = meta.map_err(BlobsError::Row)?;
            let meta: Vec<u8> = meta.map_err(BlobsError::Row)?;
            let meta = Metadata::from_bytes(&meta).map_err(BlobsError::MetadataFromBlob)?;
            metas.push(meta);
        }
        Ok(metas)
    }

    pub fn find(conn: &Connection, label: &Label) -> Result<Vec<Metadata>, BlobsError> {
        let mut stmt = conn
            .prepare("SELECT metadata FROM chunks WHERE label = ?1")
            .map_err(BlobsError::Prepare)?;
        let iter = stmt
            .query_map(&[(1, &label_param(label))], |row| {
                let data: Result<Vec<u8>, _> = row.get(0);
                Ok(data)
            })
            .map_err(BlobsError::Query)?;
        let mut metas = vec![];
        for meta in iter {
            let meta: Result<Vec<u8>, _> = meta.map_err(BlobsError::Row)?;
            let meta: Vec<u8> = meta.map_err(BlobsError::Row)?;
            let meta = Metadata::from_bytes(&meta).map_err(BlobsError::MetadataFromBlob)?;
            metas.push(meta);
        }

        Ok(metas)
    }
}

/// All potential errors from backup repository.
#[derive(Debug, thiserror::Error)]
pub enum BlobsError {
    /// Backup repository directory doesn't exist.
    #[error("backup repository directory does not exist: {0}")]
    NoSuchDir(PathBuf),

    /// Can't re-initialize a directory.
    #[error("directory is already initialized as a backup repository: {0}")]
    AlreadyInit(PathBuf),

    /// Backup repository directory has not been initialized.
    #[error("directory is not initialized as a backup repository: {0}")]
    NotInit(PathBuf),

    /// Can't create chunk index.
    #[error("can't create chunk index database {0}")]
    CreateDb(PathBuf, #[source] rusqlite::Error),

    /// Can't create SQL tables in chunk index.
    #[error("can't create tables in chunk index database {0}")]
    CreateTables(PathBuf, #[source] rusqlite::Error),

    /// Can't write blob.
    #[error("can't write chunk to disk")]
    WriteBlob(#[source] UtilError),

    /// Can't insert chunk into index.
    #[error("can't store chunk information in chunk index database")]
    Insert(#[source] rusqlite::Error),

    /// Can't serialize chunk metadata for index.
    #[error("can't serialize chunk metadata for storing in chunk index database")]
    MetadataToBlob(#[source] ChunkError),

    /// Can't parse chunk metadata from index.
    #[error("can't deserialize chunk metadata from in chunk index database")]
    MetadataFromBlob(#[source] ChunkError),

    /// Can't prepare SQL statement.
    #[error("can't prepare SQL statement")]
    Prepare(#[source] rusqlite::Error),

    /// Can't run SQL query.
    #[error("can't query chunk index database")]
    Query(#[source] rusqlite::Error),

    /// Can't understand SQL query result row.
    #[error("can't query parse query result row from chunk index database")]
    Row(#[source] rusqlite::Error),

    /// Can't read chunk file from disk.
    #[error("can't read chunk file")]
    ReadBlob(#[source] UtilError),

    /// Can't remove chunk file.
    #[error("can't remove chunk file {0}")]
    RemoveBlobFile(PathBuf, #[source] std::io::Error),

    /// Can't remove chunk from index.
    #[error("can't remove chunk from chunk index database")]
    RemoveFromIndex(#[source] rusqlite::Error),

    /// Blob doesn't exsist.
    #[error("blob does not exist in repository: {0}")]
    NoSuchBlob(Id),

    /// Duplicate blob IDs in repository.
    #[error("blob repository has more than one instance of ID {0}")]
    DuplicateId(Id),
}

#[cfg(test)]
mod test {
    use crate::{
        chunk::{Chunk, DataChunk, Label, MetadataFactory},
        cipher::{Engine, EngineKind, Key},
        plaintext::Plaintext,
    };

    use super::*;

    #[test]
    fn nonexistent_dir_is_not_init() {
        assert!(!BlobRepository::is_init(Path::new("/this/does/not/exist")));
    }

    #[test]
    fn root_dir_is_not_init() {
        assert!(!BlobRepository::is_init(Path::new("/")));
    }

    #[test]
    fn empty_dir_is_not_init() {
        let dir = tempfile::tempdir().unwrap();
        assert!(!BlobRepository::is_init(dir.path()));
    }

    #[test]
    fn cant_init_nonexistent_dir() {
        let dir = Path::new("/this/does/not/exist");
        assert!(matches!(
            BlobRepository::init(dir),
            Err(BlobsError::NoSuchDir(_))
        ));
    }

    #[test]
    fn inits_empty_dir() {
        let dir = tempfile::tempdir().unwrap();
        BlobRepository::init(dir.path()).unwrap();
        assert!(BlobRepository::is_init(dir.path()));
    }

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

    fn create_blob(data: &'static str, label: &'static str) -> (Vec<u8>, Metadata) {
        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();
        let chunk = Chunk::data(DataChunk::encrypt(&engine, &plaintext, meta.clone()).unwrap());
        let blob = chunk.serialize().unwrap();
        (blob, meta)
    }

    #[test]
    fn adds_blob() {
        let (blob, meta) = create_blob("hello, world", "data-chunk");

        let dir = tempfile::tempdir().unwrap();
        BlobRepository::init(dir.path()).unwrap();
        let repo = BlobRepository::open(dir.path()).unwrap();
        repo.add_blob(&blob, &meta).unwrap();
        assert_eq!(repo.all_blobs().unwrap(), vec![meta.clone()]);

        let (gotten_blob, gotten_meta) = repo.get_blob(meta.id()).unwrap();
        assert_eq!(gotten_blob, blob);
        assert_eq!(gotten_meta, meta);
    }

    #[test]
    fn removes_blob() {
        let (blob, meta) = create_blob("hello, world", "data-chunk");

        let dir = tempfile::tempdir().unwrap();
        BlobRepository::init(dir.path()).unwrap();
        let repo = BlobRepository::open(dir.path()).unwrap();
        repo.add_blob(&blob, &meta).unwrap();
        repo.remove_blob(meta.id()).unwrap();
        assert_eq!(repo.all_blobs().unwrap(), vec![]);
    }

    #[test]
    fn finds_no_blob_in_empty_repo() {
        let label = Label::from("data-chunk");
        let dir = tempfile::tempdir().unwrap();
        BlobRepository::init(dir.path()).unwrap();
        let repo = BlobRepository::open(dir.path()).unwrap();
        assert_eq!(repo.find_blobs(&label).unwrap(), vec![]);
    }

    #[test]
    fn finds_no_blob_when_none_match_label() {
        let label = Label::from("data-chunk");

        let (blob, meta) = create_blob("hello, world", "other-chunk");

        let dir = tempfile::tempdir().unwrap();
        BlobRepository::init(dir.path()).unwrap();
        let repo = BlobRepository::open(dir.path()).unwrap();
        repo.add_blob(&blob, &meta).unwrap();

        assert_eq!(repo.find_blobs(&label).unwrap(), vec![]);
    }

    #[test]
    fn finds_blob_when_labels_match() {
        let data = "hello, world";
        let label_text = "data-chunk";
        let label = Label::from(label_text);

        let (other, other_meta) = create_blob(data, "other-label");
        let (blob1, meta1) = create_blob(data, label_text);
        let (blob2, meta2) = create_blob(data, label_text);

        let dir = tempfile::tempdir().unwrap();
        BlobRepository::init(dir.path()).unwrap();
        let repo = BlobRepository::open(dir.path()).unwrap();
        repo.add_blob(&other, &other_meta).unwrap();
        repo.add_blob(&blob1, &meta1).unwrap();
        repo.add_blob(&blob2, &meta2).unwrap();

        assert_eq!(repo.find_blobs(&label).unwrap(), vec![meta1, meta2]);
    }
}
