#![forbid(unsafe_code)]

mod support;

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

use ironstorage::{
    command::{EditRequest, InsertInput, InsertRequest},
    crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
    repository::{EntryPath, Repository, SecretBytes},
    write::{
        EntryCommit, EntryCommitError, EntryCommitter, InsertContent, OverwriteDecision,
        VaultWriter, WriteError,
    },
};
use support::compatibility::{FixtureSet, TestResult};

struct FixtureSecrets(BTreeMap<String, Vec<u8>>);

impl FixtureSecrets {
    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 FixtureSecrets {
    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 RecordingCommitter {
    changes: Vec<EntryCommit>,
    fail: bool,
}

impl EntryCommitter for RecordingCommitter {
    fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
        self.changes.push(change.clone());
        if self.fail {
            Err(EntryCommitError::new("simulated Git failure"))
        } else {
            Ok(())
        }
    }
}

#[test]
fn insert_content_modes_validate_and_redact_input() -> TestResult {
    let hidden = InsertContent::hidden(b"secret".to_vec(), b"secret".to_vec())?;
    assert_eq!(hidden.expose(), b"secret");
    assert!(!format!("{hidden:?}").contains("secret"));
    assert!(matches!(
        InsertContent::hidden(b"first".to_vec(), b"second".to_vec()),
        Err(WriteError::ConfirmationMismatch)
    ));
    assert!(matches!(
        InsertContent::echoed(Vec::new()),
        Err(WriteError::EmptySingleLine)
    ));
    assert!(matches!(
        InsertContent::echoed(b"two\nlines".to_vec()),
        Err(WriteError::InvalidSingleLine)
    ));
    assert!(InsertContent::multiline(Vec::new()).expose().is_empty());
    Ok(())
}

#[test]
fn insert_hidden_echoed_and_multiline_use_inherited_recipients() -> 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 writer = VaultWriter::new(&repository, &keys);
    let mut committer = RecordingCommitter::default();

    for (path, input, contents) in [
        (
            "new/hidden",
            InsertInput::HiddenConfirmed,
            InsertContent::hidden(b"hidden".to_vec(), b"hidden".to_vec())?,
        ),
        (
            "new/echoed",
            InsertInput::EchoedLine,
            InsertContent::echoed(b"echoed".to_vec())?,
        ),
        (
            "new/multiline",
            InsertInput::Multiline,
            InsertContent::multiline(b"first\nsecond\n".to_vec()),
        ),
    ] {
        writer.insert(
            &insert(path, false, input),
            contents,
            OverwriteDecision::Decline,
            None,
            &mut committer,
        )?;
    }
    assert_eq!(committer.changes.len(), 3);
    assert_eq!(
        committer.changes[2].message(),
        "Add given password for new/multiline to store."
    );
    let mut provider = FixtureSecrets::all(&fixture);
    let plaintext = keys.decrypt(
        &repository.read_entry(&EntryPath::parse("new/multiline")?)?,
        &mut provider,
    )?;
    assert_eq!(plaintext.expose(), b"first\nsecond\n");
    let alice = fixture.key("alice")?;
    assert!(keys.is_encrypted_for(
        &repository.read_entry(&EntryPath::parse("new/hidden")?)?,
        &[keys.resolve(&alice.primary_fingerprint)?]
    )?);
    Ok(())
}

#[test]
fn overwrite_decision_force_and_commit_failure_are_transactional() -> 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 writer = VaultWriter::new(&repository, &keys);
    let path = EntryPath::parse("email/personal")?;
    let original = repository.read_entry(&path)?;
    let mut committer = RecordingCommitter::default();

    assert!(matches!(
        writer.insert(
            &insert("email/personal", false, InsertInput::EchoedLine),
            InsertContent::echoed(b"declined".to_vec())?,
            OverwriteDecision::Decline,
            None,
            &mut committer,
        ),
        Err(WriteError::Cancelled)
    ));
    assert_eq!(repository.read_entry(&path)?, original);

    writer.insert(
        &insert("email/personal", true, InsertInput::EchoedLine),
        InsertContent::echoed(b"forced".to_vec())?,
        OverwriteDecision::Decline,
        None,
        &mut committer,
    )?;
    let forced = repository.read_entry(&path)?;
    assert_ne!(forced, original);

    committer.fail = true;
    assert!(matches!(
        writer.insert(
            &insert("email/personal", true, InsertInput::EchoedLine),
            InsertContent::echoed(b"rollback".to_vec())?,
            OverwriteDecision::Allow,
            None,
            &mut committer,
        ),
        Err(WriteError::Commit(_))
    ));
    assert_eq!(repository.read_entry(&path)?, forced);

    assert!(matches!(
        writer.insert(
            &insert("temporary/deep/entry", false, InsertInput::Multiline),
            InsertContent::multiline(b"new".to_vec()),
            OverwriteDecision::Allow,
            None,
            &mut committer,
        ),
        Err(WriteError::Commit(_))
    ));
    assert!(!store.path().join("temporary").exists());
    Ok(())
}

