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

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

#[test]
fn doc_file_renders_public_api_with_signatures_and_shapes() {
    let source = r#"(module api (export Point Status make))

(struct Point
  (x i32)
  (label string))

(enum Status Ready (Blocked i32))

(fn helper ((value i32)) -> i32
  value)

(fn make ((x i32) (label string)) -> Point
  (Point (x x) (label label)))

(test "make is documented"
  true)
"#;
    let file = write_file("file-api", source);
    let docs = unique_path("file-api-docs");

    let output = run_glagol([
        OsStr::new("doc"),
        file.as_os_str(),
        OsStr::new("-o"),
        docs.as_os_str(),
    ]);

    assert_success("doc file", &output);
    let index = read_index(&docs);
    assert!(index.contains("## Module api"));
    assert!(index.contains("### Imports\n\nNone.\n\n"));
    assert!(index.contains("- `Point`"));
    assert!(index.contains("- `make(x i32, label string) -> Point`"));
    assert!(index.contains("- `make is documented`"));

    let api = public_api_for_module(&index, "api");
    assert!(api.contains("- `fn make(x: i32, label: string) -> Point`"));
    assert!(api.contains("- `struct Point`"));
    assert!(api.contains("  - `x: i32`"));
    assert!(api.contains("  - `label: string`"));
    assert!(api.contains("- `enum Status`"));
    assert!(api.contains("  - `Ready`"));
    assert!(api.contains("  - `Blocked(i32)`"));
    assert!(
        !api.contains("helper"),
        "non-exported helper leaked into public API:\n{}",
        api
    );
}

#[test]
fn doc_project_renders_package_and_module_public_api() {
    let project = write_project(
        "project-api",
        &[(
            "math",
            r#"(module math (export add Pair))

(struct Pair
  (left i32)
  (right i32))

(fn add ((left i32) (right i32)) -> i32
  (+ left right))

(fn private_double ((value i32)) -> i32
  (+ value value))
"#,
        )],
        "(module main)\n\n(import math (add Pair))\n\n(fn main () -> i32\n  (add 1 2))\n",
    );
    let docs = unique_path("project-api-docs");

    let output = run_glagol([
        OsStr::new("doc"),
        project.as_os_str(),
        OsStr::new("-o"),
        docs.as_os_str(),
    ]);

    assert_success("doc project", &output);
    let index = read_index(&docs);
    assert!(index.contains("# Project project-api"));
    assert!(index.contains("## Package API project-api"));
    assert!(index.contains("## Module math"));
    assert!(index.contains("## Module main"));
    assert!(index.contains("- `math`"));
    assert!(index.contains("- `add`"));

    let package_api = package_api(&index, "project-api");
    assert!(package_api.contains("### Module math"));
    assert!(package_api.contains("- `fn add(left: i32, right: i32) -> i32`"));
    assert!(package_api.contains("- `struct Pair`"));
    assert!(
        !package_api.contains("private_double"),
        "non-exported function leaked into package API:\n{}",
        package_api
    );

    let math_api = public_api_for_module(&index, "math");
    assert!(math_api.contains("- `fn add(left: i32, right: i32) -> i32`"));
}

#[test]
fn doc_workspace_renders_each_package_api_deterministically() {
    let workspace = unique_path("workspace-api");
    let scaffold = run_glagol([
        OsStr::new("new"),
        workspace.as_os_str(),
        OsStr::new("--template"),
        OsStr::new("workspace"),
    ]);
    assert_success("workspace scaffold", &scaffold);

    let docs = unique_path("workspace-api-docs");
    let output = run_glagol([
        OsStr::new("doc"),
        workspace.as_os_str(),
        OsStr::new("-o"),
        docs.as_os_str(),
    ]);

    assert_success("doc workspace", &output);
    let index = read_index(&docs);
    assert!(index.contains("## Workspace"));
    assert!(index.contains("- `packages/app`"));
    assert!(index.contains("- `packages/libutil`"));
    assert!(index.contains("## Package API app 0.1.0"));
    assert!(index.contains("## Package API libutil 0.1.0"));

    let app_api = package_api(&index, "app 0.1.0");
    assert!(app_api.contains("None."));

    let lib_api = package_api(&index, "libutil 0.1.0");
    assert!(lib_api.contains("### Module libutil"));
    assert!(lib_api.contains("- `fn answer() -> i32`"));
    assert!(lib_api.contains("- `fn label() -> string`"));
}

#[test]
fn doc_workspace_package_api_excludes_loaded_std_modules() {
    let workspace = write_workspace_with_std_import("workspace-std-api");
    let docs = unique_path("workspace-std-api-docs");

    let output = run_glagol([
        OsStr::new("doc"),
        workspace.as_os_str(),
        OsStr::new("-o"),
        docs.as_os_str(),
    ]);

    assert_success("doc workspace std import", &output);
    let index = read_index(&docs);
    assert!(
        index.contains("## Module option"),
        "module summaries should still include loaded std module docs:\n{}",
        index
    );

    let app_api = package_api(&index, "app 0.1.0");
    assert!(app_api.contains("### Module main"));
    assert!(app_api.contains("- `fn local_some(value: i32) -> (option i32)`"));
    assert!(
        !app_api.contains("Module std.option") && !app_api.contains("Module option"),
        "loaded std module leaked into package API:\n{}",
        app_api
    );
    assert!(
        !app_api.contains("some_i32"),
        "loaded std helper leaked into package API:\n{}",
        app_api
    );
}

