from __future__ import annotations

from typing import Any, Dict, List, Optional, cast

from pydantic import BaseModel, Field, field_validator

from grantflow.core.config import config
from grantflow.swarm.critic_llm_policy import build_llm_advisory_diagnostics as _build_llm_advisory_diagnostics
from grantflow.swarm.critic_llm_policy import classify_llm_finding_label as _classify_llm_finding_label
from grantflow.swarm.critic_llm_policy import is_advisory_llm_finding as _is_advisory_llm_finding
from grantflow.swarm.critic_llm_policy import is_advisory_llm_message as _is_advisory_llm_message  # noqa: F401
from grantflow.swarm.critic_llm_policy import llm_finding_policy_class as _llm_finding_policy_class
from grantflow.swarm.critic_rules import CriticFatalFlaw, evaluate_rule_based_critic
from grantflow.swarm.citations import (
    citation_has_retrieval_metadata,
    citation_traceability_status,
    is_fallback_namespace_citation_type,
    is_non_retrieval_citation_type,
    is_retrieval_grounded_citation_type,
    is_strategy_reference_citation_type,
)
from grantflow.swarm.findings import canonicalize_findings, finding_messages, write_state_critic_findings
from grantflow.swarm.grounding_gate import evaluate_grounding_gate
from grantflow.swarm.llm_provider import (
    chat_openai_init_kwargs,
    llm_model_candidates,
    openai_compatible_llm_available,
    openai_compatible_missing_reason,
)
from grantflow.swarm.state_contract import (
    normalize_state_contract,
    set_state_iteration,
    state_donor_id,
    state_donor_strategy,
    state_iteration,
    state_llm_mode,
    state_max_iterations,
)

WEAK_GROUNDING_LLM_SCORE_MAX_PENALTY = 1.5
WEAK_GROUNDING_MIN_CITATIONS_FOR_CALIBRATION = 5
WEAK_GROUNDING_LOW_CONFIDENCE_RATIO_THRESHOLD = 0.75
WEAK_GROUNDING_FALLBACK_RATIO_THRESHOLD = 0.6
WEAK_GROUNDING_TRACEABILITY_GAP_RATIO_THRESHOLD = 0.6
WEAK_GROUNDING_MIN_RETRIEVAL_METADATA_COMPLETE_RATE = 0.6
ADVISORY_ONLY_LLM_SCORE_MAX_PENALTY = 0.75
ADVISORY_GROUNDING_MIN_THRESHOLD_HIT_RATE = 0.5
ADVISORY_GROUNDING_MAX_ARCHITECT_RAG_LOW_RATIO = 0.5


class RedTeamEvaluation(BaseModel):
    """Structured evaluation from the Red Team Critic."""

    score: float = Field(
        description="A strict score from 0.0 to 10.0 based on donor compliance and logical soundness.",
        ge=0.0,
        le=10.0,
    )
    fatal_flaws: List[Dict[str, Any]] = Field(
        default_factory=list,
        description="Structured list of critical logical gaps, missing indicators, or unrealistic assumptions.",
    )
    revision_instructions: str = Field(description="Clear, actionable instructions for follow-up revision.")

    @field_validator("fatal_flaws", mode="before")
    @classmethod
    def _coerce_fatal_flaws_entities(cls, value: Any) -> list[Dict[str, Any]]:
        if not isinstance(value, list):
            return []
        out: list[Dict[str, Any]] = []
        for item in value:
            if isinstance(item, dict):
                out.append(item)
                continue
            if isinstance(item, str):
                message = item.strip()
                if message:
                    out.append({"message": message})
        return out


def _dump_model(model: BaseModel) -> Dict[str, Any]:
    dumper = getattr(model, "model_dump", None)
    if callable(dumper):
        return dumper()
    return model.dict()


