#![forbid(unsafe_code)]

mod support;

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

use ironstorage::{
    crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
    document::{
        DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity,
    },
    mobile_entry::{
        MobileEntryDraft, MobileEntryEditorError, MobileEntryEditorFieldKind,
        MobileEntryEditorInput, MobileEntryPage, MobileEntrySectionKind, field_value,
    },
    recipient::RecipientPolicyManager,
    repository::{EntryPath, Repository, SecretBytes},
    write::{EntryCommit, EntryCommitError, EntryCommitter, 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 Committer {
    changes: Vec<EntryCommit>,
    fail: bool,
}

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

#[test]
fn complex_documents_round_trip_with_storage_owned_metadata() -> 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 secrets = FixtureSecrets::all(&fixture);
    let plaintext = b"p\xc3\xa4ssw\xc3\xb6rd\r\nusername: alice\r\nautoType_enabled: true\r\nicon: Internet\r\ncustom: one\r\ncustom: \r\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\r\n\r\nfirst note\r\nsecond note\r\n\xe9\x8d\xb5: \xe5\x80\xbc\r\nunrecognized line";
    write_plaintext(&repository, &keys, "documents/complex", plaintext)?;

    let service = EntryDocumentService::new(&repository, &keys);
    let document = service.open("documents/complex", &mut secrets)?;
    assert_eq!(document.serialize().expose(), plaintext);
    assert_eq!(
        document.password().expect("password").value(),
        "pässwörd".as_bytes()
    );
    assert_eq!(document.fields().len(), 9);

    let kinds = document
        .fields()
        .iter()
        .map(|field| field.metadata().kind())
        .collect::<Vec<_>>();
    assert_eq!(
        kinds,
        [
            EntryFieldKind::Password,
            EntryFieldKind::Username,
            EntryFieldKind::Field,
            EntryFieldKind::Field,
            EntryFieldKind::Field,
            EntryFieldKind::Field,
            EntryFieldKind::OtpUri,
            EntryFieldKind::Note,
            EntryFieldKind::Field,
        ]
    );
    assert_eq!(document.fields()[1].metadata().name(), Some("username"));
    assert_eq!(
        document.fields()[1].metadata().sensitivity(),
        EntrySensitivity::Ordinary
    );
    for (index, name, value) in [
        (2, "autoType_enabled", b"true".as_slice()),
        (3, "icon", b"Internet".as_slice()),
    ] {
        assert_eq!(document.fields()[index].metadata().name(), Some(name));
        assert_eq!(document.fields()[index].value(), value);
        assert_eq!(
            document.fields()[index].metadata().sensitivity(),
            EntrySensitivity::Ordinary
        );
    }
    assert_eq!(document.fields()[4].metadata().name(), Some("custom"));
    assert_eq!(document.fields()[4].value(), b"one");
    assert_eq!(document.fields()[5].metadata().name(), Some("custom"));
    assert!(document.fields()[5].value().is_empty());
    assert_ne!(document.fields()[4].id(), document.fields()[5].id());
    assert_eq!(document.fields()[8].metadata().name(), Some("鍵"));
    assert_eq!(
        document.fields()[8].value(),
        "值\r\nunrecognized line".as_bytes()
    );
    assert_eq!(
        document.fields()[7].value(),
        b"\r\nfirst note\r\nsecond note"
    );
    assert_eq!(
        document.fields()[6].metadata().sensitivity(),
        EntrySensitivity::Sensitive
    );
    assert_eq!(
        document
            .fields()
            .iter()
            .filter(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive)
            .map(|field| field.metadata().kind())
            .collect::<Vec<_>>(),
        [EntryFieldKind::Password, EntryFieldKind::OtpUri]
    );
    let otp = document.fields()[6]
        .metadata()
        .otp()
        .expect("validated OTP metadata");
    assert_eq!(otp.kind(), ironstorage::otp::OtpKind::Totp);
    assert_eq!(otp.issuer(), Some("Example"));
    assert_eq!(otp.account(), "alice");
    assert_eq!(otp.algorithm(), ironstorage::otp::OtpAlgorithm::Sha1);
    assert_eq!(otp.digits(), 6);
    assert_eq!(otp.period(), Some(30));
    assert_eq!(otp.counter(), None);
    let copied = document.copy_field_value(document.fields()[4].id())?;
    assert_eq!(copied.expose(), b"one");
    let mobile = MobileEntryPage::from_document(&document);
    let mobile_fields = mobile
        .sections()
        .iter()
        .flat_map(|section| section.fields())
        .collect::<Vec<_>>();
    assert_eq!(mobile.title(), "complex");
    assert_eq!(mobile_fields.len(), document.fields().len());
    assert_eq!(mobile_fields[0].label(), "Password");
    assert!(mobile_fields[0].sensitive());
    assert_eq!(mobile_fields[0].value(), None);
    assert_eq!(mobile_fields[1].value(), Some("alice"));
    let mobile_custom = mobile_fields
        .iter()
        .filter(|field| field.label() == "Custom")
        .copied()
        .collect::<Vec<_>>();
    assert_eq!(mobile_custom.len(), 2);
    assert_ne!(mobile_custom[0].id(), mobile_custom[1].id());
    assert_eq!(mobile_custom[0].value(), Some("one"));
    assert_eq!(mobile_custom[1].value(), Some(""));
    let mobile_otp = mobile_fields
        .iter()
        .find(|field| field.system_image() == "timer")
        .expect("OTP field");
    assert!(mobile_otp.sensitive());
    assert_eq!(mobile_otp.value(), None);
    assert!(mobile_otp.detail().is_some_and(|detail| {
        detail.contains("TOTP") && detail.contains("Example") && detail.contains("alice")
    }));
    let mobile_note = mobile_fields
        .iter()
        .find(|field| field.system_image() == "note.text")
        .expect("multiline note");
    assert!(!mobile_note.sensitive());
    assert!(mobile_note.multiline());
    assert!(mobile_note.selectable());
    assert_eq!(mobile_note.value(), Some("\r\nfirst note\r\nsecond note"));
    assert_eq!(
        field_value(&document, document.fields()[4].id().value())?,
        "one"
    );
    assert!(!format!("{document:?}").contains("pässwörd"));
    assert!(!format!("{:?}", document.fields()[6]).contains("JBSWY3DPEHPK3PXP"));
    Ok(())
}

#[test]
fn mobile_projection_preserves_partially_understood_fields_without_exposing_them() -> 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 secrets = FixtureSecrets::all(&fixture);
    write_plaintext(
        &repository,
        &keys,
        "documents/diagnostics",
        b"password\notpauth://totp/broken\ncustom: \xff\n",
    )?;

    let document = EntryDocumentService::new(&repository, &keys)
        .open("documents/diagnostics", &mut secrets)?;
    let page = MobileEntryPage::from_document(&document);
    let fields = page
        .sections()
        .iter()
        .flat_map(|section| section.fields())
        .collect::<Vec<_>>();
    assert_eq!(fields.len(), 3);
    assert_eq!(
        fields
            .iter()
            .filter(|field| field.diagnostic().is_some())
            .count(),
        2
    );
    assert!(fields.iter().all(|field| field.value().is_none()));
    assert!(field_value(&document, document.fields()[2].id().value()).is_err());
    assert_eq!(
        document.serialize().expose(),
        b"password\notpauth://totp/broken\ncustom: \xff\n"
    );
    Ok(())
}

