use std::{
    fs,
    path::PathBuf,
    process::{Command, Output},
    sync::atomic::{AtomicUsize, Ordering},
};

static NEXT_FIXTURE_ID: AtomicUsize = AtomicUsize::new(0);

#[test]
fn unknown_function_is_rejected_without_panic() {
    assert_rejected(
        "unknown-function",
        r#"
(module main)

(fn main () -> i32
  (missing 1))
"#,
        "UnknownFunction",
    );
}

#[test]
fn arity_mismatch_is_rejected_without_panic() {
    assert_rejected(
        "arity-mismatch",
        r#"
(module main)

(fn add ((a i32) (b i32)) -> i32
  (+ a b))

(fn main () -> i32
  (add 1))
"#,
        "ArityMismatch",
    );
}

#[test]
fn type_mismatch_is_rejected_without_panic() {
    assert_rejected(
        "type-mismatch",
        r#"
(module main)

(fn id ((value i32)) -> i32
  value)

(fn main () -> i32
  (id true))
"#,
        "TypeMismatch",
    );
}

#[test]
fn unclosed_list_is_rejected_without_panic() {
    assert_rejected(
        "unclosed-list",
        r#"
(module main)

(fn main () -> i32
  (+ 1 2)
"#,
        "UnclosedList",
    );
}

#[test]
fn unknown_top_level_form_is_rejected_without_panic() {
    assert_rejected(
        "unknown-top-level-form",
        r#"
(module main)

(bogus top level)
"#,
        "UnknownTopLevelForm",
    );
}

#[test]
fn checked_if_emits_llvm_without_panic() {
    let output = run_compiler(
        "checked-if",
        r#"
(module main)

(fn main () -> i32
  (if true 1 0))
"#,
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "compiler rejected checked if\nstdout:\n{}\nstderr:\n{}",
        stdout,
        stderr,
    );
    assert!(
        stdout.contains("br i1") && stdout.contains(" phi i32 "),
        "checked if LLVM did not include branch and phi\nstdout:\n{}",
        stdout,
    );
    assert!(
        !stderr.contains("panicked at") && !stderr.contains("thread 'main' panicked"),
        "compiler panicked for checked if\nstderr:\n{}",
        stderr,
    );
}

#[test]
fn top_level_test_does_not_break_compile_to_llvm() {
    let output = run_compiler(
        "top-level-test-compile",
        r#"
(module main)

(fn add ((a i32) (b i32)) -> i32
  (+ a b))

(test "add works"
  (= (add 2 3) 5))

(fn main () -> i32
  0)
"#,
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "compiler rejected top-level test fixture\nstdout:\n{}\nstderr:\n{}",
        stdout,
        stderr,
    );
    assert!(
        stdout.contains("define i32 @add") && stdout.contains("define i32 @main"),
        "compiler did not emit expected functions\nstdout:\n{}",
        stdout,
    );
    assert!(
        !stdout.contains("add works"),
        "compiler emitted test metadata into LLVM output\nstdout:\n{}",
        stdout,
    );
    assert!(
        !stderr.contains("panicked at") && !stderr.contains("thread 'main' panicked"),
        "compiler panicked for top-level test fixture\nstderr:\n{}",
        stderr,
    );
}

#[test]
fn explicit_emit_llvm_matches_default_compile_mode() {
    let source = r#"
(module main)

(fn main () -> i32
  0)
"#;

    let default = run_compiler("default-compile-mode", source);
    let explicit = run_compiler_with_args("explicit-emit-llvm", source, ["--emit=llvm"]);

    assert_success_contains_needle("default compile", default, "define i32 @main");
    assert_success_contains_needle("explicit --emit=llvm", explicit, "define i32 @main");
}

#[test]
fn output_file_receives_default_compile_output_without_stdout() {
    let fixture = write_fixture(
        "default-output-file",
        r#"
(module main)

(fn main () -> i32
  0)
"#,
    );
    let output_path = temp_path("default-output-file", "ll");

    let output = Command::new(compiler_path())
        .arg("-o")
        .arg(&output_path)
        .arg(&fixture)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));

    assert_success_empty_stdout("default compile with -o", output);
    let output_file = fs::read_to_string(&output_path)
        .unwrap_or_else(|err| panic!("read `{}`: {}", output_path.display(), err));
    assert!(
        output_file.contains("define i32 @main"),
        "output file did not contain LLVM IR\n{}",
        output_file,
    );
}

