#![forbid(unsafe_code)]

use std::{
    collections::BTreeMap,
    error::Error,
    fs,
    io::Write,
    path::{Path, PathBuf},
};

use flate2::{Compression, write::ZlibEncoder};
use pgp::{
    composed::{
        ArmorOptions, Deserializable, DetachedSignature, EncryptionCaps, KeyType, MessageBuilder,
        SecretKeyParamsBuilder, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig,
    },
    crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
    packet::{Subpacket, SubpacketData},
    ser::Serialize as _,
    types::{KeyDetails as _, Password, Timestamp},
};
use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng};
use serde::Serialize;
use sha1::{Digest as _, Sha1};
use sha2::Sha256;
use smallvec::smallvec;

const FIXTURE_EPOCH: u32 = 1_704_067_200;
const ALICE_PASSPHRASE: &str = "fixture-alice-passphrase";
const BOB_PASSPHRASE: &str = "fixture-bob-passphrase";

#[derive(Serialize)]
struct GeneratedManifest {
    format: u8,
    generated_by: &'static str,
    fixture_seed: &'static str,
    keys: Vec<KeyRecord>,
    entries: Vec<EntryRecord>,
    repositories: Vec<RepositoryRecord>,
}

#[derive(Serialize)]
struct KeyRecord {
    name: &'static str,
    user_id: &'static str,
    primary_fingerprint: String,
    encryption_subkey_fingerprint: String,
    passphrase: &'static str,
    public_armor: String,
    public_binary: String,
    secret_armor: String,
    secret_binary: String,
}

#[derive(Serialize)]
struct EntryRecord {
    store: &'static str,
    path: &'static str,
    plaintext: &'static str,
    recipients: Vec<String>,
    ciphertext_sha256: String,
}

#[derive(Serialize)]
struct RepositoryRecord {
    name: &'static str,
    template: String,
    head: String,
    commits: Vec<String>,
}

struct SyntheticKey {
    name: &'static str,
    user_id: &'static str,
    passphrase: &'static str,
    secret: SignedSecretKey,
}

impl SyntheticKey {
    fn primary_fingerprint(&self) -> String {
        format!("{:X}", self.secret.primary_key.fingerprint())
    }

    fn encryption_fingerprint(&self) -> String {
        format!("{:X}", self.secret.secret_subkeys[0].fingerprint())
    }
}

fn main() -> Result<(), Box<dyn Error>> {
    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility");
    prepare_output(&root)?;

    let mut rng = ChaCha20Rng::from_seed([0x49; 32]);
    let alice = load_or_generate_key(
        &root,
        &mut rng,
        "alice",
        "Alice Fixture <alice@ironstorage.invalid>",
        ALICE_PASSPHRASE,
    )?;
    let bob = load_or_generate_key(
        &root,
        &mut rng,
        "bob",
        "Bob Fixture <bob@ironstorage.invalid>",
        BOB_PASSPHRASE,
    )?;

    let keys = vec![write_key(&root, &alice)?, write_key(&root, &bob)?];
    let mut entries = Vec::new();

    write_recipient_file(&root, "basic/.gpg-id", &[&alice])?;
    write_signature(&root, "basic/.gpg-id.sig", &alice, single_recipient(&alice))?;
    write_recipient_file(&root, "basic/team/.gpg-id", &[&bob])?;
    write_signature(
        &root,
        "basic/team/.gpg-id.sig",
        &bob,
        single_recipient(&bob),
    )?;
    write_recipient_file(&root, "basic/shared/.gpg-id", &[&alice, &bob])?;

    entries.push(write_entry(
        &root,
        &mut rng,
        "basic",
        "email/personal.gpg",
        "email/personal.txt",
        b"correct horse fixture\nlogin: alice@example.test\nurl: https://example.test\n",
        &[&alice],
    )?);
    entries.push(write_entry(
        &root,
        &mut rng,
        "basic",
        "unicode/咖啡.gpg",
        "unicode/咖啡.txt",
        "pässwörd-猫\nlogin: 用户@example.test\nnotes: café\n".as_bytes(),
        &[&alice],
    )?);
    entries.push(write_entry(
        &root,
        &mut rng,
        "basic",
        "otp/totp.gpg",
        "otp/totp.txt",
        b"fixture-password\notpauth://totp/IronStorage:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&algorithm=SHA1&digits=6&period=30\n",
        &[&alice],
    )?);
    entries.push(write_entry(
        &root,
        &mut rng,
        "basic",
        "otp/hotp.gpg",
        "otp/hotp.txt",
        b"otpauth://hotp/IronStorage:counter?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&counter=0&digits=8\n",
        &[&alice],
    )?);
    entries.push(write_entry(
        &root,
        &mut rng,
        "basic",
        "team/service.gpg",
        "team/service.txt",
        b"team-fixture-password\nowner: bob\n",
        &[&bob],
    )?);
    entries.push(write_entry(
        &root,
        &mut rng,
        "basic",
        "shared/multiple.gpg",
        "shared/multiple.txt",
        b"shared-fixture-password\nrecipients: alice,bob\n",
        &[&alice, &bob],
    )?);

    write_recipient_file(&root, "nested/outer/.gpg-id", &[&alice])?;
    write_recipient_file(&root, "nested/outer/inner/.gpg-id", &[&bob])?;
    entries.push(write_entry(
        &root,
        &mut rng,
        "nested",
        "outer/root-entry.gpg",
        "outer/root-entry.txt",
        b"outer repository entry\n",
        &[&alice],
    )?);
    entries.push(write_entry(
        &root,
        &mut rng,
        "nested",
        "outer/inner/nested-entry.gpg",
        "outer/inner/nested-entry.txt",
        b"inner repository entry\n",
        &[&bob],
    )?);

    let repositories = vec![
        write_basic_repository(&root)?,
        write_nested_repository(&root, "outer", "nested/outer", "root-entry.gpg")?,
        write_nested_repository(&root, "inner", "nested/outer/inner", "nested-entry.gpg")?,
    ];

    let manifest = GeneratedManifest {
        format: 1,
        generated_by: "cargo run -p ironstorage --example refresh_compatibility_fixtures",
        fixture_seed: "49 repeated 32 times (hex); checked-in keys are reused on refresh",
        keys,
        entries,
        repositories,
    };
    write(
        root.join("generated.toml"),
        toml::to_string_pretty(&manifest)?.as_bytes(),
    )?;

    println!("refreshed {}", root.display());
    Ok(())
}

