//! STEL L2 economics controller — evaluate [`StelPlan`] → [`StelDecision`] / [`StelEstimate`].
//!
//! Phase 2 P2-S3: normative admission states (`serve | degrade | bypass | cache_hit`).

use crate::protocol::format::{competent_manual_baseline_chars, estimate_tokens_from_chars};
use crate::protocol::session::SessionContext;

use super::types::{
    AdmissionDecision, GoldenRouteRow, IndexRef, RouteConfidence, StelBypassBody, StelCacheBody,
    StelDecision, StelEstimate, StelPlan, StelPlanStep, StelRequest,
};

/// Compact-3 worst-case schema tax per call (A-006 conservative path; no amortization credit).
pub const COMPACT_SCHEMA_TOKENS: u32 = 45;
/// Compact `symforge` invoke overhead per call (schema example + Phase 0 doctrine).
pub const COMPACT_INVOKE_TOKENS: u32 = 80;
/// Minimum predicted net vs manual before `serve` (schema `margin_high`).
pub const SERVE_MARGIN_TOKENS: i32 = 50;
/// Predicted net at or below this (but above zero) triggers `degrade` (`margin_low`).
pub const DEGRADE_MARGIN_LOW_TOKENS: i32 = SERVE_MARGIN_TOKENS;
/// Default capped response budget applied on degrade when request omits `max_tokens`.
pub const DEGRADE_DEFAULT_MAX_TOKENS: u32 = 400;

/// Numerator of the grounded structured-response fraction: SymForge's structured
/// serve (symbol body / outline / windowed slice) is smaller than the competent
/// windowed manual read it replaces. Calibrated conservatively at ~60% of the
/// competent-manual baseline so a grounded response prediction never *over*-claims
/// savings (a smaller predicted response would inflate `predicted_net`).
const GROUNDED_RESPONSE_FRACTION_NUM: u64 = 3;
/// Denominator of the grounded structured-response fraction (see numerator).
const GROUNDED_RESPONSE_FRACTION_DEN: u64 = 5;

/// Token economics breakdown for one planned invocation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EconomicsBreakdown {
    pub predicted_response_tokens: u32,
    pub predicted_manual_tokens: u32,
    pub predicted_schema_tokens: u32,
    pub predicted_invoke_tokens: u32,
    pub predicted_symforge_tokens: u32,
    pub predicted_net_vs_manual: i32,
}

/// Evaluate L2 admission for a draft plan (does not execute L3).
pub fn evaluate_plan(request: &StelRequest, plan: &StelPlan) -> StelDecision {
    evaluate_plan_with_session(request, plan, None)
}

/// Evaluate L2 admission with optional in-process session context for cache-hit detection.
pub fn evaluate_plan_with_session(
    request: &StelRequest,
    plan: &StelPlan,
    session: Option<&SessionContext>,
) -> StelDecision {
    if let Some(bypass) = detect_pff_bypass(request) {
        return decision_from_pff_bypass(plan, request, bypass);
    }

    if let Some(session) = session
        && let Some(cache) = detect_session_cache_hit(plan, session)
    {
        return decision_from_cache_hit(plan, request, cache);
    }

    let economics = estimate_economics(plan);
    let net = economics.predicted_net_vs_manual;

    if net <= 0 {
        let bypass = economics_bypass_body(plan, &economics);
        return StelDecision {
            plan_id: plan.plan_id.clone(),
            decision: AdmissionDecision::Bypass,
            decision_reason: format!(
                "predicted_net={net} <= 0; economics bypass via host_read `{}`",
                bypass.path
            ),
            effective_max_tokens: request.max_tokens,
            degrade_flags: vec![],
            steps: None,
            bypass: Some(bypass),
            cache: None,
        };
    }

    let mandatory_degrade =
        plan.confidence == RouteConfidence::Fallback && net < SERVE_MARGIN_TOKENS;
    let economics_degrade = net <= DEGRADE_MARGIN_LOW_TOKENS;

    if mandatory_degrade || economics_degrade {
        let mut degrade_flags = Vec::new();
        if mandatory_degrade {
            degrade_flags.push("fallback_mandatory".to_string());
        }
        if plan
            .steps
            .iter()
            .any(|step| step.tool == "get_file_context")
        {
            degrade_flags.push("outline_only".to_string());
        } else {
            degrade_flags.push("max_tokens_cap".to_string());
        }
        degrade_flags.sort();
        degrade_flags.dedup();

        let effective_max_tokens = request
            .max_tokens
            .or(Some(DEGRADE_DEFAULT_MAX_TOKENS))
            .map(|cap| cap.min(DEGRADE_DEFAULT_MAX_TOKENS));

        let reason = if mandatory_degrade {
            format!(
                "predicted_net={net} < margin={}; fallback confidence requires degrade",
                SERVE_MARGIN_TOKENS
            )
        } else {
            format!(
                "predicted_net={net} <= margin_low={}",
                DEGRADE_MARGIN_LOW_TOKENS
            )
        };

        return StelDecision {
            plan_id: plan.plan_id.clone(),
            decision: AdmissionDecision::Degrade,
            decision_reason: reason,
            effective_max_tokens,
            degrade_flags,
            steps: Some(plan.steps.clone()),
            bypass: None,
            cache: None,
        };
    }

    StelDecision {
        plan_id: plan.plan_id.clone(),
        decision: AdmissionDecision::Serve,
        decision_reason: format!("predicted_net={net} > margin={}", SERVE_MARGIN_TOKENS),
        effective_max_tokens: request.max_tokens,
        degrade_flags: vec![],
        steps: Some(plan.steps.clone()),
        bypass: None,
        cache: None,
    }
}