#[test]
fn output_file_receives_explicit_emit_llvm_output() {
    let fixture = write_fixture(
        "explicit-emit-output-file",
        r#"
(module main)

(fn main () -> i32
  0)
"#,
    );
    let output_path = temp_path("explicit-emit-output-file", "ll");

    let output = Command::new(compiler_path())
        .arg("--emit=llvm")
        .arg(&fixture)
        .arg("-o")
        .arg(&output_path)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));

    assert_success_empty_stdout("explicit --emit=llvm with -o", output);
    let output_file = fs::read_to_string(&output_path)
        .unwrap_or_else(|err| panic!("read `{}`: {}", output_path.display(), err));
    assert!(
        output_file.contains("define i32 @main"),
        "output file did not contain LLVM IR\n{}",
        output_file,
    );
}

#[test]
fn manifest_records_successful_stdout_output() {
    let fixture = write_fixture(
        "manifest-stdout-success",
        r#"
(module main)

(fn main () -> i32
  0)
"#,
    );
    let manifest_path = temp_path("manifest-stdout-success", "manifest.slo");

    let output = Command::new(compiler_path())
        .arg("--manifest")
        .arg(&manifest_path)
        .arg(&fixture)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));

    assert_success_contains_needle("manifest stdout success", output, "define i32 @main");
    let manifest = read_manifest(&manifest_path);
    assert!(
        manifest.contains("(artifact-manifest\n")
            && manifest.contains("  (schema slovo.artifact-manifest)\n")
            && manifest.contains("  (version 1)\n")
            && manifest.contains("  (mode emit-llvm)\n")
            && manifest.contains("  (success true)\n")
            && manifest.contains("  (diagnostics-schema-version 1)\n")
            && manifest.contains("    (kind llvm-ir)\n")
            && manifest.contains("    (stdout \"")
            && manifest.contains("define i32 @main"),
        "manifest did not record successful stdout LLVM output\n{}",
        manifest,
    );
}

#[test]
fn manifest_records_output_file_path_for_o() {
    let fixture = write_fixture(
        "manifest-output-file",
        r#"
(module main)

(fn main () -> i32
  0)
"#,
    );
    let output_path = temp_path("manifest-output-file", "ll");
    let manifest_path = temp_path("manifest-output-file", "manifest.slo");

    let output = Command::new(compiler_path())
        .arg("--emit=llvm")
        .arg("-o")
        .arg(&output_path)
        .arg("--manifest")
        .arg(&manifest_path)
        .arg(&fixture)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));

    assert_success_empty_stdout("manifest with -o", output);
    let output_file = fs::read_to_string(&output_path)
        .unwrap_or_else(|err| panic!("read `{}`: {}", output_path.display(), err));
    assert!(
        output_file.contains("define i32 @main"),
        "output file did not contain LLVM IR\n{}",
        output_file,
    );

    let manifest = read_manifest(&manifest_path);
    assert!(
        manifest.contains("  (success true)\n")
            && manifest.contains("    (kind llvm-ir)\n")
            && manifest.contains(&format!("    (path \"{}\")", output_path.display()))
            && manifest.contains("  (artifacts\n")
            && !manifest.contains("    (stdout \""),
        "manifest did not record -o path as the primary output\n{}",
        manifest,
    );
}

