use assert_cmd::prelude::*;
use std::process::Command;

#[test]
fn cordance_init_creates_config() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("init");
    cmd.assert().success();
    assert!(dir.path().join("cordance.toml").exists());
}

#[test]
fn cordance_init_skips_if_exists() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("cordance.toml"), "[project]\n").expect("write");
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("init");
    cmd.assert().success();
    // Content should be unchanged (the original stub)
    let content = std::fs::read_to_string(dir.path().join("cordance.toml")).expect("read");
    assert_eq!(content, "[project]\n");
}

#[test]
fn cordance_scan_json_succeeds() {
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target").arg(".").arg("scan").arg("--json");
    cmd.assert().success();
}

#[test]
fn cordance_scan_plain_succeeds() {
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target").arg(".").arg("scan");
    cmd.assert().success();
}

#[test]
fn cordance_pack_dry_run_succeeds() {
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(".")
        .arg("pack")
        .arg("--output-mode")
        .arg("dry-run");
    cmd.assert().success();
}

#[test]
fn cordance_pack_diff_mode_succeeds() {
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(".")
        .arg("pack")
        .arg("--output-mode")
        .arg("diff");
    cmd.assert().success();
}

#[test]
fn cordance_advise_succeeds() {
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target").arg(".").arg("advise");
    cmd.assert().success();
}

#[test]
fn cordance_advise_json_succeeds() {
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target").arg(".").arg("advise").arg("--json");
    cmd.assert().success();
}

#[test]
fn cordance_check_on_empty_target_exits_gracefully() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("check");
    // Should exit 1 (no sources.lock), not panic
    let status = cmd.status().expect("run");
    assert!(status.code().is_some());
}

#[test]
fn cordance_explain_unknown_rule_exits_zero() {
    let dir = tempfile::tempdir().expect("tempdir");
    // Run pack first so that `.cordance/evidence-map.json` is on disk.
    let mut pack_cmd = Command::cargo_bin("cordance").expect("bin");
    pack_cmd
        .arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("pack")
        .arg("--output-mode")
        .arg("write");
    let _ = pack_cmd.output();

    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("explain")
        .arg("nonexistent-rule-xyz-abc");
    cmd.assert().success();
}

#[test]
fn cordance_explain_finds_rule_after_pack() {
    let dir = tempfile::tempdir().expect("tempdir");
    // Run pack first to generate evidence-map.json.
    let mut pack_cmd = Command::cargo_bin("cordance").expect("bin");
    pack_cmd
        .arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("pack")
        .arg("--output-mode")
        .arg("write");
    let _ = pack_cmd.output(); // may fail in empty dir, that's fine.

    // explain should not panic regardless of whether the rule exists.
    let mut explain_cmd = Command::cargo_bin("cordance").expect("bin");
    explain_cmd
        .arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("explain")
        .arg("R-build-1");
    let _ = explain_cmd.output();
}

#[test]
fn cordance_cortex_push_dry_run_succeeds() {
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(".")
        .arg("cortex")
        .arg("push")
        .arg("--dry-run");
    cmd.assert().success();
}

#[test]
fn cortex_push_dry_run_writes_file_but_omits_handoff() {
    // BUILD_SPEC §6.6: dry-run must still persist the receipt JSON, but must
    // NOT print the operator-handoff line. The earlier inverted behaviour
    // (write only when `!dry_run`) is the regression this test guards
    // against.
    let dir = tempfile::tempdir().expect("tempdir");

    // Cortex push requires a pack first; pack from an empty tempdir is
    // best-effort because some required surfaces may be absent.
    let mut pack_cmd = Command::cargo_bin("cordance").expect("bin");
    pack_cmd
        .arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("pack")
        .arg("--output-mode")
        .arg("write");
    let _ = pack_cmd.output();

    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("cortex")
        .arg("push")
        .arg("--dry-run");
    let output = cmd.output().expect("run cordance");
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert!(
        stdout.contains("Cortex receipt:"),
        "expected receipt path in stdout; got: {stdout}"
    );
    assert!(
        !stdout.contains("To submit:"),
        "dry-run leaked the operator handoff text into stdout: {stdout}"
    );
    assert!(
        dir.path().join(".cordance/cortex-receipt.json").exists(),
        "dry-run failed to write the receipt file"
    );
}

#[test]
fn cortex_push_without_dry_run_prints_handoff() {
    // Inverse of the dry-run test: without `--dry-run` the handoff text must
    // appear AND the receipt file must exist.
    let dir = tempfile::tempdir().expect("tempdir");

    let mut pack_cmd = Command::cargo_bin("cordance").expect("bin");
    pack_cmd
        .arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("pack")
        .arg("--output-mode")
        .arg("write");
    let _ = pack_cmd.output();

    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("cortex")
        .arg("push");
    let output = cmd.output().expect("run cordance");
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert!(
        stdout.contains("Cortex receipt:"),
        "expected receipt path in stdout; got: {stdout}"
    );
    assert!(
        stdout.contains("To submit:"),
        "non-dry-run must print the operator handoff text; got: {stdout}"
    );
    assert!(
        dir.path().join(".cordance/cortex-receipt.json").exists(),
        "non-dry-run failed to write the receipt file"
    );
}

#[test]
fn cordance_pack_with_llm_none_works() {
    // `--llm none` must behave identically to no `--llm` flag: pack runs to
    // completion, no Ollama traffic, no `.cordance/llm-candidate.json` written.
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(".")
        .arg("pack")
        .arg("--output-mode")
        .arg("dry-run")
        .arg("--llm")
        .arg("none");
    cmd.assert().success();
}