fn decision_from_pff_bypass(
    plan: &StelPlan,
    request: &StelRequest,
    bypass: StelBypassBody,
) -> StelDecision {
    let economics = economics_for_bypass(&bypass);
    StelDecision {
        plan_id: plan.plan_id.clone(),
        decision: AdmissionDecision::Bypass,
        decision_reason: format!(
            "{}; predicted_net={}",
            bypass.reason, economics.predicted_net_vs_manual
        ),
        effective_max_tokens: request.max_tokens,
        degrade_flags: vec![],
        steps: None,
        bypass: Some(bypass),
        cache: None,
    }
}

fn decision_from_cache_hit(
    plan: &StelPlan,
    request: &StelRequest,
    cache: StelCacheBody,
) -> StelDecision {
    StelDecision {
        plan_id: plan.plan_id.clone(),
        decision: AdmissionDecision::CacheHit,
        decision_reason: format!(
            "session cache hit for {} `{}` (prior_tokens={})",
            cache.kind, cache.path, cache.prior_tokens
        ),
        effective_max_tokens: request.max_tokens,
        degrade_flags: vec![],
        steps: None,
        bypass: None,
        cache: Some(cache),
    }
}

fn detect_session_cache_hit(plan: &StelPlan, session: &SessionContext) -> Option<StelCacheBody> {
    let step = plan.steps.first()?;
    let age = session.session_age_secs();
    match step.tool.as_str() {
        "get_symbol" => {
            let name = step.args.get("name")?.as_str()?.trim();
            if name.is_empty() {
                return None;
            }
            let path = step
                .args
                .get("path")
                .and_then(|value| value.as_str())
                .unwrap_or("")
                .trim();
            if !path.is_empty() && session.has_symbol(path, name) {
                return Some(StelCacheBody {
                    kind: "symbol".to_string(),
                    path: path.to_string(),
                    name: name.to_string(),
                    prior_tokens: session.symbol_prior_tokens(path, name).unwrap_or(0),
                    session_age_secs: age,
                });
            }
            None
        }
        "get_file_context" | "get_file_content" => {
            let path = step.args.get("path")?.as_str()?.trim();
            if path.is_empty() || !session.has_file(path) {
                return None;
            }
            Some(StelCacheBody {
                kind: "file".to_string(),
                path: path.to_string(),
                name: String::new(),
                prior_tokens: session.file_prior_tokens(path).unwrap_or(0),
                session_age_secs: age,
            })
        }
        _ => None,
    }
}

