use std::{
    fs::File,
    path::{Path, PathBuf},
    str::FromStr,
};

use clap::Parser;

use directories::ProjectDirs;
use obnam::{
    chunk::{Chunk, ChunkError, DataChunk, MetadataFactory},
    chunk_splitter::{ChunkSplitter, SplitMethod, SplitterError},
    cipher::{Engine, EngineKind},
    client_repo::{ClientRepository, ClientRepositoryError},
    config::Config,
    filesdb::{FilesDb, FilesDbError, FsMetadata},
    id::Id,
    label::{Label, LabelError},
    plaintext::Plaintext,
};

use super::{Args, Leaf, ObnamError};

const FILES_DB_BASENAME: &str = "files.db";
const QUAL: &str = "org.obnam";
const ORG: &str = "The Obnam project";
const APP: &str = "obnam";

/// Start a new local files database for a new backup.
#[derive(Debug, Parser)]
pub struct Start {}

impl Leaf for Start {
    type Error = BackupCmdError;

    fn run(&self, _args: &Args, _config: &Config) -> Result<(), Self::Error> {
        let filename = files_db_filename()?;
        eprintln!("files database: {}", filename.display());

        if filename.exists() {
            return Err(BackupCmdError::Exists(filename));
        }

        let db = FilesDb::new(filename).map_err(BackupCmdError::Create)?;

        eprintln!("Created files database {}", db.filename().display());
        Ok(())
    }
}

/// Add a file to current local files database.
#[derive(Debug, Parser)]
pub struct Add {
    filename: PathBuf,

    /// Name of chunk encryption key from client chunk.
    #[clap(long)]
    key_name: Option<String>,
}

impl Leaf for Add {
    type Error = BackupCmdError;

    fn run(&self, args: &Args, config: &Config) -> Result<(), Self::Error> {
        let db = open_db()?;
        let file_metadata = self
            .filename
            .metadata()
            .map_err(|err| BackupCmdError::get_metadata(self.filename.clone(), err))?;
        let meta = FsMetadata::try_from(&file_metadata).map_err(BackupCmdError::FsMetadata)?;
        db.add(&self.filename, meta).map_err(BackupCmdError::Add)?;

        let repo = Self::open_repository(config)
            .map_err(|err| BackupCmdError::OpenRepository(Box::new(err)))?;

        let engine = {
            if let Ok(client_key) = Self::client_key_from_repo(args, config, &repo) {
                let client_name = config.client_name(None);
                let key_name = self.key_name.as_ref().ok_or(BackupCmdError::ClientName)?;
                let client_engine = Engine::new(EngineKind::Aead, client_key);
                let (_, client) = repo
                    .open_client(&client_engine, client_name)
                    .map_err(|err| BackupCmdError::open_client(client_name, err))?;
                let key = client.key(key_name).ok_or(BackupCmdError::NoSuchKey(
                    key_name.to_string(),
                    client_name.to_string(),
                ))?;
                Engine::new(EngineKind::Aead, key.clone())
            } else {
                return Err(BackupCmdError::NeedKey);
            }
        };

        let mut factory = MetadataFactory::default();
        let label = Label::from_str("data").map_err(BackupCmdError::Label)?;

        if file_metadata.is_file() {
            split_into_chunks_and_upload(&self.filename, &engine, &mut factory, &label, &repo)?;
        }

        Ok(())
    }
}

fn split_into_chunks_and_upload(
    filename: &Path,
    engine: &Engine,
    factory: &mut MetadataFactory,
    label: &Label,
    repo: &ClientRepository,
) -> Result<Vec<Id>, BackupCmdError> {
    let mut ids = vec![];

    let file = File::open(filename)
        .map_err(|err| BackupCmdError::OpenForReading(filename.to_path_buf(), err))?;
    let splitter = ChunkSplitter::new(SplitMethod::Single, file);
    for data in splitter {
        let data = data.map_err(|err| BackupCmdError::ReadChunk(filename.to_path_buf(), err))?;
        let plaintext = Plaintext::uncompressed(&data);

        let metadata = factory.from_label(label.clone());
        ids.push(metadata.id().clone());
        let chunk = Chunk::data(
            DataChunk::encrypt(engine, &plaintext, metadata).map_err(BackupCmdError::Encrypt)?,
        );
        repo.put_chunk(&chunk).map_err(BackupCmdError::AddChunk)?;
    }

    Ok(ids)
}

/// Finish current local files database for a new backup.
#[derive(Debug, Parser)]
pub struct Finish {
    /// Name of chunk encryption key from client chunk.
    #[clap(long)]
    key_name: Option<String>,
}

impl Leaf for Finish {
    type Error = BackupCmdError;