#[test]
fn mobile_projection_marks_an_empty_first_line_without_a_reveal_control() -> 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 secrets = FixtureSecrets::all(&fixture);
    write_plaintext(
        &repository,
        &keys,
        "documents/empty-password",
        b"\ncomments: ordinary note\n",
    )?;

    let document = EntryDocumentService::new(&repository, &keys)
        .open("documents/empty-password", &mut secrets)?;
    let password = document.password().expect("empty password field");
    assert_eq!(password.metadata().sensitivity(), EntrySensitivity::Empty);
    let page = MobileEntryPage::from_document(&document);
    let password = page.sections()[0].fields().first().expect("password row");
    assert_eq!(password.label(), "Password");
    assert_eq!(password.value(), None);
    assert_eq!(password.masked_value(), "Empty");
    assert!(!password.sensitive());
    assert_eq!(
        document.serialize().expose(),
        b"\ncomments: ordinary note\n"
    );
    Ok(())
}

#[test]
fn pin_fields_are_sensitive_case_insensitively() -> 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 secrets = FixtureSecrets::all(&fixture);
    write_plaintext(
        &repository,
        &keys,
        "documents/pins",
        b"password\nPIN: 1234\npin: 5678\nPiN: 9012\n",
    )?;

    let document =
        EntryDocumentService::new(&repository, &keys).open("documents/pins", &mut secrets)?;
    let pins = &document.fields()[1..];
    assert!(pins.iter().all(|field| {
        field.metadata().kind() == EntryFieldKind::Field
            && field.metadata().sensitivity() == EntrySensitivity::Sensitive
    }));

    let page = MobileEntryPage::from_document(&document);
    let projected = page
        .sections()
        .iter()
        .flat_map(|section| section.fields())
        .filter(|field| field.id() != 0)
        .collect::<Vec<_>>();
    assert_eq!(projected.len(), 3);
    assert!(projected.iter().all(|field| {
        field.sensitive()
            && field.value().is_none()
            && field.masked_value() == "Hidden"
            && !field.selectable()
    }));
    assert_eq!(field_value(&document, pins[0].id().value())?, "1234");

    let editor = MobileEntryDraft::new(document, false).page();
    assert!(
        editor.fields()[1..]
            .iter()
            .all(|field| field.sensitive() && field.masked_value() == "Hidden")
    );
    Ok(())
}

