#![forbid(unsafe_code)]

mod support;

use std::{collections::BTreeMap, fs, path::Path};

use ironstorage::{
    command::{CopyRequest, MoveRequest, RemoveRequest},
    crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
    mutation::{
        MutationAction, MutationError, MutationSelection, TreeCommit, TreeCommitError,
        TreeCommitter, TreeMutator,
    },
    repository::{DirectoryPath, EntryPath, Repository, SecretBytes},
    write::OverwriteDecision,
};
use support::compatibility::{FixtureSet, TestResult};

struct Secrets(BTreeMap<String, Vec<u8>>);
impl Secrets {
    fn all(fixture: &FixtureSet) -> Self {
        Self(
            fixture
                .generated
                .keys
                .iter()
                .map(|key| {
                    (
                        key.primary_fingerprint.clone(),
                        key.passphrase.as_bytes().to_vec(),
                    )
                })
                .collect(),
        )
    }
}
impl SecretProvider for Secrets {
    fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
        self.0
            .get(key.fingerprint().as_str())
            .cloned()
            .map(SecretBytes::new)
            .ok_or(SecretProviderError::Unavailable)
    }
}

#[derive(Default)]
struct Committer {
    changes: Vec<TreeCommit>,
    fail: bool,
}
impl TreeCommitter for Committer {
    fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> {
        self.changes.push(change.clone());
        if self.fail {
            Err(TreeCommitError::new("simulated"))
        } else {
            Ok(())
        }
    }
}

#[test]
fn remove_entry_supports_confirmation_force_and_empty_cleanup() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let mut committer = Committer::default();
    let mutator = TreeMutator::new(&repository, &keys);
    let request = remove("email/personal", false, false);
    assert!(matches!(
        mutator.remove(&request, OverwriteDecision::Decline, &mut committer),
        Err(MutationError::Cancelled)
    ));
    assert!(store.path().join("email/personal.gpg").is_file());
    let outcome = mutator.remove(&request, OverwriteDecision::Allow, &mut committer)?;
    assert_eq!(outcome.action(), MutationAction::Remove);
    assert!(!store.path().join("email").exists());
    assert_eq!(
        committer.changes[0].message(),
        "Remove email/personal from store."
    );

    let forced = remove("unicode/咖啡", false, true);
    mutator.remove(&forced, OverwriteDecision::Decline, &mut committer)?;
    assert!(!store.path().join("unicode").exists());
    Ok(())
}

#[test]
fn recursive_remove_deletes_entries_and_nested_policy_boundary() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let mutator = TreeMutator::new(&repository, &keys);
    let mut committer = Committer::default();
    assert!(matches!(
        mutator.remove(
            &remove("team/", false, false),
            OverwriteDecision::Allow,
            &mut committer
        ),
        Err(MutationError::RecursiveRequired)
    ));
    let outcome = mutator.remove(
        &remove("team/", true, false),
        OverwriteDecision::Allow,
        &mut committer,
    )?;
    assert_eq!(outcome.entries(), &[EntryPath::parse("team/service")?]);
    assert!(!store.path().join("team").exists());
    assert_eq!(committer.changes[0].message(), "Remove team/ from store.");
    Ok(())
}

#[test]
fn copy_preserves_source_and_reencrypts_for_destination_policy() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let mut provider = Secrets::all(&fixture);
    let mut committer = Committer::default();
    let mutator = TreeMutator::new(&repository, &keys);
    let source = repository.read_entry(&EntryPath::parse("email/personal")?)?;
    let outcome = mutator.copy(
        &CopyRequest {
            source: "email/personal".into(),
            destination: "team/personal".into(),
            force: false,
        },
        OverwriteDecision::Allow,
        None,
        &mut provider,
        &mut committer,
    )?;
    assert_eq!(
        outcome.selection(),
        Some(&MutationSelection::Entry(EntryPath::parse(
            "team/personal"
        )?))
    );
    assert_eq!(
        repository.read_entry(&EntryPath::parse("email/personal")?)?,
        source
    );
    let bob = fixture.key("bob")?;
    let destination = repository.read_entry(&EntryPath::parse("team/personal")?)?;
    assert!(keys.is_encrypted_for(&destination, &[keys.resolve(&bob.primary_fingerprint)?])?);
    let plaintext = keys.decrypt(&destination, &mut provider)?;
    assert_eq!(
        plaintext.expose(),
        fixture.read("expected/basic/email/personal.txt")?
    );
    assert_eq!(
        committer.changes[0].message(),
        "Copy email/personal to team/personal."
    );
    assert_eq!(
        committer.changes[0].changed_paths(),
        &[Path::new("team/personal.gpg").to_owned()]
    );
    Ok(())
}