#[test]
fn public_api_normalizes_local_aliases_and_omits_alias_exports() {
    let source = r#"(module aliases (export Count Score Status measure))

(type Count i32)
(type MaybeCount (option Count))

(struct Score
  (value Count)
  (maybe MaybeCount))

(enum Status Ready (Blocked Count) (Maybe MaybeCount))

(fn hidden ((value Count)) -> Count
  value)

(fn measure ((value Count) (maybe MaybeCount)) -> Count
  value)
"#;
    let file = write_file("alias-api", source);
    let docs = unique_path("alias-api-docs");

    let output = run_glagol([
        OsStr::new("doc"),
        file.as_os_str(),
        OsStr::new("-o"),
        docs.as_os_str(),
    ]);

    assert_success("doc aliases", &output);
    let index = read_index(&docs);
    assert!(
        index.contains("- `Count`"),
        "exports summary should retain the alias name"
    );
    assert!(
        index.contains("- `hidden(value Count) -> Count`"),
        "function summary should retain non-public declarations"
    );

    let api = public_api_for_module(&index, "aliases");
    assert!(api.contains("- `fn measure(value: i32, maybe: (option i32)) -> i32`"));
    assert!(api.contains("  - `value: i32`"));
    assert!(api.contains("  - `maybe: (option i32)`"));
    assert!(api.contains("  - `Blocked(i32)`"));
    assert!(api.contains("  - `Maybe((option i32))`"));
    assert!(
        !api.contains("Count"),
        "alias names leaked into public API:\n{}",
        api
    );
    assert!(
        !api.contains("hidden"),
        "non-exported function leaked into public API:\n{}",
        api
    );
}

#[test]
fn repeated_doc_generation_is_byte_identical() {
    let source = r#"(module stable (export value))

(fn value () -> i32
  42)
"#;
    let file = write_file("stable-api", source);
    let docs = unique_path("stable-api-docs");

    let first = run_glagol([
        OsStr::new("doc"),
        file.as_os_str(),
        OsStr::new("-o"),
        docs.as_os_str(),
    ]);
    assert_success("first doc", &first);
    let first_bytes = fs::read(docs.join("index.md")).expect("read first docs");

    let second = run_glagol([
        OsStr::new("doc"),
        file.as_os_str(),
        OsStr::new("-o"),
        docs.as_os_str(),
    ]);
    assert_success("second doc", &second);
    let second_bytes = fs::read(docs.join("index.md")).expect("read second docs");

    assert_eq!(first_bytes, second_bytes);
}

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_workspace_with_std_import(name: &str) -> PathBuf {
    let workspace = unique_path(name);
    let package = workspace.join("packages/app");
    fs::create_dir_all(package.join("src")).expect("create workspace package src");
    fs::write(
        workspace.join("slovo.toml"),
        "[workspace]\nmembers = [\"packages/app\"]\ndefault_package = \"app\"\n",
    )
    .expect("write workspace manifest");
    fs::write(
        package.join("slovo.toml"),
        "[package]\nname = \"app\"\nversion = \"0.1.0\"\nsource_root = \"src\"\nentry = \"main\"\n",
    )
    .expect("write package manifest");
    fs::write(
        package.join("src/main.slo"),
        r#"(module main (export local_some))

(import std.option (some_i32))

(fn local_some ((value i32)) -> (option i32)
  (some_i32 value))
"#,
    )
    .expect("write package main");
    workspace
}

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 read_index(docs: &Path) -> String {
    fs::read_to_string(docs.join("index.md")).expect("read generated docs")
}

fn package_api<'a>(docs: &'a str, package: &str) -> &'a str {
    let heading = format!("## Package API {}", package);
    let start = docs
        .find(&heading)
        .unwrap_or_else(|| panic!("missing package API heading `{}`\n{}", heading, docs));
    let rest = &docs[start..];
    let end = rest
        .find("\n## Package API ")
        .or_else(|| rest.find("\n## Module "))
        .unwrap_or(rest.len());
    &rest[..end]
}

fn public_api_for_module<'a>(docs: &'a str, module: &str) -> &'a str {
    let heading = format!("## Module {}", module);
    let module_start = if docs.starts_with(&heading) {
        0
    } else {
        let marker = format!("\n{}", heading);
        docs.find(&marker)
            .map(|index| index + 1)
            .unwrap_or_else(|| panic!("missing module heading `{}`\n{}", heading, docs))
    };
    let module_docs = &docs[module_start..];
    let module_end = module_docs
        .find("\n## Module ")
        .unwrap_or(module_docs.len());
    let module_docs = &module_docs[..module_end];
    let public_start = module_docs.find("### Public API").unwrap_or_else(|| {
        panic!(
            "missing public API for module `{}`\n{}",
            module, module_docs
        )
    });
    let public_docs = &module_docs[public_start..];
    let public_end = public_docs
        .find("\n### Structs")
        .unwrap_or(public_docs.len());
    &public_docs[..public_end]
}

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-doc-api-beta11-{}-{}-{}-{}",
        std::process::id(),
        nanos,
        id,
        name
    ))
}

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