#[test]
fn named_multiline_fields_are_one_lossless_logical_field() -> 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 secrets = FixtureSecrets::all(&fixture);
    let plaintext = b"password\ncomments: Recovery codes:\none\ntwo\nurl: https://example.test\n";
    write_plaintext(&repository, &keys, "documents/multiline", plaintext)?;

    let service = EntryDocumentService::new(&repository, &keys);
    let mut document = service.open("documents/multiline", &mut secrets)?;
    assert_eq!(document.serialize().expose(), plaintext);
    assert_eq!(document.fields().len(), 3);
    let comments = &document.fields()[1];
    assert_eq!(comments.metadata().name(), Some("comments"));
    assert_eq!(comments.value(), b"Recovery codes:\none\ntwo");
    assert_eq!(
        document.copy_field_value(comments.id())?.expose(),
        b"Recovery codes:\none\ntwo"
    );

    document.replace_field_value(comments.id(), b"New heading\nalpha\nbeta".to_vec())?;
    assert_eq!(
        document.serialize().expose(),
        b"password\ncomments: New heading\n alpha\n beta\nurl: https://example.test\n"
    );
    Ok(())
}

#[test]
fn only_whitespace_free_names_end_multiline_fields() -> 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 secrets = FixtureSecrets::all(&fixture);
    let plaintext = "password\ncomments: PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n\nautotype_enabled: True\nicon: 0\ntags: travel, train\n";
    write_plaintext(
        &repository,
        &keys,
        "documents/comments",
        plaintext.as_bytes(),
    )?;

    let document =
        EntryDocumentService::new(&repository, &keys).open("documents/comments", &mut secrets)?;
    assert_eq!(document.serialize().expose(), plaintext.as_bytes());
    assert_eq!(document.fields().len(), 5);
    assert_eq!(document.fields()[1].metadata().name(), Some("comments"));
    assert_eq!(
        document.fields()[1].metadata().sensitivity(),
        EntrySensitivity::Ordinary
    );
    assert_eq!(
        document.fields()[1].value(),
        "PIN: 5678\nVertragsnummer 1234\n\nwww.bahn.de Login:\n\nbenutzer / passwort\n\n(über Geschäftskundenlogin gehen!)\n"
            .as_bytes()
    );
    assert_eq!(
        document.fields()[2].metadata().name(),
        Some("autotype_enabled")
    );
    assert_eq!(document.fields()[3].metadata().name(), Some("icon"));
    assert_eq!(document.fields()[4].metadata().name(), Some("tags"));
    assert_eq!(
        document.fields()[4].metadata().sensitivity(),
        EntrySensitivity::Ordinary
    );
    let mobile = MobileEntryPage::from_document(&document);
    let comments = mobile
        .sections()
        .iter()
        .find(|section| section.kind() == MobileEntrySectionKind::Notes)
        .and_then(|section| section.fields().first())
        .expect("multiline comments section");
    assert_eq!(comments.label(), "Comments");
    assert!(!comments.sensitive());
    assert!(comments.multiline());
    assert_eq!(
        comments.value(),
        std::str::from_utf8(document.fields()[1].value()).ok()
    );
    assert!(matches!(
        EntryFieldDraft::field("www.bahn.de Login", Vec::new()),
        Err(DocumentError::InvalidFieldName)
    ));

    write_plaintext(
        &repository,
        &keys,
        "documents/plain-note",
        b"password\n\nordinary note\ncontinued",
    )?;
    let note =
        EntryDocumentService::new(&repository, &keys).open("documents/plain-note", &mut secrets)?;
    assert_eq!(note.password().expect("password").value(), b"password");
    assert_eq!(note.fields().len(), 2);
    assert_eq!(note.fields()[1].value(), b"\nordinary note\ncontinued");
    assert_eq!(
        note.fields()[1].metadata().sensitivity(),
        EntrySensitivity::Ordinary
    );

    let unordered = "password\nzeta: last\nnote: named\ncomments: heading\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\nordinary note\nurl: https://example.test\nlogin: alice\nalpha: first\n";
    write_plaintext(
        &repository,
        &keys,
        "documents/unordered",
        unordered.as_bytes(),
    )?;
    let mut unordered_document =
        EntryDocumentService::new(&repository, &keys).open("documents/unordered", &mut secrets)?;
    assert_eq!(
        unordered_document.serialize().expose(),
        unordered.as_bytes()
    );
    assert_eq!(
        unordered_document
            .display_fields()
            .into_iter()
            .map(|field| field.metadata().name().unwrap_or("note"))
            .collect::<Vec<_>>(),
        [
            "login", "password", "url", "otp", "note", "note", "comments", "alpha", "zeta"
        ]
    );
    let comments = unordered_document
        .fields()
        .iter()
        .find(|field| field.metadata().name() == Some("comments"))
        .expect("comments")
        .id();
    unordered_document
        .replace_field_value(comments, b"heading\nline: still a comment\n\nend".to_vec())?;
    assert_eq!(
        unordered_document.serialize().expose(),
        b"password\nzeta: last\nnote: named\ncomments: heading\n line: still a comment\n \n end\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\n ordinary note\nurl: https://example.test\nlogin: alice\nalpha: first\n"
    );
    let normalized = unordered_document.serialize();
    write_plaintext(
        &repository,
        &keys,
        "documents/normalized",
        normalized.expose(),
    )?;
    let normalized_document =
        EntryDocumentService::new(&repository, &keys).open("documents/normalized", &mut secrets)?;
    assert_eq!(
        normalized_document
            .fields()
            .iter()
            .find(|field| field.metadata().name() == Some("comments"))
            .expect("normalized comments")
            .value(),
        b"heading\nline: still a comment\n\nend"
    );
    assert_eq!(
        normalized_document
            .fields()
            .iter()
            .find(|field| field.metadata().kind() == EntryFieldKind::Note)
            .expect("normalized note")
            .value(),
        b"ordinary note"
    );
    Ok(())
}