#[test]
fn manifest_records_source_diagnostic_failure() {
    let fixture = write_fixture(
        "manifest-diagnostic-failure",
        r#"
(module main)

(fn id ((value i32)) -> i32
  value)

(fn main () -> i32
  (id true))
"#,
    );
    let manifest_path = temp_path("manifest-diagnostic-failure", "manifest.slo");

    let output = Command::new(compiler_path())
        .arg("--manifest")
        .arg(&manifest_path)
        .arg(&fixture)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert_exit_code("manifest diagnostic failure", &output, 1);
    assert!(
        stdout.is_empty(),
        "diagnostic failure wrote stdout:\n{}",
        stdout
    );
    assert!(
        stderr.contains("(diagnostic\n")
            && stderr.contains("  (schema slovo.diagnostic)\n")
            && stderr.contains("  (version 1)\n")
            && stderr.contains("  (code TypeMismatch)\n"),
        "stderr did not contain v1 machine diagnostics\n{}",
        stderr,
    );

    let manifest = read_manifest(&manifest_path);
    assert!(
        manifest.contains("  (success false)\n")
            && manifest.contains("  (diagnostics-schema-version 1)\n")
            && manifest.contains("    (kind diagnostics)\n")
            && manifest.contains("    (stream stderr)\n")
            && manifest.contains("slovo.diagnostic")
            && manifest.contains("TypeMismatch"),
        "manifest did not record diagnostic failure\n{}",
        manifest,
    );
}

#[test]
fn manifest_records_run_tests_summary() {
    let fixture = write_fixture(
        "manifest-run-tests",
        r#"
(module main)

(fn add ((a i32) (b i32)) -> i32
  (+ a b))

(test "add works"
  (= (add 2 3) 5))
"#,
    );
    let manifest_path = temp_path("manifest-run-tests", "manifest.slo");

    let output = Command::new(compiler_path())
        .arg("--run-tests")
        .arg("--manifest")
        .arg(&manifest_path)
        .arg(&fixture)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));

    assert_success_contains(
        "manifest run tests",
        output,
        "test \"add works\" ... ok\n1 test(s) passed\n",
    );
    let manifest = read_manifest(&manifest_path);
    assert!(
        manifest.contains("  (mode run-tests)\n")
            && manifest.contains("  (test-report\n")
            && manifest.contains("    (total 1)\n")
            && manifest.contains("    (passed 1)\n")
            && manifest.contains("    (failed 0)\n")
            && manifest.contains("    (skipped 0)\n"),
        "manifest did not include run-test summary\n{}",
        manifest,
    );
}

#[test]
fn run_tests_filter_selects_skips_and_records_manifest_counts() {
    let fixture = write_fixture(
        "run-tests-filter",
        r#"
(module main)

(test "alpha first" true)
(test "Alpha case" false)
(test "beta second" true)
"#,
    );
    let manifest_path = temp_path("run-tests-filter", "manifest.slo");

    let output = Command::new(compiler_path())
        .arg("test")
        .arg(&fixture)
        .arg("--filter")
        .arg("alpha")
        .arg("--manifest")
        .arg(&manifest_path)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));

    assert_success_contains(
        "filtered canonical test",
        output,
        "test \"alpha first\" ... ok\n\
test \"Alpha case\" ... skipped\n\
test \"beta second\" ... skipped\n\
1 test(s) passed (total_discovered 3, selected 1, passed 1, failed 0, skipped 2, filter \"alpha\")\n",
    );
    let manifest = read_manifest(&manifest_path);
    assert!(
        manifest.contains("    (total 3)\n")
            && manifest.contains("    (total_discovered 3)\n")
            && manifest.contains("    (selected 1)\n")
            && manifest.contains("    (passed 1)\n")
            && manifest.contains("    (failed 0)\n")
            && manifest.contains("    (skipped 2)\n")
            && manifest.contains("    (filter \"alpha\")\n"),
        "manifest did not include filtered run-test summary\n{}",
        manifest,
    );
}

#[test]
fn legacy_run_tests_filter_zero_match_is_success() {
    let output = run_compiler_with_args(
        "legacy-run-tests-filter-zero-match",
        r#"
(module main)

(test "one" true)
(test "two" true)
"#,
        ["--run-tests", "--filter", "missing"],
    );

    assert_success_contains(
        "legacy filtered zero match",
        output,
        "test \"one\" ... skipped\n\
test \"two\" ... skipped\n\
0 test(s) passed (total_discovered 2, selected 0, passed 0, failed 0, skipped 2, filter \"missing\")\n",
    );
}

