use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::{Path, PathBuf};

use rayon::prelude::*;

struct FixtureCategory {
    /// `None` = top-level mod; `Some("by_kind")` = nested inside that outer mod.
    outer_mod: Option<String>,
    mod_name: String,
    /// `(absolute_path, relative_path_from_crate_root)`
    fixtures: Vec<(PathBuf, String)>,
}

fn sorted_subdirs(dir: &Path) -> Vec<fs::DirEntry> {
    let mut entries: Vec<_> = fs::read_dir(dir)
        .unwrap()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
        .collect();
    entries.sort_by_key(|e| e.file_name());
    entries
}

fn collect_categories(fixtures_dir: &Path) -> Vec<FixtureCategory> {
    let mut out = Vec::new();

    for top in sorted_subdirs(fixtures_dir) {
        let top_name = top.file_name().to_string_lossy().into_owned();
        println!("cargo:rerun-if-changed={}", top.path().display());

        // `by-kind/` (and any future `by-*/` sibling) is a meta-directory whose
        // subdirectories are the real categories, nested under an outer mod.
        if top_name.starts_with("by-") {
            let outer_mod = top_name.replace('-', "_");
            for inner in sorted_subdirs(&top.path()) {
                println!("cargo:rerun-if-changed={}", inner.path().display());
                let inner_name = inner.file_name().to_string_lossy().into_owned();
                let rel_prefix = format!("{top_name}/{inner_name}");
                if let Some(cat) = scan_dir(
                    &inner.path(),
                    Some(outer_mod.clone()),
                    &inner_name,
                    &rel_prefix,
                ) {
                    out.push(cat);
                }
            }
        } else if let Some(cat) = scan_dir(&top.path(), None, &top_name, &top_name) {
            out.push(cat);
        }
    }

    out
}

/// Scan `dir` for `.phpt` files. Returns `None` if the directory is empty.
fn scan_dir(
    dir: &Path,
    outer_mod: Option<String>,
    mod_name: &str,
    rel_prefix: &str,
) -> Option<FixtureCategory> {
    let mut fixtures: Vec<_> = fs::read_dir(dir)
        .unwrap()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().is_some_and(|ext| ext == "phpt"))
        .collect();
    fixtures.sort_by_key(|e| e.file_name());

    if fixtures.is_empty() {
        return None;
    }

    Some(FixtureCategory {
        outer_mod,
        mod_name: mod_name.replace('-', "_"),
        fixtures: fixtures
            .into_iter()
            .map(|f| {
                let path = f.path();
                let file_name = path.file_name().unwrap().to_string_lossy().into_owned();
                (path, format!("tests/fixtures/{rel_prefix}/{file_name}"))
            })
            .collect(),
    })
}

