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

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

#[test]
fn new_creates_minimal_valid_project() {
    let project = unique_path("new-project");

    let output = run_glagol(["new".as_ref(), project.as_os_str()]);

    assert_success("glagol new", &output);
    assert!(output.stdout.is_empty(), "new wrote stdout");
    assert!(output.stderr.is_empty(), "new wrote stderr");
    assert_eq!(
        fs::read_to_string(project.join("slovo.toml")).expect("read manifest"),
        format!(
            "[project]\nname = \"{}\"\nsource_root = \"src\"\nentry = \"main\"\n",
            project.file_name().unwrap().to_string_lossy()
        )
    );
    assert_eq!(
        fs::read_to_string(project.join("src/main.slo")).expect("read main"),
        "(module main)\n\n(fn main () -> i32\n  0)\n\n(test \"main returns zero\"\n  (= (main) 0))\n"
    );

    let check = run_glagol(["check".as_ref(), project.as_os_str()]);
    assert_success("generated project check", &check);
    let fmt_check = run_glagol(["fmt".as_ref(), "--check".as_ref(), project.as_os_str()]);
    assert_success("generated project fmt check", &fmt_check);
    let test = run_glagol(["test".as_ref(), project.as_os_str()]);
    assert_success("generated project test", &test);
    assert_eq!(
        String::from_utf8_lossy(&test.stdout),
        "test \"main returns zero\" ... ok\n1 test(s) passed\n"
    );
    let docs = unique_path("new-project-docs");
    let doc = run_glagol([
        "doc".as_ref(),
        project.as_os_str(),
        "-o".as_ref(),
        docs.as_os_str(),
    ]);
    assert_success("generated project docs", &doc);
    let doc_index = fs::read_to_string(docs.join("index.md")).expect("read generated docs");
    assert!(doc_index.contains("main returns zero"));

    let binary = unique_path("new-project-bin");
    let build = run_glagol([
        "build".as_ref(),
        project.as_os_str(),
        "-o".as_ref(),
        binary.as_os_str(),
    ]);
    if build.status.success() {
        let run = Command::new(&binary)
            .output()
            .expect("run generated project");
        assert_success("generated project binary", &run);
        assert!(
            run.stdout.is_empty(),
            "generated project binary wrote stdout"
        );
    } else {
        assert_stderr_contains("generated project build", &build, "ToolchainUnavailable");
    }
}

#[test]
fn new_rejects_non_empty_target_with_structured_diagnostic() {
    let project = unique_path("new-non-empty");
    fs::create_dir_all(&project).expect("create project dir");
    fs::write(project.join("existing"), "").expect("write existing file");

    let output = run_glagol(["new".as_ref(), project.as_os_str()]);

    assert_exit_code("new non-empty", &output, 1);
    assert!(output.stdout.is_empty(), "new failure wrote stdout");
    assert_stderr_contains("new non-empty", &output, "ProjectScaffoldBlocked");
    assert_stderr_contains("new non-empty", &output, "(schema slovo.diagnostic)");
}

#[test]
fn fmt_check_and_write_work_for_files() {
    let fixture = write_file("fmt-file", "(module   main)\n(fn main() -> i32 0)\n");

    let check = run_glagol(["fmt".as_ref(), "--check".as_ref(), fixture.as_os_str()]);
    assert_exit_code("fmt check dirty file", &check, 1);
    assert_stderr_contains("fmt check dirty file", &check, "FormatCheckFailed");

    let write = run_glagol(["fmt".as_ref(), "--write".as_ref(), fixture.as_os_str()]);
    assert_success("fmt write file", &write);
    assert!(write.stdout.is_empty(), "fmt --write wrote stdout");
    assert_eq!(
        fs::read_to_string(&fixture).expect("read formatted file"),
        "(module main)\n\n(fn main () -> i32\n  0)\n"
    );

    let clean = run_glagol(["fmt".as_ref(), "--check".as_ref(), fixture.as_os_str()]);
    assert_success("fmt check clean file", &clean);
}

#[test]
fn fmt_check_and_write_work_for_projects_deterministically() {
    let project = write_project(
        "fmt-project",
        &[(
            "math",
            "(module   math (export one))\n(fn one() -> i32 1)\n",
        )],
        "(module   main)\n(import math (one))\n(fn main() -> i32 (one))\n",
    );

    let check = run_glagol(["fmt".as_ref(), "--check".as_ref(), project.as_os_str()]);
    assert_exit_code("fmt check dirty project", &check, 1);
    assert_stderr_contains("fmt check dirty project", &check, "FormatCheckFailed");

    let write = run_glagol(["fmt".as_ref(), "--write".as_ref(), project.as_os_str()]);
    assert_success("fmt write project", &write);
    assert!(write.stdout.is_empty(), "project fmt --write wrote stdout");
    assert_eq!(
        fs::read_to_string(project.join("src/math.slo")).expect("read math"),
        "(module math (export one))\n\n(fn one () -> i32\n  1)\n"
    );
    assert_eq!(
        fs::read_to_string(project.join("src/main.slo")).expect("read main"),
        "(module main)\n\n(import math (one))\n\n(fn main () -> i32\n  (one))\n"
    );
}