fn economics_bypass_body(plan: &StelPlan, economics: &EconomicsBreakdown) -> StelBypassBody {
    let path = plan_primary_path(plan).unwrap_or_else(|| "unknown".to_string());
    StelBypassBody {
        action: "host_read".to_string(),
        path: path.clone(),
        start_line: 1,
        end_line: Some(80),
        predicted_manual_tokens: economics.predicted_manual_tokens,
        predicted_symforge_tokens: economics.predicted_symforge_tokens,
        reason: format!(
            "predicted_net={} <= 0; host_read `{path}` cheaper than serve",
            economics.predicted_net_vs_manual
        ),
    }
}

fn plan_primary_path(plan: &StelPlan) -> Option<String> {
    plan.steps.first().and_then(primary_path_from_step)
}

fn primary_path_from_step(step: &StelPlanStep) -> Option<String> {
    step.args
        .get("path")
        .and_then(|value| value.as_str())
        .map(str::trim)
        .filter(|path| !path.is_empty())
        .map(str::to_string)
        .or_else(|| {
            step.args
                .get("name")
                .and_then(|value| value.as_str())
                .map(str::trim)
                .filter(|name| !name.is_empty())
                .map(str::to_string)
        })
}

/// Evaluate L2 admission for a structural edit plan (no NL P-FF bypass path).
pub fn evaluate_edit_plan(plan: &StelPlan) -> StelDecision {
    let economics = estimate_economics(plan);
    let net = economics.predicted_net_vs_manual;
    if net <= 0 {
        let bypass = economics_bypass_body(plan, &economics);
        return StelDecision {
            plan_id: plan.plan_id.clone(),
            decision: AdmissionDecision::Bypass,
            decision_reason: format!("predicted_net={net} <= 0; edit economics bypass"),
            effective_max_tokens: None,
            degrade_flags: vec![],
            steps: Some(plan.steps.clone()),
            bypass: Some(bypass),
            cache: None,
        };
    }
    if net <= DEGRADE_MARGIN_LOW_TOKENS {
        return StelDecision {
            plan_id: plan.plan_id.clone(),
            decision: AdmissionDecision::Degrade,
            decision_reason: format!(
                "predicted_net={net} <= margin_low={}",
                DEGRADE_MARGIN_LOW_TOKENS
            ),
            effective_max_tokens: Some(DEGRADE_DEFAULT_MAX_TOKENS),
            degrade_flags: vec!["max_tokens_cap".to_string()],
            steps: Some(plan.steps.clone()),
            bypass: None,
            cache: None,
        };
    }
    StelDecision {
        plan_id: plan.plan_id.clone(),
        decision: AdmissionDecision::Serve,
        decision_reason: format!("predicted_net={net} > margin={}", SERVE_MARGIN_TOKENS),
        effective_max_tokens: None,
        degrade_flags: vec![],
        steps: Some(plan.steps.clone()),
        bypass: None,
        cache: None,
    }
}

/// Build preview economics for `preview: true` (L1+L2 only).
pub fn build_estimate(
    request: &StelRequest,
    plan: &StelPlan,
    decision: &StelDecision,
) -> StelEstimate {
    let economics = if decision.decision == AdmissionDecision::Bypass {
        decision
            .bypass
            .as_ref()
            .map(economics_for_bypass)
            .unwrap_or_else(|| estimate_economics(plan))
    } else {
        estimate_economics(plan)
    };
    let recommended = decision.decision == AdmissionDecision::Serve
        && economics.predicted_net_vs_manual > SERVE_MARGIN_TOKENS;
    let _ = request;
    StelEstimate {
        plan_id: plan.plan_id.clone(),
        decision: decision.decision,
        predicted_response_tokens: economics.predicted_response_tokens,
        predicted_manual_tokens: economics.predicted_manual_tokens,
        predicted_schema_tokens: economics.predicted_schema_tokens,
        predicted_invoke_tokens: economics.predicted_invoke_tokens,
        predicted_net_vs_manual: economics.predicted_net_vs_manual,
        recommended,
    }
}