#[test]
fn existing_and_trailing_slash_destinations_have_directory_semantics() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    fs::create_dir(store.path().join("archive"))?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let mut provider = Secrets::all(&fixture);
    let mutator = TreeMutator::new(&repository, &keys);

    let copied = mutator.copy(
        &CopyRequest {
            source: "email/personal".into(),
            destination: "archive/".into(),
            force: false,
        },
        OverwriteDecision::Allow,
        None,
        &mut provider,
        &mut Committer::default(),
    )?;
    assert_eq!(
        copied.selection(),
        Some(&MutationSelection::Entry(EntryPath::parse(
            "archive/personal"
        )?))
    );
    assert!(store.path().join("archive/personal.gpg").is_file());

    let copied_directory = mutator.copy(
        &CopyRequest {
            source: "team/".into(),
            destination: "archive/".into(),
            force: false,
        },
        OverwriteDecision::Allow,
        None,
        &mut provider,
        &mut Committer::default(),
    )?;
    assert_eq!(
        copied_directory.selection(),
        Some(&MutationSelection::Directory(DirectoryPath::parse(
            "archive/team"
        )?))
    );
    assert!(store.path().join("archive/team/service.gpg").is_file());
    assert!(store.path().join("team/service.gpg").is_file());

    assert!(matches!(
        mutator.copy(
            &CopyRequest {
                source: "email/personal".into(),
                destination: "missing/".into(),
                force: false,
            },
            OverwriteDecision::Allow,
            None,
            &mut provider,
            &mut Committer::default()
        ),
        Err(MutationError::DestinationDirectoryMissing { .. })
    ));
    assert!(!store.path().join("missing").exists());
    Ok(())
}

#[test]
fn empty_subdirectories_are_preserved_and_moved_as_part_of_the_tree() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    fs::create_dir_all(store.path().join("holding/empty/nested"))?;
    fs::create_dir(store.path().join("archive"))?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let mut provider = Secrets::all(&fixture);
    let mutator = TreeMutator::new(&repository, &keys);

    mutator.copy(
        &CopyRequest {
            source: "holding/empty/".into(),
            destination: "archive/".into(),
            force: false,
        },
        OverwriteDecision::Allow,
        None,
        &mut provider,
        &mut Committer::default(),
    )?;
    assert!(store.path().join("holding/empty/nested").is_dir());
    assert!(store.path().join("archive/empty/nested").is_dir());

    mutator.move_tree(
        &MoveRequest {
            source: "holding/empty/".into(),
            destination: "relocated".into(),
            force: false,
        },
        OverwriteDecision::Allow,
        None,
        &mut provider,
        &mut Committer::default(),
    )?;
    assert!(!store.path().join("holding").exists());
    assert!(store.path().join("relocated/nested").is_dir());
    Ok(())
}

#[test]
fn entry_collisions_require_confirmation_unless_forced() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let mut provider = Secrets::all(&fixture);
    let mutator = TreeMutator::new(&repository, &keys);
    let destination = EntryPath::parse("otp/totp")?;
    let original = repository.read_entry(&destination)?;
    let request = CopyRequest {
        source: "email/personal".into(),
        destination: "otp/totp".into(),
        force: false,
    };

    assert!(matches!(
        mutator.copy(
            &request,
            OverwriteDecision::Decline,
            None,
            &mut provider,
            &mut Committer::default()
        ),
        Err(MutationError::Cancelled)
    ));
    assert_eq!(repository.read_entry(&destination)?, original);

    mutator.copy(
        &CopyRequest {
            force: true,
            ..request
        },
        OverwriteDecision::Decline,
        None,
        &mut provider,
        &mut Committer::default(),
    )?;
    let replaced = keys.decrypt(&repository.read_entry(&destination)?, &mut provider)?;
    assert_eq!(
        replaced.expose(),
        fixture.read("expected/basic/email/personal.txt")?
    );
    Ok(())
}

#[test]
fn mutation_prefers_an_entry_over_its_same_named_directory() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    fs::create_dir(store.path().join("email/personal"))?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let mut provider = Secrets::all(&fixture);
    let mutator = TreeMutator::new(&repository, &keys);

    mutator.copy(
        &CopyRequest {
            source: "email/personal".into(),
            destination: "elsewhere".into(),
            force: false,
        },
        OverwriteDecision::Allow,
        None,
        &mut provider,
        &mut Committer::default(),
    )?;
    assert!(store.path().join("email/personal").is_dir());
    assert_eq!(
        keys.decrypt(
            &repository.read_entry(&EntryPath::parse("elsewhere")?)?,
            &mut provider,
        )?
        .expose(),
        fixture.read("expected/basic/email/personal.txt")?
    );
    assert!(matches!(
        mutator.copy(
            &CopyRequest {
                source: "team/service".into(),
                destination: "team/service".into(),
                force: true,
            },
            OverwriteDecision::Allow,
            None,
            &mut provider,
            &mut Committer::default()
        ),
        Err(MutationError::SameObject)
    ));
    Ok(())
}

