#![forbid(unsafe_code)]

use std::{collections::BTreeSet, error::Error, ffi::OsStr, fs, path::Path, time::Duration};

use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT;
use ironstorage::{
    authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT},
    config::{ConfigError, ConfigLoader, EditorSource},
    desktop::DesktopStorage,
    git::GitIdentity,
    mobile::MobileTab,
    repository::EntryPath,
};
use tempfile::TempDir;

type TestResult = Result<(), Box<dyn Error>>;

struct ConfigurationFixture {
    temporary: TempDir,
}

impl ConfigurationFixture {
    fn new() -> Result<Self, Box<dyn Error>> {
        let temporary = tempfile::tempdir()?;
        fs::create_dir_all(temporary.path().join("cwd/config/keys"))?;
        fs::create_dir_all(temporary.path().join("native"))?;
        Ok(Self { temporary })
    }

    fn loader(&self) -> ConfigLoader {
        ConfigLoader::new(
            self.temporary.path().join("cwd"),
            self.temporary.path().join("native"),
        )
    }

    fn write_explicit(&self, contents: &str) -> Result<(), Box<dyn Error>> {
        fs::write(self.explicit_path(), contents)?;
        Ok(())
    }

    fn explicit_path(&self) -> std::path::PathBuf {
        self.temporary.path().join("cwd/config/config.toml")
    }

    fn valid_contents(&self) -> &'static str {
        r#"
vault = "../vault"
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
key_material = "keys"
editor = ["code", "--wait"]

[[git.remotes]]
name = "origin"
url = "https://git.example.test/alice/store.git"
server_id = "personal-git"
application_id = "ironstorage-cli"
"#
    }
}

#[test]
fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fixture.write_explicit(fixture.valid_contents())?;
    let config = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))?;
    assert_eq!(config.source(), fs::canonicalize(fixture.explicit_path())?);
    assert_eq!(
        config.vault(),
        fs::canonicalize(fixture.temporary.path())?.join("cwd/vault")
    );
    assert_eq!(
        config.key_material(),
        fs::canonicalize(fixture.temporary.path().join("cwd/config/keys"))?
    );
    assert_eq!(
        config.default_key().as_str(),
        "0123456789ABCDEF0123456789ABCDEF01234567"
    );
    assert_eq!(config.git_remotes().len(), 1);
    let remote = &config.git_remotes()[0];
    assert_eq!(remote.name().as_str(), "origin");
    assert_eq!(
        remote.url().as_str(),
        "https://git.example.test/alice/store.git"
    );
    assert_eq!(remote.server_id().as_str(), "personal-git");
    assert_eq!(remote.application_id().as_str(), "ironstorage-cli");
    assert_eq!(
        config.clipboard_timeout().duration(),
        DEFAULT_CLIPBOARD_TIMEOUT
    );
    assert_eq!(
        config.authentication_timeout().duration(),
        DEFAULT_AUTHENTICATION_TIMEOUT
    );
    Ok(())
}

#[test]
fn git_commit_identity_defaults_validates_and_persists() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fixture.write_explicit(fixture.valid_contents())?;
    let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
    assert_eq!(config.git_identity(), &GitIdentity::ironstorage());

    let identity = GitIdentity::new("Alice Example", "alice@example.test")?;
    config.update_git_identity(&identity)?;
    let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
    assert_eq!(reloaded.git_identity(), &identity);
    assert_eq!(reloaded.git_remotes(), config.git_remotes());
    let contents = fs::read_to_string(fixture.explicit_path())?;
    assert!(contents.contains("user_name = \"Alice Example\""));
    assert!(contents.contains("user_email = \"alice@example.test\""));

    fixture.write_explicit(&fixture.valid_contents().replace(
        "[[git.remotes]]",
        "[git]\nuser_name = \"Alice Example\"\n\n[[git.remotes]]",
    ))?;
    assert_eq!(
        fixture
            .loader()
            .load(Some(&fixture.explicit_path()))
            .expect_err("partial Git identity must be rejected"),
        ConfigError::InvalidField {
            field: "git.user_identity"
        }
    );
    Ok(())
}

#[test]
fn watch_totp_selection_persists_only_in_application_configuration() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fixture.write_explicit(fixture.valid_contents())?;
    let config = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))?;
    let stale = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))?;
    let selected = BTreeSet::from([
        EntryPath::parse("otp/personal")?,
        EntryPath::parse("otp/work")?,
    ]);

    config.update_watch_shared_totp_entries(&selected)?;
    stale.update_mobile_tab(MobileTab::Totp)?;
    let reloaded = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))?;

    assert_eq!(reloaded.watch_shared_totp_entries(), &selected);
    assert_eq!(reloaded.mobile_tab(), MobileTab::Totp);
    assert!(!fixture.temporary.path().join("cwd/vault").exists());
    Ok(())
}

