//! SCIP indexer detection and spawning helpers.
//!
//! Detects which SCIP indexer binary matches a repo's primary language,
//! spawns it, and returns the path to the produced `index.scip`. Used by
//! `scip_verify::resolve_scip_source`.

use std::path::{Path, PathBuf};
use std::time::Instant;

use anyhow::{Context, Result};

/// Detect a SCIP indexer on `$PATH` matching the repo's primary language and
/// spawn it. Returns the path to the produced `index.scip`. Selection rule:
///
/// - `Cargo.toml` present → `rust-analyzer scip .`
/// - `pyproject.toml` / `setup.py` / `requirements.txt` present → `scip-python`
/// - `Gemfile` / `*.gemspec` present → `scip-ruby`
/// - `package.json` + `tsconfig.json` → `scip-typescript`
/// - `package.json` only → `scip-typescript` (still covers JS via TS indexer)
///
/// Returns `Ok(None)` if no fingerprint matches or binary isn't on `$PATH`.
///
/// In non-strict (opportunistic) mode, indexer spawn/exit failures are
/// downgraded to `Ok(None)` with a warning so callers fall back to
/// tree-sitter instead of aborting the entire `ckg index` run.
pub(super) fn run_scip_indexer(repo_root: &Path, strict: bool) -> Result<Option<PathBuf>> {
    match try_run_scip_indexer(repo_root, strict) {
        Ok(v) => Ok(v),
        Err(e) if !strict => {
            tracing::warn!(
                "SCIP indexer failed for {} ({e:#}); falling back to tree-sitter",
                repo_root.display()
            );
            eprintln!(
                "scip: indexer failed ({e:#}); falling back to tree-sitter"
            );
            Ok(None)
        }
        Err(e) => Err(e),
    }
}

fn try_run_scip_indexer(repo_root: &Path, strict: bool) -> Result<Option<PathBuf>> {
    let (binary, args) = detect_scip_indexer(repo_root);
    let Some((binary, args)) = binary.zip(Some(args)) else {
        if strict {
            tracing::warn!(
                "no SCIP indexer fingerprint matched in {} (falling back to tree-sitter)",
                repo_root.display()
            );
        }
        return Ok(None);
    };
    let Some(bin_path) = which_binary(binary) else {
        if strict {
            tracing::warn!(
                "`{binary}` not found on $PATH or $CKG_HOME/bin/. Install with: \
                 `ckg setup` (all indexers) or `ckg install-scip {}` (just this one).",
                binary.trim_start_matches("scip-")
            );
        }
        return Ok(None);
    };
    eprintln!(
        "scip: detected {} ({}), spawning…",
        binary,
        bin_path.display()
    );
    let output_path = repo_root.join("index.scip");
    let started = Instant::now();
    // CLI-H8: curate subprocess env — prevent leaking RUSTFLAGS, NPM_TOKEN,
    // etc. from the parent. Only PATH, HOME, LANG, USER, and TERM are needed
    // by typical SCIP indexers.
    let mut cmd = std::process::Command::new(&bin_path);
    cmd.args(&args).current_dir(repo_root).env_clear();
    // Allowlist: only safe, non-secret vars.
    for key in ["PATH", "HOME", "USER", "LANG", "TERM", "TMPDIR", "XDG_CACHE_HOME"] {
        if let Ok(val) = std::env::var(key) {
            cmd.env(key, val);
        }
    }
    // rust-analyzer needs CARGO_HOME and RUSTUP_HOME to find the toolchain.
    let is_rust_analyzer = bin_path.to_string_lossy().contains("rust-analyzer");
    if is_rust_analyzer {
        for key in ["CARGO_HOME", "RUSTUP_HOME"] {
            if let Ok(val) = std::env::var(key) {
                cmd.env(key, val);
            }
        }
    }

    // For rust-analyzer, capture stderr and filter out the upstream
    // bug noise (Encountered enclosing definition / Bug: definition…/
    // duplicate scip symbols block). These are documented rust-analyzer
    // bugs (issue #18771 et al.); the indexer still produces a usable
    // index, but the noise drowns out signal in our logs. For all other
    // indexers, inherit stderr unchanged — their warnings are signal.
    let status = if is_rust_analyzer {
        run_with_filtered_stderr(&mut cmd, binary)?
    } else {
        cmd.status()
            .with_context(|| format!("spawning {binary}"))?
    };
    if !status.success() {
        anyhow::bail!("{binary} exited with status {status}");
    }
    if !output_path.exists() {
        anyhow::bail!(
            "{binary} succeeded but no index.scip at {}",
            output_path.display()
        );
    }
    eprintln!(
        "{binary}: produced {} ({} ms)",
        output_path.display(),
        started.elapsed().as_millis()
    );
    Ok(Some(output_path))
}