def _llm_flaws_to_structured(flaws: List[Dict[str, Any]], *, state: Dict[str, Any]) -> List[Dict[str, Any]]:
    structured: List[Dict[str, Any]] = []
    for idx, item in enumerate(flaws or []):
        payload = item if isinstance(item, dict) else {}
        msg = str(payload.get("message") or "").strip()
        if not msg:
            continue
        section = str(payload.get("section") or "general").strip().lower()
        if section not in {"toc", "logframe", "general"}:
            section = "general"
        lowered = msg.lower()
        if section == "general" and any(k in lowered for k in ("indicator", "mel", "logframe")):
            section = "logframe"
        elif section == "general" and any(k in lowered for k in ("toc", "objective", "assumption", "causal")):
            section = "toc"
        label = _classify_llm_finding_label(msg, section=section)
        structured.append(
            _dump_model(
                CriticFatalFlaw(
                    code=str(payload.get("code") or f"LLM_REVIEW_FLAG_{idx+1}"),
                    label=label,
                    severity=str(payload.get("severity") or "medium"),
                    section=section,
                    version_id=None,
                    message=msg,
                    rationale=str(
                        payload.get("rationale") or "LLM reviewer-identified risk in draft quality/compliance."
                    ),
                    fix_suggestion=str(
                        payload.get("fix_suggestion")
                        or payload.get("fix_hint")
                        or "Incorporate this reviewer feedback in the next draft iteration."
                    ),
                    fix_hint=str(
                        payload.get("fix_suggestion")
                        or payload.get("fix_hint")
                        or "Incorporate this reviewer feedback in the next draft iteration."
                    ),
                    source="llm",
                )
            )
        )
    return cast(List[Dict[str, Any]], canonicalize_findings(structured, state=state, default_source="llm"))


def _advisory_llm_findings_context(
    *,
    state: Dict[str, Any],
    rule_report: Any,
    llm_fatal_flaw_items: List[Dict[str, Any]],
) -> Dict[str, Any]:
    donor_id = state_donor_id(state)
    llm_items = [f for f in llm_fatal_flaw_items if isinstance(f, dict)]
    if not llm_items:
        return {"applies": False, "reason": "no_llm_findings"}

    rule_fatal_flaws = list(getattr(rule_report, "fatal_flaws", []) or [])
    rule_checks = list(getattr(rule_report, "checks", []) or [])
    failed_rule_checks = [c for c in rule_checks if str(getattr(c, "status", "")).lower() == "fail"]
    if rule_fatal_flaws or failed_rule_checks:
        return {"applies": False, "reason": "rule_critic_has_failures"}

    if any(not _is_advisory_llm_finding(item, donor_id=donor_id) for item in llm_items):
        return {"applies": False, "reason": "non_advisory_llm_finding_present"}

    grounding = _citation_grounding_context(state)
    architect_citations = [
        c for c in (state.get("citations") or []) if isinstance(c, dict) and str(c.get("stage") or "") == "architect"
    ]
    architect_count = len(architect_citations)
    architect_support = sum(1 for c in architect_citations if str(c.get("citation_type") or "") == "rag_claim_support")
    architect_rag_low = sum(1 for c in architect_citations if str(c.get("citation_type") or "") == "rag_low_confidence")
    architect_fallback = sum(
        1 for c in architect_citations if str(c.get("citation_type") or "") == "fallback_namespace"
    )
    threshold_hit_rate = round(architect_support / architect_count, 4) if architect_count else None
    architect_rag_low_ratio = round(architect_rag_low / architect_count, 4) if architect_count else None

    if architect_count == 0:
        return {"applies": False, "reason": "no_architect_citations"}
    if architect_fallback > 0:
        return {"applies": False, "reason": "architect_fallback_citations_present"}
    if architect_rag_low_ratio is not None and architect_rag_low_ratio > ADVISORY_GROUNDING_MAX_ARCHITECT_RAG_LOW_RATIO:
        return {
            "applies": False,
            "reason": "architect_rag_low_ratio_too_high",
            "architect_rag_low_ratio": architect_rag_low_ratio,
        }
    if threshold_hit_rate is None or threshold_hit_rate < ADVISORY_GROUNDING_MIN_THRESHOLD_HIT_RATE:
        return {
            "applies": False,
            "reason": "threshold_hit_rate_below_min",
            "threshold_hit_rate": threshold_hit_rate,
            "min_threshold_hit_rate": ADVISORY_GROUNDING_MIN_THRESHOLD_HIT_RATE,
        }

    return {
        "applies": True,
        "reason": "advisory_only_llm_findings_with_good_enough_architect_grounding",
        "donor_id": donor_id or None,
        "architect_citation_count": architect_count,
        "architect_threshold_hit_rate": threshold_hit_rate,
        "architect_rag_low_ratio": architect_rag_low_ratio,
        "grounding_context": grounding,
    }