fn main() {
    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
    let out_dir = std::env::var("OUT_DIR").unwrap();

    generate_builtin_fn_names(Path::new(&manifest_dir), Path::new(&out_dir));
    generate_stub_files(Path::new(&manifest_dir), Path::new(&out_dir));

    let fixtures_dir = Path::new(&manifest_dir).join("tests").join("fixtures");
    let out_path = Path::new(&out_dir).join("fixture_tests.rs");

    let mut code = String::from("// Auto-generated by build.rs — do not edit manually\n");

    if !fixtures_dir.exists() {
        fs::write(&out_path, &code).unwrap();
        return;
    }

    println!("cargo:rerun-if-changed={}", fixtures_dir.display());

    let categories = collect_categories(&fixtures_dir);

    // Read all fixture files in parallel.
    let all_paths: Vec<PathBuf> = categories
        .iter()
        .flat_map(|c| c.fixtures.iter().map(|(p, _)| p.clone()))
        .collect();
    let file_contents: std::collections::HashMap<PathBuf, String> = all_paths
        .par_iter()
        .map(|p| (p.clone(), fs::read_to_string(p).unwrap_or_default()))
        .collect();

    // Emit code, opening/closing outer mod blocks as the outer_mod changes.
    let mut current_outer: Option<&str> = None;
    for cat in &categories {
        let next_outer = cat.outer_mod.as_deref();
        if next_outer != current_outer {
            if current_outer.is_some() {
                code.push_str("}\n");
            }
            if let Some(outer) = next_outer {
                code.push_str(&format!("\nmod {outer} {{\n"));
            }
            current_outer = next_outer;
        }

        let indent = if current_outer.is_some() { "    " } else { "" };
        code.push_str(&format!("\n{indent}mod {} {{\n", cat.mod_name));

        for (path, rel) in &cat.fixtures {
            println!("cargo:rerun-if-changed={manifest_dir}/{rel}");
            let content = &file_contents[path];
            let stem = path
                .file_stem()
                .unwrap()
                .to_string_lossy()
                .replace('-', "_");
            let ignore = if content.contains("===ignore===") {
                format!("{indent}    #[ignore]\n")
            } else {
                String::new()
            };
            let doc = extract_description(content)
                .map(|d| {
                    d.lines()
                        .map(|l| format!("{indent}    /// {}\n", l.trim()))
                        .collect::<String>()
                })
                .unwrap_or_default();

            code.push_str(&format!(
                "{doc}{indent}    #[test]\n{ignore}{indent}    fn {stem}() {{\n\
                 {indent}        mir_analyzer::test_utils::run_fixture(\
                 concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/{rel}\"));\n\
                 {indent}    }}\n"
            ));
        }

        code.push_str(&format!("{indent}}}\n"));
    }
    if current_outer.is_some() {
        code.push_str("}\n");
    }

    fs::write(&out_path, code).unwrap();
}

