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

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

#[test]
fn sexpr_diagnostics_keep_v1_schema_across_source_pipelines() {
    let cases = [
        SourceDiagnosticCase {
            name: "parse",
            args: &["check"],
            source: "(module main",
            code: "UnclosedList",
        },
        SourceDiagnosticCase {
            name: "check",
            args: &["check"],
            source: r#"
(module main)

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

(fn main () -> i32
  (id true))
"#,
            code: "TypeMismatch",
        },
        SourceDiagnosticCase {
            name: "fmt",
            args: &["fmt"],
            source: r#"
(module main) ; comments stay outside formatter input

(fn main () -> i32
  0)
"#,
            code: "UnsupportedFormatterComment",
        },
        SourceDiagnosticCase {
            name: "test",
            args: &["test"],
            source: r#"
(module main)

(test "false case"
  false)
"#,
            code: "TestFailed",
        },
    ];

    for case in cases {
        let fixture = write_fixture(case.name, case.source);
        let mut args = case.args.iter().map(OsStr::new).collect::<Vec<_>>();
        args.push(fixture.as_os_str());

        let output = run_glagol(args);

        assert_exit_code(case.name, &output, 1);
        assert_stdout_empty(case.name, &output);
        assert_sexpr_diagnostic_schema(case.name, &output, case.code, 1);
    }

    let project = write_project(
        "sexpr-project",
        "(module main)\n\n(import missing (value))\n",
    );
    let output = run_glagol(["check".as_ref(), project.as_os_str()]);

    assert_exit_code("project", &output, 1);
    assert_stdout_empty("project", &output);
    assert_sexpr_diagnostic_schema("project", &output, "MissingImport", 1);
}

#[test]
fn json_diagnostics_keep_v1_schema_across_policy_boundaries() {
    let cases = [
        SourceDiagnosticCase {
            name: "json-parse",
            args: &["--json-diagnostics", "check"],
            source: "(module main",
            code: "UnclosedList",
        },
        SourceDiagnosticCase {
            name: "json-check",
            args: &["--json-diagnostics", "check"],
            source: r#"
(module main)

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

(fn main () -> i32
  (id true))
"#,
            code: "TypeMismatch",
        },
        SourceDiagnosticCase {
            name: "json-fmt",
            args: &["--json-diagnostics", "fmt"],
            source: r#"
(module main) ; comments stay outside formatter input

(fn main () -> i32
  0)
"#,
            code: "UnsupportedFormatterComment",
        },
        SourceDiagnosticCase {
            name: "json-test",
            args: &["--json-diagnostics", "test"],
            source: r#"
(module main)

(test "false case"
  false)
"#,
            code: "TestFailed",
        },
    ];

    for case in cases {
        let fixture = write_fixture(case.name, case.source);
        let mut args = case.args.iter().map(OsStr::new).collect::<Vec<_>>();
        args.push(fixture.as_os_str());

        let output = run_glagol(args);

        assert_exit_code(case.name, &output, 1);
        assert_stdout_empty(case.name, &output);
        assert_json_diagnostic_schema(case.name, &output, case.code, JsonSource::Source);
    }

    let project = write_project(
        "json-project",
        "(module main)\n\n(import missing (value))\n",
    );
    let output = run_glagol([
        "--json-diagnostics".as_ref(),
        "check".as_ref(),
        project.as_os_str(),
    ]);

    assert_exit_code("json project", &output, 1);
    assert_stdout_empty("json project", &output);
    assert_json_diagnostic_schema("json project", &output, "MissingImport", JsonSource::Source);

    let usage_manifest = temp_path("json-usage", "manifest.slo");
    let usage = run_glagol([
        "--json-diagnostics".as_ref(),
        "--manifest".as_ref(),
        usage_manifest.as_os_str(),
    ]);

    assert_exit_code("json usage", &usage, 2);
    assert_stdout_empty("json usage", &usage);
    assert_json_diagnostic_schema("json usage", &usage, "UsageError", JsonSource::SourceLess);
    assert_manifest_schema_fields(&read_manifest(&usage_manifest), "json");

    let toolchain_fixture = write_fixture(
        "json-toolchain",
        "(module main)\n\n(fn main () -> i32\n  0)\n",
    );
    let toolchain_manifest = temp_path("json-toolchain", "manifest.slo");
    let output_path = temp_path("json-toolchain", "bin");
    let missing_clang = temp_path("json-toolchain", "not-a-clang");
    let toolchain = Command::new(compiler_path())
        .arg("--json-diagnostics")
        .arg("build")
        .arg(&toolchain_fixture)
        .arg("-o")
        .arg(&output_path)
        .arg("--manifest")
        .arg(&toolchain_manifest)
        .env("GLAGOL_CLANG", &missing_clang)
        .env_remove("GLAGOL_RUNTIME_C")
        .env_remove("SLOVO_RUNTIME_C")
        .output()
        .unwrap_or_else(|err| panic!("run glagol build: {}", err));

    assert_exit_code("json toolchain", &toolchain, 3);
    assert_stdout_empty("json toolchain", &toolchain);
    assert_json_diagnostic_schema(
        "json toolchain",
        &toolchain,
        "ToolchainUnavailable",
        JsonSource::SourceLess,
    );
    assert_manifest_schema_fields(&read_manifest(&toolchain_manifest), "json");
}

