#![forbid(unsafe_code)]

mod support;

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

use ironstorage::{
    crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
    document::EntryDocumentService,
    mobile_totp::{
        MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService,
        MobileWatchSnapshotState,
    },
    otp::OtpError,
    recipient::RecipientPolicyManager,
    repository::{EncryptedEntry, EntryPath, Repository, SecretBytes},
};
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)
    }
}

struct CountingSecrets {
    inner: FixtureSecrets,
    requests: usize,
}

impl CountingSecrets {
    fn all(fixture: &FixtureSet) -> Self {
        Self {
            inner: FixtureSecrets::all(fixture),
            requests: 0,
        }
    }
}

impl SecretProvider for CountingSecrets {
    fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
        self.requests += 1;
        self.inner.secret_for(key)
    }
}

struct CancellingSecrets<'a> {
    inner: FixtureSecrets,
    operation: &'a MobileTotpOperation,
    requests: usize,
}

struct MutatingSecrets<'a> {
    inner: FixtureSecrets,
    repository: &'a Repository,
    path: EntryPath,
    replacement: Option<EncryptedEntry>,
}

impl SecretProvider for MutatingSecrets<'_> {
    fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
        let secret = self.inner.secret_for(key)?;
        if let Some(replacement) = self.replacement.take() {
            self.repository
                .write_entry(&self.path, &replacement)
                .expect("fixture mutation succeeds");
        }
        Ok(secret)
    }
}

impl SecretProvider for CancellingSecrets<'_> {
    fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
        self.requests += 1;
        let secret = self.inner.secret_for(key)?;
        if self.requests == 1 {
            self.operation.cancel();
        }
        Ok(secret)
    }
}

#[test]
fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    write_plaintext(
        &repository,
        &keys,
        "otp/alice",
        b"password\notpauth://totp/Acme:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme&digits=8&period=30\n",
    )?;
    write_plaintext(
        &repository,
        &keys,
        "otp/counter",
        b"otpauth://hotp/Counter?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0\n",
    )?;
    write_plaintext(&repository, &keys, "ordinary", b"password\nlogin: alice\n")?;

    let service = MobileTotpService::new(&repository, &keys);
    let mut secrets = FixtureSecrets::all(&fixture);
    let page = service.page(&BTreeSet::new(), &mut secrets)?;
    let alice = page
        .rows()
        .iter()
        .find(|row| row.path() == "otp/alice")
        .expect("inserted TOTP row");
    assert_eq!(alice.issuer(), Some("Acme"));
    assert_eq!(alice.account(), "alice@example.com");
    assert!(!alice.shared_with_watch());
    assert!(page.rows().iter().all(|row| row.path() != "otp/counter"));
    assert_eq!(page.watch().state(), MobileWatchSnapshotState::Unavailable);
    assert!(!format!("{page:?}").contains("94287082"));

    let detail = service.detail("otp/alice", 59, &BTreeSet::new(), &mut secrets)?;
    assert_eq!(detail.code().expose(), b"94287082");
    assert_eq!(detail.valid_until(), 60);
    assert_eq!(detail.period(), 30);
    assert!(!format!("{detail:?}").contains("94287082"));

    let selected = BTreeSet::from([EntryPath::parse("otp/alice")?]);
    let page = service.page(&selected, &mut secrets)?;
    assert!(
        page.rows()
            .iter()
            .find(|row| row.path() == "otp/alice")
            .expect("selected TOTP row")
            .shared_with_watch()
    );
    assert_eq!(page.watch().state(), MobileWatchSnapshotState::Pending);
    let detail = service.detail("otp/alice", 60, &selected, &mut secrets)?;
    assert!(detail.shared_with_watch());
    assert_eq!(detail.valid_until(), 90);
    Ok(())
}