def _downgrade_advisory_llm_findings(
    llm_fatal_flaw_items: List[Dict[str, Any]],
    *,
    advisory_ctx: Dict[str, Any],
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
    if not advisory_ctx.get("applies"):
        return llm_fatal_flaw_items, None

    changed = 0
    out: List[Dict[str, Any]] = []
    for item in llm_fatal_flaw_items:
        current = dict(item)
        if _llm_finding_policy_class(current, donor_id=str(advisory_ctx.get("donor_id") or "")) == "advisory":
            if str(current.get("severity") or "").lower() != "low":
                current["severity"] = "low"
                changed += 1
        out.append(current)
    labels_downgraded = sorted(
        {
            str(item.get("label") or "").strip()
            for item in out
            if str(item.get("severity") or "").lower() == "low" and str(item.get("label") or "").strip()
        }
    )
    return out, {
        "applied": True,
        "reason": str(advisory_ctx.get("reason") or ""),
        "downgraded_count": changed,
        "architect_threshold_hit_rate": advisory_ctx.get("architect_threshold_hit_rate"),
        "labels_downgraded": labels_downgraded,
        "policy_mode": "label_first_with_text_fallback",
    }


def _apply_advisory_llm_score_cap(
    *,
    combined_score: float,
    rule_score: float,
    llm_score: Optional[float],
    advisory_ctx: Dict[str, Any],
) -> tuple[float, Optional[Dict[str, Any]]]:
    if llm_score is None or not advisory_ctx.get("applies"):
        return round(float(combined_score), 2), None
    if float(llm_score) >= float(rule_score):
        return round(float(combined_score), 2), None

    min_allowed = float(rule_score) - ADVISORY_ONLY_LLM_SCORE_MAX_PENALTY
    adjusted = max(float(combined_score), min_allowed)
    if adjusted <= float(combined_score):
        return round(float(combined_score), 2), None
    return round(adjusted, 2), {
        "applied": True,
        "reason": "advisory_only_llm_findings_caps_penalty",
        "rule_score": round(float(rule_score), 2),
        "raw_llm_score": round(float(llm_score), 2),
        "combined_score_before": round(float(combined_score), 2),
        "combined_score_after": round(adjusted, 2),
        "max_llm_penalty": ADVISORY_ONLY_LLM_SCORE_MAX_PENALTY,
        "advisory_context": advisory_ctx,
    }


def _normalize_fatal_flaw_items(
    items: List[Dict[str, Any]],
    *,
    state: Dict[str, Any],
    previous_items: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
    return cast(
        List[Dict[str, Any]],
        canonicalize_findings(items, state=state, previous_items=previous_items, default_source="rules"),
    )


def _citation_grounding_context(state: Dict[str, Any]) -> Dict[str, Any]:
    raw_citations = state.get("citations")
    citations = list(raw_citations) if isinstance(raw_citations, list) else []
    citation_count = 0
    low_confidence_count = 0
    rag_low_confidence_count = 0
    fallback_namespace_count = 0
    strategy_reference_count = 0
    retrieval_grounded_count = 0
    non_retrieval_count = 0
    traceability_complete_count = 0
    traceability_partial_count = 0
    traceability_missing_count = 0
    retrieval_metadata_complete_count = 0
    architect_traceability_gap_count = 0
    architect_citation_count = 0
    for citation in citations:
        if not isinstance(citation, dict):
            continue
        citation_count += 1
        if str(citation.get("stage") or "") == "architect":
            architect_citation_count += 1
        citation_type = str(citation.get("citation_type") or "")
        if citation_type == "rag_low_confidence":
            rag_low_confidence_count += 1
        if is_fallback_namespace_citation_type(citation_type):
            fallback_namespace_count += 1
        if is_strategy_reference_citation_type(citation_type):
            strategy_reference_count += 1
        if is_retrieval_grounded_citation_type(citation_type):
            retrieval_grounded_count += 1
        if is_non_retrieval_citation_type(citation_type):
            non_retrieval_count += 1
        traceability_status = citation_traceability_status(citation)
        if traceability_status == "complete":
            traceability_complete_count += 1
        elif traceability_status == "partial":
            traceability_partial_count += 1
            if str(citation.get("stage") or "") == "architect":
                architect_traceability_gap_count += 1
        else:
            traceability_missing_count += 1
            if str(citation.get("stage") or "") == "architect":
                architect_traceability_gap_count += 1
        if is_retrieval_grounded_citation_type(citation_type) and citation_has_retrieval_metadata(citation):
            retrieval_metadata_complete_count += 1
        confidence = citation.get("citation_confidence")
        try:
            conf_value = float(confidence) if confidence is not None else None
        except (TypeError, ValueError):
            conf_value = None
        if conf_value is not None and conf_value < 0.3:
            low_confidence_count += 1

    raw_architect_retrieval = state.get("architect_retrieval")
    architect_retrieval = raw_architect_retrieval if isinstance(raw_architect_retrieval, dict) else {}
    retrieval_enabled = bool(architect_retrieval.get("enabled")) if architect_retrieval else False
    raw_hits_count = architect_retrieval.get("hits_count")
    if raw_hits_count is None:
        retrieval_hits_count = None
    else:
        try:
            retrieval_hits_count = int(raw_hits_count)
        except (TypeError, ValueError):
            retrieval_hits_count = None

    low_confidence_ratio = round(low_confidence_count / citation_count, 4) if citation_count else None
    weak_rag_or_fallback_count = rag_low_confidence_count + fallback_namespace_count
    weak_rag_or_fallback_ratio = round(weak_rag_or_fallback_count / citation_count, 4) if citation_count else None
    non_retrieval_ratio = round(non_retrieval_count / citation_count, 4) if citation_count else None
    retrieval_grounded_ratio = round(retrieval_grounded_count / citation_count, 4) if citation_count else None
    retrieval_metadata_complete_rate = (
        round(retrieval_metadata_complete_count / retrieval_grounded_count, 4) if retrieval_grounded_count else None
    )
    traceability_gap_count = traceability_partial_count + traceability_missing_count
    traceability_gap_ratio = round(traceability_gap_count / citation_count, 4) if citation_count else None
    architect_traceability_gap_ratio = (
        round(architect_traceability_gap_count / architect_citation_count, 4) if architect_citation_count else None
    )

    weak_grounding_reasons: list[str] = []
    if retrieval_enabled and retrieval_hits_count == 0:
        weak_grounding_reasons.append("architect_retrieval_no_hits")
    if (
        citation_count >= WEAK_GROUNDING_MIN_CITATIONS_FOR_CALIBRATION
        and weak_rag_or_fallback_ratio is not None
        and weak_rag_or_fallback_ratio >= WEAK_GROUNDING_FALLBACK_RATIO_THRESHOLD
    ):
        weak_grounding_reasons.append("fallback_or_low_rag_citations_dominate")
    if (
        retrieval_enabled
        and citation_count >= WEAK_GROUNDING_MIN_CITATIONS_FOR_CALIBRATION
        and non_retrieval_ratio is not None
        and non_retrieval_ratio >= WEAK_GROUNDING_FALLBACK_RATIO_THRESHOLD
    ):
        weak_grounding_reasons.append("non_retrieval_citations_dominate_when_retrieval_enabled")
    if (
        citation_count >= WEAK_GROUNDING_MIN_CITATIONS_FOR_CALIBRATION
        and low_confidence_ratio is not None
        and low_confidence_ratio >= WEAK_GROUNDING_LOW_CONFIDENCE_RATIO_THRESHOLD
    ):
        weak_grounding_reasons.append("low_confidence_citations_dominate")
    if (
        citation_count >= WEAK_GROUNDING_MIN_CITATIONS_FOR_CALIBRATION
        and traceability_gap_ratio is not None
        and traceability_gap_ratio >= WEAK_GROUNDING_TRACEABILITY_GAP_RATIO_THRESHOLD
    ):
        weak_grounding_reasons.append("citation_traceability_gaps_dominate")
    if (
        retrieval_enabled
        and retrieval_grounded_count > 0
        and retrieval_metadata_complete_rate is not None
        and retrieval_metadata_complete_rate < WEAK_GROUNDING_MIN_RETRIEVAL_METADATA_COMPLETE_RATE
    ):
        weak_grounding_reasons.append("retrieval_metadata_completeness_below_min")

    return {
        "citation_count": citation_count,
        "low_confidence_citation_count": low_confidence_count,
        "rag_low_confidence_citation_count": rag_low_confidence_count,
        "fallback_namespace_citation_count": fallback_namespace_count,
        "strategy_reference_citation_count": strategy_reference_count,
        "retrieval_grounded_citation_count": retrieval_grounded_count,
        "non_retrieval_citation_count": non_retrieval_count,
        "traceability_complete_citation_count": traceability_complete_count,
        "traceability_partial_citation_count": traceability_partial_count,
        "traceability_missing_citation_count": traceability_missing_count,
        "traceability_gap_citation_count": traceability_gap_count,
        "traceability_gap_ratio": traceability_gap_ratio,
        "retrieval_metadata_complete_citation_count": retrieval_metadata_complete_count,
        "retrieval_metadata_complete_rate": retrieval_metadata_complete_rate,
        "architect_traceability_gap_citation_count": architect_traceability_gap_count,
        "architect_traceability_gap_ratio": architect_traceability_gap_ratio,
        "low_confidence_ratio": low_confidence_ratio,
        "weak_rag_or_fallback_ratio": weak_rag_or_fallback_ratio,
        "non_retrieval_ratio": non_retrieval_ratio,
        "retrieval_grounded_ratio": retrieval_grounded_ratio,
        "architect_retrieval_enabled": retrieval_enabled,
        "architect_retrieval_hits_count": retrieval_hits_count,
        "weak_grounding": bool(weak_grounding_reasons),
        "weak_grounding_reasons": weak_grounding_reasons,
    }


def _combine_critic_scores(
    *,
    rule_score: float,
    llm_score: Optional[float],
    state: Dict[str, Any],
) -> tuple[float, Optional[Dict[str, Any]]]:
    if llm_score is None:
        return float(rule_score), None

    base_combined = min(float(rule_score), float(llm_score))
    grounding_context = _citation_grounding_context(state)
    if llm_score >= rule_score or not bool(grounding_context.get("weak_grounding")):
        return round(base_combined, 2), {
            "applied": False,
            "rule_score": round(float(rule_score), 2),
            "raw_llm_score": round(float(llm_score), 2),
            "combined_score": round(base_combined, 2),
            "reason": None if llm_score >= rule_score else "no_weak_grounding_context",
            "grounding_context": grounding_context,
        }

    capped_llm_score = max(float(llm_score), float(rule_score) - WEAK_GROUNDING_LLM_SCORE_MAX_PENALTY)
    combined = min(float(rule_score), capped_llm_score)
    return round(combined, 2), {
        "applied": True,
        "reason": "weak_grounding_caps_llm_penalty",
        "rule_score": round(float(rule_score), 2),
        "raw_llm_score": round(float(llm_score), 2),
        "calibrated_llm_score": round(capped_llm_score, 2),
        "combined_score": round(combined, 2),
        "max_llm_penalty": WEAK_GROUNDING_LLM_SCORE_MAX_PENALTY,
        "grounding_context": grounding_context,
    }


def red_team_critic(state: Dict[str, Any]) -> Dict[str, Any]:
    """Evaluates the drafted ToC and LogFrame and updates loop-control fields."""
    normalize_state_contract(state)
    donor_strategy = state_donor_strategy(state)
    if not donor_strategy:
        raise ValueError("Critical Error: DonorStrategy not found in state.")

    evaluation: Optional[RedTeamEvaluation] = None
    llm_mode = state_llm_mode(state, default=False)
    critic_engine = "rules"
    llm_reason: Optional[str] = None
    llm_models_tried: list[str] = []
    llm_selected_model: Optional[str] = None
    llm_failure_reasons: list[str] = []
    rule_report = evaluate_rule_based_critic(state)

    if llm_mode and openai_compatible_llm_available():
        try:
            from langchain_core.messages import HumanMessage, SystemMessage
            from langchain_openai import ChatOpenAI

            system_prompt = donor_strategy.get_system_prompts().get(
                "Red_Team_Critic", "Evaluate quality and compliance strictly."
            )
            human_prompt = (
                "Evaluate the following grant proposal artifacts:\n\n"
                f"Theory of Change:\n{state.get('toc_draft', {})}\n\n"
                f"Logical Framework / Indicators:\n{state.get('logframe_draft', {})}\n\n"
                "Be strict and return the structured schema only."
            )
            model_candidates = llm_model_candidates(
                str(getattr(config.llm, "critic_model", "") or ""),
                str(getattr(config.llm, "reasoning_model", "") or ""),
                str(getattr(config.llm, "cheap_model", "") or ""),
            )
            for model_name in model_candidates:
                llm_models_tried.append(model_name)
                llm_kwargs = chat_openai_init_kwargs(model=model_name, temperature=0.1)
                if llm_kwargs is None:
                    llm_failure_reasons.append(f"{model_name}: {openai_compatible_missing_reason()}")
                    continue
                try:
                    llm = ChatOpenAI(**llm_kwargs)
                    evaluator_llm = llm.with_structured_output(RedTeamEvaluation)
                    evaluation = evaluator_llm.invoke(
                        [SystemMessage(content=system_prompt), HumanMessage(content=human_prompt)]
                    )
                    critic_engine = f"rules+llm:{model_name}"
                    llm_selected_model = model_name
                    break
                except Exception as model_exc:
                    llm_failure_reasons.append(f"{model_name}: {model_exc}")
            if evaluation is None:
                if llm_failure_reasons:
                    llm_reason = f"LLM unavailable: {'; '.join(llm_failure_reasons[:3])}"
                else:
                    llm_reason = "LLM unavailable: no configured model candidates"
        except Exception as exc:
            llm_reason = f"LLM unavailable: {exc}"
    else:
        llm_reason = "llm_mode=false" if not llm_mode else openai_compatible_missing_reason()

    iteration = state_iteration(state) + 1
    max_iters = state_max_iterations(state, default=3)
    llm_score = float(evaluation.score) if evaluation is not None else None
    llm_fatal_flaw_items: List[Dict[str, Any]] = []
    if evaluation is not None:
        llm_fatal_flaw_items = _llm_flaws_to_structured(list(evaluation.fatal_flaws or []), state=state)
    advisory_ctx = _advisory_llm_findings_context(
        state=state,
        rule_report=rule_report,
        llm_fatal_flaw_items=llm_fatal_flaw_items,
    )
    llm_advisory_diagnostics = _build_llm_advisory_diagnostics(
        llm_fatal_flaw_items=llm_fatal_flaw_items,
        advisory_ctx=advisory_ctx,
        donor_id=state_donor_id(state),
    )
    llm_fatal_flaw_items, llm_advisory_normalization = _downgrade_advisory_llm_findings(
        llm_fatal_flaw_items,
        advisory_ctx=advisory_ctx,
    )

    score, llm_score_calibration = _combine_critic_scores(
        rule_score=float(rule_report.score),
        llm_score=llm_score,
        state=state,
    )
    score, llm_advisory_score_calibration = _apply_advisory_llm_score_cap(
        combined_score=score,
        rule_score=float(rule_report.score),
        llm_score=llm_score,
        advisory_ctx=advisory_ctx,
    )
    threshold = float(getattr(config.graph, "critic_threshold", 8.0) or 8.0)

    previous_notes = state.get("critic_notes") if isinstance(state.get("critic_notes"), dict) else {}
    previous_fatal_flaws = (
        [f for f in previous_notes.get("fatal_flaws", []) if isinstance(f, dict)]
        if isinstance(previous_notes, dict)
        else []
    )

    fatal_flaw_items: List[Dict[str, Any]] = [_dump_model(item) for item in rule_report.fatal_flaws]
    if llm_fatal_flaw_items:
        fatal_flaw_items.extend(llm_fatal_flaw_items)

    grounding_gate = evaluate_grounding_gate(
        state,
        mode=str(getattr(config.graph, "grounding_gate_mode", "warn") or "warn"),
        min_citations_for_calibration=int(
            getattr(
                config.graph, "grounding_min_citations_for_calibration", WEAK_GROUNDING_MIN_CITATIONS_FOR_CALIBRATION
            )
            or WEAK_GROUNDING_MIN_CITATIONS_FOR_CALIBRATION
        ),
        max_weak_rag_or_fallback_ratio=float(
            getattr(config.graph, "grounding_max_weak_rag_or_fallback_ratio", WEAK_GROUNDING_FALLBACK_RATIO_THRESHOLD)
            or WEAK_GROUNDING_FALLBACK_RATIO_THRESHOLD
        ),
        max_low_confidence_ratio=float(
            getattr(config.graph, "grounding_max_low_confidence_ratio", WEAK_GROUNDING_LOW_CONFIDENCE_RATIO_THRESHOLD)
            or WEAK_GROUNDING_LOW_CONFIDENCE_RATIO_THRESHOLD
        ),
        max_traceability_gap_ratio=float(
            getattr(
                config.graph,
                "grounding_max_traceability_gap_ratio",
                WEAK_GROUNDING_TRACEABILITY_GAP_RATIO_THRESHOLD,
            )
            or WEAK_GROUNDING_TRACEABILITY_GAP_RATIO_THRESHOLD
        ),
        min_retrieval_metadata_complete_rate=float(
            getattr(
                config.graph,
                "grounding_min_retrieval_metadata_complete_rate",
                WEAK_GROUNDING_MIN_RETRIEVAL_METADATA_COMPLETE_RATE,
            )
            or WEAK_GROUNDING_MIN_RETRIEVAL_METADATA_COMPLETE_RATE
        ),
    )
    if grounding_gate.get("blocking"):
        fatal_flaw_items.append(
            _dump_model(
                CriticFatalFlaw(
                    code="GROUNDING_GATE_STRICT_BLOCK",
                    severity="high",
                    section="general",
                    version_id=None,
                    message="Grounding gate (strict mode) blocked finalization due to weak citation grounding.",
                    rationale=f"Signals: {grounding_gate.get('summary')}",
                    fix_suggestion="Improve corpus grounding and reduce fallback/low-confidence citations before finalize/export.",
                    fix_hint="Ingest relevant donor/context documents and rerun with grounded mode.",
                    source="rules",
                )
            )
        )
    fatal_flaw_items = _normalize_fatal_flaw_items(
        fatal_flaw_items,
        state=state,
        previous_items=previous_fatal_flaws,
    )

    fatal_flaw_messages = finding_messages(fatal_flaw_items, fallback="Minor improvements suggested")

    revision_parts = [rule_report.revision_instructions]
    if evaluation is not None and str(evaluation.revision_instructions or "").strip():
        revision_parts.append(f"LLM reviewer notes: {evaluation.revision_instructions}")
    elif llm_reason and llm_mode:
        revision_parts.append(f"LLM critic fallback reason: {llm_reason}")
    if not bool(grounding_gate.get("passed")) and str(grounding_gate.get("mode") or "") != "off":
        revision_parts.append(
            "Grounding gate warning: "
            f"{grounding_gate.get('summary')}. "
            "Improve corpus relevance and citation confidence before external use."
        )
    revision_instructions = "\n\n".join([p for p in revision_parts if p])

    notes = {
        "score": score,
        "fatal_flaws": fatal_flaw_items,
        "fatal_flaw_messages": fatal_flaw_messages,
        "revision_instructions": revision_instructions,
        "rule_checks": [_dump_model(c) for c in rule_report.checks],
        "rule_score": float(rule_report.score),
        "llm_score": llm_score,
        "engine": critic_engine,
        "llm_models_tried": llm_models_tried,
        "llm_selected_model": llm_selected_model,
        "grounding_gate": grounding_gate,
    }
    if llm_reason:
        notes["llm_reason"] = llm_reason
    if llm_failure_reasons:
        notes["llm_failure_reasons"] = llm_failure_reasons[:5]
    if llm_score_calibration is not None:
        notes["llm_score_calibration"] = llm_score_calibration
    if llm_fatal_flaw_items:
        notes["llm_advisory_diagnostics"] = llm_advisory_diagnostics
    if llm_advisory_normalization is not None:
        notes["llm_advisory_normalization"] = llm_advisory_normalization
    if llm_advisory_score_calibration is not None:
        notes["llm_advisory_score_calibration"] = llm_advisory_score_calibration

    state["quality_score"] = score
    state["critic_score"] = score
    state["critic_notes"] = notes
    write_state_critic_findings(
        state,
        fatal_flaw_items,
        previous_items=previous_fatal_flaws,
        default_source="rules",
    )
    state["grounding_gate"] = grounding_gate

    history = list(state.get("critic_feedback_history") or [])
    history.append(
        f"Iteration {iteration}: score={score}; flaws={fatal_flaw_messages}; instructions={revision_instructions}"
    )
    state["critic_feedback_history"] = history

    set_state_iteration(state, iteration)
    state["needs_revision"] = score < threshold and iteration < max_iters
    state["next_step"] = "architect" if state["needs_revision"] else "end"
    return state