#[test]
fn project_failure_manifests_record_schema_encoding_and_count_deterministically() {
    let project = write_project(
        "manifest-project",
        "(module main)\n\n(import missing (value))\n",
    );

    let first_sexpr = run_project_failure_manifest(&project, "sexpr", false);
    let second_sexpr = run_project_failure_manifest(&project, "sexpr-repeat", false);
    assert_manifest_schema_fields(&first_sexpr, "sexpr");
    assert_project_diagnostics_count(&first_sexpr, 1);
    assert_eq!(
        project_block(&first_sexpr),
        project_block(&second_sexpr),
        "S-expression failure manifest project block drifted"
    );

    let first_json = run_project_failure_manifest(&project, "json", true);
    let second_json = run_project_failure_manifest(&project, "json-repeat", true);
    assert_manifest_schema_fields(&first_json, "json");
    assert_project_diagnostics_count(&first_json, 1);
    assert_eq!(
        project_block(&first_json),
        project_block(&second_json),
        "JSON failure manifest project block drifted"
    );
}

struct SourceDiagnosticCase {
    name: &'static str,
    args: &'static [&'static str],
    source: &'static str,
    code: &'static str,
}

#[derive(Copy, Clone)]
enum JsonSource {
    Source,
    SourceLess,
}

fn run_project_failure_manifest(project: &Path, name: &str, json: bool) -> String {
    let manifest = temp_path(name, "manifest.slo");
    let output = if json {
        run_glagol([
            "--json-diagnostics".as_ref(),
            "check".as_ref(),
            "--manifest".as_ref(),
            manifest.as_os_str(),
            project.as_os_str(),
        ])
    } else {
        run_glagol([
            "check".as_ref(),
            "--manifest".as_ref(),
            manifest.as_os_str(),
            project.as_os_str(),
        ])
    };

    assert_exit_code(name, &output, 1);
    assert_stdout_empty(name, &output);
    read_manifest(&manifest)
}

fn assert_sexpr_diagnostic_schema(
    context: &str,
    output: &Output,
    expected_code: &str,
    expected_count: usize,
) {
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        !stderr.trim().is_empty(),
        "{} did not emit diagnostics",
        context
    );
    assert!(
        !stderr
            .lines()
            .all(|line| line.starts_with('{') && line.ends_with('}')),
        "{} unexpectedly emitted JSON diagnostics:\n{}",
        context,
        stderr
    );
    assert_eq!(
        stderr.matches("(diagnostic\n").count(),
        expected_count,
        "{} diagnostic block count drifted:\n{}",
        context,
        stderr
    );
    assert_eq!(
        stderr.matches("  (schema slovo.diagnostic)\n").count(),
        expected_count,
        "{} diagnostic schema name drifted:\n{}",
        context,
        stderr
    );
    assert_eq!(
        stderr.matches("  (version 1)\n").count(),
        expected_count,
        "{} diagnostic schema version drifted:\n{}",
        context,
        stderr
    );
    assert!(
        stderr.contains("  (severity error)\n")
            && stderr.contains(&format!("  (code {})\n", expected_code))
            && stderr.contains("  (file ")
            && stderr.contains("  (span\n"),
        "{} S-expression diagnostic missed required structural fields:\n{}",
        context,
        stderr
    );
}

