//! ENV-REGISTRY FREEZE GATE (risk R-15 / D7 / R2; PARITY cross-cutting "single
//! env-var registry").
//!
//! The framework must have **exactly one** env resolution table:
//! [`indusagi_core::env`]. The TS source carried two competing tables (the brand
//! `envName()` gate *and* the facade's `PROVIDER_ENV_MAP`); the clean-room Rust
//! port collapses every *config/provider* env read onto the one registry so the
//! precedence is pinned and cannot re-fragment across crates.
//!
//! This test walks every crate's `src/` and fails if a `std::env::var(` /
//! `std::env::var_os(` read appears outside the sanctioned set. It documents the
//! allowed exceptions inline so a reviewer sees *why* each survives:
//!
//!  1. **The registry module itself** (`indusagi-core/src/env.rs`) — the one
//!     place `std::env::var` is allowed; `read_env`/`read_raw`/`indusagi_home`
//!     funnel through it.
//!  2. **The facade crate** (`indusagi-facade`) — an intentional thin shim over
//!     the OLD `type`/`role` vocabulary (risk R-4). The TS facade read
//!     `process.env` *directly* (`facade/ml/env-api-keys.ts`,
//!     `facade/mcp-core/config.ts`/`client.ts`, `facade/agent/session-manager.ts`
//!     all probe `process.env.*`), so the faithful Rust port reads env directly
//!     too. Routing it through the registry would be a *divergence* from TS, not
//!     parity. (The Python port chose to route these; the Rust port follows the
//!     plan's facade rule — "if the TS facade read env directly, that is
//!     faithful".)
//!  3. **`os.homedir()`-parity reads of `HOME` / `USERPROFILE`** for path
//!     *display/expansion* (the TUI `homedir()`/`shorten_path` helpers). These
//!     mirror Node's `os.homedir()` (which itself reads `process.env.HOME`) and
//!     the registry's own [`indusagi_core::env::home_dir`]; they are not a config
//!     or provider-key table. Allowed by *variable name* (`HOME`/`USERPROFILE`),
//!     not by file, so a stray provider/config read in those files is still
//!     caught.
//!  4. **Process-spawning** that spreads the full environ into a child process is
//!     exempt by the registry's own doc-comment (parity row 25) — it is not a
//!     config *read*. Such code uses [`std::env::vars`]/`vars_os`, which this gate
//!     does not match.
//!
//! Everything else — provider keys (`*_API_KEY`), regions (`AWS_*`), Vertex
//! project/location (`GOOGLE_*`), `INDUSAGI_*`, `XDG_*`, etc. — must go through
//! [`indusagi_core::env::read_raw`] / [`indusagi_core::env::read_env`]. The
//! bedrock region chain (P-2) and the Vertex project/location probes are the two
//! reads this gate's authoring closed.
//!
//! The scan is `#[cfg(test)]`-aware: env mutation inside test modules
//! (`set_var`/`remove_var`, and the paired `var(key)` read-and-restore) is
//! stripped before matching, because those touch the *process* environment for a
//! test, not the *config* table.

use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};

/// Names that may be read directly anywhere as `os.homedir()`-parity path
/// helpers (exception #3). A read of any *other* name outside the registry/facade
/// is a violation.
const HOME_PARITY_NAMES: &[&str] = &["HOME", "USERPROFILE"];

/// Crates whose `src/` is exempt wholesale (exceptions #1, #2). Kept tiny and
/// explicit so adding a crate here is a conscious, reviewed act.
fn crate_is_exempt(crate_name: &str) -> bool {
    matches!(crate_name, "indusagi-core" | "indusagi-facade")
}

#[test]
fn no_config_env_reads_outside_the_registry() {
    let crates_dir = workspace_crates_dir();
    let mut violations: Vec<String> = Vec::new();
    // Track that the two reads this gate's authoring fixed are actually routed
    // through the registry now (a regression guard with teeth, not just a grep).
    let mut bedrock_routed = false;
    let mut vertex_routed = false;

    for crate_dir in read_dirs(&crates_dir) {
        let crate_name = crate_dir
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("")
            .to_string();
        let src = crate_dir.join("src");
        if !src.is_dir() {
            continue;
        }
        let exempt = crate_is_exempt(&crate_name);

        for file in rs_files(&src) {
            let text = fs::read_to_string(&file).unwrap_or_default();
            let rel = file
                .strip_prefix(&crates_dir)
                .unwrap_or(&file)
                .display()
                .to_string();

            // Confirm the bedrock / vertex connectors route region & project
            // through the registry (parity P-2 + Vertex cross-cutting row).
            if rel.ends_with("connectors/bedrock.rs") && text.contains("env::read_raw(name)") {
                bedrock_routed = true;
            }
            if rel.ends_with("connectors/google_vertex.rs") && text.contains("env::read_raw(name)")
            {
                vertex_routed = true;
            }

            if exempt {
                continue;
            }

            // Strip `#[cfg(test)]` modules and `#[test]`/`#[tokio::test]` fns so
            // env-mutating *test* code is not mistaken for a config read.
            let scanned = strip_test_code(&text);

            for (idx, raw_line) in scanned.lines().enumerate() {
                let line = raw_line.trim();
                if line.starts_with("//") || line.starts_with("//!") {
                    continue; // a doc/comment mention is not a read
                }
                // Match a direct read. `vars()`/`vars_os()` (process-spawn env
                // spread, exception #4) are deliberately NOT matched.
                let is_read = line.contains("std::env::var(")
                    || line.contains("std::env::var_os(")
                    || line.contains("env::var(") // also catch a `use std::env;` alias
                    || line.contains("env::var_os(");
                if !is_read {
                    continue;
                }
                if reads_only_home_parity_name(line) {
                    continue; // exception #3
                }
                violations.push(format!(
                    "{rel}:{}: direct env read outside the registry: `{}`",
                    idx + 1,
                    line
                ));
            }
        }
    }

    assert!(
        violations.is_empty(),
        "env-registry freeze violated — these reads must go through \
         indusagi_core::env::read_raw / read_env (or be a documented exception):\n{}",
        violations.join("\n")
    );

    assert!(
        bedrock_routed,
        "bedrock connector must resolve AWS_REGION via indusagi_core::env::read_raw (P-2)"
    );
    assert!(
        vertex_routed,
        "google-vertex connector must resolve project/location via indusagi_core::env::read_raw"
    );
}

