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

use clap::Parser;
use log::debug;
use serde::Serialize;

use obnam::{
    chunk::{Chunk, ChunkError, DataChunk, Label, MetadataFactory},
    cipher::{Engine, EngineKind, Key},
    client::ClientChunk,
    config::Config,
    id::{Id, IdError},
    plaintext::{Plaintext, PlaintextError},
    repository::{BackupRepository, RepositoryError},
    util::{UtilError, read_file, read_stdin, write, write_file},
};

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

/// Encrypt plain text data as a chunk.
///
/// This creates a new, encrypted chunk from some plain text data and
/// a chunk label. It invents a new random identifier for the chunk.
#[derive(Parser)]
pub struct Encrypt {
    /// Name of file whose content will be put in the chunk; see also
    /// the `--stdin` option.
    #[clap(required_unless_present("stdin"))]
    filename: Option<PathBuf>,

    /// Read input from the standard input.
    #[clap(long, required_unless_present("filename"))]
    stdin: bool,

    /// Compress data before encryption.
    #[clap(long)]
    compress: bool,

    /// Label for the chunk to declare what is inside the chunk.
    #[clap(long)]
    label: String,

    /// Encryption key.
    #[clap(long)]
    key: Option<String>,

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

    /// Name of client.
    #[clap(long)]
    client_name: Option<String>,

    /// Store chunk from repository with this ID. This is meant for
    /// testing.
    #[clap(long)]
    id: Option<String>,

    /// Write ID of chunk to this file.
    #[clap(long)]
    id_file: Option<PathBuf>,

    /// Write the encoded chunk to this file. Default is to store it
    /// in the repository set with the global `--repository` option.
    #[clap(long)]
    output: Option<PathBuf>,
}

impl Leaf for Encrypt {
    type Error = ChunkCmdError;

    fn run(&self, args: &Args, config: &Config) -> Result<(), Self::Error> {
        debug!("Create an encrypted chunk");

        let plaintext = if self.stdin {
            read_stdin().map_err(ChunkCmdError::ReadChunk)?
        } else if let Some(filename) = &self.filename {
            read_file(filename).map_err(ChunkCmdError::ReadChunk)?
        } else {
            return Err(ChunkCmdError::WhenceChunk);
        };

        let plaintext = if self.compress {
            debug!("Compress data using DEFLATE before encrypting");
            Plaintext::deflated(&plaintext).map_err(ChunkCmdError::Plaintext)?
        } else {
            debug!("Don't compresss data before encrypting");
            Plaintext::uncompressed(&plaintext)
        };

        let engine = if let Some(key) = &self.key {
            Engine::new(EngineKind::Aead, Key::from(key))
        } else {
            let repo = Self::open_repository(config)?;
            if let Ok(client_key) = Self::client_key_from_repo(args, config, &repo) {
                let client_name = config.client_name(self.client_name.as_deref());
                let key_name = self.key_name.as_ref().ok_or(ChunkCmdError::ClientName)?;
                let client_engine = Engine::new(EngineKind::Aead, client_key);
                let client = open_client(&repo, &client_engine, client_name)?;
                let key = client.key(key_name).ok_or(ChunkCmdError::NoSuchKey(
                    key_name.to_string(),
                    client_name.to_string(),
                ))?;
                Engine::new(EngineKind::Aead, key.clone())
            } else {
                return Err(ChunkCmdError::NeedKey);
            }
        };

        let mut factory = MetadataFactory::default();
        let metadata = if let Some(id) = &self.id {
            let id = Id::try_from(id).map_err(ChunkCmdError::Id)?;
            factory
                .metadata(id.clone(), Label::from(self.label.as_str()))
                .map_err(ChunkCmdError::Metadata)?
        } else {
            factory
                .from_label(&self.label)
                .map_err(ChunkCmdError::Metadata)?
        };

        let id = metadata.id().to_string();
        let chunk = Chunk::data(
            DataChunk::encrypt(&engine, &plaintext, metadata).map_err(ChunkCmdError::Encrypt)?,
        );

        let result = if let Some(filename) = &self.output {
            debug!("Write encrypted chunk to file {}", filename.display());
            let ciphertext = chunk.serialize().map_err(ChunkCmdError::Serialize)?;
            write_file(filename, &ciphertext).map_err(ChunkCmdError::WriteChunk)
        } else {
            debug!("Store encrypted chunk in backup repository");
            let repo = Self::open_repository(config)?;
            repo.add_chunk(&chunk).map_err(ChunkCmdError::AddChunk)
        };

        if let Some(filename) = &self.id_file {
            write_file(filename, id.as_bytes())
                .map_err(|err| ChunkCmdError::WriteId(filename.to_path_buf(), err))?;
        }

        result
    }
}

/// Decrypt a chunk.
#[derive(Parser)]
pub struct Decrypt {
    /// Name of file whose content will be put in the chunk. If not
    /// given, chunk is read from stdin. Default is to look up chunk
    /// by ID from repository.
    filename: Option<PathBuf>,

