#![forbid(unsafe_code)]

use std::{error::Error, num::NonZeroUsize, path::Path};

use ironstorage::command::{
    CliAction, CommandRequest, CompletionShell, EXIT_USAGE, GeneratedPresentation,
    GitConfigRequest, GitRemoteRequest, GitRequest, HelpTopic, InputPlan, InsertInput,
    OtpInputSource, OtpRequest, OtpUriPresentation, Presentation, completion_script, help_text,
    otp_version_text, parse_from, version_text,
};
use ironstorage::kdbx::{KdbxImportMode, KdbxImportRequest};

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

fn request(arguments: &[&str]) -> Result<CommandRequest, Box<dyn Error>> {
    let mut complete = vec!["ironstorage"];
    complete.extend_from_slice(arguments);
    match parse_from(complete)? {
        CliAction::Run(invocation) => Ok(invocation.into_parts().1),
        CliAction::Display(_) => Err("expected a command request".into()),
    }
}

#[test]
fn completion_scripts_are_generated_in_process_for_supported_shells() -> TestResult {
    for (name, shell, marker) in [
        ("bash", CompletionShell::Bash, "complete"),
        ("elvish", CompletionShell::Elvish, "edit:completion"),
        ("fish", CompletionShell::Fish, "complete"),
        (
            "powershell",
            CompletionShell::PowerShell,
            "Register-ArgumentCompleter",
        ),
        ("zsh", CompletionShell::Zsh, "compdef"),
    ] {
        assert_eq!(
            request(&["completion", name])?,
            CommandRequest::Completion { shell }
        );
        let script = String::from_utf8(completion_script(shell))?;
        assert!(script.contains("ironstorage"));
        assert!(
            script.contains(marker),
            "missing {marker} in {name} completion"
        );
    }
    assert_eq!(
        parse_from(["ironstorage", "completion", "unsupported"])
            .unwrap_err()
            .exit_code(),
        EXIT_USAGE
    );
    Ok(())
}

#[test]
fn implicit_show_and_list_aliases_are_canonical_requests() -> TestResult {
    assert!(matches!(
        request(&[])?,
        CommandRequest::Show(ref show)
            if show.entry.is_none() && show.presentation == Presentation::Terminal
    ));
    assert!(matches!(
        request(&["email/personal"] )?,
        CommandRequest::Show(ref show)
            if show.entry.as_deref() == Some("email/personal")
    ));
    assert_eq!(request(&["ls", "team"])?, request(&["list", "team"])?);
    assert_eq!(
        request(&["find", "service"])?,
        request(&["search", "service"])?
    );
    Ok(())
}

#[test]
fn mutation_aliases_map_to_the_same_storage_request() -> TestResult {
    assert_eq!(
        request(&["insert", "-f", "new/entry"])?,
        request(&["add", "--force", "new/entry"])?
    );
    assert_eq!(
        request(&["rm", "-rf", "team/"])?,
        request(&["remove", "--recursive", "--force", "team/"])?
    );
    assert_eq!(request(&["rm", "entry"])?, request(&["delete", "entry"])?);
    assert_eq!(
        request(&["mv", "old", "new"])?,
        request(&["rename", "old", "new"])?
    );
    assert_eq!(
        request(&["cp", "old", "new"])?,
        request(&["copy", "old", "new"])?
    );
    Ok(())
}

#[test]
fn show_presentation_lines_are_typed_and_conflicts_fail() -> TestResult {
    assert!(matches!(
        request(&["show", "--clip=2", "entry"] )?,
        CommandRequest::Show(ref show)
            if show.presentation == Presentation::Clipboard {
                line: NonZeroUsize::new(2).expect("nonzero")
            }
    ));
    assert!(matches!(
        request(&["show", "-q3", "entry"] )?,
        CommandRequest::Show(ref show)
            if show.presentation == Presentation::QrCode {
                line: NonZeroUsize::new(3).expect("nonzero")
            }
    ));
    for invalid in [
        vec!["show", "--clip", "--qrcode", "entry"],
        vec!["show", "--clip=0", "entry"],
        vec!["show", "--clip=not-a-number", "entry"],
    ] {
        let error = parse_from(std::iter::once("ironstorage").chain(invalid))
            .expect_err("invalid presentation");
        assert_eq!(error.exit_code(), EXIT_USAGE);
    }
    Ok(())
}