#[test]
fn pass_otp_only_entries_keep_typed_metadata_in_the_first_line() -> 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 secrets = FixtureSecrets::all(&fixture);
    write_plaintext(
        &repository,
        &keys,
        "documents/otp-only",
        b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\n",
    )?;
    let document =
        EntryDocumentService::new(&repository, &keys).open("documents/otp-only", &mut secrets)?;
    assert_eq!(document.fields().len(), 1);
    assert_eq!(
        document.fields()[0].metadata().kind(),
        EntryFieldKind::OtpUri
    );
    assert_eq!(
        document.fields()[0]
            .metadata()
            .otp()
            .and_then(|otp| otp.issuer()),
        Some("Example")
    );
    Ok(())
}

#[test]
fn field_ids_survive_updates_removal_and_reordering() -> 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 secrets = FixtureSecrets::all(&fixture);
    write_plaintext(
        &repository,
        &keys,
        "documents/editable",
        b"password\nusername: alice\nnote: one\nnote: two\n",
    )?;
    let service = EntryDocumentService::new(&repository, &keys);
    let mut document = service.open("documents/editable", &mut secrets)?;
    let password_id = document.fields()[0].id();
    let username_id = document.fields()[1].id();
    let note_one_id = document.fields()[2].id();
    let note_two_id = document.fields()[3].id();
    let token = document.conflict_token();

    document.update(
        username_id,
        EntryFieldDraft::field("username", b"bob".to_vec())?,
    )?;
    let email_id = document.add(
        2,
        EntryFieldDraft::field("email", "bob@例.test".as_bytes().to_vec())?,
    )?;
    document.reorder(note_two_id, 2)?;
    let removed = document.remove(note_one_id)?;
    assert_eq!(removed.id(), note_one_id);
    assert_eq!(document.conflict_token(), token);
    assert_eq!(document.fields()[0].id(), password_id);
    assert_eq!(
        document.field(username_id).expect("username").value(),
        b"bob"
    );
    assert_eq!(
        document.field(email_id).expect("email").metadata().kind(),
        EntryFieldKind::Email
    );
    assert_eq!(document.fields()[2].id(), note_two_id);
    assert_eq!(
        document.serialize().expose(),
        "password\nusername: bob\nnote: two\nemail: bob@例.test\n".as_bytes()
    );

    let mut empty = service.open("documents/missing", &mut secrets)?;
    assert!(empty.fields().is_empty());
    assert!(empty.password().is_none());
    empty.add(0, EntryFieldDraft::line(b"password".to_vec())?)?;
    empty.add(1, EntryFieldDraft::blank())?;
    assert_eq!(empty.fields().len(), 2);
    assert_eq!(empty.fields()[1].metadata().kind(), EntryFieldKind::Note);
    assert_eq!(empty.serialize().expose(), b"password\n ");

    assert!(matches!(
        EntryFieldDraft::line(b"two\nlines".to_vec()),
        Err(DocumentError::LineBreak)
    ));
    assert!(matches!(
        EntryFieldDraft::field(" invalid ", Vec::new()),
        Err(DocumentError::InvalidFieldName)
    ));
    assert!(matches!(
        EntryFieldDraft::otp_uri(b"otpauth://totp/missing-secret".to_vec()),
        Err(DocumentError::InvalidOtpUri)
    ));
    Ok(())
}