#[test]
fn run_tests_filter_selected_failure_records_counts() {
    let fixture = write_fixture(
        "run-tests-filter-selected-failure",
        r#"
(module main)

(test "passing skipped" true)
(test "target failure" false)
"#,
    );
    let manifest_path = temp_path("run-tests-filter-selected-failure", "manifest.slo");

    let output = Command::new(compiler_path())
        .arg("test")
        .arg(&fixture)
        .arg("--filter")
        .arg("target")
        .arg("--manifest")
        .arg(&manifest_path)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert_exit_code("filtered selected failure", &output, 1);
    assert!(
        output.stdout.is_empty()
            && stderr.contains("TestFailed")
            && stderr.contains("test summary: total_discovered 2, selected 1, passed 0, failed 1, skipped 1, filter \"target\"")
            && !stderr.contains("passing skipped"),
        "filtered failure had unexpected output\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        stderr,
    );
    let manifest = read_manifest(&manifest_path);
    assert!(
        manifest.contains("  (success false)\n")
            && manifest.contains("    (total_discovered 2)\n")
            && manifest.contains("    (selected 1)\n")
            && manifest.contains("    (passed 0)\n")
            && manifest.contains("    (failed 1)\n")
            && manifest.contains("    (skipped 1)\n")
            && manifest.contains("    (filter \"target\")\n"),
        "manifest did not include filtered failure summary\n{}",
        manifest,
    );
}

#[test]
fn filter_cli_misuse_is_rejected() {
    let fixture = write_fixture(
        "filter-cli-misuse",
        "(module main)\n\n(fn main () -> i32\n  0)\n",
    );

    let missing = Command::new(compiler_path())
        .arg("test")
        .arg(&fixture)
        .arg("--filter")
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));
    assert_cli_rejected("missing filter value", missing, "`--filter` requires");

    let duplicate = Command::new(compiler_path())
        .arg("test")
        .arg(&fixture)
        .arg("--filter")
        .arg("one")
        .arg("--filter")
        .arg("two")
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));
    assert_cli_rejected(
        "duplicate filter",
        duplicate,
        "`--filter` was provided more than once",
    );

    let wrong_mode = Command::new(compiler_path())
        .arg("check")
        .arg(&fixture)
        .arg("--filter")
        .arg("one")
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));
    assert_cli_rejected(
        "filter wrong mode",
        wrong_mode,
        "`--filter` is only supported",
    );
}

#[test]
fn unreadable_source_file_writes_failure_manifest_after_manifest_path_is_parsed() {
    let mut source_path = std::env::temp_dir();
    let id = NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed);
    source_path.push(format!(
        "glagol-strict-v0-{}-{}-manifest-missing.slo",
        std::process::id(),
        id
    ));
    let manifest_path = temp_path("manifest-unreadable-source", "manifest.slo");

    let output = Command::new(compiler_path())
        .arg("--manifest")
        .arg(&manifest_path)
        .arg(&source_path)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", source_path.display(), err));

    assert_exit_code("unreadable source with manifest", &output, 1);
    let manifest = read_manifest(&manifest_path);
    assert!(
        manifest.contains("  (success false)\n")
            && manifest.contains("  (mode emit-llvm)\n")
            && manifest.contains("InputReadFailed"),
        "input/read failure manifest mismatch\n{}",
        manifest
    );
}

#[test]
fn manifest_path_must_not_match_output_path() {
    let fixture = write_fixture(
        "manifest-output-same-path",
        r#"
(module main)

(fn main () -> i32
  0)
"#,
    );
    let shared_path = temp_path("manifest-output-same-path", "out");

    let output = Command::new(compiler_path())
        .arg("-o")
        .arg(&shared_path)
        .arg("--manifest")
        .arg(&shared_path)
        .arg(&fixture)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));

    assert_cli_rejected(
        "manifest output same path",
        output,
        "output path and manifest path must be different",
    );
}