#[test]
fn insert_modes_conflicts_and_noninteractive_input_are_explicit() -> TestResult {
    let hidden = request(&["insert", "entry"])?;
    let CommandRequest::Insert(hidden) = hidden else {
        return Err("expected insert".into());
    };
    assert_eq!(hidden.input, InsertInput::HiddenConfirmed);
    assert_eq!(hidden.input_plan(true), InputPlan::HiddenConfirmed);
    assert_eq!(hidden.input_plan(false), InputPlan::StandardInputLine);

    let multiline = request(&["insert", "--multiline", "entry"])?;
    let CommandRequest::Insert(multiline) = multiline else {
        return Err("expected multiline insert".into());
    };
    assert_eq!(multiline.input, InsertInput::Multiline);
    assert_eq!(multiline.input_plan(false), InputPlan::StandardInputToEnd);

    let echo = request(&["insert", "--echo", "entry"])?;
    let CommandRequest::Insert(echo) = echo else {
        return Err("expected echo insert".into());
    };
    assert_eq!(echo.input_plan(true), InputPlan::EchoedLine);

    assert_eq!(
        parse_from(["ironstorage", "insert", "--echo", "--multiline", "entry"])
            .expect_err("conflicting insert modes")
            .exit_code(),
        EXIT_USAGE
    );
    Ok(())
}

#[test]
fn generate_options_defaults_and_conflicts_are_validated() -> TestResult {
    let generated = request(&["generate", "--no-symbols", "--clip", "entry", "32"])?;
    assert!(matches!(
        generated,
        CommandRequest::Generate(ref request)
            if request.no_symbols
                && request.length == NonZeroUsize::new(32)
                && request.presentation == GeneratedPresentation::Clipboard
    ));
    for invalid in [
        ["generate", "--clip", "--qrcode", "entry"].as_slice(),
        ["generate", "--force", "--in-place", "entry"].as_slice(),
        ["generate", "entry", "0"].as_slice(),
    ] {
        let error = parse_from(std::iter::once("ironstorage").chain(invalid.iter().copied()))
            .expect_err("invalid generate request");
        assert_eq!(error.exit_code(), EXIT_USAGE);
    }
    Ok(())
}

#[test]
fn grep_support_is_explicit_and_unknown_gnu_options_fail() -> TestResult {
    assert!(matches!(
        request(&["grep", "-ivnF", "fixture"] )?,
        CommandRequest::Grep(ref grep)
            if grep.ignore_case && grep.invert_match && grep.line_number && grep.fixed_strings
    ));
    let error = parse_from(["ironstorage", "grep", "--binary-files=text", "fixture"])
        .expect_err("unsupported grep option");
    assert_eq!(error.exit_code(), EXIT_USAGE);
    Ok(())
}

#[test]
fn git_workflows_are_typed_and_arbitrary_passthrough_is_rejected() -> TestResult {
    assert_eq!(
        request(&["git", "init"])?,
        CommandRequest::Git(GitRequest::Init)
    );
    assert_eq!(
        request(&["git", "remote"])?,
        CommandRequest::Git(GitRequest::Remote(GitRemoteRequest::List))
    );
    assert_eq!(
        request(&[
            "git",
            "remote",
            "add",
            "origin",
            "https://example.test/store.git",
        ])?,
        CommandRequest::Git(GitRequest::Remote(GitRemoteRequest::Add {
            name: "origin".to_owned(),
            url: "https://example.test/store.git".to_owned(),
        }))
    );
    assert_eq!(
        request(&["git", "config", "--get", "remote.origin.url"])?,
        CommandRequest::Git(GitRequest::Config(GitConfigRequest::Get {
            key: "remote.origin.url".to_owned()
        }))
    );
    assert_eq!(
        request(&[
            "git",
            "config",
            "remote.origin.url",
            "https://example.test/store.git",
        ])?,
        CommandRequest::Git(GitRequest::Config(GitConfigRequest::Set {
            key: "remote.origin.url".to_owned(),
            value: "https://example.test/store.git".to_owned(),
        }))
    );
    for command in ["rebase", "cherry-pick", "credential"] {
        assert_eq!(
            parse_from(["ironstorage", "git", command])
                .expect_err("unsupported Git passthrough")
                .exit_code(),
            EXIT_USAGE
        );
    }
    Ok(())
}