/// Sum step estimates + compact surface overhead into a manual-vs-symforge comparison.
///
/// US5 grounding (010 FR-014, D2): when a step carries real [`IndexRef`] byte
/// sizes (populated by the index-aware serve path before the gate), the
/// per-step response/manual token estimates are derived from those real bytes
/// via the byte-grounded estimator ([`competent_manual_baseline_chars`]) rather
/// than the planner's `400/800` plan-only constants. This is why predictions now
/// vary with the actual work and why the adaptive economics branches
/// (`bypass`/`degrade`) become reachable for small/cheap inputs (a tiny file's
/// manual baseline is below SymForge's fixed schema+invoke overhead, so
/// `predicted_net` correctly goes non-positive). A step with no `index_refs`
/// (plan-only callers, preview, unit fixtures) keeps the deterministic per-step
/// `est_response_tokens`/`est_manual_tokens` floor unchanged. The result is still
/// an *estimate* (predicted from real bytes, not a measured token count) — the
/// envelope keeps the `est_`/`heuristic` label (relabel != measure).
pub fn estimate_economics(plan: &StelPlan) -> EconomicsBreakdown {
    let (predicted_response_tokens, predicted_manual_tokens) = plan
        .steps
        .iter()
        .map(grounded_step_tokens)
        .fold((0u32, 0u32), |(resp, manual), (step_resp, step_manual)| {
            (
                resp.saturating_add(step_resp),
                manual.saturating_add(step_manual),
            )
        });
    let predicted_symforge_tokens = predicted_response_tokens
        .saturating_add(COMPACT_SCHEMA_TOKENS)
        .saturating_add(COMPACT_INVOKE_TOKENS);
    let predicted_net_vs_manual = predicted_manual_tokens as i32 - predicted_symforge_tokens as i32;
    EconomicsBreakdown {
        predicted_response_tokens,
        predicted_manual_tokens,
        predicted_schema_tokens: COMPACT_SCHEMA_TOKENS,
        predicted_invoke_tokens: COMPACT_INVOKE_TOKENS,
        predicted_symforge_tokens,
        predicted_net_vs_manual,
    }
}

/// Per-step `(predicted_response_tokens, predicted_manual_tokens)`.
///
/// Grounded path: when `step.index_refs` is non-empty the manual baseline is the
/// competent windowed read of the real target bytes, and the predicted response
/// is the structured-serve fraction of that baseline (SymForge's symbol/outline/
/// slice is smaller than a windowed manual read). Falls back to the plan-only
/// `est_*` constants when no real size is known (preserves determinism for
/// plan-only callers and existing fixtures).
fn grounded_step_tokens(step: &StelPlanStep) -> (u32, u32) {
    if step.index_refs.is_empty() {
        return (step.est_response_tokens, step.est_manual_tokens);
    }
    let total_raw_chars: usize = step
        .index_refs
        .iter()
        .map(|index_ref| index_ref.raw_chars as usize)
        .sum();
    grounded_tokens_from_raw_chars(total_raw_chars)
}

/// Map real target byte length to `(predicted_response_tokens, predicted_manual_tokens)`.
///
/// `manual` = competent windowed read of the real bytes (what a disciplined
/// agent would read by hand); `response` = the structured-serve fraction of that
/// baseline. Both flow through the existing byte-grounded estimator so there is a
/// single source of truth for the manual baseline (no duplicate estimator).
fn grounded_tokens_from_raw_chars(raw_chars: usize) -> (u32, u32) {
    let manual_chars = competent_manual_baseline_chars(raw_chars);
    let manual_tokens = estimate_tokens_from_chars(manual_chars);
    let response_tokens = manual_tokens.saturating_mul(GROUNDED_RESPONSE_FRACTION_NUM)
        / GROUNDED_RESPONSE_FRACTION_DEN;
    (clamp_u32(response_tokens), clamp_u32(manual_tokens))
}

/// Saturating `u64` -> `u32` (token counts never legitimately exceed `u32::MAX`).
fn clamp_u32(value: u64) -> u32 {
    value.min(u32::MAX as u64) as u32
}

/// Build a grounded [`IndexRef`] for a resolved target. Used by the index-aware
/// serve path to stamp real byte sizes onto plan steps before the L2 gate.
pub fn index_ref_for_target(path: impl Into<String>, raw_chars: u64) -> IndexRef {
    IndexRef {
        path: path.into(),
        raw_chars,
    }
}