#[test]
fn atomic_save_creates_updates_and_rejects_stale_documents() -> 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 secrets = FixtureSecrets::all(&fixture);
    let service = EntryDocumentService::new(&repository, &keys);
    let mut committer = Committer::default();

    let mut created = service.open("documents/new", &mut secrets)?;
    created.add(0, EntryFieldDraft::line(b"new password".to_vec())?)?;
    created.add(1, EntryFieldDraft::field("username", b"alice".to_vec())?)?;
    service.save(created, None, &mut committer)?;
    assert_eq!(committer.changes.len(), 1);
    assert_eq!(
        decrypt(&repository, &keys, "documents/new", &mut secrets)?.expose(),
        b"new password\nusername: alice"
    );

    let mut first = service.open("documents/new", &mut secrets)?;
    let mut stale = service.open("documents/new", &mut secrets)?;
    assert_eq!(first.conflict_token(), stale.conflict_token());
    let first_password = first.password().expect("password").id();
    first.update(first_password, EntryFieldDraft::line(b"winner".to_vec())?)?;
    service.save(first, None, &mut committer)?;
    let winner = repository.read_entry(&EntryPath::parse("documents/new")?)?;

    let stale_password = stale.password().expect("password").id();
    stale.update(stale_password, EntryFieldDraft::line(b"stale".to_vec())?)?;
    assert!(matches!(
        service.save_recoverable(&stale, None, &mut committer),
        Err(DocumentError::Write(
            WriteError::ConcurrentModification { .. }
        ))
    ));
    assert_eq!(stale.serialize().expose(), b"stale\nusername: alice");
    assert_eq!(
        repository.read_entry(&EntryPath::parse("documents/new")?)?,
        winner
    );

    let mut rollback = service.open("documents/new", &mut secrets)?;
    let password = rollback.password().expect("password").id();
    rollback.update(password, EntryFieldDraft::line(b"must roll back".to_vec())?)?;
    committer.fail = true;
    assert!(matches!(
        service.save_recoverable(&rollback, None, &mut committer),
        Err(DocumentError::Write(WriteError::Commit(_)))
    ));
    assert!(
        rollback
            .serialize()
            .expose()
            .starts_with(b"must roll back\n")
    );
    assert_eq!(
        repository.read_entry(&EntryPath::parse("documents/new")?)?,
        winner
    );
    committer.fail = false;
    std::fs::write(
        store.path().join(".gpg-id"),
        b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\n",
    )?;
    let retained = rollback.serialize();
    assert!(
        service
            .save_recoverable(&rollback, None, &mut committer)
            .is_err()
    );
    assert_eq!(rollback.serialize().expose(), retained.expose());
    assert_eq!(
        repository.read_entry(&EntryPath::parse("documents/new")?)?,
        winner
    );
    Ok(())
}

