//! Workspace policy tests.
//!
//! These tests scan the source tree for references that violate
//! product-level invariants. They run as part of `cargo nextest` and are
//! cheap (text scans only).

use walkdir::WalkDir;

use crate::workspace_root;

/// `fabro_model::bootstrap_catalog` (and its module) is the install/API-key
/// validation hatch from the settings-driven LLM catalog plan. It must
/// **not** appear in request-serving paths — server handlers, workflow
/// operations, agent runtime, hooks, or completion handlers — because those
/// must use the resolved `Arc<Catalog>` threaded through their state.
///
/// The allowed-callers list below is the policy boundary. Adding a new
/// caller is intentional and requires updating this list.
///
/// The walker only descends into `lib/`, so non-`lib/` paths (docs, top-level
/// markdown) are not part of the allowlist.
const BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS: &[&str] = &[
    // The bootstrap module itself.
    "lib/foundation/fabro-model/src/bootstrap_catalog",
    // Public module declaration for the bootstrap hatch.
    "lib/foundation/fabro-model/src/lib.rs",
    // Install / first-run / API-key validation flows that legitimately need
    // a built-in catalog before any project settings have been loaded.
    "lib/components/fabro-install/",
    "lib/apps/fabro-cli/src/commands/install/",
    "lib/apps/fabro-cli/src/shared/install_",
    "lib/apps/fabro-cli/src/shared/api_key_validation",
    // Test support modules.
    "tests/",
    "test_support",
    "/tests/it/",
    "/tests/policy.rs",
];

/// Production runtime code should build catalogs from resolved settings and
/// thread the resulting `Arc<Catalog>` through state. Direct use of
/// `Catalog::builtin()` is reserved for `fabro-model` internals and tests.
const CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS: &[&str] = &[
    // The catalog owner may define and test the built-in/default catalog.
    "lib/foundation/fabro-model/",
    // Tests and test support may use built-ins as fixtures.
    "/tests/",
    "/tests/it/",
    "test_support",
    "/tests/policy.rs",
];

const TEMPLATE_RENDER_ALLOWED_PATH_FRAGMENTS: &[&str] = &[
    // The template crate owns the rendering API and its tests.
    "lib/foundation/fabro-template/src/lib.rs",
    // Workflow-definition rendering must stay centralized here.
    "lib/components/fabro-workflow/src/transforms/variable_expansion.rs",
    // Hook header/env interpolation is a separate system.
    "lib/components/fabro-hooks/src/executor.rs",
    // This policy test names the forbidden patterns.
    "/tests/it/policy.rs",
];

const TEMPLATE_RENDER_FORBIDDEN_PATTERNS: &[&str] = &[
    "render_template(",
    "render_lenient(",
    "render_scan_template",
    "render as render_template",
    "render_lenient as",
    "fabro_template::{",
];

#[test]
fn bootstrap_catalog_references_stay_in_allowlist() {
    let violations = source_symbol_violations(
        "bootstrap_catalog",
        BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS,
    );

    assert!(
        violations.is_empty(),
        "bootstrap_catalog (install-only) referenced from non-allowlisted source files:\n{}\n\nIf this is intentional, add the path fragment to BOOTSTRAP_CATALOG_ALLOWED_PATH_FRAGMENTS in lib/foundation/fabro-dev/tests/it/policy.rs.",
        format_violations(violations),
    );
}

#[test]
fn catalog_builtin_references_stay_in_allowlist() {
    let violations =
        source_symbol_violations("Catalog::builtin()", CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS);

    assert!(
        violations.is_empty(),
        "Catalog::builtin() referenced from non-allowlisted production source files:\n{}\n\nRuntime code should use a resolved settings catalog via `Catalog::from_builtin_with_overrides(...)` or an injected `Arc<Catalog>`. If this is intentional test/bootstrap code, add the path fragment to CATALOG_BUILTIN_ALLOWED_PATH_FRAGMENTS in lib/foundation/fabro-dev/tests/it/policy.rs.",
        format_violations(violations),
    );
}

#[test]
fn workflow_template_rendering_call_sites_stay_in_allowlist() {
    let mut violations = Vec::new();
    for pattern in TEMPLATE_RENDER_FORBIDDEN_PATTERNS {
        violations.extend(source_symbol_violations(
            pattern,
            TEMPLATE_RENDER_ALLOWED_PATH_FRAGMENTS,
        ));
    }

    assert!(
        violations.is_empty(),
        "Workflow template rendering must go through TemplateTransform. Add an allowlist entry only for non-workflow interpolation with a reason:\n{}",
        format_violations(violations),
    );
}