/// True iff the line's env read names only `HOME`/`USERPROFILE`. A line that
/// reads any other variable (even alongside `HOME`) is a violation, so a stray
/// provider/config read in a path-helper file is still flagged.
fn reads_only_home_parity_name(line: &str) -> bool {
    let mut found_any = false;
    // Find each quoted literal that is the argument to a `var(`/`var_os(` read.
    for needle in [
        "std::env::var(",
        "std::env::var_os(",
        "env::var(",
        "env::var_os(",
    ] {
        let mut from = 0;
        while let Some(pos) = line[from..].find(needle) {
            let after = from + pos + needle.len();
            // The argument starts right after `(`; if it's a string literal,
            // capture it. A non-literal arg (e.g. `var(name)` over a variable)
            // cannot be proven home-only, so treat it as not-home-parity.
            let rest = &line[after..];
            let trimmed = rest.trim_start();
            if let Some(stripped) = trimmed.strip_prefix('"') {
                if let Some(end) = stripped.find('"') {
                    let name = &stripped[..end];
                    found_any = true;
                    if !HOME_PARITY_NAMES.contains(&name) {
                        return false;
                    }
                } else {
                    return false;
                }
            } else {
                // `var(name)` over a non-literal — cannot prove it's home-only.
                return false;
            }
            from = after;
        }
    }
    found_any
}

/// Remove `#[cfg(test)]`-gated modules and `#[test]`/`#[tokio::test]` functions
/// from `text` (replacing the spans with blank lines so line numbers in any
/// surviving violation stay roughly meaningful). Brace-balanced; good enough for
/// the well-formed source in this workspace.
fn strip_test_code(text: &str) -> String {
    let lines: Vec<&str> = text.lines().collect();
    let mut out: Vec<String> = Vec::with_capacity(lines.len());
    let mut i = 0;
    while i < lines.len() {
        let trimmed = lines[i].trim_start();
        let is_test_attr = trimmed == "#[cfg(test)]"
            || trimmed == "#[test]"
            || trimmed.starts_with("#[tokio::test")
            || trimmed.starts_with("#[test"); // tolerate `#[test]` variants
        if is_test_attr {
            // Advance to the first `{` that opens the gated item, then skip a
            // brace-balanced block.
            // Blank out the attribute line.
            out.push(String::new());
            i += 1;
            // Skip intermediate attribute/signature lines until the opening `{`.
            let mut depth: i32 = 0;
            let mut opened = false;
            while i < lines.len() {
                let l = lines[i];
                for ch in l.chars() {
                    if ch == '{' {
                        depth += 1;
                        opened = true;
                    } else if ch == '}' {
                        depth -= 1;
                    }
                }
                out.push(String::new());
                i += 1;
                if opened && depth <= 0 {
                    break;
                }
            }
            continue;
        }
        out.push(lines[i].to_string());
        i += 1;
    }
    out.join("\n")
}

/// All immediate sub-directories of `dir`.
fn read_dirs(dir: &Path) -> Vec<PathBuf> {
    let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                dirs.insert(path);
            }
        }
    }
    dirs.into_iter().collect()
}

/// Every `*.rs` file under `dir`, recursively.
fn rs_files(dir: &Path) -> Vec<PathBuf> {
    let mut files = Vec::new();
    let mut stack = vec![dir.to_path_buf()];
    while let Some(d) = stack.pop() {
        if let Ok(entries) = fs::read_dir(&d) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.is_dir() {
                    stack.push(path);
                } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
                    files.push(path);
                }
            }
        }
    }
    files.sort();
    files
}

/// The workspace `crates/` directory, resolved from this crate's manifest dir
/// (`crates/indusagi-core` → `../`).
fn workspace_crates_dir() -> PathBuf {
    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); // .../crates/indusagi-core
    manifest
        .parent()
        .expect("indusagi-core sits under crates/")
        .to_path_buf()
}
