#![forbid(unsafe_code)]

mod support;

use std::{collections::BTreeMap, num::NonZeroUsize};

use ironstorage::{
    command::{GenerateRequest, GeneratedPresentation},
    crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
    generate::{
        GenerateError, GeneratedChannel, GeneratorConfig, MAX_PASSWORD_LENGTH, PasswordGenerator,
    },
    repository::{EntryPath, Repository, SecretBytes},
    write::{EntryCommit, EntryCommitError, EntryCommitter, OverwriteDecision, WriteError},
};
use rand_chacha::{
    ChaCha20Rng,
    rand_core::{CryptoRng, Error as RandError, RngCore, SeedableRng},
};
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 Committer {
    changes: Vec<EntryCommit>,
    fail: bool,
}

struct FailingRng;

impl RngCore for FailingRng {
    fn next_u32(&mut self) -> u32 {
        0
    }

    fn next_u64(&mut self) -> u64 {
        0
    }

    fn fill_bytes(&mut self, _destination: &mut [u8]) {
        panic!("fallible generator must use try_fill_bytes")
    }

    fn try_fill_bytes(&mut self, _destination: &mut [u8]) -> Result<(), RandError> {
        Err(RandError::new("simulated unavailable randomness"))
    }
}

impl CryptoRng for FailingRng {}

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

#[test]
fn deterministic_generation_uses_only_requested_characters_and_length() -> 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 service = PasswordGenerator::new(&repository, &keys, GeneratorConfig::new(12, "abc123")?);
    let mut rng = ChaCha20Rng::from_seed([0x81; 32]);
    let mut provider = FixtureSecrets::all(&fixture);
    let mut committer = Committer::default();

    let outcome = service.generate_with_rng(
        &request(
            "generated/custom",
            Some(64),
            false,
            false,
            false,
            GeneratedPresentation::Terminal,
        ),
        OverwriteDecision::Allow,
        None,
        &mut provider,
        &mut committer,
        &mut rng,
    )?;
    assert_eq!(outcome.password().expose().len(), 64);
    assert!(
        outcome
            .password()
            .expose()
            .iter()
            .all(|byte| b"abc123".contains(byte))
    );
    assert_eq!(outcome.channel(), GeneratedChannel::Terminal);
    assert!(!format!("{outcome:?}").contains(std::str::from_utf8(outcome.password().expose())?));
    assert_eq!(
        committer.changes[0].message(),
        "Add generated password for generated/custom."
    );
    Ok(())
}

#[test]
fn in_process_generation_returns_a_zeroizing_unstored_secret() -> TestResult {
    let config = GeneratorConfig::new(12, "abc123")?;
    let generated = config.generate_secret(NonZeroUsize::new(48), false)?;
    assert_eq!(generated.expose().len(), 48);
    assert!(
        generated
            .expose()
            .iter()
            .all(|byte| b"abc123".contains(byte))
    );
    assert!(!format!("{generated:?}").contains(std::str::from_utf8(generated.expose())?));
    Ok(())
}

#[test]
fn default_no_symbols_and_presentation_actions_are_typed() -> 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 service = PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults());
    let mut provider = FixtureSecrets::all(&fixture);
    let mut committer = Committer::default();
    let mut rng = ChaCha20Rng::from_seed([0x82; 32]);

    for (path, presentation, channel) in [
        (
            "generated/clip",
            GeneratedPresentation::Clipboard,
            GeneratedChannel::Clipboard,
        ),
        (
            "generated/qr",
            GeneratedPresentation::QrCode,
            GeneratedChannel::QrCode,
        ),
    ] {
        let outcome = service.generate_with_rng(
            &request(path, None, true, false, false, presentation),
            OverwriteDecision::Allow,
            None,
            &mut provider,
            &mut committer,
            &mut rng,
        )?;
        assert_eq!(outcome.password().expose().len(), 25);
        assert!(
            outcome
                .password()
                .expose()
                .iter()
                .all(u8::is_ascii_alphanumeric)
        );
        assert_eq!(outcome.channel(), channel);
    }
    Ok(())
}