#[test]
fn otp_default_dispatch_aliases_and_input_contract_are_complete() -> TestResult {
    let default = request(&["otp", "otp/totp"])?;
    assert_eq!(default, request(&["otp", "code", "otp/totp"])?);
    assert_eq!(default, request(&["otp", "show", "otp/totp"])?);
    assert_eq!(
        request(&["otp", "insert", "otp/new"])?,
        request(&["otp", "add", "otp/new"])?
    );

    let inserted = request(&[
        "otp",
        "insert",
        "--secret",
        "--issuer",
        "Issuer",
        "--account",
        "account",
    ])?;
    let CommandRequest::Otp(OtpRequest::Insert(inserted)) = inserted else {
        return Err("expected OTP insert".into());
    };
    assert_eq!(
        inserted.source,
        OtpInputSource::Secret {
            issuer: Some("Issuer".to_owned()),
            account: Some("account".to_owned())
        }
    );
    assert_eq!(inserted.input_plan(true), InputPlan::HiddenConfirmed);
    assert_eq!(inserted.input_plan(false), InputPlan::StandardInputLine);

    assert_eq!(
        parse_from(["ironstorage", "otp", "insert", "--secret"])
            .expect_err("secret without identity")
            .exit_code(),
        EXIT_USAGE
    );
    assert_eq!(
        parse_from(["ironstorage", "otp", "insert", "--issuer", "Issuer"])
            .expect_err("issuer without secret")
            .exit_code(),
        EXIT_USAGE
    );
    Ok(())
}

#[test]
fn otp_uri_presentation_and_conflicts_are_typed() -> TestResult {
    assert!(matches!(
        request(&["otp", "uri", "--qrcode", "otp/totp"] )?,
        CommandRequest::Otp(OtpRequest::Uri(ref uri))
            if uri.presentation == OtpUriPresentation::QrCode
    ));
    assert_eq!(
        parse_from([
            "ironstorage",
            "otp",
            "uri",
            "--clip",
            "--qrcode",
            "otp/totp",
        ])
        .expect_err("conflicting OTP URI presentation")
        .exit_code(),
        EXIT_USAGE
    );
    Ok(())
}

#[test]
fn configuration_option_and_meta_commands_have_stable_contracts() -> TestResult {
    let action = parse_from([
        "ironstorage",
        "--config",
        "relative/config.toml",
        "show",
        "entry",
    ])?;
    let CliAction::Run(invocation) = action else {
        return Err("expected invocation".into());
    };
    assert_eq!(invocation.config(), Some(Path::new("relative/config.toml")));

    assert_eq!(
        request(&["help", "search"])?,
        CommandRequest::Help {
            topic: Some(HelpTopic::Find)
        }
    );
    assert_eq!(request(&["version"])?, CommandRequest::Version);
    assert!(!help_text(None).is_empty());
    assert!(help_text(Some(HelpTopic::Otp)).contains("Usage"));
    assert!(version_text().starts_with("IronStorage "));
    assert_eq!(otp_version_text(), "1.1.1\n");

    for option in ["--help", "--version"] {
        assert!(matches!(
            parse_from(["ironstorage", option])?,
            CliAction::Display(ref text) if !text.is_empty()
        ));
    }
    assert!(matches!(
        parse_from(["ironstorage", "otp", "--help"] )?,
        CliAction::Display(ref text) if text.contains("Usage")
    ));
    assert_eq!(
        request(&["otp", "--version"])?,
        CommandRequest::Otp(OtpRequest::Version)
    );
    assert_eq!(
        parse_from(["ironstorage", "help", "not-a-command"])
            .expect_err("unknown help topic")
            .exit_code(),
        EXIT_USAGE
    );
    Ok(())
}

#[test]
fn kdbx_import_contract_supports_update_and_quick_add_modes() -> TestResult {
    assert_eq!(
        request(&["import-kdbx", "vault.kdbx"])?,
        CommandRequest::ImportKdbx(KdbxImportRequest::new(
            "vault.kdbx",
            None,
            KdbxImportMode::AddAndUpdate,
        ))
    );
    assert_eq!(
        request(&[
            "import-kdbx",
            "vault.kdbx",
            "--key-file",
            "vault.key",
            "--quick-add",
        ])?,
        CommandRequest::ImportKdbx(KdbxImportRequest::new(
            "vault.kdbx",
            Some("vault.key".into()),
            KdbxImportMode::QuickAdd,
        ))
    );
    assert!(help_text(Some(HelpTopic::ImportKdbx)).contains("--quick-add"));
    Ok(())
}