#[test]
fn authentication_timeout_defaults_overrides_and_rejects_invalid_values() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fixture.write_explicit(&fixture.valid_contents().replace(
        "editor = [\"code\", \"--wait\"]",
        "editor = [\"code\", \"--wait\"]\n[security]\ninactivity_timeout_seconds = 300",
    ))?;
    let config = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))?;
    assert_eq!(
        config.authentication_timeout().duration(),
        Duration::from_secs(300)
    );

    for timeout in [0, MAX_AUTHENTICATION_TIMEOUT.as_secs() + 1] {
        fixture.write_explicit(&fixture.valid_contents().replace(
            "editor = [\"code\", \"--wait\"]",
            &format!(
                "editor = [\"code\", \"--wait\"]\n[security]\ninactivity_timeout_seconds = {timeout}"
            ),
        ))?;
        assert_eq!(
            fixture
                .loader()
                .load(Some(Path::new("config/config.toml")))
                .expect_err("invalid authentication timeout"),
            ConfigError::InvalidField {
                field: "security.inactivity_timeout_seconds"
            }
        );
    }
    Ok(())
}

#[test]
fn clipboard_timeout_defaults_overrides_and_rejects_unsafe_values() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fixture.write_explicit(&fixture.valid_contents().replace(
        "editor = [\"code\", \"--wait\"]",
        "editor = [\"code\", \"--wait\"]\nclipboard_timeout_seconds = 30",
    ))?;
    let config = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))?;
    assert_eq!(
        config.clipboard_timeout().duration(),
        Duration::from_secs(30)
    );

    for timeout in [0, 301] {
        fixture.write_explicit(&fixture.valid_contents().replace(
            "editor = [\"code\", \"--wait\"]",
            &format!("editor = [\"code\", \"--wait\"]\nclipboard_timeout_seconds = {timeout}"),
        ))?;
        assert_eq!(
            fixture
                .loader()
                .load(Some(Path::new("config/config.toml")))
                .expect_err("unsafe clipboard timeout"),
            ConfigError::InvalidField {
                field: "clipboard_timeout_seconds"
            }
        );
    }
    Ok(())
}

#[test]
fn native_default_path_is_used_without_an_explicit_path() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    let default = fixture.loader().default_path();
    fs::create_dir_all(default.parent().expect("default parent"))?;
    fs::create_dir_all(default.parent().expect("default parent").join("keys"))?;
    fs::write(&default, fixture.valid_contents())?;

    let config = fixture.loader().load(None)?;
    assert_eq!(config.source(), fs::canonicalize(default)?);
    Ok(())
}

#[test]
fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
    fixture.write_explicit(fixture.valid_contents())?;
    let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
    assert_eq!(config.mobile_tab(), MobileTab::Home);

    config.update_mobile_tab(MobileTab::Totp)?;
    let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
    assert_eq!(reloaded.mobile_tab(), MobileTab::Totp);
    assert!(
        fs::read_to_string(fixture.explicit_path())?.contains("selected_mobile_tab = \"totp\"")
    );

    reloaded.update_mobile_tab(MobileTab::Search)?;
    let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
    assert_eq!(reloaded.mobile_tab(), MobileTab::Search);

    fixture.write_explicit(&format!(
        "{}\n[ui]\nselected_mobile_tab = \"unknown\"\n",
        fixture.valid_contents()
    ))?;
    assert_eq!(
        fixture
            .loader()
            .load(Some(&fixture.explicit_path()))
            .expect_err("unknown mobile tab"),
        ConfigError::InvalidField {
            field: "ui.selected_mobile_tab"
        }
    );
    Ok(())
}

#[test]
fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
    fixture.write_explicit(fixture.valid_contents())?;
    let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
    assert!(!config.biometric_unlock_enabled());

    config.update_biometric_unlock(true)?;
    let contents = fs::read_to_string(fixture.explicit_path())?;
    assert!(contents.contains("biometric_unlock_enabled = true"));
    assert!(!contents.to_ascii_lowercase().contains("passphrase"));
    let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
    assert!(reloaded.biometric_unlock_enabled());

    reloaded.update_biometric_unlock(false)?;
    assert!(
        !fixture
            .loader()
            .load(Some(&fixture.explicit_path()))?
            .biometric_unlock_enabled()
    );
    Ok(())
}