#[test]
fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResult {
    let fixture = FixtureSet::load()?;
    let store = fixture.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let keys = KeyStore::load(fixture.path("keys"))?;
    write_plaintext(
        &repository,
        &keys,
        "otp/alice",
        b"password\notpauth://totp/Acme:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme\n",
    )?;
    for path in ["team-a", "team-a/shared", "team-b/shared"] {
        write_plaintext(
            &repository,
            &keys,
            path,
            b"otpauth://totp/Shared?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n",
        )?;
    }
    write_plaintext(&repository, &keys, "ordinary", b"password\nlogin: alice\n")?;
    let cache_directory = tempfile::tempdir()?;
    let cache = cache_directory.path().join("totp-catalog.toml");
    let service = MobileTotpService::new(&repository, &keys);

    let cold = MobileTotpOperation::default();
    let mut cold_secrets = CountingSecrets::all(&fixture);
    let page = service.discover(&BTreeSet::new(), &mut cold_secrets, &cache, &cold)?;
    assert!(page.rows().iter().any(|row| row.path() == "otp/alice"));
    assert!(page.rows().iter().any(|row| row.path() == "team-a"));
    assert!(page.rows().iter().any(|row| row.path() == "team-a/shared"));
    assert!(page.rows().iter().any(|row| row.path() == "team-b/shared"));
    assert_eq!(cold.progress().phase(), MobileTotpDiscoveryPhase::Complete);
    assert_eq!(cold.progress().inspected(), cold.progress().total());
    assert!(cold_secrets.requests > 0);

    let encoded = fs::read_to_string(&cache)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;

        assert_eq!(fs::metadata(&cache)?.permissions().mode() & 0o777, 0o600);
    }
    assert!(encoded.contains("is_totp = true"));
    assert!(encoded.contains("ciphertext_hash ="));
    assert!(encoded.contains("otp/alice"));
    for secret in [
        "otpauth://",
        "secret=",
        "alice@example.com",
        "Acme",
        "password",
    ] {
        assert!(!encoded.contains(secret), "cache leaked {secret}");
    }

    let warm = MobileTotpOperation::default();
    let mut warm_secrets = CountingSecrets::all(&fixture);
    let warm_page = service.discover(&BTreeSet::new(), &mut warm_secrets, &cache, &warm)?;
    assert_eq!(warm_secrets.requests, 0);
    assert_eq!(warm.progress().cache_hits(), warm.progress().total());
    assert_eq!(warm_page.rows(), page.rows());
    assert_eq!(
        service
            .cached_page(&BTreeSet::new(), &cache)
            .expect("created cache")
            .rows(),
        page.rows()
    );
    assert_eq!(
        service
            .search_cached_page(&BTreeSet::new(), &cache, " TEAM-A ")
            .expect("created cache")
            .rows()
            .iter()
            .map(|row| row.path())
            .collect::<Vec<_>>(),
        ["team-a/shared", "team-a"]
    );
    assert!(
        service
            .search_cached_page(&BTreeSet::new(), &cache, "missing")
            .expect("created cache")
            .rows()
            .is_empty()
    );

    write_plaintext(
        &repository,
        &keys,
        "ordinary",
        b"otpauth://totp/New:new@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=New\n",
    )?;
    let changed = MobileTotpOperation::default();
    let mut changed_secrets = CountingSecrets::all(&fixture);
    let changed_page =
        service.discover(&BTreeSet::new(), &mut changed_secrets, &cache, &changed)?;
    assert_eq!(changed_secrets.requests, 1);
    assert_eq!(
        changed.progress().cache_hits() + 1,
        changed.progress().total()
    );
    assert!(
        changed_page
            .rows()
            .iter()
            .any(|row| row.path() == "ordinary")
    );

    repository.remove_entry(&EntryPath::parse("otp/alice")?)?;
    let deleted = MobileTotpOperation::default();
    let mut deleted_secrets = CountingSecrets::all(&fixture);
    let deleted_page =
        service.discover(&BTreeSet::new(), &mut deleted_secrets, &cache, &deleted)?;
    assert_eq!(deleted_secrets.requests, 0);
    assert!(
        deleted_page
            .rows()
            .iter()
            .all(|row| row.path() != "otp/alice")
    );
    assert!(
        service
            .cached_page(&BTreeSet::new(), &cache)
            .expect("updated cache")
            .rows()
            .iter()
            .all(|row| row.path() != "otp/alice")
    );

    let cancelled = MobileTotpOperation::default();
    cancelled.cancel();
    let mut cancelled_secrets = CountingSecrets::all(&fixture);
    assert!(matches!(
        service.discover(&BTreeSet::new(), &mut cancelled_secrets, &cache, &cancelled,),
        Err(MobileTotpError::Otp(OtpError::Cancelled))
    ));
    assert_eq!(
        cancelled.progress().phase(),
        MobileTotpDiscoveryPhase::Cancelled
    );

    fs::write(&cache, "not a TOTP catalog")?;
    let recovery = MobileTotpOperation::default();
    let mut recovery_secrets = CountingSecrets::all(&fixture);
    let recovered = service.discover(&BTreeSet::new(), &mut recovery_secrets, &cache, &recovery)?;
    assert!(recovered.cache_notice().is_some());
    assert!(service.cached_page(&BTreeSet::new(), &cache).is_some());

    let other_store = fixture.materialize_store("nested")?;
    let other_repository = Repository::open(other_store.path())?;
    assert!(
        MobileTotpService::new(&other_repository, &keys)
            .cached_page(&BTreeSet::new(), &cache)
            .is_none()
    );
    Ok(())
}