fn assert_json_diagnostic_schema(
    context: &str,
    output: &Output,
    expected_code: &str,
    source: JsonSource,
) {
    let stderr = String::from_utf8_lossy(&output.stderr);
    let lines = stderr
        .lines()
        .filter(|line| !line.trim().is_empty())
        .collect::<Vec<_>>();
    assert!(!lines.is_empty(), "{} did not emit diagnostics", context);

    for line in &lines {
        assert!(
            line.starts_with('{') && line.ends_with('}'),
            "{} emitted non-JSON diagnostic text:\n{}",
            context,
            stderr
        );
        assert!(
            line.contains(r#""schema":"slovo.diagnostic""#)
                && line.contains(r#""version":1"#)
                && line.contains(r#""severity":"error""#)
                && line.contains(r#""message":"#)
                && line.contains(r#""file":"#)
                && line.contains(r#""span":"#),
            "{} JSON diagnostic missed required schema fields:\n{}",
            context,
            line
        );
    }

    assert!(
        lines
            .iter()
            .any(|line| line.contains(&format!(r#""code":"{}""#, expected_code))),
        "{} JSON diagnostics did not include code `{}`:\n{}",
        context,
        expected_code,
        stderr
    );

    match source {
        JsonSource::Source => assert!(
            lines
                .iter()
                .any(|line| line.contains(r#""span":{"byte_start":"#)),
            "{} JSON diagnostics should include a concrete source span:\n{}",
            context,
            stderr
        ),
        JsonSource::SourceLess => assert!(
            lines
                .iter()
                .any(|line| line.contains(r#""file":null"#) && line.contains(r#""span":null"#)),
            "{} JSON diagnostics should be source-less:\n{}",
            context,
            stderr
        ),
    }
}

fn assert_manifest_schema_fields(manifest: &str, encoding: &str) {
    assert!(
        manifest.contains("  (schema slovo.artifact-manifest)\n")
            && manifest.contains("  (version 1)\n")
            && manifest.contains("  (diagnostics-schema-version 1)\n")
            && manifest.contains(&format!("  (diagnostics-encoding {})\n", encoding))
            && manifest.contains("slovo.diagnostic"),
        "manifest diagnostic schema fields drifted:\n{}",
        manifest
    );
}

fn assert_project_diagnostics_count(manifest: &str, expected: usize) {
    assert!(
        manifest.contains(&format!("    (diagnostics_count {})\n", expected)),
        "manifest diagnostics count drifted:\n{}",
        manifest
    );
}

fn project_block(manifest: &str) -> &str {
    let start = manifest
        .find("  (project\n")
        .expect("manifest did not contain project block");
    manifest[start..]
        .strip_suffix("\n)\n")
        .expect("manifest did not end with artifact-manifest close")
}

fn run_glagol<I, S>(args: I) -> Output
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    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 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 write_project(name: &str, main_source: &str) -> PathBuf {
    let root = temp_dir(name);
    let source_root = root.join("src");
    fs::create_dir_all(&source_root)
        .unwrap_or_else(|err| panic!("create `{}`: {}", source_root.display(), err));
    fs::write(
        root.join("slovo.toml"),
        "[project]\nname = \"beta13\"\nsource_root = \"src\"\nentry = \"main\"\n",
    )
    .unwrap_or_else(|err| panic!("write project manifest: {}", err));
    fs::write(source_root.join("main.slo"), main_source)
        .unwrap_or_else(|err| panic!("write main source: {}", err));
    root
}

fn read_manifest(path: &Path) -> 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-beta13-{}-{}-{}.{}",
        std::process::id(),
        id,
        name,
        extension,
    ));
    path
}

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

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_stdout_empty(context: &str, output: &Output) {
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.is_empty(), "{} wrote stdout:\n{}", context, stdout);
}