#[test]
fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
    let selected = fixture.temporary.path().join("selected-vault");
    fs::create_dir(&selected)?;
    let keys = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility/keys");
    let contents = fixture
        .valid_contents()
        .replace(
            "0123456789ABCDEF0123456789ABCDEF01234567",
            "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30",
        )
        .replace(
            "key_material = \"keys\"",
            &format!("key_material = {keys:?}"),
        )
        .replace(
            "editor = [\"code\", \"--wait\"]",
            "editor = [\"code\", \"--wait\"]\n[security]\ninactivity_timeout_seconds = 300",
        );
    fixture.write_explicit(&contents)?;

    let storage = DesktopStorage::load(Some(&fixture.explicit_path()))?;
    let switched = storage.switch_vault(&selected)?;
    assert_eq!(switched.vault(), fs::canonicalize(&selected)?);

    let mut settings = switched.settings();
    settings.set_default_key("B37027B56FC406BD3F6A622B2AC03492B992D06F".to_owned());
    settings.set_editor(Some(vec!["nano".to_owned(), "-w".to_owned()]));
    settings.set_authentication_timeout(Duration::from_secs(600));
    let updated = switched.update_settings(settings)?;

    let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
    assert_eq!(reloaded.vault(), fs::canonicalize(&selected)?);
    assert_eq!(
        reloaded.default_key().as_str(),
        "B37027B56FC406BD3F6A622B2AC03492B992D06F"
    );
    assert_eq!(
        reloaded.authentication_timeout().duration(),
        Duration::from_secs(600)
    );
    let editor = reloaded.configured_editor().expect("configured editor");
    assert_eq!(editor.program(), "nano");
    assert_eq!(editor.arguments(), ["-w"]);
    assert_eq!(reloaded.git_remotes().len(), 1);

    let before_rejection = fs::read(fixture.explicit_path())?;
    let mut invalid = updated.settings();
    invalid.set_default_key("missing-key".to_owned());
    assert!(updated.update_settings(invalid).is_err());
    assert_eq!(fs::read(fixture.explicit_path())?, before_rejection);
    Ok(())
}

#[test]
fn rejected_vault_switches_leave_configuration_and_session_unchanged() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?;
    fixture.write_explicit(fixture.valid_contents())?;
    let before = fs::read(fixture.explicit_path())?;
    let storage = DesktopStorage::load(Some(&fixture.explicit_path()))?;
    let current = storage.vault().to_owned();

    for invalid in [
        fixture.temporary.path().join("missing"),
        fixture.temporary.path().join("ordinary-file"),
    ] {
        if invalid.file_name() == Some(OsStr::new("ordinary-file")) {
            fs::write(&invalid, b"not a folder")?;
        }
        assert!(storage.switch_vault(&invalid).is_err());
        assert_eq!(storage.vault(), current);
        assert_eq!(fs::read(fixture.explicit_path())?, before);
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::symlink;

        let target = fixture.temporary.path().join("symlink-target");
        let link = fixture.temporary.path().join("symlink-vault");
        fs::create_dir(&target)?;
        symlink(&target, &link)?;
        assert!(storage.switch_vault(&link).is_err());
        assert_eq!(storage.vault(), current);
        assert_eq!(fs::read(fixture.explicit_path())?, before);
    }
    Ok(())
}

#[test]
fn editor_precedence_and_argument_splitting_are_storage_owned() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fixture.write_explicit(fixture.valid_contents())?;
    let config = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))?;
    let editor = config.resolve_editor_from(
        Some(OsStr::new("visual --ignored")),
        Some(OsStr::new("editor --ignored")),
    )?;
    assert_eq!(editor.source(), EditorSource::Configuration);
    assert_eq!(editor.command().program(), "code");
    assert_eq!(editor.command().arguments(), ["--wait"]);

    fixture.write_explicit(
        r#"
vault = "vault"
default_key = "alice@example.test"
key_material = "keys"
"#,
    )?;
    let config = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))?;
    let visual = config.resolve_editor_from(
        Some(OsStr::new("code --wait 'two words'")),
        Some(OsStr::new("nano")),
    )?;
    assert_eq!(visual.source(), EditorSource::VisualEnvironment);
    assert_eq!(visual.command().program(), "code");
    assert_eq!(visual.command().arguments(), ["--wait", "two words"]);

    let editor = config.resolve_editor_from(None, Some(OsStr::new("nano -w")))?;
    assert_eq!(editor.source(), EditorSource::EditorEnvironment);
    assert_eq!(editor.command().program(), "nano");
    assert_eq!(editor.command().arguments(), ["-w"]);

    let fallback = config.resolve_editor_from(None, None)?;
    assert_eq!(fallback.source(), EditorSource::Fallback);
    assert_eq!(fallback.command().program(), "vim");
    assert!(fallback.command().arguments().is_empty());
    Ok(())
}