#[test]
fn totp_cache_survives_application_container_relocation() -> TestResult {
    let fixture = FixtureSet::load()?;
    let application = tempfile::tempdir()?;
    let installed = application.path().join("installed");
    fs::create_dir(&installed)?;
    let vault = installed.join("vault");
    fs::rename(fixture.materialize_store("basic")?.keep(), &vault)?;
    let cache = installed.join("totp-catalog.toml");
    let keys = KeyStore::load(fixture.path("keys"))?;
    let expected = {
        let repository = Repository::open(&vault)?;
        let service = MobileTotpService::new(&repository, &keys);
        let operation = MobileTotpOperation::default();
        let mut secrets = CountingSecrets::all(&fixture);
        service.discover(&BTreeSet::new(), &mut secrets, &cache, &operation)?
    };

    let updated = application.path().join("updated");
    fs::rename(&installed, &updated)?;
    let repository = Repository::open(updated.join("vault"))?;
    let service = MobileTotpService::new(&repository, &keys);
    let relocated_cache = updated.join("totp-catalog.toml");
    assert_eq!(
        service
            .cached_page(&BTreeSet::new(), &relocated_cache)
            .expect("relocated cache remains valid")
            .rows(),
        expected.rows()
    );
    let operation = MobileTotpOperation::default();
    let mut secrets = CountingSecrets::all(&fixture);
    service.discover(&BTreeSet::new(), &mut secrets, &relocated_cache, &operation)?;
    assert_eq!(secrets.requests, 0);
    assert_eq!(
        operation.progress().cache_hits(),
        operation.progress().total()
    );
    Ok(())
}