    /// Decrypt chunk with this ID from repository. This is overridden
    /// by --filename.
    #[clap(long)]
    id: Option<String>,

    /// Get chunk ID from this file.
    #[clap(long)]
    id_file: Option<PathBuf>,

    /// Encryption key.
    #[clap(long)]
    key: Option<String>,

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

    /// Name of client.
    #[clap(long)]
    client_name: Option<String>,

    /// Write the encoded chunk to this file. Default is to the
    /// standard output.
    #[clap(long)]
    output: Option<PathBuf>,
}

impl Leaf for Decrypt {
    type Error = ChunkCmdError;

    fn run(&self, args: &Args, config: &Config) -> Result<(), Self::Error> {
        let chunk = if let Some(filename) = &self.filename {
            debug!("Decrypt a chunk from file {}", filename.display());
            let ciphertext = read_file(filename).map_err(ChunkCmdError::ReadChunk)?;
            Chunk::parse(&ciphertext).map_err(ChunkCmdError::ParseChunk)?
        } else if let Some(filename) = &self.id_file {
            let id = read_file(filename)
                .map_err(|err| ChunkCmdError::ReadId(filename.to_path_buf(), err))?;
            let id = String::from_utf8_lossy(&id).to_string();
            let id = Id::try_from(id).map_err(ChunkCmdError::Id)?;
            debug!("Decrypt chunk from chunk repository: {id}");
            let repo = Self::open_repository(config)?;
            repo.get_data_chunk(&id).map_err(ChunkCmdError::GetChunk)?
        } else if let Some(id) = &self.id {
            let id = Id::try_from(id).map_err(ChunkCmdError::Id)?;
            debug!("Decrypt chunk from chunk repository: {id}");
            let repo = Self::open_repository(config)?;
            repo.get_data_chunk(&id).map_err(ChunkCmdError::GetChunk)?
        } else {
            return Err(ChunkCmdError::DecryptWhat);
        };

        let engine = if let Some(key) = &self.key {
            Engine::new(EngineKind::Aead, Key::from(key))
        } else {
            let repo = Self::open_repository(config)?;
            if let Ok(client_key) = Self::client_key_from_repo(args, config, &repo) {
                let client_name = config.client_name(self.client_name.as_deref());
                eprintln!("{:?}", self.key_name);
                let key_name = self.key_name.as_ref().ok_or(ChunkCmdError::ClientName)?;
                let client_engine = Engine::new(EngineKind::Aead, client_key);
                let client = open_client(&repo, &client_engine, client_name)?;
                let key = client.key(key_name).ok_or(ChunkCmdError::NoSuchKey(
                    key_name.to_string(),
                    client_name.to_string(),
                ))?;
                Engine::new(EngineKind::Aead, key.clone())
            } else {
                return Err(ChunkCmdError::NeedKey);
            }
        };

        let plaintext = match chunk {
            Chunk::Data(data) => data.decrypt(&engine).map_err(ChunkCmdError::Decrypt)?,
            _ => return Err(ChunkCmdError::NotData),
        };
        let plaintext = plaintext.as_bytes().map_err(ChunkCmdError::Plaintext)?;
        write(&self.output, &plaintext).map_err(ChunkCmdError::WriteChunk)?;
        Ok(())
    }
}

/// Show details of what is in a chunk.
///
/// This is a troubleshooting command to help you figure what a chunk
/// contains.
///
/// The output contains the chunk identifier, label, and content,
/// encoded as JSON.
#[derive(Parser)]
pub struct Inspect {
    /// Read chunk from this file.
    #[clap(long)]
    filename: Option<PathBuf>,

    /// Inspect chunk with this ID from repository. This is overridden
    /// by --filename.
    #[clap(long)]
    id: Option<String>,

    /// Chunk ID from file.
    #[clap(long)]
    id_file: Option<PathBuf>,

    /// Encryption key.
    #[clap(long)]
    key: Option<String>,

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

    /// Name of client.
    #[clap(long)]
    client_name: Option<String>,
}

impl Leaf for Inspect {
    type Error = ChunkCmdError;

