//! Manage lists of files in backups.
//!
//! The list is implemented as an SQLite file.

use std::{
    ffi::OsString,
    fs::Metadata,
    os::unix::ffi::OsStringExt,
    path::{Path, PathBuf},
};

use log::trace;
use rusqlite::{Connection, OpenFlags, named_params, params};
use serde::{Deserialize, Serialize};

use crate::id::Id;

/// List of files and their metadata in a backup.
pub struct FilesDb {
    filename: PathBuf,
    conn: Connection,
}

impl FilesDb {
    /// Create a new files database.
    pub fn new(filename: impl AsRef<Path>) -> Result<Self, FilesDbError> {
        let filename = filename.as_ref();
        trace!("new FilesDb at {}", filename.display());

        let flags = OpenFlags::SQLITE_OPEN_CREATE | OpenFlags::SQLITE_OPEN_READ_WRITE;
        let conn = Connection::open_with_flags(filename, flags)?;
        conn.execute(
            "CREATE TABLE IF NOT EXISTS files (filename BLOB, metadata TEXT)",
            params![],
        )?;
        conn.pragma_update(None, "journal_mode", "WAL")?;
        Ok(Self {
            conn,
            filename: filename.to_path_buf(),
        })
    }

    /// Return filename of database.
    pub fn filename(&self) -> &Path {
        &self.filename
    }

    /// Add a file to the database.
    pub fn add(
        &self,
        filename: impl Into<PathBuf>,
        metadata: impl Into<FsMetadata>,
    ) -> Result<(), FilesDbError> {
        let filename = filename.into().into_os_string().into_encoded_bytes();
        let metadata = metadata.into().to_json()?;

        let mut stmt = self.conn.prepare_cached(
            "INSERT INTO files (filename, metadata) VALUES (:filename, :metadata)",
        )?;
        let t = self.conn.unchecked_transaction()?;
        stmt.execute(named_params! {
            ":filename": filename,
            ":metadata": metadata,
        })?;
        t.commit()?;

        Ok(())
    }

    /// All files currently in the database.
    pub fn files(&self) -> Result<Vec<FileInDb>, FilesDbError> {
        let mut files = vec![];

        let mut stmt = self
            .conn
            .prepare_cached("SELECT filename, metadata FROM files")?;

        let mut rows = stmt.query([])?;
        while let Some(row) = rows.next()? {
            let filename: Vec<u8> = row.get("filename")?;
            let filename: OsString = OsString::from_vec(filename);
            let filename = PathBuf::from(&filename);
            let meta: String = row.get("metadata")?;
            let meta = FsMetadata::from_json(meta)?;
            files.push(FileInDb::new(filename, meta));
        }

        Ok(files)
    }
}

/// A file stored in [`FilesDb`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileInDb {
    filename: PathBuf,
    metadata: FsMetadata,
    chunks: Vec<Id>,
}

impl FileInDb {
    /// Create a new [`FileInDb`].
    pub fn new(filename: impl Into<PathBuf>, meta: impl Into<FsMetadata>) -> Self {
        let filename = filename.into();
        Self {
            filename,
            metadata: meta.into(),
            chunks: vec![],
        }
    }

    /// Append another chunk ID.
    pub fn push_chunk_id(&mut self, id: Id) {
        self.chunks.push(id);
    }

    /// Get the filename.
    pub fn filename(&self) -> &Path {
        &self.filename
    }

    /// Get the metadata.
    pub fn metadata(&self) -> &FsMetadata {
        &self.metadata
    }

    /// Get list of chunks that store file content.
    pub fn chunks(&self) -> &[Id] {
        &self.chunks
    }
}

impl TryFrom<&Path> for FileInDb {
    type Error = FilesDbError;

    fn try_from(filename: &Path) -> Result<Self, FilesDbError> {
        let filename = filename.into();
        let metadata = std::fs::metadata(&filename)
            .map_err(|err| FilesDbError::get_fs_metadata(&filename, err))?;
        Ok(Self {
            filename,
            metadata: FsMetadata::try_from(&metadata)?,
            chunks: vec![],
        })
    }
}

/// Filesystem metadta for a file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsMetadata {
    file_type: FsFileType,
    len: u64,
}

impl FsMetadata {
    /// Serizalize to JSON.
    pub fn to_json(&self) -> Result<String, FilesDbError> {
        serde_json::to_string(self).map_err(FilesDbError::FsMetadataToJson)
    }

    /// Deserizalize from JSON.
    pub fn from_json(text: impl AsRef<str>) -> Result<Self, FilesDbError> {
        serde_json::from_str(text.as_ref()).map_err(FilesDbError::FsMetadataFromJson)
    }
}

impl TryFrom<&Metadata> for FsMetadata {
    type Error = FilesDbError;
    fn try_from(meta: &Metadata) -> Result<Self, Self::Error> {
        Ok(Self {
            file_type: FsFileType::try_from(meta)?,
            len: meta.len(),
        })
    }
}

/// Type of file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FsFileType {
    /// Regular file, one that contains data.
    Regular,

    /// A directory.
    Directory,
}

impl TryFrom<&Metadata> for FsFileType {
    type Error = FilesDbError;
    fn try_from(meta: &Metadata) -> Result<Self, Self::Error> {
        if meta.is_dir() {
            Ok(Self::Directory)
        } else if meta.is_file() {
            Ok(Self::Regular)
        } else {
            Err(FilesDbError::unknown_fs_filetype(meta.clone()))
        }
    }
}

/// Errors from handling lists of files in a backup.
#[derive(Debug, thiserror::Error)]
pub enum FilesDbError {
    /// Can't stat a file.
    #[error("failed to get file system metadata for {filename}")]
    GetFsMetadata {
        /// Name of file.
        filename: PathBuf,

        /// Underlying error.
        source: std::io::Error,
    },

    /// Unknown file type.
    #[error("unknown file type {0:?}")]
    UnknownFsFileType(Box<Metadata>),

    /// Error from `rusqlite`.
    #[error("SQLite error")]
    Sqlite(#[source] rusqlite::Error),

    /// Can't serialize to JSON.
    #[error("failed to serialize filesystem metadata to JSON")]
    FsMetadataToJson(#[source] serde_json::Error),

    /// Can't deserialize to JSON.
    #[error("failed to deserialize filesystem metadata from JSON")]
    FsMetadataFromJson(#[source] serde_json::Error),
}

impl FilesDbError {
    fn get_fs_metadata(filename: impl Into<PathBuf>, source: std::io::Error) -> Self {
        Self::GetFsMetadata {
            filename: filename.into(),
            source,
        }
    }

    fn unknown_fs_filetype(metadata: Metadata) -> Self {
        Self::UnknownFsFileType(Box::new(metadata))
    }
}

impl From<rusqlite::Error> for FilesDbError {
    fn from(value: rusqlite::Error) -> Self {
        Self::Sqlite(value)
    }
}

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

    use super::*;

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

    #[test]
    fn is_empty_initially() -> AnyResult<()> {
        let tmp = tempdir()?;
        let filename = tmp.path().join("file.db");
        let db = FilesDb::new(filename)?;

        assert_eq!(db.files()?, &[]);

        Ok(())
    }
}