#[test]
fn doc_generates_markdown_for_file_and_project() {
    let fixture = write_file(
        "doc-file",
        "(module docs)\n\n(struct Point (x i32))\n\n(fn main () -> i32\n  0)\n\n(test \"zero\"\n  (= 0 0))\n",
    );
    let file_docs = unique_path("doc-file-out");

    let file_output = run_glagol([
        "doc".as_ref(),
        fixture.as_os_str(),
        "-o".as_ref(),
        file_docs.as_os_str(),
    ]);
    assert_success("doc file", &file_output);
    let file_index = fs::read_to_string(file_docs.join("index.md")).expect("read file docs");
    assert!(file_index.contains("# File "));
    assert!(file_index.contains("## Module docs"));
    assert!(file_index.contains("- `Point`"));
    assert!(file_index.contains("- `main() -> i32`"));
    assert!(file_index.contains("- `zero`"));

    let project = write_project(
        "doc-project",
        &[(
            "math",
            "(module math (export one))\n\n(fn one () -> i32\n  1)\n",
        )],
        "(module main)\n\n(import math (one))\n\n(fn main () -> i32\n  (one))\n",
    );
    let project_docs = unique_path("doc-project-out");
    let project_output = run_glagol([
        "doc".as_ref(),
        project.as_os_str(),
        "-o".as_ref(),
        project_docs.as_os_str(),
    ]);
    assert_success("doc project", &project_output);
    let project_index =
        fs::read_to_string(project_docs.join("index.md")).expect("read project docs");
    assert!(project_index.contains("# Project doc-project"));
    assert!(project_index.contains("## Module math"));
    assert!(project_index.contains("## Module main"));
    assert!(project_index.contains("- `math`"));
}

#[cfg(unix)]
#[test]
fn project_tooling_rejects_symlinked_module_escape() {
    use std::os::unix::fs::symlink;

    let project = write_project(
        "tooling-escape",
        &[],
        "(module main)\n\n(fn main () -> i32\n  0)\n",
    );
    let outside = write_file(
        "outside-module",
        "(module escape)\n\n(fn value () -> i32\n  1)\n",
    );
    symlink(&outside, project.join("src/escape.slo")).expect("create module symlink");

    let output = run_glagol(["fmt".as_ref(), "--check".as_ref(), project.as_os_str()]);

    assert_exit_code("fmt check symlink escape", &output, 1);
    assert_stderr_contains(
        "fmt check symlink escape",
        &output,
        "ProjectManifestInvalid",
    );
    assert_stderr_contains(
        "fmt check symlink escape",
        &output,
        "escapes the source root",
    );
}

#[test]
fn release_gate_script_exists_and_names_required_commands() {
    let script = Path::new("../scripts/release-gate.sh");
    let text = fs::read_to_string(script).expect("read release gate script");
    assert!(text.contains("git diff --check"));
    assert!(text.contains("cargo fmt --check"));
    assert!(text.contains("cargo test"));
    assert!(text.contains("dx_v1_7"));
    assert!(text.contains("beta_1_0_0"));
    assert!(text.contains("beta_v2_0_0_beta_1"));
    assert!(text.contains("promotion_gate"));
    assert!(text.contains("binary_smoke"));
    assert!(text.contains("llvm_smoke"));
}

fn write_project(name: &str, modules: &[(&str, &str)], main: &str) -> PathBuf {
    let project = unique_path(name);
    fs::create_dir_all(project.join("src")).expect("create project src");
    fs::write(
        project.join("slovo.toml"),
        format!(
            "[project]\nname = \"{}\"\nsource_root = \"src\"\nentry = \"main\"\n",
            name
        ),
    )
    .expect("write manifest");
    for (module, source) in modules {
        fs::write(project.join("src").join(format!("{}.slo", module)), source)
            .expect("write module");
    }
    fs::write(project.join("src/main.slo"), main).expect("write main");
    project
}

fn write_file(name: &str, source: &str) -> PathBuf {
    let path = unique_path(name).with_extension("slo");
    fs::write(&path, source).expect("write fixture");
    path
}

fn unique_path(name: &str) -> PathBuf {
    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("system clock before UNIX_EPOCH")
        .as_nanos();
    std::env::temp_dir().join(format!(
        "glagol-dx-{}-{}-{}-{}",
        std::process::id(),
        nanos,
        id,
        name
    ))
}

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

fn assert_success(context: &str, output: &Output) {
    assert!(
        output.status.success(),
        "{} failed\nstdout:\n{}\nstderr:\n{}",
        context,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

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

fn assert_stderr_contains(context: &str, output: &Output, needle: &str) {
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains(needle),
        "{} stderr did not contain `{}`:\n{}",
        context,
        needle,
        stderr
    );
}