    fn run(&self, args: &Args, config: &Config) -> Result<(), Self::Error> {
        let chunk = if let Some(filename) = &self.filename {
            debug!("inspect chunk from file {}", filename.display());
            let data = read_file(filename).map_err(ChunkCmdError::ReadChunk)?;
            Chunk::parse(&data).map_err(ChunkCmdError::ParseChunk)?
        } else if let Some(id) = &self.id {
            let id = Id::try_from(id).map_err(ChunkCmdError::Id)?;
            debug!("inspect chunk from chunk repository {id}");
            let repo = Self::open_repository(config)?;
            repo.get_data_chunk(&id).map_err(ChunkCmdError::GetChunk)?
        } else if let Some(filename) = &self.id_file {
            let id = id_from_file(filename)?;
            debug!("inspect chunk from chunk repository {id}");
            let repo = Self::open_repository(config)?;
            repo.get_data_chunk(&id).map_err(ChunkCmdError::GetChunk)?
        } else {
            return Err(ChunkCmdError::InspectWhat);
        };

        let without_decryption = InspectedChunk {
            id: chunk.metadata().id().to_string(),
            label: chunk.metadata().label().to_string(),
            data: None,
        };

        let inspected = if let Some(key) = &self.key {
            let engine = Engine::new(EngineKind::Aead, Key::from(key));
            let plaintext = match &chunk {
                Chunk::Data(data) => data.decrypt(&engine).map_err(ChunkCmdError::Decrypt)?,
                _ => return Err(ChunkCmdError::NotData),
            };
            let plaintext = plaintext.as_bytes().map_err(ChunkCmdError::Plaintext)?;
            let plaintext = String::from_utf8_lossy(&plaintext).to_string();

            InspectedChunk {
                id: chunk.metadata().id().to_string(),
                label: chunk.metadata().label().to_string(),
                data: Some(plaintext),
            }
        } else if let Ok(repo) = Self::open_repository(config) {
            if let Ok(client_key) = Self::client_key_from_repo(args, config, &repo) {
                let key_name = self.key_name.as_ref().ok_or(ChunkCmdError::ClientName)?;
                let client_name = self.client_name.as_ref().ok_or(ChunkCmdError::ClientName)?;
                let client_engine = Engine::new(EngineKind::Aead, client_key);
                let client = open_client(&repo, &client_engine, client_name)?;
                let key = client.key(key_name).ok_or(ChunkCmdError::NoSuchKey(
                    key_name.to_string(),
                    client_name.to_string(),
                ))?;
                let engine = Engine::new(EngineKind::Aead, key.clone());
                let plaintext = match &chunk {
                    Chunk::Data(data) => data.decrypt(&engine).map_err(ChunkCmdError::Decrypt)?,
                    _ => return Err(ChunkCmdError::NotData),
                };
                let plaintext = plaintext.as_bytes().map_err(ChunkCmdError::Plaintext)?;
                let plaintext = String::from_utf8_lossy(&plaintext).to_string();

                InspectedChunk {
                    id: chunk.metadata().id().to_string(),
                    label: chunk.metadata().label().to_string(),
                    data: Some(plaintext),
                }
            } else {
                without_decryption
            }
        } else {
            without_decryption
        };

        let json = serde_json::to_string_pretty(&inspected).map_err(ChunkCmdError::ToJson)?;
        println!("{json}");

        Ok(())
    }
}

#[derive(Serialize)]
struct InspectedChunk {
    id: String,
    label: String,
    data: Option<String>,
}

fn open_client(
    repo: &BackupRepository,
    engine: &Engine,
    client_name: &str,
) -> Result<ClientChunk, ChunkCmdError> {
    let (_, client) = repo
        .open_client(engine, client_name)
        .map_err(|err| ChunkCmdError::OpenClient(client_name.to_string(), err))?;
    Ok(client)
}

fn id_from_file(filename: &Path) -> Result<Id, ChunkCmdError> {
    let id =
        read_file(filename).map_err(|err| ChunkCmdError::ReadId(filename.to_path_buf(), err))?;
    let id = String::from_utf8_lossy(&id).to_string();
    let id = Id::try_from(&id).map_err(ChunkCmdError::Id)?;
    Ok(id)
}

#[derive(Debug, thiserror::Error)]
pub enum ChunkCmdError {
    #[error("don't know where to read chunk data from; use --stdin or give filename")]
    WhenceChunk,

    #[error("failed to read chunk")]
    ReadChunk(#[source] obnam::util::UtilError),

    #[error("failed to create chunk")]
    Serialize(#[source] ChunkError),

    #[error("failed to parse chunk")]
    ParseChunk(#[source] ChunkError),

    #[error("failed to write chunk")]
    WriteChunk(#[source] obnam::util::UtilError),

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

    #[error("failed to decrypt chunk")]
    Decrypt(#[source] ChunkError),

    #[error("failed to serialize chunk or metadata to JSON")]
    ToJson(#[source] serde_json::Error),

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

    #[error("failed to get chunk from repository")]
    GetChunk(#[source] RepositoryError),

    #[error("failed to parse chunk from repository as a data chunk")]
    NotData,

    #[error("must use --id or --filename for chunk decrypt")]
    DecryptWhat,

    #[error("must use --id or --filename for chunk inspect")]
    InspectWhat,

    #[error(transparent)]
    Main(#[from] MainError),

    #[error(transparent)]
    Plaintext(#[from] PlaintextError),

    #[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 client chunk for {0}")]
    OpenClient(String, #[source] obnam::repository::RepositoryError),

    #[error("failed to create chunk metadata")]
    Metadata(#[source] ChunkError),

    #[error("failed to parse as chunk ID: {0:?}")]
    Id(#[source] IdError),

    #[error("failed to read chunk ID from {0}")]
    ReadId(PathBuf, #[source] UtilError),

    #[error("failed to write chunk ID to {0}")]
    WriteId(PathBuf, #[source] UtilError),
}