#[test]
fn mobile_editor_creates_edits_generates_and_preserves_dynamic_fields() -> 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 secrets = FixtureSecrets::all(&fixture);
    write_plaintext(
        &repository,
        &keys,
        "documents/mobile-editor",
        b"old password\nlogin: alice\ncomments: first\nsecond\nnote: repeated one\nnote: repeated two\nbinary: \xff\n",
    )?;
    let service = EntryDocumentService::new(&repository, &keys);
    let document = service.open("documents/mobile-editor", &mut secrets)?;
    let mut draft = MobileEntryDraft::new(document, false);
    let page = draft.page();
    assert!(!page.dirty());
    assert!(!page.creating());
    assert_eq!(page.default_password_length(), 25);
    assert_eq!(page.fields().len(), 6);
    assert!(page.fields()[2].multiline());
    assert_eq!(page.fields()[2].value(), Some("first\nsecond"));
    assert!(page.fields()[5].value().is_none());

    let password = page.fields()[0].id();
    assert!(matches!(
        draft.remove(password),
        Err(MobileEntryEditorError::ProtectedPasswordField)
    ));
    assert!(matches!(
        draft.reorder(password, 1),
        Err(MobileEntryEditorError::ProtectedPasswordField)
    ));
    assert!(draft.generate_password(Some(0), false).is_err());

    let repeated_one = page.fields()[3].id();
    let inputs = page
        .fields()
        .iter()
        .map(|field| {
            let (name, value) = if field.label() == "Login" {
                (Some("username".to_owned()), Some("bob".to_owned()))
            } else if field.label() == "Comments" {
                (
                    field.name().map(str::to_owned),
                    Some("heading\nline: preserved\n\nend".to_owned()),
                )
            } else {
                (
                    field.name().map(str::to_owned),
                    field.value().map(str::to_owned),
                )
            };
            MobileEntryEditorInput::new(field.id(), name, value)
        })
        .collect();
    draft.apply(inputs)?;
    draft.add(
        MobileEntryEditorFieldKind::Field,
        Some("tags".to_owned()),
        "work, mobile".to_owned(),
    )?;
    draft.add(
        MobileEntryEditorFieldKind::Note,
        None,
        "open note\nsecond line".to_owned(),
    )?;
    draft.remove(repeated_one)?;
    let open_note = draft.page().fields().last().expect("open note").id();
    draft.reorder(open_note, 2)?;
    draft.generate_password(Some(32), true)?;

    let edited = draft.page();
    assert!(edited.dirty());
    let password = edited.fields().first().expect("password").value().unwrap();
    assert_eq!(password.len(), 32);
    assert!(
        password
            .chars()
            .all(|character| character.is_ascii_alphanumeric())
    );
    assert_eq!(edited.fields()[2].value(), Some("open note\nsecond line"));

    let mut committer = Committer::default();
    service.save_recoverable(draft.document(), None, &mut committer)?;
    let reopened = service.open("documents/mobile-editor", &mut secrets)?;
    assert_eq!(
        reopened
            .fields()
            .iter()
            .filter(|field| field.metadata().name() == Some("note"))
            .count(),
        1
    );
    assert!(
        reopened.fields().iter().any(|field| {
            field.metadata().name() == Some("username") && field.value() == b"bob"
        })
    );
    assert!(reopened.fields().iter().any(|field| {
        field.metadata().name() == Some("comments")
            && field.value() == b"heading\nline: preserved\n\nend"
    }));
    assert!(reopened.fields().iter().any(|field| {
        field.metadata().name() == Some("tags") && field.value() == b"work, mobile"
    }));
    assert!(
        reopened
            .fields()
            .iter()
            .any(|field| { field.metadata().name() == Some("binary") && field.value() == b"\xff" })
    );

    let mut created = service.open("documents/cancelled-mobile", &mut secrets)?;
    created.add(0, EntryFieldDraft::line(Vec::new())?)?;
    let created = MobileEntryDraft::new(created, true);
    assert!(created.page().creating());
    assert!(created.page().dirty());
    drop(created);
    assert!(matches!(
        repository.read_entry(&EntryPath::parse("documents/cancelled-mobile")?),
        Err(ironstorage::repository::RepositoryError::NotFound { .. })
    ));
    Ok(())
}