#[test]
fn cordance_pack_llm_flag_accepts_ollama_model_override() {
    // The flag must parse and the dry-run must succeed even when an Ollama
    // model is named — if Ollama is unreachable the LLM block warns and
    // continues; the pack itself never fails.
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(".")
        .arg("pack")
        .arg("--output-mode")
        .arg("dry-run")
        .arg("--llm")
        .arg("none")
        .arg("--ollama-model")
        .arg("qwen2.5-coder:14b");
    cmd.assert().success();
}

/// Tests the "Ollama unavailable" path end-to-end. Ignored by default because
/// it depends on nothing listening on port 11434, which is not guaranteed on
/// every developer machine. Run with `cargo test -- --ignored`.
#[test]
#[ignore = "depends on port 11434 being free; run with --ignored locally"]
fn cordance_pack_with_llm_ollama_unavailable_warns_but_succeeds() {
    let dir = tempfile::tempdir().expect("tempdir");
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(dir.path())
        .arg("pack")
        .arg("--output-mode")
        .arg("dry-run")
        .arg("--llm")
        .arg("ollama");
    cmd.assert().success();
}

#[test]
fn cli_rejects_target_outside_cwd() {
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    // Use a path that exists but is above cwd — e.g. system root.
    let outside = if cfg!(windows) { "C:\\" } else { "/" };
    cmd.arg("--target").arg(outside).arg("scan");
    cmd.assert()
        .failure()
        .stderr(predicates::str::contains("outside"));
}

#[test]
fn cli_accepts_target_inside_cwd() {
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target").arg(".").arg("scan").arg("--json");
    cmd.assert().success();
}

#[test]
fn cli_allow_outside_cwd_overrides_guard() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(tmp.path())
        .arg("--allow-outside-cwd")
        .arg("scan")
        .arg("--json");
    cmd.assert().success();
}

/// Round-3 bughunt #1: a relative symlink whose target uses `..` segments to
/// escape the temp directory must be rejected by the CLI's path guard. The
/// earlier guard relied on `Path::starts_with`, a syntactic prefix check —
/// so a target like `<tempdir>/trap` pointing at `../../../etc/passwd` slipped
/// through (the joined path still LOOKED like it started with the cwd, even
/// though `..` segments meant it didn't end there).
#[test]
#[cfg(unix)]
fn cli_rejects_relative_symlink_escaping_cwd() {
    use std::os::unix::fs::symlink;
    let dir = tempfile::tempdir().expect("tempdir");
    let link = dir.path().join("trap");
    // Relative target with enough `..` to escape the tempdir on any reasonable
    // path depth. Target is intentionally non-existent: the validator must
    // still reject without dereferencing.
    symlink("../../../../../../../../../../../etc/passwd-nonexistent", &link)
        .expect("symlink");

    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(&link)
        .arg("scan")
        .arg("--json");
    cmd.assert().failure().stderr(
        predicates::str::contains("symlink").or(predicates::str::contains("outside")),
    );
}

/// Round-3 redteam #2: the production `pack` path now flows through
/// `load_doctrine_with_fallback`, not the legacy `load_doctrine_or_default`.
/// We can't easily simulate a fallback clone from a unit test without network
/// access, but we can exercise the failure-on-doctrine path: when the doctrine
/// sibling is absent AND the fallback URL is unreachable, `pack` must still
/// succeed (the loader is non-fatal) and must record the doctrine load
/// failure on the `residual_risk` audit trail.
///
/// This test runs `pack` in `dry-run` mode in an empty tempdir with the
/// doctrine source pointed at a non-existent path. The fallback URL is set
/// to a syntactically valid but unresolvable hostname so the fallback fails
/// deterministically without making it past DNS.
#[test]
fn pack_uses_hardened_doctrine_loader_and_records_failure() {
    let dir = tempfile::tempdir().expect("tempdir");
    // Point doctrine at a path that won't exist, and configure a fallback URL
    // that will be rejected/unreachable so the hardened loader's failure path
    // is what we observe. We use a TLD that doesn't resolve so this test
    // does not depend on network policy.
    std::fs::write(
        dir.path().join("cordance.toml"),
        r#"
[doctrine]
source = "definitely-does-not-exist-engineering-doctrine"
fallback_repo = "https://nonexistent.example.invalid/doctrine"
pin_commit = "auto"
"#,
    )
    .expect("write cordance.toml");

    let mut cmd = Command::cargo_bin("cordance").expect("bin");
    cmd.arg("--target")
        .arg(dir.path())
        .arg("--allow-outside-cwd")
        .arg("pack")
        .arg("--output-mode")
        .arg("write");
    // The pack itself must succeed (doctrine failure is non-fatal), and the
    // resulting pack.json should land in `.cordance/`.
    cmd.assert().success();

    let pack_path = dir.path().join(".cordance/pack.json");
    assert!(
        pack_path.exists(),
        "pack.json was not written; hardened doctrine loader may have aborted"
    );
    let pack_json = std::fs::read_to_string(&pack_path).expect("read pack.json");
    // The residual_risk audit trail must show the doctrine load failure
    // produced by `load_doctrine_with_fallback`. Without that signal, a
    // future regression that silently swallows the failure would land
    // unnoticed.
    assert!(
        pack_json.contains("doctrine load failed"),
        "residual_risk did not record the doctrine load failure (pack.json snippet: {})",
        &pack_json[..pack_json.len().min(1024)]
    );
}