/// Parse `PhpStormStubsMap.php` and generate four sorted static slices for the
/// extensions present in `stubs/`:
/// * `BUILTIN_FN_NAMES` — function names (legacy; used for existence checks)
/// * `STUB_FN_INDEX` — lowercased fn name → stub virtual path
/// * `STUB_CLASS_INDEX` — lowercased FQCN → stub virtual path (PHP class
///   names are case-insensitive so the lookup key is lowercased; backslashes
///   preserved)
/// * `STUB_CONST_INDEX` — case-sensitive constant name → stub virtual path
///
/// All `*_INDEX` entries store the stub virtual path that matches keys of
/// `STUB_FILES` (e.g. `"stubs/standard/standard_0.php"`), so the lazy-stub-
/// loading code in `crate::stubs` can resolve a name → path → embedded
/// content in two `O(log n)` lookups.
fn generate_builtin_fn_names(manifest_dir: &Path, out_dir: &Path) {
    let map_path = manifest_dir.join("stubs").join("PhpStormStubsMap.php");
    let out_path = out_dir.join("phpstorm_builtin_fns.rs");

    if !map_path.exists() {
        fs::write(
            &out_path,
            "pub(crate) static BUILTIN_FN_NAMES: &[&str] = &[];\n\
             pub(crate) static STUB_FN_INDEX: &[(&str, &str)] = &[];\n\
             pub(crate) static STUB_CLASS_INDEX: &[(&str, &str)] = &[];\n\
             pub(crate) static STUB_CONST_INDEX: &[(&str, &str)] = &[];\n",
        )
        .unwrap();
        return;
    }

    println!("cargo:rerun-if-changed={}", map_path.display());

    let content = fs::read_to_string(&map_path).unwrap();

    // Build lowercase set of stub directory names from the stubs/ directory for O(1) lookup.
    // `stubs/` lives inside the crate so it is included in `cargo package` and survives
    // publication to crates.io — see `generate_stub_files` for the regression history.
    let stubs_dir = manifest_dir.join("stubs");
    assert!(
        stubs_dir.is_dir(),
        "mir-analyzer build.rs: stubs/ directory is missing at {} — \
         the stub index would be empty and all built-ins would be reported as undefined. \
         If this fired in cargo package, ensure stubs/ lives inside the crate.",
        stubs_dir.display()
    );
    let stub_dirs_lower: std::collections::HashSet<String> = fs::read_dir(&stubs_dir)
        .unwrap()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
        .map(|e| e.file_name().to_string_lossy().to_lowercase())
        .collect();

    // Section state: which array we're currently parsing. Sections appear in
    // file order: CLASSES, FUNCTIONS, CONSTANTS.
    enum Section {
        None,
        Classes,
        Functions,
        Constants,
    }
    let mut section = Section::None;

    let mut fn_names: Vec<String> = Vec::new();
    // Index entries: (lookup_key, virtual_path). Virtual path matches STUB_FILES keys.
    let mut fn_index: Vec<(String, String)> = Vec::new();
    let mut class_index: Vec<(String, String)> = Vec::new();
    let mut const_index: Vec<(String, String)> = Vec::new();

    for line in content.lines() {
        let trimmed = line.trim();

        match trimmed {
            "const CLASSES = array (" => {
                section = Section::Classes;
                continue;
            }
            "const FUNCTIONS = array (" => {
                section = Section::Functions;
                continue;
            }
            "const CONSTANTS = array (" => {
                section = Section::Constants;
                continue;
            }
            ");" => {
                section = Section::None;
                continue;
            }
            _ => {}
        }

        // Each entry: '\\some\\name' => 'ext_dir/file.php',
        let Some(rest) = trimmed.strip_prefix('\'') else {
            continue;
        };
        let Some((raw_name, rest)) = rest.split_once('\'') else {
            continue;
        };
        let Some(rest) = rest.trim().strip_prefix("=> '") else {
            continue;
        };
        let Some((path, _)) = rest.split_once('\'') else {
            continue;
        };
        let Some((dir, _)) = path.split_once('/') else {
            continue;
        };
        if !stub_dirs_lower.contains(&dir.to_lowercase()) {
            continue;
        }

        // PHP source `'\\Foo'` represents the literal string `\Foo`; un-escape.
        let name = raw_name.replace("\\\\", "\\");
        let virtual_path = format!("stubs/{path}");

        match section {
            Section::Functions => {
                fn_names.push(name.clone());
                fn_index.push((name.to_lowercase(), virtual_path));
            }
            Section::Classes => {
                class_index.push((name.to_lowercase(), virtual_path));
            }
            Section::Constants => {
                const_index.push((name, virtual_path));
            }
            Section::None => {}
        }
    }

    fn_names.sort();
    fn_names.dedup();
    fn_index.sort_by(|a, b| a.0.cmp(&b.0));
    fn_index.dedup_by(|a, b| a.0 == b.0);
    class_index.sort_by(|a, b| a.0.cmp(&b.0));
    class_index.dedup_by(|a, b| a.0 == b.0);
    const_index.sort_by(|a, b| a.0.cmp(&b.0));
    const_index.dedup_by(|a, b| a.0 == b.0);

    let mut code = String::from(
        "// Auto-generated by build.rs from PhpStormStubsMap.php — do not edit directly.\n\n\
         /// Sorted list of PHP built-in function names. Used for fast existence checks.\n\
         pub(crate) static BUILTIN_FN_NAMES: &[&str] = &[\n",
    );
    for name in &fn_names {
        writeln!(code, "    {name:?},").unwrap();
    }
    code.push_str("];\n\n");

    fn write_index(code: &mut String, doc: &str, name: &str, entries: &[(String, String)]) {
        writeln!(code, "/// {doc}").unwrap();
        writeln!(code, "pub(crate) static {name}: &[(&str, &str)] = &[").unwrap();
        for (key, path) in entries {
            writeln!(code, "    ({key:?}, {path:?}),").unwrap();
        }
        code.push_str("];\n\n");
    }

    write_index(
        &mut code,
        "Sorted lowercased function name → stub virtual path.",
        "STUB_FN_INDEX",
        &fn_index,
    );
    write_index(
        &mut code,
        "Sorted lowercased class FQCN → stub virtual path.",
        "STUB_CLASS_INDEX",
        &class_index,
    );
    write_index(
        &mut code,
        "Sorted constant name (case-sensitive) → stub virtual path.",
        "STUB_CONST_INDEX",
        &const_index,
    );

    fs::write(&out_path, code).unwrap();
}