#[test]
fn decrypted_entry_details_reconcile_totp_cache_without_persisting_secrets() -> 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 path = EntryPath::parse("otp/detail")?;
    write_plaintext(
        &repository,
        &keys,
        "otp/detail",
        b"password\notpauth://totp/Acme:detail@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme&digits=8&period=30\n",
    )?;
    let cache_directory = tempfile::tempdir()?;
    let cache = cache_directory.path().join("totp-catalog.toml");
    let service = MobileTotpService::new(&repository, &keys);
    let mut secrets = FixtureSecrets::all(&fixture);
    let document =
        EntryDocumentService::new(&repository, &keys).open("otp/detail", &mut secrets)?;
    let ciphertext = repository.read_entry(&path)?;

    let reconciliation =
        service.reconcile_document(&document, &ciphertext, Some(59), &BTreeSet::new(), &cache)?;
    let detail = reconciliation.detail().expect("valid TOTP detail");
    assert_eq!(detail.code().expose(), b"94287082");
    assert_eq!(detail.valid_until(), 60);
    assert!(
        service
            .cached_page(&BTreeSet::new(), &cache)
            .expect("detail created cache")
            .rows()
            .iter()
            .any(|row| row.path() == "otp/detail")
    );
    let encoded = fs::read_to_string(&cache)?;
    assert!(encoded.contains("otp/detail"));
    assert!(encoded.contains("is_totp = true"));
    for secret in ["otpauth://", "secret=", "detail@example.com", "password"] {
        assert!(!encoded.contains(secret), "cache leaked {secret}");
    }

    write_plaintext(
        &repository,
        &keys,
        "otp/detail",
        b"password\nlogin: detail\n",
    )?;
    let mut secrets = FixtureSecrets::all(&fixture);
    let document =
        EntryDocumentService::new(&repository, &keys).open("otp/detail", &mut secrets)?;
    let ciphertext = repository.read_entry(&path)?;
    let reconciliation =
        service.reconcile_document(&document, &ciphertext, Some(59), &BTreeSet::new(), &cache)?;
    assert!(reconciliation.detail().is_none());
    assert!(
        service
            .cached_page(&BTreeSet::new(), &cache)
            .expect("detail updated cache")
            .rows()
            .iter()
            .all(|row| row.path() != "otp/detail")
    );
    assert!(fs::read_to_string(cache)?.contains("is_totp = false"));
    Ok(())
}

#[test]
fn cancelled_discovery_checkpoints_completed_entries() -> 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 cache_directory = tempfile::tempdir()?;
    let cache = cache_directory.path().join("totp-catalog.toml");
    let service = MobileTotpService::new(&repository, &keys);
    let interrupted = MobileTotpOperation::default();
    let mut secrets = CancellingSecrets {
        inner: FixtureSecrets::all(&fixture),
        operation: &interrupted,
        requests: 0,
    };

    assert!(matches!(
        service.discover(&BTreeSet::new(), &mut secrets, &cache, &interrupted,),
        Err(MobileTotpError::Otp(OtpError::Cancelled))
    ));
    assert!(cache.is_file());

    let resumed = MobileTotpOperation::default();
    let mut resumed_secrets = CountingSecrets::all(&fixture);
    service.discover(&BTreeSet::new(), &mut resumed_secrets, &cache, &resumed)?;
    assert!(resumed.progress().cache_hits() >= 1);
    assert!(resumed_secrets.requests < resumed.progress().total() as usize);
    Ok(())
}

#[test]
fn discovery_rejects_a_result_when_ciphertext_changes_mid_scan() -> 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 path = EntryPath::parse("ordinary")?;
    write_plaintext(&repository, &keys, "ordinary", b"password\n")?;
    let recipients =
        RecipientPolicyManager::new(&repository, &keys).resolve_for_entry(&path, None)?;
    let replacement = keys.encrypt(
        SecretBytes::new(b"changed password\n".to_vec()),
        recipients.recipients(),
    )?;
    let cache_directory = tempfile::tempdir()?;
    let cache = cache_directory.path().join("totp-catalog.toml");
    let operation = MobileTotpOperation::default();
    let mut secrets = MutatingSecrets {
        inner: FixtureSecrets::all(&fixture),
        repository: &repository,
        path,
        replacement: Some(replacement),
    };

    assert!(matches!(
        MobileTotpService::new(&repository, &keys).discover(
            &BTreeSet::new(),
            &mut secrets,
            &cache,
            &operation,
        ),
        Err(MobileTotpError::ConcurrentModification)
    ));
    assert_ne!(
        operation.progress().phase(),
        MobileTotpDiscoveryPhase::Complete
    );
    Ok(())
}

fn write_plaintext(
    repository: &Repository,
    keys: &KeyStore,
    path: &str,
    plaintext: &[u8],
) -> TestResult {
    let path = EntryPath::parse(path)?;
    let recipients =
        RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?;
    let encrypted = keys.encrypt(
        SecretBytes::new(plaintext.to_vec()),
        recipients.recipients(),
    )?;
    repository.write_entry(&path, &encrypted)?;
    Ok(())
}