fn prepare_output(root: &Path) -> Result<(), Box<dyn Error>> {
    fs::create_dir_all(root)?;
    fs::create_dir_all(root.join("keys"))?;
    for generated in ["stores", "expected", "repositories"] {
        let path = root.join(generated);
        if path.exists() {
            fs::remove_dir_all(&path)?;
        }
        fs::create_dir_all(path)?;
    }
    Ok(())
}

fn load_or_generate_key(
    root: &Path,
    rng: &mut ChaCha20Rng,
    name: &'static str,
    user_id: &'static str,
    passphrase: &'static str,
) -> Result<SyntheticKey, Box<dyn Error>> {
    let path = root.join(format!("keys/{name}-secret.asc"));
    if path.is_file() {
        let (secret, _) = SignedSecretKey::from_armor_single(fs::File::open(path)?)?;
        secret.verify_bindings()?;
        return Ok(SyntheticKey {
            name,
            user_id,
            passphrase,
            secret,
        });
    }
    generate_key(rng, name, user_id, passphrase)
}

fn generate_key(
    rng: &mut ChaCha20Rng,
    name: &'static str,
    user_id: &'static str,
    passphrase: &'static str,
) -> Result<SyntheticKey, Box<dyn Error>> {
    let created_at = Timestamp::from_secs(FIXTURE_EPOCH);
    let subkey = SubkeyParamsBuilder::default()
        .key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy))
        .can_encrypt(EncryptionCaps::All)
        .created_at(created_at)
        .passphrase(Some(passphrase.to_owned()))
        .build()?;
    let secret = SecretKeyParamsBuilder::default()
        .key_type(KeyType::Ed25519Legacy)
        .can_certify(true)
        .can_sign(true)
        .created_at(created_at)
        .primary_user_id(user_id.to_owned())
        .passphrase(Some(passphrase.to_owned()))
        .preferred_symmetric_algorithms(smallvec![
            SymmetricKeyAlgorithm::AES256,
            SymmetricKeyAlgorithm::AES128,
        ])
        .preferred_hash_algorithms(smallvec![HashAlgorithm::Sha256])
        .preferred_compression_algorithms(smallvec![])
        .subkey(subkey)
        .build()?
        .generate(rng)?;
    secret.verify_bindings()?;
    Ok(SyntheticKey {
        name,
        user_id,
        passphrase,
        secret,
    })
}