pub fn economics_for_bypass(bypass: &StelBypassBody) -> EconomicsBreakdown {
    let predicted_symforge_tokens = bypass
        .predicted_symforge_tokens
        .saturating_add(COMPACT_SCHEMA_TOKENS)
        .saturating_add(COMPACT_INVOKE_TOKENS);
    let predicted_net_vs_manual =
        bypass.predicted_manual_tokens as i32 - predicted_symforge_tokens as i32;
    EconomicsBreakdown {
        predicted_response_tokens: 0,
        predicted_manual_tokens: bypass.predicted_manual_tokens,
        predicted_schema_tokens: COMPACT_SCHEMA_TOKENS,
        predicted_invoke_tokens: COMPACT_INVOKE_TOKENS,
        predicted_symforge_tokens,
        predicted_net_vs_manual,
    }
}

/// Policy P-FF: whole-file review queries bypass SymForge serve (A-012 / golden corpus).
pub fn detect_pff_bypass(request: &StelRequest) -> Option<StelBypassBody> {
    let query = request.query.trim();
    let lower = query.to_ascii_lowercase();
    if !is_pff_query(&lower) {
        return None;
    }
    let path = extract_pff_path(query, &lower)?;
    Some(StelBypassBody {
        action: "host_read".to_string(),
        path: path.clone(),
        start_line: 1,
        end_line: None,
        predicted_manual_tokens: 320,
        predicted_symforge_tokens: COMPACT_SCHEMA_TOKENS + COMPACT_INVOKE_TOKENS + 256,
        reason: format!("policy=P-FF whole-file review; host_read `{path}`"),
    })
}

fn is_pff_query(lower: &str) -> bool {
    const PHRASES: &[&str] = &[
        "entire ",
        "whole ",
        "full file",
        "complete ",
        "line by line",
        "audit full ",
        "review entire ",
        "read complete ",
    ];
    PHRASES.iter().any(|phrase| lower.contains(phrase))
}

fn extract_pff_path(query: &str, lower: &str) -> Option<String> {
    for token in query.split_whitespace().rev() {
        let cleaned = token.trim_matches(|c: char| !c.is_alphanumeric() && c != '.' && c != '/');
        if cleaned.contains('.') && !cleaned.eq_ignore_ascii_case("line") {
            return Some(cleaned.to_string());
        }
    }
    for token in lower.split_whitespace() {
        if token.contains('.') {
            return Some(token.to_string());
        }
    }
    None
}