#[test]
fn manifest_path_must_not_alias_output_path() {
    let fixture = write_fixture(
        "manifest-output-alias-path",
        r#"
(module main)

(fn main () -> i32
  0)
"#,
    );
    let output_path = temp_path("manifest-output-alias-path", "out");
    let manifest_alias = output_path
        .parent()
        .expect("temp path has parent")
        .join(".")
        .join(output_path.file_name().expect("temp path has file name"));

    let output = Command::new(compiler_path())
        .arg("-o")
        .arg(&output_path)
        .arg("--manifest")
        .arg(&manifest_alias)
        .arg(&fixture)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));

    assert_cli_rejected(
        "manifest output alias path",
        output,
        "output path and manifest path must be different",
    );
}

#[test]
fn output_file_receives_other_primary_mode_output() {
    let fixture = write_fixture(
        "format-output-file",
        r#"
(module main)

(fn main () -> i32 0)
"#,
    );
    let output_path = temp_path("format-output-file", "slo");

    let output = Command::new(compiler_path())
        .arg("--format")
        .arg("-o")
        .arg(&output_path)
        .arg(&fixture)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));

    assert_success_empty_stdout("--format with -o", output);
    let output_file = fs::read_to_string(&output_path)
        .unwrap_or_else(|err| panic!("read `{}`: {}", output_path.display(), err));
    assert_eq!(
        output_file, "(module main)\n\n(fn main () -> i32\n  0)\n",
        "formatted output file mismatch",
    );
}

#[test]
fn top_level_tests_can_be_checked_and_run() {
    let source = r#"
(module main)

(fn add ((a i32) (b i32)) -> i32
  (+ a b))

(test "add works"
  (= (add 2 3) 5))
"#;

    let checked = run_compiler_with_args("top-level-test-check", source, ["--check-tests"]);
    assert_success_contains(
        "check top-level tests",
        checked,
        "test \"add works\" ... checked\n1 test(s) checked\n",
    );

    let run = run_compiler_with_args("top-level-test-run", source, ["--run-tests"]);
    assert_success_contains(
        "run top-level tests",
        run,
        "test \"add works\" ... ok\n1 test(s) passed\n",
    );
}

#[test]
fn failing_top_level_test_is_reported_without_panic() {
    assert_rejected_with_args(
        "failing-top-level-test",
        r#"
(module main)

(test "false is false"
  false)
"#,
        ["--run-tests"],
        "TestFailed",
    );
}

#[test]
fn invalid_test_name_is_rejected_without_panic() {
    assert_rejected(
        "invalid-test-name",
        r#"
(module main)

(test not-a-string true)
"#,
        "InvalidTestName",
    );
}

#[test]
fn invalid_decoded_test_names_are_rejected_without_panic() {
    let cases = [
        (
            "empty-test-name",
            r#"
(module main)

(test "" true)
"#,
        ),
        (
            "escaped-newline-test-name",
            r#"
(module main)

(test "bad\nname" true)
"#,
        ),
        (
            "escaped-quote-test-name",
            r#"
(module main)

(test "bad\"name" true)
"#,
        ),
        (
            "escaped-backslash-test-name",
            r#"
(module main)

(test "bad\\name" true)
"#,
        ),
    ];

    for (name, source) in cases {
        assert_rejected(name, source, "InvalidTestName");
    }
}

#[test]
fn duplicate_test_name_is_rejected_without_panic() {
    assert_rejected(
        "duplicate-test-name",
        r#"
(module main)

(test "same" true)
(test "same" true)
"#,
        "DuplicateTestName",
    );
}

#[test]
fn unsupported_escape_in_test_name_is_rejected_without_panic() {
    assert_rejected(
        "unsupported-escape-test-name",
        r#"
(module main)

(test "same" true)
(test "sa\me" true)
"#,
        "UnsupportedStringEscape",
    );
}

#[test]
fn non_bool_test_expression_is_rejected_without_panic() {
    assert_rejected(
        "non-bool-test-expression",
        r#"
(module main)

(test "not bool"
  1)
"#,
        "TestExpressionNotBool",
    );
}