#[test]
fn malformed_unknown_and_insecure_configuration_are_redacted() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fixture.write_explicit("vault = [\"unterminated\"\npassword = \"do-not-repeat\"")?;
    let error = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))
        .expect_err("malformed TOML");
    assert!(matches!(error, ConfigError::Malformed { .. }));
    assert!(!error.to_string().contains("do-not-repeat"));

    fixture.write_explicit(
        r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
colour = "blue"
"#,
    )?;
    let error = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))
        .expect_err("unknown field");
    assert_eq!(
        error,
        ConfigError::UnknownField {
            field: "colour".to_owned()
        }
    );

    fixture.write_explicit(
        r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
token = "do-not-repeat"
"#,
    )?;
    let error = fixture
        .loader()
        .load(Some(Path::new("config/config.toml")))
        .expect_err("secret field");
    assert_eq!(
        error,
        ConfigError::InsecureField {
            field: "token".to_owned()
        }
    );
    assert!(!error.to_string().contains("do-not-repeat"));
    Ok(())
}

#[test]
fn missing_and_invalid_required_fields_are_typed() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fixture.write_explicit("default_key = \"alice\"\nkey_material = \"keys\"\n")?;
    assert_eq!(
        fixture
            .loader()
            .load(Some(Path::new("config/config.toml")))
            .expect_err("missing vault"),
        ConfigError::MissingField { field: "vault" }
    );

    fixture.write_explicit("vault = \"vault\"\ndefault_key = \" \"\nkey_material = \"keys\"\n")?;
    assert_eq!(
        fixture
            .loader()
            .load(Some(Path::new("config/config.toml")))
            .expect_err("empty identity"),
        ConfigError::InvalidField {
            field: "default_key"
        }
    );

    fixture.write_explicit(
        "vault = \"vault\"\ndefault_key = \"alice\"\nkey_material = \"missing\"\n",
    )?;
    assert!(matches!(
        fixture
            .loader()
            .load(Some(Path::new("config/config.toml")))
            .expect_err("missing keys"),
        ConfigError::KeyMaterialNotFound { .. }
    ));

    fs::write(
        fixture.temporary.path().join("cwd/config/not-a-vault"),
        b"file",
    )?;
    fixture.write_explicit(
        "vault = \"not-a-vault\"\ndefault_key = \"alice\"\nkey_material = \"keys\"\n",
    )?;
    assert!(matches!(
        fixture
            .loader()
            .load(Some(Path::new("config/config.toml")))
            .expect_err("vault file"),
        ConfigError::VaultIsNotDirectory { .. }
    ));
    Ok(())
}

#[test]
fn git_configuration_rejects_non_https_and_embedded_credentials() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    for url in [
        "ssh://git@example.test/store.git",
        "git://example.test/store.git",
        "file:///tmp/store.git",
        "../store.git",
        "https://user:password@example.test/store.git",
        "https://example.test/store.git?token=do-not-store",
        "https://example.test/store.git#fragment",
    ] {
        fixture.write_explicit(&format!(
            r#"
vault = "vault"
default_key = "alice"
key_material = "keys"

[[git.remotes]]
name = "origin"
url = "{url}"
server_id = "server"
application_id = "application"
"#
        ))?;
        assert_eq!(
            fixture
                .loader()
                .load(Some(Path::new("config/config.toml")))
                .expect_err("forbidden remote"),
            ConfigError::InvalidRemoteUrl {
                name: "origin".to_owned()
            },
            "URL should be rejected without entering transport: {url}"
        );
    }
    Ok(())
}

#[test]
fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult {
    let fixture = ConfigurationFixture::new()?;
    fixture.write_explicit(
        r#"
vault = "vault"
default_key = "alice"
key_material = "keys"

[[git.remotes]]
name = "origin"
url = "https://one.example.test/store.git"
server_id = "one"
application_id = "app"

[[git.remotes]]
name = "origin"
url = "https://two.example.test/store.git"
server_id = "two"
application_id = "app"
"#,
    )?;
    assert_eq!(
        fixture
            .loader()
            .load(Some(Path::new("config/config.toml")))
            .expect_err("duplicate name"),
        ConfigError::DuplicateRemote {
            name: "origin".to_owned()
        }
    );

    fixture.write_explicit(
        r#"
vault = "vault"
default_key = "alice"
key_material = "keys"

[[git.remotes]]
name = "one"
url = "https://one.example.test/store.git"
server_id = "server"
application_id = "app"

[[git.remotes]]
name = "two"
url = "https://two.example.test/store.git"
server_id = "server"
application_id = "app"
"#,
    )?;
    assert_eq!(
        fixture
            .loader()
            .load(Some(Path::new("config/config.toml")))
            .expect_err("duplicate credential reference"),
        ConfigError::DuplicateCredentialReference
    );
    Ok(())
}