#[test]
fn edit_session_handles_replacement_unchanged_and_commit_failure() -> 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 writer = VaultWriter::new(&repository, &keys);
    let request = EditRequest {
        entry: "email/personal".to_owned(),
    };
    let path = EntryPath::parse("email/personal")?;
    let mut provider = FixtureSecrets::all(&fixture);
    let mut committer = RecordingCommitter::default();

    let unchanged = writer.begin_edit(&request, &mut provider)?;
    let same = SecretBytes::new(unchanged.plaintext().expose().to_vec());
    assert!(matches!(
        writer.finish_edit(unchanged, same, "fixture-editor", None, &mut committer),
        Err(WriteError::Unchanged)
    ));
    assert!(committer.changes.is_empty());

    let session = writer.begin_edit(&request, &mut provider)?;
    writer.finish_edit(
        session,
        SecretBytes::new(b"edited\nlogin: replacement\n".to_vec()),
        "fixture-editor",
        None,
        &mut committer,
    )?;
    assert_eq!(
        committer.changes[0].message(),
        "Edit password for email/personal using fixture-editor."
    );
    let edited = repository.read_entry(&path)?;
    let plaintext = keys.decrypt(&edited, &mut provider)?;
    assert_eq!(plaintext.expose(), b"edited\nlogin: replacement\n");

    let session = writer.begin_edit(&request, &mut provider)?;
    committer.fail = true;
    assert!(matches!(
        writer.finish_edit(
            session,
            SecretBytes::new(b"must roll back".to_vec()),
            "fixture-editor",
            None,
            &mut committer,
        ),
        Err(WriteError::Commit(_))
    ));
    assert_eq!(repository.read_entry(&path)?, edited);
    Ok(())
}

#[test]
fn edit_detects_concurrent_ciphertext_change_before_writing() -> 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 writer = VaultWriter::new(&repository, &keys);
    let request = EditRequest {
        entry: "email/personal".to_owned(),
    };
    let path = EntryPath::parse("email/personal")?;
    let mut provider = FixtureSecrets::all(&fixture);
    let session = writer.begin_edit(&request, &mut provider)?;
    let mut committer = RecordingCommitter::default();
    writer.insert(
        &insert("email/personal", true, InsertInput::EchoedLine),
        InsertContent::echoed(b"concurrent".to_vec())?,
        OverwriteDecision::Allow,
        None,
        &mut committer,
    )?;
    let concurrent = repository.read_entry(&path)?;

    assert!(matches!(
        writer.finish_edit(
            session,
            SecretBytes::new(b"stale editor".to_vec()),
            "fixture-editor",
            None,
            &mut committer,
        ),
        Err(WriteError::ConcurrentModification { .. })
    ));
    assert_eq!(repository.read_entry(&path)?, concurrent);
    Ok(())
}

#[test]
fn edit_session_can_create_a_new_entry_and_detect_one_appearing_concurrently() -> 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 writer = VaultWriter::new(&repository, &keys);
    let request = EditRequest {
        entry: "new/from-editor".to_owned(),
    };
    let mut provider = FixtureSecrets::all(&fixture);
    let session = writer.begin_edit(&request, &mut provider)?;
    assert!(session.plaintext().expose().is_empty());
    let mut committer = RecordingCommitter::default();
    writer.finish_edit(
        session,
        SecretBytes::new(b"created by editor\n".to_vec()),
        "fixture-editor",
        None,
        &mut committer,
    )?;
    assert!(store.path().join("new/from-editor.gpg").is_file());

    let concurrent_request = EditRequest {
        entry: "new/concurrent-editor".to_owned(),
    };
    let session = writer.begin_edit(&concurrent_request, &mut provider)?;
    writer.insert(
        &insert("new/concurrent-editor", false, InsertInput::EchoedLine),
        InsertContent::echoed(b"appeared".to_vec())?,
        OverwriteDecision::Allow,
        None,
        &mut committer,
    )?;
    assert!(matches!(
        writer.finish_edit(
            session,
            SecretBytes::new(b"stale".to_vec()),
            "fixture-editor",
            None,
            &mut committer,
        ),
        Err(WriteError::ConcurrentModification { .. })
    ));
    Ok(())
}

#[test]
fn invalid_recipient_policy_fails_before_entry_creation() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    fs::create_dir(store.path().join("invalid"))?;
    fs::write(
        store.path().join("invalid/.gpg-id"),
        b"missing@ironstorage.invalid\n",
    )?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let writer = VaultWriter::new(&repository, &keys);

    assert!(matches!(
        writer.insert(
            &insert("invalid/entry", false, InsertInput::Multiline),
            InsertContent::multiline(b"secret".to_vec()),
            OverwriteDecision::Allow,
            None,
            &mut RecordingCommitter::default(),
        ),
        Err(WriteError::RecipientPolicy(
            ironstorage::recipient::RecipientPolicyError::Crypto(
                CryptoError::MissingIdentity { .. }
            )
        ))
    ));
    assert!(!store.path().join("invalid/entry.gpg").exists());
    Ok(())
}

fn insert(entry: &str, force: bool, input: InsertInput) -> InsertRequest {
    InsertRequest {
        entry: entry.to_owned(),
        input,
        force,
    }
}