#[test]
fn malformed_test_form_is_rejected_without_panic() {
    assert_rejected(
        "malformed-test-form",
        r#"
(module main)

(test "too many" true false)
"#,
        "MalformedTestForm",
    );
}

#[test]
fn test_modes_are_mutually_exclusive_without_order_dependence() {
    let source = r#"
(module main)

(test "ok" true)
"#;
    let cases = [
        ("check-run-tests", ["--check-tests", "--run-tests"]),
        ("run-check-tests", ["--run-tests", "--check-tests"]),
        ("format-run-tests", ["--format", "--run-tests"]),
        ("emit-format", ["--emit=llvm", "--format"]),
        ("format-emit", ["--format", "--emit=llvm"]),
        (
            "checked-lowering-check-tests",
            ["--inspect-lowering=checked", "--check-tests"],
        ),
    ];

    for (name, args) in cases {
        let output = run_compiler_with_args(name, source, args);
        assert_cli_rejected(name, output, "mode flags are mutually exclusive");
    }
}

#[test]
fn string_literal_backend_gap_is_diagnostic_not_panic() {
    assert_rejected(
        "string-if",
        r#"
(module main)

(fn id ((value (ptr i32))) -> i32
  0)
"#,
        "UnsupportedBackendFeature",
    );
}

#[test]
fn integer_out_of_range_is_rejected_without_panic() {
    assert_rejected(
        "integer-out-of-range",
        r#"
(module main)

(fn main () -> i32
  2147483648)
"#,
        "IntegerOutOfRange",
    );
}

#[test]
fn unsupported_signature_type_is_rejected_without_panic() {
    assert_rejected(
        "unsupported-signature-type",
        r#"
(module main)

(fn id ((value (ptr i32))) -> i32
  0)
"#,
        "UnsupportedBackendFeature",
    );
}

#[test]
fn unsupported_unit_return_signature_is_rejected_without_panic() {
    assert_rejected(
        "unsupported-unit-return-signature",
        r#"
(module main)

(fn main () -> unit
  (print_i32 1))
"#,
        "UnsupportedUnitSignatureType",
    );
}

#[test]
fn unsupported_unit_parameter_signature_is_rejected_without_panic() {
    assert_rejected(
        "unsupported-unit-parameter-signature",
        r#"
(module main)

(fn ignore ((value unit)) -> i32
  0)
"#,
        "UnsupportedUnitSignatureType",
    );
}

#[test]
fn unsupported_unit_signatures_are_rejected_before_surface_lowering() {
    assert_rejected_with_args(
        "unsupported-unit-return-signature-surface-lowering",
        r#"
(module main)

(fn main () -> unit
  (print_i32 1))
"#,
        ["--inspect-lowering=surface"],
        "UnsupportedUnitSignatureType",
    );
    assert_rejected_with_args(
        "unsupported-unit-parameter-signature-surface-lowering",
        r#"
(module main)

(fn ignore ((value unit)) -> i32
  0)
"#,
        ["--inspect-lowering=surface"],
        "UnsupportedUnitSignatureType",
    );
}

#[test]
fn help_is_a_successful_usage_request() {
    let output = run_compiler_raw(["--help"]);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert_exit_code("help", &output, 0);
    assert!(stdout.is_empty(), "help wrote stdout:\n{}", stdout);
    assert!(
        stderr.contains("usage: glagol")
            && stderr.contains("--emit=llvm")
            && stderr.contains("--format")
            && stderr.contains("--print-tree")
            && stderr.contains("--inspect-lowering=surface")
            && stderr.contains("--inspect-lowering=checked")
            && stderr.contains("--check-tests")
            && stderr.contains("--run-tests")
            && stderr.contains("-o <path>")
            && stderr.contains("--manifest <path>")
            && stderr.contains("--version"),
        "help output did not describe the v0 CLI modes\nstderr:\n{}",
        stderr,
    );
}