/// Spawn `cmd`, stream child stderr through a filter that drops known
/// rust-analyzer bug-noise lines, and return its exit status. stdout is
/// inherited unchanged. The filter is conservative — only suppresses
/// well-known rust-analyzer internal-bug lines plus the multi-line
/// "Encountered duplicate scip symbols" trailer. Anything else
/// (including legitimate compile errors) passes through.
fn run_with_filtered_stderr(
    cmd: &mut std::process::Command,
    binary: &str,
) -> Result<std::process::ExitStatus> {
    use std::io::{BufRead, BufReader, Write};
    use std::process::Stdio;

    cmd.stderr(Stdio::piped());
    let mut child = cmd
        .spawn()
        .with_context(|| format!("spawning {binary}"))?;

    let stderr = child
        .stderr
        .take()
        .ok_or_else(|| anyhow::anyhow!("rust-analyzer stderr pipe missing"))?;

    let handle = std::thread::spawn(move || {
        let reader = BufReader::new(stderr);
        let mut sink = std::io::stderr().lock();
        // Trailing rust-analyzer block we want to swallow:
        //   "Encountered duplicate scip symbols, indicating an internal …"
        //   followed by ~20 lines of explanation + duplicate listings,
        //   ending at "Generating SCIP finished …" (which we keep).
        let mut in_dup_block = false;
        for line in reader.lines().map_while(std::result::Result::ok) {
            if in_dup_block {
                if line.starts_with("Generating SCIP finished") {
                    in_dup_block = false;
                    let _ = writeln!(sink, "{line}");
                }
                continue;
            }
            if line.contains("ERROR Encountered enclosing definition with no name")
                || line.contains("ERROR Bug: definition at")
            {
                continue;
            }
            if line.starts_with("Encountered duplicate scip symbols") {
                in_dup_block = true;
                continue;
            }
            let _ = writeln!(sink, "{line}");
        }
    });

    let status = child
        .wait()
        .with_context(|| format!("waiting on {binary}"))?;
    let _ = handle.join();
    Ok(status)
}

/// Returns `(indexer_binary, args)` based on which language fingerprints the
/// repo. Args use each indexer's convention for "index this repo and write
/// index.scip into the cwd".
fn detect_scip_indexer(repo_root: &Path) -> (Option<&'static str>, Vec<String>) {
    let exists = |p: &str| repo_root.join(p).exists();
    let glob_exists = |suffix: &str| {
        repo_root
            .read_dir()
            .map(|d| {
                d.flatten()
                    .any(|e| e.file_name().to_string_lossy().ends_with(suffix))
            })
            .unwrap_or(false)
    };
    if exists("Cargo.toml") {
        // Canonical Rust SCIP indexer is rust-analyzer (`rust-analyzer scip .`).
        // Ships with rustup.
        return (Some("rust-analyzer"), vec!["scip".into(), ".".into()]);
    }
    if exists("pyproject.toml") || exists("setup.py") || exists("requirements.txt") {
        return (Some("scip-python"), vec!["index".into(), ".".into()]);
    }
    if exists("Gemfile") || glob_exists(".gemspec") {
        // scip-ruby (Sorbet) crashes on common 3rd-party tree contents
        // (e.g. ZSuperArgs in vendored gems). Exclude vendored / build
        // dirs — they're not the user's code anyway.
        //
        // --gem-metadata is REQUIRED for non-gem apps (Rails apps,
        // anything without a parsable .gemspec). Without it scip-ruby
        // exits 100 with "Failed to extract name and version from
        // Gemfile.lock". Synthesize from the repo dir name + git SHA.
        //
        // Phase-3 refactor: `--ignore=<pat>` flags come from the
        // AdapterRegistry (Ruby + Rails + JS/Node patterns) rather
        // than a hard-coded list. The Ruby branch keeps `node_modules`
        // because Rails apps frequently bundle a Webpacker / esbuild
        // node tree; the JS adapter contributes it when package.json
        // is present, otherwise scip_indexer's fallback adds it.
        let registry = ckg_lang::AdapterRegistry::detect(repo_root);
        let mut ignores = registry.union_scip_ignores();
        // Always exclude node_modules for scip-ruby: a Rails repo may
        // have a package.json (Webpacker / esbuild) without activating
        // the JS adapter's full set, and scip-ruby has historically
        // choked on it.
        if !ignores.contains(&"/node_modules") {
            ignores.push("/node_modules");
        }
        let mut args: Vec<String> = vec!["--index-file=index.scip".into()];
        for p in ignores {
            args.push(format!("--ignore={p}"));
        }
        args.push(format!("--gem-metadata={}", synth_gem_metadata(repo_root)));
        args.push(".".into());
        return (Some("scip-ruby"), args);
    }
    if exists("tsconfig.json") || exists("package.json") {
        return (Some("scip-typescript"), vec!["index".into()]);
    }
    (None, Vec::new())
}

