#![forbid(unsafe_code)]

mod support;

use std::{error::Error, fs, path::Path};

use ironstorage::repository::{
    DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, ResolvedObject,
    SecretBytes,
};
use support::compatibility::{FixtureSet, TestResult};

#[test]
fn ordinary_nested_and_unicode_pass_trees_are_discovered() -> TestResult {
    let fixtures = FixtureSet::load()?;
    let store = fixtures.materialize_store("basic")?;
    let repository = Repository::open(store.path())?;
    let snapshot = repository.snapshot()?;

    let paths = snapshot
        .entries()
        .map(|entry| entry.path().as_path().to_owned())
        .collect::<Vec<_>>();
    assert_eq!(paths.len(), 6);
    assert!(paths.contains(&Path::new("email/personal").to_owned()));
    assert!(paths.contains(&Path::new("unicode/咖啡").to_owned()));
    assert_eq!(snapshot.auxiliary_files().len(), 0);
    assert_eq!(snapshot.recipient_policies().len(), 3);

    let unicode = EntryPath::parse("unicode/咖啡")?;
    let encrypted = repository.read_entry(&unicode)?;
    assert_eq!(
        encrypted.as_bytes(),
        fs::read(store.path().join("unicode/咖啡.gpg"))?
    );

    let personal = EntryPath::parse("email/personal")?;
    let root_policy = snapshot
        .recipient_policy(&personal)
        .expect("root recipient policy");
    assert_eq!(root_policy.directory(), &DirectoryPath::root());
    assert_eq!(root_policy.recipients_path(), Path::new(".gpg-id"));
    assert_eq!(root_policy.signature_path(), Some(Path::new(".gpg-id.sig")));

    let service = EntryPath::parse("team/service")?;
    let team_policy = snapshot
        .recipient_policy(&service)
        .expect("nested recipient policy");
    assert_eq!(team_policy.directory().as_path(), Path::new("team"));
    assert_eq!(team_policy.recipients_path(), Path::new("team/.gpg-id"));
    Ok(())
}

#[test]
fn innermost_git_repository_is_selected_without_scanning_git_objects() -> TestResult {
    let fixtures = FixtureSet::load()?;
    let store = fixtures.materialize_store("nested/outer")?;
    copy_tree(
        &fixtures.path("repositories/outer.git"),
        &store.path().join(".git"),
    )?;
    copy_tree(
        &fixtures.path("repositories/inner.git"),
        &store.path().join("inner/.git"),
    )?;

    let repository = Repository::open(store.path())?;
    let snapshot = repository.snapshot()?;
    assert_eq!(snapshot.entries().len(), 2);
    assert_eq!(snapshot.git_repositories().len(), 2);
    assert_eq!(snapshot.auxiliary_files().len(), 0);

    let root = snapshot
        .git_repository(&EntryPath::parse("root-entry")?)
        .expect("outer repository");
    assert_eq!(root.work_tree(), &DirectoryPath::root());
    assert_eq!(root.git_directory(), Path::new(".git"));

    let inner = snapshot
        .git_repository(&EntryPath::parse("inner/nested-entry")?)
        .expect("inner repository");
    assert_eq!(inner.work_tree().as_path(), Path::new("inner"));
    assert_eq!(inner.git_directory(), Path::new("inner/.git"));
    Ok(())
}

#[test]
fn entry_and_directory_with_the_same_logical_path_remain_independent() -> TestResult {
    let temporary = tempfile::tempdir()?;
    fs::create_dir(temporary.path().join("ambiguous"))?;
    fs::write(temporary.path().join("ambiguous.gpg"), b"ciphertext")?;
    fs::write(
        temporary.path().join("ambiguous/child.gpg"),
        b"child ciphertext",
    )?;
    let repository = Repository::open(temporary.path())?;
    let snapshot = repository.snapshot()?;

    assert!(matches!(
        snapshot.resolve("ambiguous")?,
        ResolvedObject::Entry(entry) if entry.path().as_path() == Path::new("ambiguous")
    ));
    assert!(matches!(
        snapshot.resolve("ambiguous/")?,
        ResolvedObject::Directory(directory)
            if directory.path().as_path() == Path::new("ambiguous")
    ));
    assert_eq!(
        snapshot
            .entries()
            .map(|entry| entry.path().as_path())
            .collect::<Vec<_>>(),
        [Path::new("ambiguous"), Path::new("ambiguous/child")]
    );

    repository.write_entry(
        &EntryPath::parse("ambiguous")?,
        &EncryptedEntry::new(b"replacement".to_vec()),
    )?;
    repository.write_entry(
        &EntryPath::parse("ambiguous/new-child")?,
        &EncryptedEntry::new(b"new child".to_vec()),
    )?;
    assert_eq!(
        repository
            .read_entry(&EntryPath::parse("ambiguous")?)?
            .as_bytes(),
        b"replacement"
    );
    assert_eq!(
        repository
            .read_entry(&EntryPath::parse("ambiguous/new-child")?)?
            .as_bytes(),
        b"new child"
    );
    Ok(())
}

#[test]
fn hostile_logical_paths_are_rejected_before_filesystem_access() {
    for path in ["", ".", "..", "../escape", "safe/../../escape", "/absolute"] {
        assert!(
            matches!(
                EntryPath::parse(path),
                Err(RepositoryError::InvalidPath { .. })
            ),
            "hostile path was accepted: {path}"
        );
    }
    assert!(matches!(
        EntryPath::parse("directory/"),
        Err(RepositoryError::InvalidPath { .. })
    ));
    assert_eq!(
        EntryPath::parse("unicode/咖啡")
            .expect("valid Unicode path")
            .as_path(),
        Path::new("unicode/咖啡")
    );
    assert_eq!(
        DirectoryPath::parse("nested//directory/")
            .expect("normalized directory")
            .as_path(),
        Path::new("nested/directory")
    );
}