    fn run(&self, args: &Args, config: &Config) -> Result<(), Self::Error> {
        let filename = files_db_filename()?;

        if filename.exists() {
            let repo = Self::open_repository(config)
                .map_err(|err| BackupCmdError::OpenRepository(Box::new(err)))?;

            let engine = {
                if let Ok(client_key) = Self::client_key_from_repo(args, config, &repo) {
                    let client_name = config.client_name(None);
                    let key_name = self.key_name.as_ref().ok_or(BackupCmdError::ClientName)?;
                    let client_engine = Engine::new(EngineKind::Aead, client_key);
                    let (_, client) = repo
                        .open_client(&client_engine, client_name)
                        .map_err(|err| BackupCmdError::open_client(client_name, err))?;
                    let key = client.key(key_name).ok_or(BackupCmdError::NoSuchKey(
                        key_name.to_string(),
                        client_name.to_string(),
                    ))?;
                    Engine::new(EngineKind::Aead, key.clone())
                } else {
                    return Err(BackupCmdError::NeedKey);
                }
            };

            let mut factory = MetadataFactory::default();
            let label = Label::from_str("data").map_err(BackupCmdError::Label)?;

            split_into_chunks_and_upload(&filename, &engine, &mut factory, &label, &repo)?;

            std::fs::remove_file(&filename)
                .map_err(|err| BackupCmdError::RemoveDb(filename, err))?;
        }
        eprintln!("finished");
        Ok(())
    }
}

fn dirs() -> Result<ProjectDirs, BackupCmdError> {
    let res = ProjectDirs::from(QUAL, ORG, APP).ok_or(BackupCmdError::ProjectDirs);
    // eprintln!("project dirs: {res:#?}");
    res
}

fn state_dir() -> Result<PathBuf, BackupCmdError> {
    let dir = dirs()?;
    let dir = dir.state_dir().ok_or(BackupCmdError::StateDir)?;
    if !dir.exists() {
        std::fs::create_dir_all(dir)
            .map_err(|err| BackupCmdError::CreateStateDir(dir.to_path_buf(), err))?;
    }
    Ok(dir.to_path_buf())
}

fn files_db_filename() -> Result<PathBuf, BackupCmdError> {
    let dir = state_dir()?;
    Ok(dir.join(FILES_DB_BASENAME))
}

fn open_db() -> Result<FilesDb, BackupCmdError> {
    let filename = files_db_filename()?;
    FilesDb::new(filename).map_err(BackupCmdError::Open)
}

#[derive(Debug, thiserror::Error)]
pub enum BackupCmdError {
    #[error("failed to determie XDG project directories")]
    ProjectDirs,

    #[error("failed to determine the XDG project state diretory")]
    StateDir,

    #[error("failed to create state directory {0}")]
    CreateStateDir(PathBuf, #[source] std::io::Error),

    #[error("files database already exists: {0}")]
    Exists(PathBuf),

    #[error("failed to create files database")]
    Create(#[source] FilesDbError),

    #[error("failedc to open files database")]
    Open(#[source] FilesDbError),

    #[error("failed to get file metadata for {filename}")]
    GetMetadata {
        filename: PathBuf,
        source: std::io::Error,
    },

    #[error("failed to convert metadata to internal format")]
    FsMetadata(#[source] FilesDbError),

    #[error("failed to add file to database")]
    Add(#[source] FilesDbError),

    #[error("failed to remove database file {0}")]
    RemoveDb(PathBuf, #[source] std::io::Error),

    #[error("need either chunk key (--key) or client key (--client-key)")]
    NeedKey,

    #[error("if --key-name is used, --client-name must also be used (???)")]
    ClientName,

    #[error("no key {0} for client {1}")]
    NoSuchKey(String, String),

    #[error("failed to open repository")]
    OpenRepository(#[source] Box<ObnamError>),

    #[error("failed to open client {0}")]
    OpenClient(String, #[source] Box<ClientRepositoryError>),

    #[error("failed to create a chunk label")]
    Label(#[source] LabelError),

    #[error("failed open file for reading: {0}")]
    OpenForReading(PathBuf, #[source] std::io::Error),

    #[error("failed to read chunk from file {0}")]
    ReadChunk(PathBuf, #[source] SplitterError),

    #[error("failed to encrypt chunk")]
    Encrypt(#[source] ChunkError),

    #[error("failed to add chunk to repository")]
    AddChunk(#[source] ClientRepositoryError),
}

impl BackupCmdError {
    fn get_metadata(filename: impl Into<PathBuf>, source: std::io::Error) -> Self {
        Self::GetMetadata {
            filename: filename.into(),
            source,
        }
    }

    fn open_client<S: Into<String>>(name: S, err: ClientRepositoryError) -> Self {
        Self::OpenClient(name.into(), Box::new(err))
    }
}

impl From<BackupCmdError> for ObnamError {
    fn from(value: BackupCmdError) -> Self {
        ObnamError::Backup(value)
    }
}