/// Recursively collect `.php` files under `dir`, appending `include_str!` entries to `code`.
fn collect_php_files(dir: &Path, stubs_root: &Path, code: &mut String) {
    let mut entries: Vec<PathBuf> = match fs::read_dir(dir) {
        Ok(rd) => rd.filter_map(|e| e.ok()).map(|e| e.path()).collect(),
        Err(_) => return,
    };
    entries.sort();

    for path in entries {
        if path.is_dir() {
            collect_php_files(&path, stubs_root, code);
        } else if path.extension().is_some_and(|e| e == "php") {
            let relative = path
                .strip_prefix(stubs_root)
                .unwrap_or(&path)
                .to_string_lossy()
                .replace('\\', "/");

            // Canonicalize gives us a stable absolute path for include_str!.
            // On Windows, canonicalize() returns \\?\-prefixed UNC paths; strip
            // that prefix so include_str! receives a plain absolute path.
            let abs = {
                let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
                let s = canonical.to_string_lossy();
                let s = s.strip_prefix(r"\\?\").unwrap_or(&s);
                s.replace('\\', "/")
            };

            writeln!(
                code,
                "    ({}, include_str!({})),",
                format_args!("{relative:?}"),
                format_args!("{abs:?}"),
            )
            .unwrap();
        }
    }
}

// ---------------------------------------------------------------------------
// Stub embedding — stubs/{ext}/*.php
// ---------------------------------------------------------------------------

/// Walk every `stubs/{ext}/` directory and embed each `.php` file as a
/// `(workspace-relative-path, content)` pair in `STUB_FILES`.
///
/// Paths use the workspace root as the prefix so they look like
/// `"stubs/standard/standard_9.php"` — stable virtual identifiers for go-to-definition.
fn generate_stub_files(manifest_dir: &Path, out_dir: &Path) {
    let stubs_dir = manifest_dir.join("stubs");
    let out_path = out_dir.join("stub_files.rs");

    // Hard fail rather than silently emit an empty `STUB_FILES`. An empty static is the
    // exact failure mode that shipped in 0.17.1: the workspace `stubs/` directory was
    // not packaged into the crate, the build script took the "no stubs" path, and every
    // built-in function/class was reported `UndefinedFunction` / `UndefinedClass` for
    // every downstream consumer. `tests/packaging.rs` guards the packaging side; this
    // guard catches the local-build side.
    assert!(
        stubs_dir.is_dir(),
        "mir-analyzer build.rs: stubs/ directory is missing at {} — \
         the published crate would have no built-in symbols. \
         If this fired in cargo package, ensure stubs/ lives inside the crate.",
        stubs_dir.display()
    );

    println!("cargo:rerun-if-changed={}", stubs_dir.display());

    let mut code = String::from(
        "/// PHP stubs embedded from stubs/ — the single source of built-in definitions.\n\
         /// Auto-generated by build.rs — do not edit directly.\n\
         pub(crate) static STUB_FILES: &[(&str, &str)] = &[\n",
    );

    let mut ext_dirs: Vec<PathBuf> = fs::read_dir(&stubs_dir)
        .unwrap()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
        .map(|e| e.path())
        .collect();
    ext_dirs.sort();

    for ext_dir in &ext_dirs {
        println!("cargo:rerun-if-changed={}", ext_dir.display());
        // Strip the crate root so embedded paths look like `"stubs/Core/Core.php"` —
        // stable virtual identifiers used by go-to-definition.
        collect_php_files(ext_dir, manifest_dir, &mut code);
    }

    code.push_str("];\n");
    fs::write(&out_path, code).unwrap();
}

/// Extract the body of `===description===` from a `.phpt` file, if present.
fn extract_description(content: &str) -> Option<String> {
    const MARKER: &str = "===description===";
    let start = content.find(MARKER)? + MARKER.len();
    let end = content[start..]
        .find("===")
        .map(|r| start + r)
        .unwrap_or(content.len());
    let text = content[start..end].trim();
    if text.is_empty() {
        None
    } else {
        Some(text.to_string())
    }
}