#[test]
fn every_compatibility_entry_round_trips_as_a_document() -> TestResult {
    let fixture = FixtureSet::load()?;
    let stores = fixture
        .generated
        .entries
        .iter()
        .map(|entry| entry.store.as_str())
        .collect::<BTreeSet<_>>();
    for store_id in stores {
        let store = fixture.materialize_store(store_id)?;
        let repository = Repository::open(store.path())?;
        let keys = KeyStore::load(fixture.path("keys"))?;
        let mut secrets = FixtureSecrets::all(&fixture);
        let service = EntryDocumentService::new(&repository, &keys);
        for entry in fixture
            .generated
            .entries
            .iter()
            .filter(|entry| entry.store == store_id)
        {
            let logical = entry
                .path
                .strip_suffix(".gpg")
                .expect("fixture entry suffix");
            let document = service.open(logical, &mut secrets)?;
            assert_eq!(
                document.serialize().expose(),
                fixture.read(
                    std::path::Path::new("expected")
                        .join(store_id)
                        .join(&entry.plaintext),
                )?,
                "document round trip for {}/{}",
                store_id,
                entry.path
            );
        }
    }
    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(())
}

fn decrypt(
    repository: &Repository,
    keys: &KeyStore,
    path: &str,
    secrets: &mut impl SecretProvider,
) -> Result<SecretBytes, Box<dyn std::error::Error>> {
    Ok(keys.decrypt(&repository.read_entry(&EntryPath::parse(path)?)?, secrets)?)
}