#[test]
fn in_place_replaces_only_first_line_and_preserves_suffix_bytes() -> 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 service = PasswordGenerator::new(&repository, &keys, GeneratorConfig::new(8, "XYZ")?);
    let mut provider = FixtureSecrets::all(&fixture);
    let mut committer = Committer::default();
    let mut rng = ChaCha20Rng::from_seed([0x83; 32]);
    let original = fixture.read("expected/basic/email/personal.txt")?;
    let suffix = &original[original
        .iter()
        .position(|byte| *byte == b'\n')
        .expect("newline")..];

    let outcome = service.generate_with_rng(
        &request(
            "email/personal",
            Some(8),
            false,
            false,
            true,
            GeneratedPresentation::Terminal,
        ),
        OverwriteDecision::Decline,
        None,
        &mut provider,
        &mut committer,
        &mut rng,
    )?;
    let plaintext = keys.decrypt(
        &repository.read_entry(&EntryPath::parse("email/personal")?)?,
        &mut provider,
    )?;
    assert_eq!(&plaintext.expose()[8..], suffix);
    assert_eq!(&plaintext.expose()[..8], outcome.password().expose());
    Ok(())
}

#[test]
fn validates_lengths_character_sets_and_incompatible_flags() -> TestResult {
    assert!(matches!(
        GeneratorConfig::new(0, "abc"),
        Err(GenerateError::InvalidLength)
    ));
    assert!(matches!(
        GeneratorConfig::new(MAX_PASSWORD_LENGTH + 1, "abc"),
        Err(GenerateError::InvalidLength)
    ));
    assert!(matches!(
        GeneratorConfig::new(10, ""),
        Err(GenerateError::EmptyCharacterSet)
    ));
    assert!(matches!(
        GeneratorConfig::new(10, "aab"),
        Err(GenerateError::InvalidCharacterSet)
    ));
    assert!(matches!(
        GeneratorConfig::new(10, "a\nb"),
        Err(GenerateError::InvalidCharacterSet)
    ));

    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    let service = PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults());
    let mut provider = FixtureSecrets::all(&fixture);
    let mut rng = ChaCha20Rng::from_seed([0x84; 32]);
    assert!(matches!(
        service.generate_with_rng(
            &request(
                "entry",
                Some(8),
                false,
                true,
                true,
                GeneratedPresentation::Terminal
            ),
            OverwriteDecision::Allow,
            None,
            &mut provider,
            &mut Committer::default(),
            &mut rng,
        ),
        Err(GenerateError::IncompatibleFlags)
    ));
    assert!(matches!(
        service.generate_with_rng(
            &request(
                "randomness-failure",
                Some(8),
                false,
                false,
                false,
                GeneratedPresentation::Terminal,
            ),
            OverwriteDecision::Allow,
            None,
            &mut provider,
            &mut Committer::default(),
            &mut FailingRng,
        ),
        Err(GenerateError::RandomnessUnavailable)
    ));
    assert!(!store.path().join("randomness-failure.gpg").exists());
    Ok(())
}

#[test]
fn decline_missing_in_place_and_commit_failure_leave_entries_unchanged() -> 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 service = PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults());
    let path = EntryPath::parse("email/personal")?;
    let original = repository.read_entry(&path)?;
    let mut provider = FixtureSecrets::all(&fixture);
    let mut rng = ChaCha20Rng::from_seed([0x85; 32]);

    assert!(matches!(
        service.generate_with_rng(
            &request(
                "email/personal",
                Some(8),
                false,
                false,
                false,
                GeneratedPresentation::Terminal
            ),
            OverwriteDecision::Decline,
            None,
            &mut provider,
            &mut Committer::default(),
            &mut rng,
        ),
        Err(GenerateError::Write(WriteError::Cancelled))
    ));
    assert_eq!(repository.read_entry(&path)?, original);
    assert!(matches!(
        service.generate_with_rng(
            &request(
                "missing",
                Some(8),
                false,
                false,
                true,
                GeneratedPresentation::Terminal
            ),
            OverwriteDecision::Allow,
            None,
            &mut provider,
            &mut Committer::default(),
            &mut rng,
        ),
        Err(GenerateError::Repository(_))
    ));

    let mut failing = Committer {
        fail: true,
        ..Committer::default()
    };
    assert!(matches!(
        service.generate_with_rng(
            &request(
                "email/personal",
                Some(8),
                false,
                true,
                false,
                GeneratedPresentation::Terminal
            ),
            OverwriteDecision::Allow,
            None,
            &mut provider,
            &mut failing,
            &mut rng,
        ),
        Err(GenerateError::Write(WriteError::Commit(_)))
    ));
    assert_eq!(repository.read_entry(&path)?, original);
    Ok(())
}

fn request(
    entry: &str,
    length: Option<usize>,
    no_symbols: bool,
    force: bool,
    in_place: bool,
    presentation: GeneratedPresentation,
) -> GenerateRequest {
    GenerateRequest {
        entry: entry.to_owned(),
        length: length.map(|length| NonZeroUsize::new(length).expect("test length is nonzero")),
        no_symbols,
        force,
        in_place,
        presentation,
    }
}