#[expect(
    clippy::disallowed_methods,
    reason = "policy test reads source files synchronously with std::fs"
)]
fn source_symbol_violations(
    symbol: &str,
    allowed_path_fragments: &[&str],
) -> Vec<(String, usize, String)> {
    let root = workspace_root();
    let lib_root = root.join("lib");
    let mut violations: Vec<(String, usize, String)> = Vec::new();

    let walker = WalkDir::new(&lib_root).into_iter().filter_entry(|entry| {
        // Skip generated/output directories at any depth.
        let name = entry.file_name().to_string_lossy();
        !matches!(
            name.as_ref(),
            "target" | ".git" | "node_modules" | "dist" | "build"
        )
    });

    for entry in walker.flatten() {
        let path = entry.path();
        if !path.is_file() || path.extension().is_none_or(|ext| ext != "rs") {
            continue;
        }
        let Ok(contents) = std::fs::read_to_string(path) else {
            continue;
        };
        // Cheap early-out: avoids per-line work for the ~99% of files with no
        // reference to the symbol.
        if !contents.contains(symbol) {
            continue;
        }
        let rel = path.strip_prefix(&root).unwrap_or(path);
        let rel_str = rel.to_string_lossy().replace('\\', "/");
        let path_allowed = allowed_path_fragments
            .iter()
            .any(|frag| rel_str.contains(frag));
        if path_allowed {
            continue;
        }
        let mut pending_cfg_test = false;
        let mut cfg_test_depth = None;
        let mut brace_depth = 0usize;
        for (idx, line) in contents.lines().enumerate() {
            let trimmed = line.trim_start();
            let starts_cfg_test_module =
                pending_cfg_test && trimmed.contains("mod tests") && trimmed.contains('{');
            let in_cfg_test_module = cfg_test_depth.is_some() || starts_cfg_test_module;

            if !line.contains(symbol) {
                update_test_module_state(
                    trimmed,
                    &mut pending_cfg_test,
                    &mut cfg_test_depth,
                    &mut brace_depth,
                    starts_cfg_test_module,
                );
                continue;
            }
            // Skip comments referencing the symbol in prose.
            if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
                update_test_module_state(
                    trimmed,
                    &mut pending_cfg_test,
                    &mut cfg_test_depth,
                    &mut brace_depth,
                    starts_cfg_test_module,
                );
                continue;
            }
            if in_cfg_test_module {
                update_test_module_state(
                    trimmed,
                    &mut pending_cfg_test,
                    &mut cfg_test_depth,
                    &mut brace_depth,
                    starts_cfg_test_module,
                );
                continue;
            }
            violations.push((rel_str.clone(), idx + 1, line.to_string()));
            update_test_module_state(
                trimmed,
                &mut pending_cfg_test,
                &mut cfg_test_depth,
                &mut brace_depth,
                starts_cfg_test_module,
            );
        }
    }

    violations
}

fn update_test_module_state(
    trimmed: &str,
    pending_cfg_test: &mut bool,
    cfg_test_depth: &mut Option<usize>,
    brace_depth: &mut usize,
    starts_cfg_test_module: bool,
) {
    let depth_before = *brace_depth;
    let open_count = trimmed.chars().filter(|c| *c == '{').count();
    let close_count = trimmed.chars().filter(|c| *c == '}').count();
    *brace_depth = brace_depth.saturating_add(open_count);
    *brace_depth = brace_depth.saturating_sub(close_count);

    if starts_cfg_test_module {
        *cfg_test_depth = Some(
            depth_before
                .saturating_add(open_count)
                .saturating_sub(close_count),
        );
    }
    if cfg_test_depth.is_some_and(|depth| *brace_depth < depth) {
        *cfg_test_depth = None;
    }

    if trimmed.starts_with("#[cfg(test)]") {
        *pending_cfg_test = true;
    } else if !trimmed.is_empty() && !trimmed.starts_with("#[") {
        *pending_cfg_test = false;
    }
}

fn format_violations(violations: Vec<(String, usize, String)>) -> String {
    violations
        .into_iter()
        .map(|(p, l, s)| format!("  {p}:{l}: {}", s.trim()))
        .collect::<Vec<_>>()
        .join("\n")
}