#[test]
fn version_is_a_successful_metadata_request() {
    let output = run_compiler_raw(["--version"]);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert_exit_code("version", &output, 0);
    assert_eq!(
        stdout,
        format!("{} {}\n", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
        "version stdout mismatch",
    );
    assert!(stderr.is_empty(), "version wrote stderr:\n{}", stderr);
}

#[test]
fn missing_source_file_is_usage_error() {
    let output = run_compiler_raw([]);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert_exit_code("missing source file", &output, 2);
    assert!(
        stdout.is_empty(),
        "missing source wrote stdout:\n{}",
        stdout
    );
    assert!(
        stderr.contains("usage: glagol"),
        "missing source did not print usage\nstderr:\n{}",
        stderr,
    );
}

#[test]
fn missing_output_path_is_usage_error() {
    let output = run_compiler_raw(["-o"]);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert_exit_code("missing output path", &output, 2);
    assert!(
        stdout.is_empty(),
        "missing output path wrote stdout:\n{}",
        stdout
    );
    assert!(
        stderr.contains("`-o` requires a following path") && stderr.contains("usage: glagol"),
        "missing output path did not report usage failure\nstderr:\n{}",
        stderr,
    );
}

#[test]
fn unreadable_source_file_is_input_error() {
    let mut path = std::env::temp_dir();
    let id = NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed);
    path.push(format!(
        "glagol-strict-v0-{}-{}-missing.slo",
        std::process::id(),
        id
    ));

    let output = Command::new(compiler_path())
        .arg(&path)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", path.display(), err));
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert_exit_code("unreadable source file", &output, 1);
    assert!(
        stdout.is_empty(),
        "unreadable source wrote stdout:\n{}",
        stdout
    );
    assert!(
        stderr.contains("cannot read"),
        "unreadable source did not report input failure\nstderr:\n{}",
        stderr,
    );
}

#[test]
fn extra_argument_is_usage_error() {
    let fixture = write_fixture(
        "extra-argument",
        r#"
(module main)

(fn main () -> i32
  0)
"#,
    );

    let output = Command::new(compiler_path())
        .arg(&fixture)
        .arg("extra")
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert_exit_code("extra argument", &output, 2);
    assert!(
        stdout.is_empty(),
        "extra argument wrote stdout:\n{}",
        stdout
    );
    assert!(
        stderr.contains("unexpected argument `extra`") && stderr.contains("usage: glagol"),
        "extra argument did not report usage failure\nstderr:\n{}",
        stderr,
    );
}

#[test]
fn explicit_emit_llvm_extra_argument_is_usage_error() {
    let fixture = write_fixture(
        "explicit-emit-extra-argument",
        r#"
(module main)

(fn main () -> i32
  0)
"#,
    );

    let output = Command::new(compiler_path())
        .arg("--emit=llvm")
        .arg(&fixture)
        .arg("extra")
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err));

    assert_cli_rejected(
        "explicit emit extra argument",
        output,
        "unexpected argument `extra`",
    );
}

fn assert_rejected(name: &str, source: &str, code: &str) {
    assert_rejected_with_args(name, source, [], code);
}

fn assert_rejected_with_args<const N: usize>(
    name: &str,
    source: &str,
    args: [&str; N],
    code: &str,
) {
    let output = run_compiler_with_args(name, source, args);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        !output.status.success(),
        "compiler unexpectedly accepted fixture `{}`\nstdout:\n{}\nstderr:\n{}",
        name,
        stdout,
        stderr,
    );
    assert_exit_code(name, &output, 1);
    assert!(
        stdout.is_empty(),
        "compiler emitted LLVM/stdout for rejected fixture `{}`\nstdout:\n{}\nstderr:\n{}",
        name,
        stdout,
        stderr,
    );
    assert!(
        stderr.contains(&format!("error[{}]", code))
            || stderr.contains(&format!("(code {})", code)),
        "stderr for fixture `{}` did not contain diagnostic code `{}`\nstderr:\n{}",
        name,
        code,
        stderr,
    );
    assert!(
        !stderr.contains("panicked at") && !stderr.contains("thread 'main' panicked"),
        "compiler panicked for fixture `{}`\nstderr:\n{}",
        name,
        stderr,
    );
}