/// Golden-row helper: L2 decision should match fixture `expected_decision` for representative rows.
pub fn decision_matches_golden(
    row: &GoldenRouteRow,
    plan: &StelPlan,
    request: &StelRequest,
) -> bool {
    let decision = evaluate_plan(request, plan);
    decision.decision == row.expected_decision
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    use crate::stel::golden_replay::{GOLDEN_ROUTES_FIXTURE, load_golden_rows};
    use crate::stel::planner::build_plan;
    use crate::stel::types::{IntentBucket, RouteConfidence, StelPlan, StelPlanStep};

    fn row_by_id<'a>(rows: &'a [GoldenRouteRow], id: &str) -> &'a GoldenRouteRow {
        rows.iter()
            .find(|row| row.id == id)
            .unwrap_or_else(|| panic!("missing golden row {id}"))
    }

    fn request_for_row(row: &GoldenRouteRow) -> StelRequest {
        let mut request = row.to_request();
        request.intent = row.intent;
        request
    }

    fn low_net_plan() -> StelPlan {
        StelPlan {
            plan_id: "low-net".to_string(),
            intent: IntentBucket::Read,
            confidence: RouteConfidence::Inferred,
            confidence_rationale: "test".to_string(),
            steps: vec![StelPlanStep {
                order: 1,
                tool: "get_file_context".to_string(),
                args: serde_json::json!({ "path": "src/lib.rs" }),
                est_response_tokens: 900,
                est_manual_tokens: 100,
                index_refs: vec![],
            }],
            suggested_followup: None,
        }
    }

    #[test]
    fn serve_rows_classify_as_serve() {
        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_ROUTES_FIXTURE);
        let rows = load_golden_rows(&path).expect("golden fixture");
        for id in [
            "cfg-if/t1_search",
            "cfg-if/t4_refs",
            "records/t4_refs",
            "compression/t5_dependents",
        ] {
            let row = row_by_id(&rows, id);
            let request = request_for_row(row);
            let plan = build_plan(&request);
            let decision = evaluate_plan(&request, &plan);
            assert_eq!(
                decision.decision,
                AdmissionDecision::Serve,
                "{id}: {:?}",
                decision.decision_reason
            );
            assert!(row.eligible_h6.unwrap_or(false));
            let economics = estimate_economics(&plan);
            assert!(
                economics.predicted_net_vs_manual > SERVE_MARGIN_TOKENS,
                "{id} net {}",
                economics.predicted_net_vs_manual
            );
        }
    }

    #[test]
    fn pff_rows_classify_as_bypass() {
        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_ROUTES_FIXTURE);
        let rows = load_golden_rows(&path).expect("golden fixture");
        for id in [
            "cfg-if/pff_whole_lib",
            "records/pff_whole_module",
            "is-plain/pff_whole_index",
            "compression/pff_whole_service",
        ] {
            let row = row_by_id(&rows, id);
            let request = request_for_row(row);
            let plan = build_plan(&request);
            let decision = evaluate_plan(&request, &plan);
            assert_eq!(decision.decision, AdmissionDecision::Bypass, "{id}");
            assert!(decision.bypass.is_some(), "{id} bypass body");
            let bypass = decision.bypass.as_ref().expect("bypass body");
            assert_eq!(
                bypass.end_line, None,
                "{id} P-FF bypass must request whole-file host read"
            );
            assert_eq!(row.eligible_h6, Some(false));
            let estimate = build_estimate(&request, &plan, &decision);
            assert!(!estimate.recommended, "{id} should not recommend serve");
        }
    }

    #[test]
    fn negative_net_non_pff_bypass_has_host_read_body() {
        let plan = low_net_plan();
        let request = StelRequest::default();
        let decision = evaluate_plan(&request, &plan);
        assert_eq!(decision.decision, AdmissionDecision::Bypass);
        let bypass = decision.bypass.as_ref().expect("economics bypass body");
        assert_eq!(bypass.path, "src/lib.rs");
        assert!(decision.decision_reason.contains("predicted_net="));
    }

    #[test]
    fn marginal_net_degrades_with_outline_only_flag() {
        let plan = StelPlan {
            plan_id: "degrade".to_string(),
            intent: IntentBucket::Read,
            confidence: RouteConfidence::Inferred,
            confidence_rationale: "test".to_string(),
            steps: vec![StelPlanStep {
                order: 1,
                tool: "get_file_context".to_string(),
                args: serde_json::json!({ "path": "src/lib.rs" }),
                est_response_tokens: 400,
                est_manual_tokens: 530,
                index_refs: vec![],
            }],
            suggested_followup: None,
        };
        let request = StelRequest::default();
        let decision = evaluate_plan(&request, &plan);
        assert_eq!(decision.decision, AdmissionDecision::Degrade);
        assert!(decision.degrade_flags.contains(&"outline_only".to_string()));
        assert!(decision.effective_max_tokens.is_some());
    }

    #[test]
    fn fallback_confidence_forces_degrade_below_margin_high() {
        let plan = StelPlan {
            plan_id: "fallback".to_string(),
            intent: IntentBucket::Read,
            confidence: RouteConfidence::Fallback,
            confidence_rationale: "test".to_string(),
            steps: vec![StelPlanStep {
                order: 1,
                tool: "get_file_content".to_string(),
                args: serde_json::json!({ "path": "src/lib.rs" }),
                est_response_tokens: 400,
                est_manual_tokens: 530,
                index_refs: vec![],
            }],
            suggested_followup: None,
        };
        let request = StelRequest::default();
        let decision = evaluate_plan(&request, &plan);
        assert_eq!(decision.decision, AdmissionDecision::Degrade);
        assert!(
            decision
                .degrade_flags
                .contains(&"fallback_mandatory".to_string())
        );
    }

    #[test]
    fn session_cache_hit_for_prefetched_symbol() {
        let session = SessionContext::new();
        session.record_symbol("src/lib.rs", "cfg_if", 128);
        let plan = StelPlan {
            plan_id: "cache".to_string(),
            intent: IntentBucket::Read,
            confidence: RouteConfidence::Exact,
            confidence_rationale: "test".to_string(),
            steps: vec![StelPlanStep {
                order: 1,
                tool: "get_symbol".to_string(),
                args: serde_json::json!({ "path": "src/lib.rs", "name": "cfg_if" }),
                est_response_tokens: 400,
                est_manual_tokens: 800,
                index_refs: vec![],
            }],
            suggested_followup: None,
        };
        let request = StelRequest::default();
        let decision = evaluate_plan_with_session(&request, &plan, Some(&session));
        assert_eq!(decision.decision, AdmissionDecision::CacheHit);
        let cache = decision.cache.as_ref().expect("cache body");
        assert_eq!(cache.kind, "symbol");
        assert_eq!(cache.prior_tokens, 128);
    }

    #[test]
    fn eligible_h6_serve_rows_remain_eligible() {
        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_ROUTES_FIXTURE);
        let rows = load_golden_rows(&path).expect("golden fixture");
        let eligible: Vec<_> = rows
            .iter()
            .filter(|row| {
                row.eligible_h6 == Some(true) && row.expected_decision == AdmissionDecision::Serve
            })
            .collect();
        assert!(eligible.len() >= 20);
        for row in eligible.iter().take(6) {
            let request = request_for_row(row);
            let plan = build_plan(&request);
            assert!(
                decision_matches_golden(row, &plan, &request),
                "eligible serve row {} decision mismatch",
                row.id
            );
        }
    }

    #[test]
    fn preview_estimate_carries_schema_and_invoke_costs() {
        let request = StelRequest {
            query: "who references cfg_if".to_string(),
            ..Default::default()
        };
        let plan = build_plan(&request);
        let decision = evaluate_plan(&request, &plan);
        let estimate = build_estimate(&request, &plan, &decision);
        assert_eq!(estimate.predicted_schema_tokens, COMPACT_SCHEMA_TOKENS);
        assert_eq!(estimate.predicted_invoke_tokens, COMPACT_INVOKE_TOKENS);
        assert_eq!(estimate.decision, AdmissionDecision::Serve);
        assert!(estimate.recommended);
    }

    #[test]
    fn fallback_confidence_still_serves_when_net_positive() {
        let plan = StelPlan {
            plan_id: "test".to_string(),
            intent: IntentBucket::Read,
            confidence: RouteConfidence::Fallback,
            confidence_rationale: "test".to_string(),
            steps: vec![StelPlanStep {
                order: 1,
                tool: "get_file_context".to_string(),
                args: serde_json::json!({}),
                est_response_tokens: 400,
                est_manual_tokens: 800,
                index_refs: vec![],
            }],
            suggested_followup: None,
        };
        let request = StelRequest::default();
        let decision = evaluate_plan(&request, &plan);
        assert_eq!(decision.decision, AdmissionDecision::Serve);
    }

    /// Build a single-step read plan grounded in a real target byte size.
    fn grounded_read_plan(raw_chars: u64, confidence: RouteConfidence) -> StelPlan {
        StelPlan {
            plan_id: format!("grounded-{raw_chars}"),
            intent: IntentBucket::Read,
            confidence,
            confidence_rationale: "test".to_string(),
            steps: vec![StelPlanStep {
                order: 1,
                tool: "get_file_context".to_string(),
                args: serde_json::json!({ "path": "src/lib.rs" }),
                // Plan-only floor that grounding must override when index_refs present.
                est_response_tokens: 400,
                est_manual_tokens: 800,
                index_refs: vec![IndexRef {
                    path: "src/lib.rs".to_string(),
                    raw_chars,
                }],
            }],
            suggested_followup: None,
        }
    }

    /// T035 (SC-005, US5 AC-1): the same operation over a small and a large real
    /// input yields DIFFERENT grounded predictions — never the pre-grounding
    /// fixed constant. The prediction scales with the actual target byte length.
    #[test]
    fn grounded_predictions_vary_with_real_input_size() {
        let small = estimate_economics(&grounded_read_plan(600, RouteConfidence::Inferred));
        let large = estimate_economics(&grounded_read_plan(40_000, RouteConfidence::Inferred));

        // Predictions are not the fixed 400/800-derived constant.
        assert_ne!(
            small.predicted_manual_tokens, large.predicted_manual_tokens,
            "manual baseline must vary with real size"
        );
        assert_ne!(
            small.predicted_net_vs_manual, large.predicted_net_vs_manual,
            "predicted net must vary with real size"
        );
        // Larger input ⇒ larger manual baseline ⇒ a more favorable (higher) net.
        assert!(
            large.predicted_manual_tokens > small.predicted_manual_tokens,
            "bigger file ⇒ bigger manual baseline (small={} large={})",
            small.predicted_manual_tokens,
            large.predicted_manual_tokens
        );
        assert!(
            large.predicted_net_vs_manual > small.predicted_net_vs_manual,
            "bigger file ⇒ higher predicted net (small={} large={})",
            small.predicted_net_vs_manual,
            large.predicted_net_vs_manual
        );

        // A step with NO index_refs keeps the deterministic plan-only floor
        // (preserves determinism for plan-only callers / preview / fixtures).
        let plan_only = StelPlan {
            steps: vec![StelPlanStep {
                index_refs: vec![],
                ..grounded_read_plan(600, RouteConfidence::Inferred).steps[0].clone()
            }],
            ..grounded_read_plan(600, RouteConfidence::Inferred)
        };
        let floor = estimate_economics(&plan_only);
        assert_eq!(floor.predicted_response_tokens, 400);
        assert_eq!(floor.predicted_manual_tokens, 800);
    }

    /// T036 (US5 AC-2, TR-04b): a sufficiently small grounded request makes a
    /// non-serve economics branch REACHABLE — a tiny file's competent-manual
    /// baseline falls below SymForge's fixed schema+invoke overhead, so
    /// `predicted_net <= 0` and the gate correctly BYPASSES (host-read is cheaper
    /// than serving). This branch was unreachable while every step carried the
    /// 400/800 constant (net was permanently ~275 ⇒ always serve).
    #[test]
    fn grounded_small_request_reaches_economics_bypass() {
        // 180-char file: below the 200-char small-file threshold ⇒ manual
        // baseline is the whole tiny file (~45 tokens), well under the 125-token
        // schema+invoke floor ⇒ predicted_net is non-positive.
        let tiny = grounded_read_plan(180, RouteConfidence::Inferred);
        let economics = estimate_economics(&tiny);
        assert!(
            economics.predicted_net_vs_manual <= 0,
            "tiny grounded request must predict non-positive net, got {}",
            economics.predicted_net_vs_manual
        );
        let decision = evaluate_plan(&StelRequest::default(), &tiny);
        assert_eq!(
            decision.decision,
            AdmissionDecision::Bypass,
            "tiny grounded request must reach economics bypass: {}",
            decision.decision_reason
        );
        assert!(
            decision.bypass.is_some(),
            "economics bypass must carry a host-read body"
        );
    }

    /// T036 / N-2: a low-confidence Fallback route with a grounded marginal net
    /// reaches the `mandatory_degrade` branch — the third dead branch the
    /// constant kept unreachable. Low-confidence routes now get an economic
    /// guardrail (degrade) instead of being served at full budget.
    #[test]
    fn grounded_fallback_marginal_reaches_mandatory_degrade() {
        // Choose a raw size whose grounded net lands in (0, SERVE_MARGIN): a
        // ~1600-char file ⇒ manual ~400 tokens, response ~240, net = 400 - (240+125)
        // = 35 ⇒ 0 < 35 < 50 = SERVE_MARGIN. On a Fallback route that triggers
        // mandatory_degrade (and economics_degrade), never serve, never bypass.
        let plan = grounded_read_plan(1600, RouteConfidence::Fallback);
        let economics = estimate_economics(&plan);
        assert!(
            economics.predicted_net_vs_manual > 0
                && economics.predicted_net_vs_manual < SERVE_MARGIN_TOKENS,
            "grounded net must be marginal (0 < net < {SERVE_MARGIN_TOKENS}), got {}",
            economics.predicted_net_vs_manual
        );
        let decision = evaluate_plan(&StelRequest::default(), &plan);
        assert_eq!(
            decision.decision,
            AdmissionDecision::Degrade,
            "marginal fallback must degrade: {}",
            decision.decision_reason
        );
        assert!(
            decision
                .degrade_flags
                .contains(&"fallback_mandatory".to_string()),
            "N-2: low-confidence fallback must carry the mandatory degrade guardrail: {:?}",
            decision.degrade_flags
        );
    }
}
