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

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

#[test]
fn unsigned_integer_to_string_fixture_lowers_and_runs_tests() {
    let fixture =
        Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/unsigned-integer-to-string.slo");

    let llvm = run_glagol([fixture.as_os_str()]);
    assert_success("compile unsigned-integer-to-string fixture", &llvm);
    let stdout = String::from_utf8_lossy(&llvm.stdout);
    assert!(
        stdout.contains("declare ptr @__glagol_num_u32_to_string(i32)")
            && stdout.contains("declare ptr @__glagol_num_u64_to_string(i64)")
            && stdout.contains("call ptr @__glagol_num_u32_to_string(i32 0)")
            && stdout.contains("call ptr @__glagol_num_u32_to_string(i32 4294967295)")
            && stdout.contains("call ptr @__glagol_num_u64_to_string(i64 18446744073709551615)")
            && stdout.contains("call ptr @__glagol_num_u64_to_string(i64 4294967296)")
            && stdout.contains("call void @print_string(ptr %")
            && !stdout.contains("@std.num."),
        "unsigned-integer-to-string LLVM shape drifted\nstdout:\n{}",
        stdout
    );

    let tests = run_glagol([OsStr::new("test"), fixture.as_os_str()]);
    assert_success("run unsigned-integer-to-string fixture tests", &tests);
    assert_eq!(
        String::from_utf8_lossy(&tests.stdout),
        concat!(
            "test \"u32 zero to string\" ... ok\n",
            "test \"u32 high to string\" ... ok\n",
            "test \"u32 high string length\" ... ok\n",
            "test \"u64 zero to string\" ... ok\n",
            "test \"u64 high to string\" ... ok\n",
            "test \"u64 beyond u32 to string\" ... ok\n",
            "test \"u64 high string length\" ... ok\n",
            "7 test(s) passed\n",
        ),
        "unsigned-integer-to-string test runner stdout drifted"
    );
}

#[test]
fn unsigned_integer_to_string_hosted_runtime_formats_bounds_when_toolchain_is_available() {
    let fixture =
        Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/unsigned-integer-to-string.slo");
    let binary = unique_path("unsigned-integer-to-string-bin");

    let build = run_glagol([
        OsStr::new("build"),
        fixture.as_os_str(),
        OsStr::new("-o"),
        binary.as_os_str(),
    ]);
    if !build.status.success() {
        let stdout = String::from_utf8_lossy(&build.stdout);
        let stderr = String::from_utf8_lossy(&build.stderr);
        assert!(
            stdout.is_empty(),
            "failed unsigned-integer-to-string build wrote stdout:\n{}",
            stdout
        );
        assert!(
            stderr.contains("ToolchainUnavailable"),
            "unsigned-integer-to-string build failed unexpectedly\nstderr:\n{}",
            stderr
        );
        return;
    }

    let run = Command::new(&binary)
        .output()
        .expect("run unsigned-integer-to-string binary");
    assert_success("run unsigned-integer-to-string binary", &run);
    assert_eq!(
        String::from_utf8_lossy(&run.stdout),
        "0\n4294967295\n0\n18446744073709551615\n4294967296\n",
        "unsigned-integer-to-string binary stdout drifted"
    );
    assert!(
        run.stderr.is_empty(),
        "unsigned-integer-to-string binary wrote stderr:\n{}",
        String::from_utf8_lossy(&run.stderr)
    );
}

#[test]
fn unsigned_integer_to_string_formatter_and_lowering_are_visible() {
    let fixture =
        Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/unsigned-integer-to-string.slo");

    let formatted = run_glagol([OsStr::new("--format"), fixture.as_os_str()]);
    assert_success("format unsigned-integer-to-string fixture", &formatted);
    let formatted_stdout = String::from_utf8_lossy(&formatted.stdout);
    assert!(
        formatted_stdout.contains("(std.num.u32_to_string 4294967295u32)")
            && formatted_stdout.contains("(std.num.u64_to_string 18446744073709551615u64)")
            && formatted_stdout.contains("(std.string.len (u64_high_text))"),
        "unsigned-integer-to-string formatter output omitted expected calls\nstdout:\n{}",
        formatted_stdout
    );

    let surface = run_glagol([
        OsStr::new("--inspect-lowering=surface"),
        fixture.as_os_str(),
    ]);
    assert_success(
        "inspect unsigned-integer-to-string surface lowering",
        &surface,
    );
    assert_eq!(
        String::from_utf8_lossy(&surface.stdout),
        fs::read_to_string(
            Path::new(env!("CARGO_MANIFEST_DIR"))
                .join("../tests/unsigned-integer-to-string.surface.lower"),
        )
        .expect("read unsigned-integer-to-string surface snapshot"),
        "unsigned-integer-to-string surface lowering snapshot drifted"
    );

    let checked = run_glagol([
        OsStr::new("--inspect-lowering=checked"),
        fixture.as_os_str(),
    ]);
    assert_success(
        "inspect unsigned-integer-to-string checked lowering",
        &checked,
    );
    assert_eq!(
        String::from_utf8_lossy(&checked.stdout),
        fs::read_to_string(
            Path::new(env!("CARGO_MANIFEST_DIR"))
                .join("../tests/unsigned-integer-to-string.checked.lower"),
        )
        .expect("read unsigned-integer-to-string checked snapshot"),
        "unsigned-integer-to-string checked lowering snapshot drifted"
    );
}

#[test]
fn unsigned_integer_to_string_rejections_are_explicit() {
    for (name, source, expected) in [
        (
            "u32-arity",
            "(module main)\n\n(fn main () -> string\n  (std.num.u32_to_string))\n",
            "wrong number of arguments",
        ),
        (
            "u64-type",
            "(module main)\n\n(fn main () -> string\n  (std.num.u64_to_string 1u32))\n",
            "cannot call `std.num.u64_to_string` with argument of wrong type",
        ),
        (
            "generic-to-string",
            "(module main)\n\n(fn main () -> i32\n  (std.num.to_string 1u32)\n  0)\n",
            "standard library call `std.num.to_string` is not supported",
        ),
    ] {
        let fixture = write_fixture(name, source);
        let output = run_glagol([fixture.as_os_str()]);
        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);

        assert!(
            !output.status.success(),
            "compiler unexpectedly accepted unsigned-integer-to-string rejection fixture `{}`\nstdout:\n{}\nstderr:\n{}",
            name,
            stdout,
            stderr
        );
        assert!(
            stdout.is_empty(),
            "rejected compile wrote stdout for `{}`:\n{}",
            name,
            stdout
        );
        assert!(
            stderr.contains(expected),
            "unsigned-integer-to-string diagnostic drifted for `{}`\nstderr:\n{}",
            name,
            stderr
        );
    }
}

fn run_glagol<I, S>(args: I) -> Output
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    Command::new(env!("CARGO_BIN_EXE_glagol"))
        .args(args)
        .current_dir(Path::new(env!("CARGO_MANIFEST_DIR")))
        .output()
        .expect("run glagol")
}

fn write_fixture(name: &str, source: &str) -> PathBuf {
    let mut path = env::temp_dir();
    path.push(format!(
        "glagol-unsigned-integer-to-string-alpha-{}-{}-{}.slo",
        name,
        std::process::id(),
        NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
    ));
    fs::write(&path, source).unwrap_or_else(|err| panic!("write `{}`: {}", path.display(), err));
    path
}

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

fn assert_success(context: &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{}",
        context,
        stdout,
        stderr
    );
    assert!(stderr.is_empty(), "{} wrote stderr:\n{}", context, stderr);
}