fn write_key(root: &Path, key: &SyntheticKey) -> Result<KeyRecord, Box<dyn Error>> {
    let public = key.secret.to_public_key();
    let public_armor_path = format!("keys/{}-public.asc", key.name);
    let public_binary_path = format!("keys/{}-public.pgp", key.name);
    let secret_armor_path = format!("keys/{}-secret.asc", key.name);
    let secret_binary_path = format!("keys/{}-secret.pgp", key.name);

    write(
        root.join(&public_armor_path),
        public
            .to_armored_string(ArmorOptions::default())?
            .as_bytes(),
    )?;
    let mut public_binary = Vec::new();
    public.to_writer(&mut public_binary)?;
    write(root.join(&public_binary_path), &public_binary)?;
    write(
        root.join(&secret_armor_path),
        key.secret
            .to_armored_string(ArmorOptions::default())?
            .as_bytes(),
    )?;
    let mut secret_binary = Vec::new();
    key.secret.to_writer(&mut secret_binary)?;
    write(root.join(&secret_binary_path), &secret_binary)?;

    Ok(KeyRecord {
        name: key.name,
        user_id: key.user_id,
        primary_fingerprint: key.primary_fingerprint(),
        encryption_subkey_fingerprint: key.encryption_fingerprint(),
        passphrase: key.passphrase,
        public_armor: public_armor_path,
        public_binary: public_binary_path,
        secret_armor: secret_armor_path,
        secret_binary: secret_binary_path,
    })
}

fn recipient_bytes(keys: &[&SyntheticKey]) -> Vec<u8> {
    let mut bytes = keys
        .iter()
        .map(|key| key.primary_fingerprint())
        .collect::<Vec<_>>()
        .join("\n")
        .into_bytes();
    bytes.push(b'\n');
    bytes
}

fn single_recipient(key: &SyntheticKey) -> Vec<u8> {
    recipient_bytes(&[key])
}

fn write_recipient_file(
    root: &Path,
    relative: &str,
    keys: &[&SyntheticKey],
) -> Result<(), Box<dyn Error>> {
    write(root.join("stores").join(relative), &recipient_bytes(keys))
}

fn write_signature(
    root: &Path,
    relative: &str,
    key: &SyntheticKey,
    data: Vec<u8>,
) -> Result<(), Box<dyn Error>> {
    let hashed = vec![
        Subpacket::regular(SubpacketData::IssuerFingerprint(
            key.secret.primary_key.fingerprint(),
        ))?,
        Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::from_secs(
            FIXTURE_EPOCH,
        )))?,
    ];
    let unhashed = vec![Subpacket::regular(SubpacketData::IssuerKeyId(
        key.secret.primary_key.legacy_key_id(),
    ))?];
    let signature = DetachedSignature::sign_binary_data_with_subpackets(
        ChaCha20Rng::from_seed([0x53; 32]),
        &key.secret.primary_key,
        &Password::from(key.passphrase),
        HashAlgorithm::Sha256,
        data.as_slice(),
        SubpacketConfig::UserDefined { hashed, unhashed },
    )?;
    let mut bytes = Vec::new();
    signature.to_writer(&mut bytes)?;
    write(root.join("stores").join(relative), &bytes)
}

fn write_entry(
    root: &Path,
    rng: &mut ChaCha20Rng,
    store: &'static str,
    path: &'static str,
    plaintext_path: &'static str,
    plaintext: &[u8],
    recipients: &[&SyntheticKey],
) -> Result<EntryRecord, Box<dyn Error>> {
    let mut message = MessageBuilder::from_bytes("", plaintext.to_vec())
        .seipd_v1(&mut *rng, SymmetricKeyAlgorithm::AES256);
    for recipient in recipients {
        let public = recipient.secret.to_public_key();
        message.encrypt_to_key(&mut *rng, &public.public_subkeys[0])?;
    }
    let ciphertext = message.to_vec(&mut *rng)?;
    write(root.join("stores").join(store).join(path), &ciphertext)?;
    write(
        root.join("expected").join(store).join(plaintext_path),
        plaintext,
    )?;

    Ok(EntryRecord {
        store,
        path,
        plaintext: plaintext_path,
        recipients: recipients
            .iter()
            .map(|key| key.primary_fingerprint())
            .collect(),
        ciphertext_sha256: hex::encode(Sha256::digest(&ciphertext)),
    })
}