#[cfg(unix)]
#[test]
fn symlinks_and_unsupported_file_types_are_rejected() -> TestResult {
    use std::os::unix::fs::symlink;

    use nix::{sys::stat::Mode, unistd::mkfifo};

    let temporary = tempfile::tempdir()?;
    let vault = temporary.path().join("vault");
    fs::create_dir(&vault)?;
    fs::write(temporary.path().join("outside.gpg"), b"outside")?;
    symlink("../outside.gpg", vault.join("escape.gpg"))?;
    let repository = Repository::open(&vault)?;
    assert!(matches!(
        repository.snapshot(),
        Err(RepositoryError::Symlink { ref path }) if path == Path::new("escape.gpg")
    ));
    assert!(matches!(
        repository.read_entry(&EntryPath::parse("escape")?),
        Err(RepositoryError::Symlink { .. })
    ));
    assert_eq!(fs::read(temporary.path().join("outside.gpg"))?, b"outside");

    fs::remove_file(vault.join("escape.gpg"))?;
    mkfifo(
        &vault.join("unsupported.gpg"),
        Mode::from_bits_truncate(0o600),
    )?;
    assert!(matches!(
        repository.snapshot(),
        Err(RepositoryError::UnsupportedFileType { ref path })
            if path == Path::new("unsupported.gpg")
    ));
    Ok(())
}

#[test]
fn atomic_entry_replacement_has_private_permissions_and_complete_contents() -> TestResult {
    let temporary = tempfile::tempdir()?;
    let repository = Repository::open(temporary.path())?;
    let path = EntryPath::parse("new/deep/entry")?;
    let first = EncryptedEntry::new(vec![0x11; 16 * 1024]);
    let second = EncryptedEntry::new(vec![0x22; 31 * 1024]);
    repository.write_entry(&path, &first)?;
    assert_eq!(repository.read_entry(&path)?, first);
    repository.write_entry(&path, &second)?;
    assert_eq!(repository.read_entry(&path)?, second);
    assert_eq!(
        fs::read_dir(temporary.path().join("new/deep"))?
            .collect::<Result<Vec<_>, _>>()?
            .len(),
        1
    );

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;

        let mode = fs::metadata(temporary.path().join("new/deep/entry.gpg"))?
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600);
        for directory in ["new", "new/deep"] {
            let mode = fs::metadata(temporary.path().join(directory))?
                .permissions()
                .mode()
                & 0o777;
            assert_eq!(mode, 0o700);
        }
    }
    Ok(())
}

#[test]
fn empty_directory_cleanup_stops_at_content_and_recipient_boundaries() -> TestResult {
    let temporary = tempfile::tempdir()?;
    fs::create_dir_all(temporary.path().join("empty/one/two"))?;
    fs::create_dir_all(temporary.path().join("policy/empty"))?;
    fs::write(temporary.path().join("policy/.gpg-id"), b"ALICE\n")?;
    let repository = Repository::open(temporary.path())?;

    let removed = repository.cleanup_empty_directories(&DirectoryPath::parse("empty/one/two")?)?;
    assert_eq!(
        removed
            .iter()
            .map(DirectoryPath::as_path)
            .collect::<Vec<_>>(),
        [
            Path::new("empty/one/two"),
            Path::new("empty/one"),
            Path::new("empty")
        ]
    );
    assert!(!temporary.path().join("empty").exists());

    let removed = repository.cleanup_empty_directories(&DirectoryPath::parse("policy/empty")?)?;
    assert_eq!(removed.len(), 1);
    assert!(temporary.path().join("policy/.gpg-id").is_file());
    Ok(())
}

#[test]
fn auxiliary_regular_files_are_inventory_objects_but_git_files_are_rejected() -> TestResult {
    let temporary = tempfile::tempdir()?;
    fs::write(temporary.path().join("README"), b"ordinary file")?;
    let repository = Repository::open(temporary.path())?;
    let snapshot = repository.snapshot()?;
    let auxiliary = snapshot.auxiliary_files().collect::<Vec<_>>();
    assert_eq!(auxiliary.len(), 1);
    assert_eq!(auxiliary[0].path(), Path::new("README"));
    assert_eq!(auxiliary[0].length(), 13);

    fs::write(temporary.path().join(".git"), b"gitdir: ../outside")?;
    assert!(matches!(
        repository.snapshot(),
        Err(RepositoryError::InvalidGitBoundary { .. })
    ));
    Ok(())
}

#[test]
fn secret_bytes_never_render_their_contents() {
    let mut secret = SecretBytes::new(b"fixture secret".to_vec());
    assert_eq!(secret.expose(), b"fixture secret");
    secret.expose_mut()[0] = b'F';
    let rendered = format!("{secret:?}");
    assert_eq!(rendered, "SecretBytes([REDACTED])");
    assert!(!rendered.contains("fixture"));
}

fn copy_tree(source: &Path, destination: &Path) -> Result<(), Box<dyn Error>> {
    fs::create_dir_all(destination)?;
    let mut entries = fs::read_dir(source)?.collect::<Result<Vec<_>, _>>()?;
    entries.sort_by_key(fs::DirEntry::file_name);
    for entry in entries {
        let target = destination.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            copy_tree(&entry.path(), &target)?;
        } else {
            fs::copy(entry.path(), target)?;
        }
    }
    Ok(())
}
