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

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

#[test]
fn duplicate_package_keys_report_package_manifest_invalid() {
    let workspace = write_workspace(
        "duplicate-package-key",
        "[workspace]\nmembers = [\"packages/app\"]\n",
        &[WorkspacePackageSpec {
            member: "packages/app",
            manifest: "[package]\nname = \"app\"\nname = \"other\"\nversion = \"0.1.0\"\n",
            modules: &[("main", "(module main)\n\n(fn main () -> i32\n  0)\n")],
        }],
    );

    let output = run_glagol([
        "--json-diagnostics".as_ref(),
        "check".as_ref(),
        workspace.as_os_str(),
    ]);
    assert_exit_code("duplicate package key", &output, 1);
    assert_json_diagnostic_code("duplicate package key", &output, "PackageManifestInvalid");
    assert_json_diagnostic_code_absent("duplicate package key", &output, "ProjectManifestInvalid");
}

#[test]
fn invalid_dependency_key_reports_invalid_package_dependency_name() {
    let workspace = write_workspace(
        "invalid-dependency-key",
        "[workspace]\nmembers = [\"packages/app\"]\n",
        &[WorkspacePackageSpec {
            member: "packages/app",
            manifest: "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nBad_Name = { path = \"../util\" }\n",
            modules: &[("main", "(module main)\n\n(fn main () -> i32\n  0)\n")],
        }],
    );

    let output = run_glagol([
        "--json-diagnostics".as_ref(),
        "check".as_ref(),
        workspace.as_os_str(),
    ]);
    assert_exit_code("invalid dependency key", &output, 1);
    assert_json_diagnostic_code(
        "invalid dependency key",
        &output,
        "InvalidPackageDependencyName",
    );
}

#[test]
fn duplicate_dependency_keys_report_duplicate_package_dependency_name() {
    let workspace = write_workspace(
        "duplicate-dependency-key",
        "[workspace]\nmembers = [\"packages/app\"]\n",
        &[WorkspacePackageSpec {
            member: "packages/app",
            manifest: "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nutil = { path = \"../util\" }\nutil = { path = \"../util-again\" }\n",
            modules: &[("main", "(module main)\n\n(fn main () -> i32\n  0)\n")],
        }],
    );

    let output = run_glagol([
        "--json-diagnostics".as_ref(),
        "check".as_ref(),
        workspace.as_os_str(),
    ]);
    assert_exit_code("duplicate dependency key", &output, 1);
    assert_json_diagnostic_code(
        "duplicate dependency key",
        &output,
        "DuplicatePackageDependencyName",
    );
}

#[test]
fn valid_dependency_identity_checks_cleanly() {
    let workspace = write_workspace(
        "valid-dependency-identity",
        "[workspace]\nmembers = [\"packages/app\", \"packages/util\"]\n",
        &[
            WorkspacePackageSpec {
                member: "packages/util",
                manifest: "[package]\nname = \"util\"\nversion = \"0.1.0\"\n",
                modules: &[(
                    "util",
                    "(module util (export answer))\n\n(fn answer () -> i32\n  42)\n",
                )],
            },
            WorkspacePackageSpec {
                member: "packages/app",
                manifest: "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nutil = { path = \"../util\" }\n",
                modules: &[(
                    "main",
                    "(module main)\n\n(import util.util (answer))\n\n(fn main () -> i32\n  (answer))\n",
                )],
            },
        ],
    );

    let output = run_glagol(["check".as_ref(), workspace.as_os_str()]);
    assert_success_stdout("valid dependency identity", output, "");
}

struct WorkspacePackageSpec<'a> {
    member: &'a str,
    manifest: &'a str,
    modules: &'a [(&'a str, &'a str)],
}

fn write_workspace(
    name: &str,
    workspace_manifest: &str,
    packages: &[WorkspacePackageSpec<'_>],
) -> PathBuf {
    let root = unique_path(name);
    fs::create_dir_all(&root).expect("create workspace root");
    fs::write(root.join("slovo.toml"), workspace_manifest).expect("write workspace manifest");

    for package in packages {
        let package_root = root.join(package.member);
        let src = package_root.join("src");
        fs::create_dir_all(&src).expect("create workspace package src");
        fs::write(package_root.join("slovo.toml"), package.manifest)
            .expect("write workspace package manifest");
        for (module, source) in package.modules {
            fs::write(src.join(format!("{}.slo", module)), source)
                .expect("write workspace package module");
        }
    }

    root
}

fn unique_path(name: &str) -> PathBuf {
    let id = NEXT_WORKSPACE_ID.fetch_add(1, Ordering::SeqCst);
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or(0);
    std::env::temp_dir().join(format!(
        "glagol-package-workspace-discipline-beta24-{}-{}-{}-{}",
        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_stdout(context: &str, output: Output, expected: &str) {
    assert!(
        output.status.success(),
        "{} failed\nstdout:\n{}\nstderr:\n{}",
        context,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(
        String::from_utf8_lossy(&output.stdout),
        expected,
        "{} stdout mismatch",
        context
    );
    assert!(
        output.stderr.is_empty(),
        "{} wrote stderr:\n{}",
        context,
        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_json_diagnostic_code(context: &str, output: &Output, expected: &str) {
    let diagnostics = diagnostic_text(output);
    assert!(
        diagnostics.contains(&format!(r#""code":"{}""#, expected)),
        "{} did not report `{}`:\n{}",
        context,
        expected,
        diagnostics
    );
}

fn assert_json_diagnostic_code_absent(context: &str, output: &Output, unexpected: &str) {
    let diagnostics = diagnostic_text(output);
    assert!(
        !diagnostics.contains(&format!(r#""code":"{}""#, unexpected)),
        "{} unexpectedly reported `{}`:\n{}",
        context,
        unexpected,
        diagnostics
    );
}

fn diagnostic_text(output: &Output) -> String {
    format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    )
}