fn write_basic_repository(root: &Path) -> Result<RepositoryRecord, Box<dyn Error>> {
    let repository = root.join("repositories/basic.git");
    prepare_git_repository(&repository)?;
    let recipient = fs::read(root.join("stores/basic/.gpg-id"))?;
    let encrypted = fs::read(root.join("stores/basic/email/personal.gpg"))?;

    let recipient_blob = write_git_object(&repository, "blob", &recipient)?;
    let initial_tree = write_git_tree(
        &repository,
        &[("100644", ".gpg-id", recipient_blob.clone())],
    )?;
    let initial = write_git_commit(
        &repository,
        &initial_tree,
        None,
        "Set GPG id to Alice Fixture.",
    )?;

    let encrypted_blob = write_git_object(&repository, "blob", &encrypted)?;
    let email_tree = write_git_tree(&repository, &[("100644", "personal.gpg", encrypted_blob)])?;
    let final_tree = write_git_tree(
        &repository,
        &[
            ("100644", ".gpg-id", recipient_blob),
            ("40000", "email", email_tree),
        ],
    )?;
    let head = write_git_commit(
        &repository,
        &final_tree,
        Some(&initial),
        "Add given password for email/personal to store.",
    )?;
    write_ref(&repository, &head)?;

    Ok(RepositoryRecord {
        name: "basic",
        template: "repositories/basic.git".to_owned(),
        head: head.clone(),
        commits: vec![initial, head],
    })
}

fn write_nested_repository(
    root: &Path,
    name: &'static str,
    store: &str,
    entry: &str,
) -> Result<RepositoryRecord, Box<dyn Error>> {
    let template = format!("repositories/{name}.git");
    let repository = root.join(&template);
    prepare_git_repository(&repository)?;
    let recipient = fs::read(root.join("stores").join(store).join(".gpg-id"))?;
    let encrypted = fs::read(root.join("stores").join(store).join(entry))?;
    let recipient_blob = write_git_object(&repository, "blob", &recipient)?;
    let entry_blob = write_git_object(&repository, "blob", &encrypted)?;
    let tree = write_git_tree(
        &repository,
        &[
            ("100644", ".gpg-id", recipient_blob),
            ("100644", entry, entry_blob),
        ],
    )?;
    let head = write_git_commit(
        &repository,
        &tree,
        None,
        &format!("Add current contents of {name} password store."),
    )?;
    write_ref(&repository, &head)?;
    Ok(RepositoryRecord {
        name,
        template,
        head: head.clone(),
        commits: vec![head],
    })
}

fn prepare_git_repository(repository: &Path) -> Result<(), Box<dyn Error>> {
    fs::create_dir_all(repository.join("objects"))?;
    fs::create_dir_all(repository.join("refs/heads"))?;
    write(repository.join("HEAD"), b"ref: refs/heads/main\n")?;
    write(
        repository.join("config"),
        b"[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n[pass]\n\tsigncommits = false\n",
    )?;
    Ok(())
}

fn write_git_tree(
    repository: &Path,
    entries: &[(&str, &str, String)],
) -> Result<String, Box<dyn Error>> {
    let mut sorted = BTreeMap::new();
    for (mode, name, id) in entries {
        sorted.insert(name.as_bytes(), (*mode, id));
    }
    let mut body = Vec::new();
    for (name, (mode, id)) in sorted {
        body.extend_from_slice(mode.as_bytes());
        body.push(b' ');
        body.extend_from_slice(name);
        body.push(0);
        body.extend_from_slice(&hex::decode(id)?);
    }
    write_git_object(repository, "tree", &body)
}

fn write_git_commit(
    repository: &Path,
    tree: &str,
    parent: Option<&str>,
    message: &str,
) -> Result<String, Box<dyn Error>> {
    let mut body = format!("tree {tree}\n");
    if let Some(parent) = parent {
        body.push_str(&format!("parent {parent}\n"));
    }
    body.push_str("author IronStorage Fixture <fixture@ironstorage.invalid> 1704067200 +0000\n");
    body.push_str(
        "committer IronStorage Fixture <fixture@ironstorage.invalid> 1704067200 +0000\n\n",
    );
    body.push_str(message);
    body.push('\n');
    write_git_object(repository, "commit", body.as_bytes())
}

fn write_git_object(repository: &Path, kind: &str, body: &[u8]) -> Result<String, Box<dyn Error>> {
    let mut canonical = format!("{kind} {}\0", body.len()).into_bytes();
    canonical.extend_from_slice(body);
    let id = hex::encode(Sha1::digest(&canonical));
    let path = repository.join("objects").join(&id[..2]).join(&id[2..]);
    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
    encoder.write_all(&canonical)?;
    write(path, &encoder.finish()?)?;
    Ok(id)
}

fn write_ref(repository: &Path, head: &str) -> Result<(), Box<dyn Error>> {
    write(
        repository.join("refs/heads/main"),
        format!("{head}\n").as_bytes(),
    )
}

fn write(path: PathBuf, contents: &[u8]) -> Result<(), Box<dyn Error>> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, contents)?;
    Ok(())
}