fn run_compiler(name: &str, source: &str) -> Output {
    run_compiler_with_args(name, source, [])
}

fn run_compiler_with_args<const N: usize>(name: &str, source: &str, args: [&str; N]) -> Output {
    let fixture = write_fixture(name, source);

    Command::new(compiler_path())
        .args(args)
        .arg(&fixture)
        .output()
        .unwrap_or_else(|err| panic!("run glagol on `{}`: {}", fixture.display(), err))
}

fn run_compiler_raw<const N: usize>(args: [&str; N]) -> Output {
    Command::new(compiler_path())
        .args(args)
        .output()
        .unwrap_or_else(|err| panic!("run glagol: {}", err))
}

fn compiler_path() -> &'static str {
    env!("CARGO_BIN_EXE_glagol")
}

fn assert_success_contains(name: &str, output: Output, expected: &str) {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "{} failed\nstdout:\n{}\nstderr:\n{}",
        name,
        stdout,
        stderr,
    );
    assert_eq!(stdout, expected, "{} stdout mismatch", name);
    assert!(stderr.is_empty(), "{} wrote stderr:\n{}", name, stderr,);
}

fn assert_success_contains_needle(name: &str, output: Output, expected: &str) {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "{} failed\nstdout:\n{}\nstderr:\n{}",
        name,
        stdout,
        stderr,
    );
    assert!(
        stdout.contains(expected),
        "{} stdout did not contain `{}`\nstdout:\n{}",
        name,
        expected,
        stdout,
    );
    assert!(stderr.is_empty(), "{} wrote stderr:\n{}", name, stderr,);
}

fn assert_success_empty_stdout(name: &str, output: Output) {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "{} failed\nstdout:\n{}\nstderr:\n{}",
        name,
        stdout,
        stderr,
    );
    assert!(stdout.is_empty(), "{} wrote stdout:\n{}", name, stdout);
    assert!(stderr.is_empty(), "{} wrote stderr:\n{}", name, stderr);
}

fn assert_cli_rejected(name: &str, output: Output, expected: &str) {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        !output.status.success(),
        "compiler unexpectedly accepted CLI fixture `{}`\nstdout:\n{}\nstderr:\n{}",
        name,
        stdout,
        stderr,
    );
    assert_exit_code(name, &output, 2);
    assert!(
        stdout.is_empty(),
        "compiler emitted stdout for rejected CLI fixture `{}`\nstdout:\n{}\nstderr:\n{}",
        name,
        stdout,
        stderr,
    );
    assert!(
        stderr.contains(expected),
        "stderr for CLI fixture `{}` did not contain `{}`\nstderr:\n{}",
        name,
        expected,
        stderr,
    );
    assert!(
        !stderr.contains("panicked at") && !stderr.contains("thread 'main' panicked"),
        "compiler panicked for CLI fixture `{}`\nstderr:\n{}",
        name,
        stderr,
    );
}

fn write_fixture(name: &str, source: &str) -> PathBuf {
    let path = temp_path(name, "slo");
    fs::write(&path, source).unwrap_or_else(|err| panic!("write `{}`: {}", path.display(), err));
    path
}

fn read_manifest(path: &PathBuf) -> String {
    fs::read_to_string(path).unwrap_or_else(|err| panic!("read `{}`: {}", path.display(), err))
}

fn temp_path(name: &str, extension: &str) -> PathBuf {
    let mut path = std::env::temp_dir();
    let id = NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed);
    path.push(format!(
        "glagol-strict-v0-{}-{}-{}.{}",
        std::process::id(),
        id,
        name,
        extension,
    ));
    path
}

fn assert_exit_code(name: &str, output: &Output, expected: i32) {
    assert_eq!(
        output.status.code(),
        Some(expected),
        "{} exit code mismatch\nstdout:\n{}\nstderr:\n{}",
        name,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}