/// Synthesize a `name@version` string for scip-ruby's `--gem-metadata`.
///
/// Resolution order:
/// 1. First `*.gemspec` filename stem in repo_root → name; version "0.0.0"
///    (parsing the gemspec for the literal version is brittle — the
///    file is Ruby code with arbitrary expressions).
/// 2. Repo dir basename → name; git HEAD short SHA → version (per
///    scip-ruby docs: "for repositories which index every commit, the
///    SHA should be used for the version").
/// 3. Hard fallback: "ckg-unknown@0.0.0".
///
/// Sanitizes the name to ASCII-alnum/dash/underscore so scip-ruby's
/// SCIP symbol encoder doesn't choke on spaces or special chars.
fn synth_gem_metadata(repo_root: &Path) -> String {
    let name = first_gemspec_stem(repo_root)
        .or_else(|| {
            repo_root
                .file_name()
                .and_then(|s| s.to_str())
                .map(str::to_owned)
        })
        .unwrap_or_else(|| "ckg-unknown".to_string());
    let name = sanitize_gem_name(&name);

    let version = git_short_sha(repo_root).unwrap_or_else(|| "0.0.0".to_string());
    format!("{name}@{version}")
}

fn first_gemspec_stem(repo_root: &Path) -> Option<String> {
    let mut stems: Vec<String> = repo_root
        .read_dir()
        .ok()?
        .flatten()
        .filter_map(|e| {
            let n = e.file_name().to_string_lossy().into_owned();
            n.strip_suffix(".gemspec").map(str::to_owned)
        })
        .collect();
    // Deterministic pick when multiple gemspecs exist.
    stems.sort();
    stems.into_iter().next()
}

fn sanitize_gem_name(s: &str) -> String {
    let cleaned: String = s
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '-'
            }
        })
        .collect();
    if cleaned.is_empty() {
        "ckg-unknown".to_string()
    } else {
        cleaned
    }
}

fn git_short_sha(repo_root: &Path) -> Option<String> {
    let out = std::process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .current_dir(repo_root)
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if s.is_empty() { None } else { Some(s) }
}

/// Best-effort `which` — checks `$CKG_HOME/bin/` first (so binaries
/// installed by `ckg install-scip` are found even when not on `$PATH`),
/// then each `$PATH` entry for an executable file.
fn which_binary(name: &str) -> Option<PathBuf> {
    if let Some(home) = std::env::var_os("CKG_HOME")
        .map(PathBuf::from)
        .or_else(|| dirs::home_dir().map(|h| h.join(".codebase-kg")))
    {
        let candidate = home.join("bin").join(name);
        if candidate.is_file() {
            return Some(candidate);
        }
    }
    let path_var = std::env::var_os("PATH")?;
    for dir in std::env::split_paths(&path_var) {
        let full = dir.join(name);
        if full.is_file() {
            return Some(full);
        }
    }
    None
}