#[test]
fn move_writes_durable_destination_before_removing_source() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let mut provider = Secrets::all(&fixture);
    let mut committer = Committer::default();
    let mutator = TreeMutator::new(&repository, &keys);
    mutator.move_tree(
        &MoveRequest {
            source: "email/personal".into(),
            destination: "archive/personal".into(),
            force: false,
        },
        OverwriteDecision::Allow,
        None,
        &mut provider,
        &mut committer,
    )?;
    assert!(!store.path().join("email").exists());
    assert!(store.path().join("archive/personal.gpg").is_file());
    assert_eq!(
        committer.changes[0].message(),
        "Rename email/personal to archive/personal."
    );
    assert_eq!(
        committer.changes[0].changed_paths(),
        &[
            Path::new("archive/personal.gpg").to_owned(),
            Path::new("email/personal.gpg").to_owned(),
        ]
    );
    Ok(())
}

#[test]
fn directory_copy_preserves_entries_and_recipient_files() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let mut provider = Secrets::all(&fixture);
    let mutator = TreeMutator::new(&repository, &keys);
    mutator.copy(
        &CopyRequest {
            source: "team/".into(),
            destination: "archive/team".into(),
            force: false,
        },
        OverwriteDecision::Allow,
        None,
        &mut provider,
        &mut Committer::default(),
    )?;
    assert!(store.path().join("team/service.gpg").is_file());
    assert_eq!(
        fs::read(store.path().join("archive/team/.gpg-id"))?,
        fs::read(store.path().join("team/.gpg-id"))?
    );
    assert_eq!(
        fs::read(store.path().join("archive/team/.gpg-id.sig"))?,
        fs::read(store.path().join("team/.gpg-id.sig"))?
    );
    assert!(store.path().join("archive/team/service.gpg").is_file());
    Ok(())
}

#[test]
fn collisions_auxiliary_files_and_commit_failure_never_leave_partial_trees() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let mut provider = Secrets::all(&fixture);
    let mutator = TreeMutator::new(&repository, &keys);
    let before = tree_bytes(store.path())?;
    let mut failing = Committer {
        fail: true,
        ..Committer::default()
    };
    assert!(matches!(
        mutator.move_tree(
            &MoveRequest {
                source: "email/personal".into(),
                destination: "archive/personal".into(),
                force: false
            },
            OverwriteDecision::Allow,
            None,
            &mut provider,
            &mut failing
        ),
        Err(MutationError::Commit(_))
    ));
    assert_eq!(tree_bytes(store.path())?, before);

    assert!(matches!(
        mutator.copy(
            &CopyRequest {
                source: "email/personal".into(),
                destination: "otp/totp".into(),
                force: true,
            },
            OverwriteDecision::Decline,
            None,
            &mut provider,
            &mut failing
        ),
        Err(MutationError::Commit(_))
    ));
    assert_eq!(tree_bytes(store.path())?, before);

    assert!(matches!(
        mutator.move_tree(
            &MoveRequest {
                source: "team/".into(),
                destination: "archive/team".into(),
                force: false,
            },
            OverwriteDecision::Allow,
            None,
            &mut provider,
            &mut failing
        ),
        Err(MutationError::Commit(_))
    ));
    assert_eq!(tree_bytes(store.path())?, before);

    fs::write(store.path().join("team/notes.txt"), b"auxiliary")?;
    assert!(matches!(
        mutator.copy(
            &CopyRequest {
                source: "team/".into(),
                destination: "archive/team".into(),
                force: false
            },
            OverwriteDecision::Allow,
            None,
            &mut provider,
            &mut Committer::default()
        ),
        Err(MutationError::UnsupportedAuxiliary { .. })
    ));
    assert!(!store.path().join("archive").exists());
    Ok(())
}

fn remove(entry: &str, recursive: bool, force: bool) -> RemoveRequest {
    RemoveRequest {
        entry: entry.into(),
        recursive,
        force,
    }
}

fn tree_bytes(root: &Path) -> TestResult<BTreeMap<std::path::PathBuf, Vec<u8>>> {
    fn visit(
        root: &Path,
        current: &Path,
        output: &mut BTreeMap<std::path::PathBuf, Vec<u8>>,
    ) -> TestResult {
        for entry in fs::read_dir(current)? {
            let entry = entry?;
            if entry.file_type()?.is_dir() {
                visit(root, &entry.path(), output)?;
            } else {
                output.insert(
                    entry.path().strip_prefix(root)?.into(),
                    fs::read(entry.path())?,
                );
            }
        }
        Ok(())
    }
    let mut output = BTreeMap::new();
    visit(root, root, &mut output)?;
    Ok(output)
}
