"""Core utilities for Codex GraphRAG index, retrieval, and workflow recommendation."""

from __future__ import annotations

import json
import re
import time as _time
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Set, Tuple

from .runtime_ingestion import append_runtime_event_file, normalize_runtime_event
from .snowflake_events import SnowflakeDiscrepancyStore
from .trust_policy import (
    POLICY_MODE_SHADOW,
    classify_retention_tier,
    compute_policy_decision,
    compute_trust_score,
)

DEFAULT_DOCS_ROOT = "docs/Codex"
DEFAULT_INDEX_PATH = "data/codex_graphrag/index.json"
DEFAULT_PLAYBOOKS_PATH = "data/codex_graphrag/playbooks.json"
DEFAULT_STRATEGY_PATH = "data/codex_graphrag/strategy_ontology.json"
DEFAULT_TRUST_CASES_PATH = "data/codex_graphrag/trust_cases.json"
DEFAULT_RUNTIME_TRUST_CASES_PATH = "data/codex_graphrag/runtime_discrepancy_events.json"
DEFAULT_ANTI_PATTERNS_PATH = "data/codex_graphrag/anti_patterns.json"
DEFAULT_METRICS_CATALOG_PATH = "data/codex_graphrag/metrics_catalog.json"
STOPWORDS = {
    "the", "a", "an", "to", "for", "and", "or", "of", "in", "on", "with", "by",
    "is", "are", "be", "this", "that", "it", "as", "from", "at", "how", "what",
    "when", "which", "can", "i", "we", "you",
}

V3_EDGE_TYPES = [
    "enables",
    "validates",
    "mitigates",
    "compresses",
    "depends_on",
    "fails_when",
    "proves",
    "translated_to",
    "related",
]

DEFAULT_STRATEGY_NODES: List[Dict[str, str]] = [
    {"id": "concept:IntelligenceArbitrage", "name": "IntelligenceArbitrage", "domain": "strategy"},
    {"id": "concept:AutomatingTrust", "name": "AutomatingTrust", "domain": "strategy"},
    {"id": "concept:CFOStandard", "name": "CFOStandard", "domain": "strategy"},
    {"id": "concept:ClosureLoop", "name": "ClosureLoop", "domain": "strategy"},
    {"id": "concept:SprinklerBehavior", "name": "SprinklerBehavior", "domain": "strategy"},
    {"id": "concept:SemanticTranslation", "name": "SemanticTranslation", "domain": "strategy"},
    {"id": "concept:SpeedAsGovernance", "name": "SpeedAsGovernance", "domain": "strategy"},
]

DEFAULT_STRATEGY_EDGES: List[Dict[str, str]] = [
    {"source": "concept:IntelligenceArbitrage", "target": "concept:AutomatingTrust", "type": "enables"},
    {"source": "concept:AutomatingTrust", "target": "concept:CFOStandard", "type": "validates"},
    {"source": "concept:ClosureLoop", "target": "concept:AutomatingTrust", "type": "enables"},
    {"source": "concept:SprinklerBehavior", "target": "concept:ClosureLoop", "type": "depends_on"},
    {"source": "concept:SemanticTranslation", "target": "concept:CFOStandard", "type": "translated_to"},
    {"source": "concept:SpeedAsGovernance", "target": "concept:IntelligenceArbitrage", "type": "compresses"},
]

DEFAULT_TRUST_CASES: List[Dict[str, Any]] = [
    {
        "case_id": "trust_case.energy_erp.bus_matrix_001",
        "source_system": "Energy ERP",
        "metric": "Gross Margin",
        "symptom": "Dashboard margin != P&L margin",
        "lineage_path": ["erp.gl_entries", "stg_finance", "fct_margin_daily"],
        "root_cause": "Incorrect revenue account mapping in transformation logic",
        "fix": "Adjusted mapping logic and reran dimensional build",
        "verification": "Certified reconciliation achieved vs finance report",
        "time_to_closure": "45m",
        "confidence": 0.92,
        "tags": ["cfo", "reconciliation", "closure-loop", "energy"],
    }
]

DEFAULT_ANTI_PATTERNS: List[Dict[str, str]] = [
    {"id": "anti:SmokeDetectorOnly", "name": "SmokeDetectorOnly", "risk": "high", "mitigation": "Enable automated root-cause closure loop."},
    {"id": "anti:CloseEnoughMetrics", "name": "CloseEnoughMetrics", "risk": "high", "mitigation": "Require certified reconciliation before publish."},
    {"id": "anti:TicketQueueDelay", "name": "TicketQueueDelay", "risk": "medium", "mitigation": "Automate discrepancy remediation and reruns."},
    {"id": "anti:ShadowITDrift", "name": "ShadowITDrift", "risk": "medium", "mitigation": "Increase validated delivery cadence and governance."},
]

DEFAULT_METRICS_CATALOG: List[Dict[str, str]] = [
    {"id": "metric:discovery_time_saved", "name": "discovery_time_saved", "unit": "hours", "description": "Discovery time reduced through automated introspection."},
    {"id": "metric:auto_closed_discrepancies", "name": "auto_closed_discrepancies", "unit": "percent", "description": "Discrepancies closed without manual ticket handling."},
    {"id": "metric:reconciliation_pass_rate", "name": "reconciliation_pass_rate", "unit": "percent", "description": "Share of runs reaching certified reconciliation."},
    {"id": "metric:playbook_reuse_rate", "name": "playbook_reuse_rate", "unit": "percent", "description": "Workflow step reuse across engagements."},
    {"id": "metric:acceleration_factor", "name": "acceleration_factor", "unit": "ratio", "description": "Relative speed improvement from project #1 to later projects."},
]

PLAYBOOKS: Dict[str, List[str]] = {
    "data-modeling": [
        "model_discover",
        "model_relationships",
        "model_infer_relationships_by_name",
        "model_detect_dimensions",
        "model_generate_bus_matrix",
        "model_auto_generate_dm_specs",
        "model_load_dimensional_model",
    ],
    "hierarchy": [
        "create_hierarchy",
        "import_flexible_hierarchy",
        "add_hierarchy_node",
        "export_hierarchy_csv",
    ],
    "data-quality": [
        "generate_expectation_suite",
        "run_validation",
        "get_validation_results",
    ],
    "dbt-integration": [
        "create_dbt_project",
        "generate_dbt_sources",
        "generate_dbt_model",
        "generate_dbt_schema",
        "validate_dbt_project",
        "export_dbt_project",
    ],
    "data-catalog": [
        "catalog_scan_connection",
        "catalog_scan_table",
        "catalog_create_asset",
        "catalog_search",
        "catalog_auto_lineage_from_sql",
    ],
    "wright": [
        "create_mart_config",
        "generate_mart_pipeline",
        "wright_from_hierarchy",
        "wright_hierarchy_sync",
    ],
}


def load_playbooks(path: str = DEFAULT_PLAYBOOKS_PATH) -> Dict[str, List[str]]:
    """Load playbooks from JSON config file, falling back to hardcoded PLAYBOOKS constant."""
    try:
        p = Path(path)
        if p.exists():
            data = json.loads(p.read_text(encoding="utf-8"))
            if isinstance(data, dict):
                return data
    except Exception:
        pass
    return dict(PLAYBOOKS)


def _load_list_file(path: str) -> List[Dict[str, Any]]:
    try:
        p = Path(path)
        if not p.exists():
            return []
        data = json.loads(p.read_text(encoding="utf-8"))
        if isinstance(data, list):
            return [x for x in data if isinstance(x, dict)]
        return []
    except Exception:
        return []


def load_strategy_ontology(path: str = DEFAULT_STRATEGY_PATH) -> Dict[str, List[Dict[str, Any]]]:
    payload = {"nodes": list(DEFAULT_STRATEGY_NODES), "edges": list(DEFAULT_STRATEGY_EDGES)}
    try:
        p = Path(path)
        if p.exists():
            data = json.loads(p.read_text(encoding="utf-8"))
            if isinstance(data, dict):
                nodes = data.get("nodes")
                edges = data.get("edges")
                if isinstance(nodes, list):
                    payload["nodes"] = [x for x in nodes if isinstance(x, dict)]
                if isinstance(edges, list):
                    payload["edges"] = [x for x in edges if isinstance(x, dict)]
    except Exception:
        pass
    return payload


def load_trust_cases(path: str = DEFAULT_TRUST_CASES_PATH) -> List[Dict[str, Any]]:
    cases = _load_list_file(path)
    return cases if cases else list(DEFAULT_TRUST_CASES)


def load_runtime_trust_cases(path: str = DEFAULT_RUNTIME_TRUST_CASES_PATH) -> List[Dict[str, Any]]:
    return _load_list_file(path)


def load_anti_patterns(path: str = DEFAULT_ANTI_PATTERNS_PATH) -> List[Dict[str, Any]]:
    rows = _load_list_file(path)
    return rows if rows else list(DEFAULT_ANTI_PATTERNS)


def load_metrics_catalog(path: str = DEFAULT_METRICS_CATALOG_PATH) -> List[Dict[str, Any]]:
    rows = _load_list_file(path)
    return rows if rows else list(DEFAULT_METRICS_CATALOG)


@dataclass
class Node:
    id: str
    name: str
    qualname: str
    category: str
    kind: str
    module: str
    domain: str
    prefix: str
    risk_level: str
    maturity: str
    aliases: List[str]
    questions: List[str]
    source_path: str
    source_line: int
    raw: Dict


def tokenize(text: str) -> List[str]:
    tokens = re.findall(r"[a-zA-Z0-9_]+", text.lower())
    return [t for t in tokens if len(t) > 1 and t not in STOPWORDS]


def iter_symbol_jsons(docs_root: Path) -> List[Path]:
    out = []
    for p in docs_root.rglob("*.json"):
        rel = str(p.relative_to(docs_root)).replace("\\", "/")
        if rel.startswith("inventory/") or rel.startswith("generation/"):
            continue
        out.append(p)
    return sorted(out)


def load_nodes(docs_root: Path) -> Tuple[Dict[str, Node], Dict[str, List[str]]]:
    nodes: Dict[str, Node] = {}
    qualname_to_ids: Dict[str, List[str]] = defaultdict(list)
    for path in iter_symbol_jsons(docs_root):
        try:
            raw = json.loads(path.read_text(encoding="utf-8"))
        except Exception:
            continue
        if not isinstance(raw, dict):
            continue
        if "id" not in raw or "name" not in raw or "qualname" not in raw:
            continue
        sem = raw.get("semantic_tags", {}) or {}
        src = raw.get("source", {}) or {}
        node = Node(
            id=raw["id"],
            name=raw.get("name", ""),
            qualname=raw.get("qualname", ""),
            category=raw.get("category", "function"),
            kind=raw.get("kind", "function"),
            module=raw.get("module", ""),
            domain=sem.get("domain", "misc"),
            prefix=sem.get("prefix", "misc"),
            risk_level=sem.get("risk_level", "low"),
            maturity=sem.get("maturity", "unknown"),
            aliases=raw.get("retrieval_aliases", []) or [],
            questions=raw.get("golden_questions", []) or [],
            source_path=src.get("path", ""),
            source_line=int(src.get("line", 0) or 0),
            raw=raw,
        )
        nodes[node.id] = node
        qualname_to_ids[node.qualname].append(node.id)
    return nodes, qualname_to_ids


def _strategy_node_raw(row: Dict[str, Any]) -> Dict[str, Any]:
    sid = row.get("id", "")
    name = row.get("name", sid.split(":")[-1] if sid else "strategy_node")
    domain = row.get("domain", "strategy")
    return {
        "id": sid,
        "name": name,
        "qualname": name,
        "category": "concept",
        "kind": "concept",
        "module": "data.codex_graphrag.strategy",
        "source": {"path": DEFAULT_STRATEGY_PATH, "line": 1, "end_line": 1},
        "semantic_tags": {
            "domain": domain,
            "prefix": "concept",
            "risk_level": "low",
            "maturity": "stable",
            "artifact_type": "strategy_concept",
            "erp": [],
            "data_source": ["json"],
        },
        "retrieval_aliases": [name.lower(), name.replace("_", " ").lower()],
        "golden_questions": [f"How does {name} work in the strategy?"],
        "cross_links": {"same_domain": [], "same_prefix": [], "related_tools": [], "calls": [], "called_by": []},
        "workflow_role": {"phase": "strategy", "upstream_dependencies": [], "downstream_consumers": [], "critical_path": True},
        "io_contract": {"required_inputs": []},
        "usage_intent": {"when_to_use": f"Use when reasoning about strategy concept {name}."},
    }


def _anti_pattern_node_raw(row: Dict[str, Any]) -> Dict[str, Any]:
    aid = row.get("id", "")
    name = row.get("name", aid.split(":")[-1] if aid else "anti_pattern")
    risk = row.get("risk", "medium")
    mitigation = row.get("mitigation", "")
    return {
        "id": aid,
        "name": name,
        "qualname": name,
        "category": "guardrail",
        "kind": "anti_pattern",
        "module": "data.codex_graphrag.anti_patterns",
        "source": {"path": DEFAULT_ANTI_PATTERNS_PATH, "line": 1, "end_line": 1},
        "semantic_tags": {
            "domain": "trust",
            "prefix": "anti",
            "risk_level": risk,
            "maturity": "stable",
            "artifact_type": "anti_pattern",
            "erp": [],
            "data_source": ["json"],
        },
        "retrieval_aliases": [name.lower(), name.replace("_", " ").lower(), mitigation.lower()],
        "golden_questions": [f"What is the {name} anti-pattern and how to mitigate it?"],
        "cross_links": {"same_domain": [], "same_prefix": [], "related_tools": [], "calls": [], "called_by": []},
        "workflow_role": {"phase": "qa", "upstream_dependencies": [], "downstream_consumers": [], "critical_path": False},
        "io_contract": {"required_inputs": []},
        "usage_intent": {"when_to_use": f"Use when detecting or mitigating the {name} anti-pattern."},
        "anti_pattern": row,
    }


def _metric_node_raw(row: Dict[str, Any]) -> Dict[str, Any]:
    mid = row.get("id", "")
    name = row.get("name", mid.split(":")[-1] if mid else "metric")
    unit = row.get("unit", "")
    description = row.get("description", "")
    return {
        "id": mid,
        "name": name,
        "qualname": name,
        "category": "metric",
        "kind": "delivery_metric",
        "module": "data.codex_graphrag.metrics_catalog",
        "source": {"path": DEFAULT_METRICS_CATALOG_PATH, "line": 1, "end_line": 1},
        "semantic_tags": {
            "domain": "strategy",
            "prefix": "metric",
            "risk_level": "low",
            "maturity": "stable",
            "artifact_type": "delivery_metric",
            "erp": [],
            "data_source": ["json"],
        },
        "retrieval_aliases": [name.lower(), name.replace("_", " ").lower(), description.lower()],
        "golden_questions": [f"How is {name} measured ({unit})?"],
        "cross_links": {"same_domain": [], "same_prefix": [], "related_tools": [], "calls": [], "called_by": []},
        "workflow_role": {"phase": "strategy", "upstream_dependencies": [], "downstream_consumers": [], "critical_path": False},
        "io_contract": {"required_inputs": []},
        "usage_intent": {"when_to_use": f"Use when measuring or reporting on {name}."},
        "metric_definition": row,
    }


def _trust_case_node_raw(case: Dict[str, Any]) -> Dict[str, Any]:
    cid = case.get("case_id", "")
    name = case.get("metric", "trust_case")
    tags = case.get("tags", []) or []
    source_path = str(case.get("_source_path", DEFAULT_TRUST_CASES_PATH))
    return {
        "id": f"trustcase:{cid}",
        "name": f"trust_case_{name}".replace(" ", "_").lower(),
        "qualname": cid or f"trust_case_{name}",
        "category": "evidence",
        "kind": "trust_case",
        "module": "data.codex_graphrag.trust_cases",
        "source": {"path": source_path, "line": 1, "end_line": 1},
        "semantic_tags": {
            "domain": "trust",
            "prefix": "trustcase",
            "risk_level": "low",
            "maturity": "stable",
            "artifact_type": "trust_case",
            "erp": [case.get("source_system", "")],
            "data_source": ["json"],
        },
        "retrieval_aliases": [case.get("metric", ""), case.get("symptom", "")] + tags,
        "golden_questions": [f"What reconciliation evidence exists for {case.get('metric', 'this metric')}?"],
        "cross_links": {"same_domain": [], "same_prefix": [], "related_tools": [], "calls": [], "called_by": []},
        "workflow_role": {"phase": "qa", "upstream_dependencies": [], "downstream_consumers": [], "critical_path": True},
        "io_contract": {"required_inputs": []},
        "usage_intent": {"when_to_use": "Use for CFO-grade trust and discrepancy closure evidence."},
        "trust_case": case,
    }


def _add_edge_targets(
    nid: str,
    values: List[str],
    qualname_to_ids: Dict[str, List[str]],
    name_index: Dict[str, List[str]],
    edges: Dict[str, Set[str]],
) -> None:
    """Resolve cross-link values to node IDs and add forward edges."""
    for v in values:
        qmatches = qualname_to_ids.get(v, [])
        if qmatches:
            for qid in qmatches:
                if qid != nid:
                    edges[nid].add(qid)
            continue
        short = v.split(".")[-1]
        for mid in name_index.get(short, []):
            if mid != nid:
                edges[nid].add(mid)


def build_edges(nodes: Dict[str, Node], qualname_to_ids: Dict[str, List[str]]) -> Dict[str, Set[str]]:
    edges: Dict[str, Set[str]] = defaultdict(set)
    name_index: Dict[str, List[str]] = defaultdict(list)
    for nid, n in nodes.items():
        name_index[n.name].append(nid)

    link_keys = ("same_domain", "same_prefix", "related_tools", "called_by", "calls")
    for nid, node in nodes.items():
        links = node.raw.get("cross_links", {}) or {}
        for key in link_keys:
            _add_edge_targets(nid, links.get(key, []) or [], qualname_to_ids, name_index, edges)

        wf = node.raw.get("workflow_role", {}) or {}
        _add_edge_targets(nid, wf.get("downstream_consumers", []) or [], qualname_to_ids, name_index, edges)
        _add_edge_targets(nid, wf.get("upstream_dependencies", []) or [], qualname_to_ids, name_index, edges)

    # Fix 4: add reverse edges for bidirectional traversal
    reverse: Dict[str, Set[str]] = defaultdict(set)
    for nid, neighbors in edges.items():
        for nei in neighbors:
            reverse[nei].add(nid)
    for nid, rev_neighbors in reverse.items():
        edges[nid].update(rev_neighbors)

    return edges


def build_inverted_index(nodes: Dict[str, Node]) -> Dict[str, Set[str]]:
    inv: Dict[str, Set[str]] = defaultdict(set)
    for nid, n in nodes.items():
        fields = [
            n.name, n.qualname, n.module, n.domain, n.prefix, n.category, n.kind,
            n.risk_level, n.maturity, n.source_path,
            " ".join(n.aliases), " ".join(n.questions),
        ]
        sem = n.raw.get("semantic_tags", {}) or {}
        fields.extend([
            " ".join(sem.get("erp", []) or []),
            " ".join(sem.get("data_source", []) or []),
            sem.get("artifact_type", ""),
        ])
        for token in tokenize(" ".join(fields)):
            inv[token].add(nid)
    return inv


def _count_by(nodes: Dict[str, Node], key) -> Dict[str, int]:
    out: Dict[str, int] = defaultdict(int)
    for n in nodes.values():
        out[key(n)] += 1
    return out


def serialize_index(nodes: Dict[str, Node], edges: Dict[str, Set[str]], inv: Dict[str, Set[str]]) -> Dict:
    return {
        "schema_version": "3.0",
        "node_types": sorted({n.category for n in nodes.values()}),
        "edge_types": list(V3_EDGE_TYPES),
        "nodes": {nid: n.raw for nid, n in nodes.items()},
        "edges": {nid: sorted(list(nei)) for nid, nei in edges.items()},
        "inverted_index": {tok: sorted(list(ids)) for tok, ids in inv.items()},
        "stats": {
            "node_count": len(nodes),
            "edge_count": sum(len(v) for v in edges.values()),
            "token_count": len(inv),
            "categories": dict(_count_by(nodes, key=lambda n: n.category)),
            "domains": dict(_count_by(nodes, key=lambda n: n.domain)),
        },
    }


def write_index(path: Path, payload: Dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


_MAX_CACHE_SIZE = 8
_index_cache: Dict[str, Tuple[float, float, Dict]] = {}  # path -> (mtime, access_time, parsed_index)


def get_cached_index(path: Path) -> Dict:
    """Load index with mtime-based caching and LRU eviction."""
    key = str(path)
    mtime = path.stat().st_mtime
    cached = _index_cache.get(key)
    if cached and cached[0] == mtime:
        _index_cache[key] = (cached[0], _time.monotonic(), cached[2])
        return cached[2]
    index = json.loads(path.read_text(encoding="utf-8"))
    if len(_index_cache) >= _MAX_CACHE_SIZE and key not in _index_cache:
        lru_key = min(_index_cache, key=lambda k: _index_cache[k][1])
        del _index_cache[lru_key]
    _index_cache[key] = (mtime, _time.monotonic(), index)
    return index


def load_index(path: Path) -> Dict:
    return get_cached_index(path)


def _append_raw_nodes(
    nodes: Dict[str, Node],
    qmap: Dict[str, List[str]],
    raws: List[Dict[str, Any]],
) -> None:
    for raw in raws:
        if not isinstance(raw, dict):
            continue
        nid = raw.get("id")
        qn = raw.get("qualname")
        if not nid or not qn:
            continue
        sem = raw.get("semantic_tags", {}) or {}
        src = raw.get("source", {}) or {}
        nodes[nid] = Node(
            id=nid,
            name=raw.get("name", ""),
            qualname=qn,
            category=raw.get("category", "function"),
            kind=raw.get("kind", "function"),
            module=raw.get("module", ""),
            domain=sem.get("domain", "misc"),
            prefix=sem.get("prefix", "misc"),
            risk_level=sem.get("risk_level", "low"),
            maturity=sem.get("maturity", "unknown"),
            aliases=raw.get("retrieval_aliases", []) or [],
            questions=raw.get("golden_questions", []) or [],
            source_path=src.get("path", ""),
            source_line=int(src.get("line", 0) or 0),
            raw=raw,
        )
        qmap[qn].append(nid)


def _apply_strategy_edges(
    edges: Dict[str, Set[str]],
    strategy_edges: List[Dict[str, Any]],
) -> List[Dict[str, str]]:
    typed: List[Dict[str, str]] = []
    for row in strategy_edges:
        src = str(row.get("source", "")).strip()
        tgt = str(row.get("target", "")).strip()
        etype = str(row.get("type", "related")).strip() or "related"
        if not src or not tgt:
            continue
        edges[src].add(tgt)
        edges[tgt].add(src)
        typed.append({"source": src, "target": tgt, "type": etype})
    return typed


def _merge_trust_cases(curated: List[Dict[str, Any]], runtime: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    merged: Dict[str, Dict[str, Any]] = {}
    for row in curated + runtime:
        cid = str(row.get("case_id", "")).strip()
        if not cid:
            continue
        merged[cid] = row
    return list(merged.values())


def _link_trust_cases_to_strategy(
    edges: Dict[str, Set[str]],
    trust_cases: List[Dict[str, Any]],
) -> List[Dict[str, str]]:
    out: List[Dict[str, str]] = []
    for case in trust_cases:
        cid = str(case.get("case_id", "")).strip()
        if not cid:
            continue
        nid = f"trustcase:{cid}"
        for concept, etype in (
            ("concept:AutomatingTrust", "validates"),
            ("concept:ClosureLoop", "proves"),
        ):
            edges[nid].add(concept)
            edges[concept].add(nid)
            out.append({"source": nid, "target": concept, "type": etype})
    return out


def _normalize_discrepancy_event(event: Dict[str, Any]) -> Dict[str, Any]:
    return normalize_runtime_event(event)


def _append_runtime_trust_case(case: Dict[str, Any], runtime_path: Path) -> Tuple[bool, int]:
    inserted, total, _ = append_runtime_event_file(case, runtime_path)
    return inserted, total


def build_index(docs_root: str = DEFAULT_DOCS_ROOT, index_path: str = DEFAULT_INDEX_PATH) -> Dict:
    docs = Path(docs_root)
    idx = Path(index_path)
    nodes, qmap = load_nodes(docs)
    include_strategy_layer = docs.resolve() == Path(DEFAULT_DOCS_ROOT).resolve()
    strategy = {"nodes": [], "edges": []}
    trust_cases: List[Dict[str, Any]] = []
    anti_patterns: List[Dict[str, Any]] = []
    metrics_catalog: List[Dict[str, Any]] = []
    playbooks = load_playbooks()
    if include_strategy_layer:
        strategy = load_strategy_ontology()
        curated_cases = load_trust_cases()
        runtime_cases = load_runtime_trust_cases()
        for row in runtime_cases:
            row["_source_path"] = DEFAULT_RUNTIME_TRUST_CASES_PATH
        trust_cases = _merge_trust_cases(curated_cases, runtime_cases)
        anti_patterns = load_anti_patterns()
        metrics_catalog = load_metrics_catalog()
        strategy_raws = [_strategy_node_raw(x) for x in strategy.get("nodes", [])]
        trust_raws = [_trust_case_node_raw(x) for x in trust_cases]
        anti_raws = [_anti_pattern_node_raw(x) for x in anti_patterns]
        metric_raws = [_metric_node_raw(x) for x in metrics_catalog]
        _append_raw_nodes(nodes, qmap, strategy_raws + trust_raws + anti_raws + metric_raws)

    edges = build_edges(nodes, qmap)
    typed_edges = _apply_strategy_edges(edges, strategy.get("edges", []))
    typed_edges.extend(_link_trust_cases_to_strategy(edges, trust_cases))
    inv = build_inverted_index(nodes)
    payload = serialize_index(nodes, edges, inv)
    payload["typed_edges"] = typed_edges
    payload["trust_cases"] = trust_cases
    payload["anti_patterns"] = anti_patterns
    payload["metrics_catalog"] = metrics_catalog
    payload["playbooks"] = playbooks
    write_index(idx, payload)
    _index_cache.pop(str(idx), None)
    return {"status": "ok", "index": str(idx), "stats": payload["stats"]}


def ingest_discrepancy_event(
    event: Dict[str, Any],
    index_path: str = DEFAULT_INDEX_PATH,
    docs_root: str = DEFAULT_DOCS_ROOT,
    runtime_events_path: str = DEFAULT_RUNTIME_TRUST_CASES_PATH,
    auto_build: bool = True,
    tenant_id: str = "default",
    ingest_mode: str = "dual",
    dedupe_window_seconds: int = 900,
    policy_mode: str = POLICY_MODE_SHADOW,
) -> Dict[str, Any]:
    """Ingest a runtime discrepancy event and update trust memory + index."""
    if not isinstance(event, dict):
        return {"error": "event must be a JSON object"}
    mode = (ingest_mode or "dual").strip().lower()
    normalized = normalize_runtime_event(
        event,
        tenant_id=tenant_id or str(event.get("tenant_id", "default")),
        dedupe_window_seconds=dedupe_window_seconds,
    )
    score_bundle = compute_trust_score(normalized)
    fix_type = str(event.get("fix", normalized.get("fix", "pending")))
    risk_tier = str(event.get("risk_tier", "unknown"))
    policy_bundle = compute_policy_decision(
        trust_score=float(score_bundle.get("trust_score", 0.0)),
        risk_tier=risk_tier,
        fix_type=fix_type,
        policy_mode=policy_mode,
    )
    normalized.update(score_bundle)
    normalized.update(policy_bundle)
    normalized["retention_tier"] = classify_retention_tier(event_ts=int(normalized.get("event_ts", 0) or 0))

    runtime_path = Path(runtime_events_path)
    inserted, total, file_written = append_runtime_event_file(normalized, runtime_path)
    deduped = not inserted

    idx = Path(index_path)
    index_updated = False
    snowflake_written = False
    snowflake_error = ""
    if not idx.exists():
        if auto_build:
            build_index(docs_root=docs_root, index_path=index_path)
        else:
            return {
                "status": "queued",
                "ingested": inserted,
                "runtime_events_path": str(runtime_path),
                "runtime_event_count": total,
                "warning": "index missing; event stored but index not updated",
            }

    if idx.exists():
        index = load_index(idx)
        trust_cases = index.get("trust_cases", []) or []
        trust_cases = _merge_trust_cases(trust_cases, [dict(normalized, _source_path=str(runtime_path).replace("\\", "/"))])
        index["trust_cases"] = trust_cases

        raw = _trust_case_node_raw(dict(normalized, _source_path=str(runtime_path).replace("\\", "/")))
        nid = raw["id"]
        index.setdefault("nodes", {})[nid] = raw
        index.setdefault("edges", {}).setdefault(nid, [])
        for concept in ("concept:AutomatingTrust", "concept:ClosureLoop"):
            if concept in index.get("nodes", {}):
                if concept not in index["edges"][nid]:
                    index["edges"][nid].append(concept)
                index["edges"].setdefault(concept, [])
                if nid not in index["edges"][concept]:
                    index["edges"][concept].append(nid)
        typed = index.setdefault("typed_edges", [])
        for edge in (
            {"source": nid, "target": "concept:AutomatingTrust", "type": "validates"},
            {"source": nid, "target": "concept:ClosureLoop", "type": "proves"},
        ):
            if edge not in typed:
                typed.append(edge)

        inv = index.setdefault("inverted_index", {})
        text = " ".join(
            [
                raw.get("name", ""),
                raw.get("qualname", ""),
                " ".join(raw.get("retrieval_aliases", []) or []),
                " ".join(raw.get("golden_questions", []) or []),
                normalized.get("source_system", ""),
                normalized.get("metric", ""),
                normalized.get("symptom", ""),
                normalized.get("root_cause", ""),
            ]
        )
        for tok in tokenize(text):
            bucket = inv.setdefault(tok, [])
            if nid not in bucket:
                bucket.append(nid)

        nodes = index.get("nodes", {})
        stats = index.setdefault("stats", {})
        stats["node_count"] = len(nodes)
        stats["edge_count"] = sum(len(v) for v in (index.get("edges", {}) or {}).values())
        stats["token_count"] = len(inv)
        cats: Dict[str, int] = defaultdict(int)
        domains: Dict[str, int] = defaultdict(int)
        for node in nodes.values():
            cats[node.get("category", "unknown")] += 1
            domains[(node.get("semantic_tags", {}) or {}).get("domain", "misc")] += 1
        stats["categories"] = dict(cats)
        stats["domains"] = dict(domains)

        write_index(idx, index)
        _index_cache.pop(str(idx), None)
        index_updated = True

    if mode in {"dual", "snowflake_only"}:
        store = SnowflakeDiscrepancyStore()
        snowflake_written, snowflake_error = store.upsert_event(
            dict(normalized, risk_tier=risk_tier),
            index_updated=index_updated,
        )

    status = "ok"
    warning = ""
    if mode in {"dual", "snowflake_only"} and not snowflake_written and mode == "snowflake_only":
        status = "error"
        warning = snowflake_error or "snowflake_write_failed"
    elif mode in {"dual", "snowflake_only"} and not snowflake_written:
        warning = snowflake_error or "snowflake_write_failed"
        if any(x in warning for x in ("snowflake_connector_unavailable", "snowflake_env_missing")):
            status = "ok"
        else:
            status = "partial_ok"

    result = {
        "status": status,
        "ingested": inserted,
        "deduped": deduped,
        "case_id": normalized.get("case_id"),
        "event_key": normalized.get("event_key"),
        "tenant_id": normalized.get("tenant_id", "default"),
        "trust_score": normalized.get("trust_score", 0.0),
        "policy_decision": normalized.get("policy_decision", "unknown"),
        "policy_mode": normalized.get("policy_mode", POLICY_MODE_SHADOW),
        "retention_tier": normalized.get("retention_tier", "hot"),
        "file_written": bool(file_written),
        "snowflake_written": bool(snowflake_written),
        "index_updated": bool(index_updated),
        "runtime_events_path": str(runtime_path),
        "runtime_event_count": total,
        "index": str(idx),
    }
    if warning:
        result["warning"] = warning
    return result


def _classify_intent(query: str) -> str:
    q = query.lower()
    if any(x in q for x in ["cfo", "reconciliation", "p&l", "financial discrepancy", "certified"]):
        return "qa_closure"
    if any(x in q for x in ["ceo", "executive", "ebitda", "margin", "churn"]):
        return "executive"
    if any(x in q for x in ["lineage", "sql", "table", "join", "schema", "api"]):
        return "technical"
    if any(x in q for x in ["faster", "weeks", "compress", "timeline"]):
        return "delivery_speed"
    if any(x in q for x in ["anti-pattern", "antipattern", "guardrail", "smoke detector", "risk"]):
        return "guardrail"
    if any(x in q for x in ["metric", "kpi", "measurement", "acceleration", "reuse rate"]):
        return "metric"
    return "balanced"


def _trust_case_boost(index: Dict, query: str) -> Dict[str, float]:
    boosts: Dict[str, float] = defaultdict(float)
    query_tokens = set(tokenize(query))
    for case in index.get("trust_cases", []) or []:
        node_id = f"trustcase:{case.get('case_id', '')}"
        text = " ".join(
            [
                str(case.get("metric", "")),
                str(case.get("symptom", "")),
                str(case.get("root_cause", "")),
                " ".join(case.get("tags", []) or []),
            ]
        )
        overlap = query_tokens.intersection(set(tokenize(text)))
        if overlap:
            boosts[node_id] += min(1.25 + (0.1 * len(overlap)), 0.5)
    return boosts


def score_nodes(index: Dict, query: str, category: str = "", domain: str = "", persona: str = "balanced") -> Dict[str, float]:
    inv = index["inverted_index"]
    nodes = index["nodes"]
    scores: Dict[str, float] = defaultdict(float)
    persona = (persona or "balanced").strip().lower()
    for t in tokenize(query):
        ids = inv.get(t, [])
        if not ids:
            continue
        idf = 1.0 / max(1, len(ids))
        for nid in ids:
            scores[nid] += 1.0 + idf
    for nid in list(scores.keys()):
        n = nodes[nid]
        if category and n.get("category") == category:
            scores[nid] += 0.7
        if domain and (n.get("semantic_tags", {}) or {}).get("domain") == domain:
            scores[nid] += 0.7
        if n.get("workflow_role", {}).get("critical_path") is True:
            scores[nid] += 0.2
        nd = (n.get("semantic_tags", {}) or {}).get("domain", "")
        if persona == "executive" and nd in {"strategy", "trust"}:
            scores[nid] += 0.5
        if persona == "technical" and n.get("category") in {"tool", "function", "workflow"}:
            scores[nid] += 0.3
    trust_boost = _trust_case_boost(index, query)
    for nid, bonus in trust_boost.items():
        scores[nid] += bonus
    return scores


def expand_with_graph(index: Dict, base_scores: Dict[str, float], depth: int = 1) -> Dict[str, float]:
    if depth <= 0:
        return base_scores
    edges = index["edges"]
    scores = dict(base_scores)
    frontier = list(base_scores.keys())
    seen = set(frontier)
    for d in range(1, depth + 1):
        nxt = []
        bonus = 0.35 / d
        for nid in frontier:
            for nei in edges.get(nid, []):
                scores[nei] = scores.get(nei, 0.0) + bonus
                if nei not in seen:
                    seen.add(nei)
                    nxt.append(nei)
        frontier = nxt
        if not frontier:
            break
    return scores


def top_k(scores: Dict[str, float], k: int) -> List[Tuple[str, float]]:
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:k]


def _build_cfo_sections(query: str, results: List[Dict], index: Dict, response_mode: str, include_guardrails: bool) -> Dict[str, Any]:
    strict = (response_mode or "plain").lower() == "cfo_strict"
    trust_hits = [r for r in results if r.get("category") == "evidence"]
    anti = index.get("anti_patterns", []) or []
    if not strict and not include_guardrails:
        return {}
    return {
        "summary": {
            "intent": _classify_intent(query),
            "result_count": len(results),
            "trust_evidence_count": len(trust_hits),
        },
        "business_claim": "Recommended steps and artifacts can be executed with evidence-backed trust controls.",
        "mechanism": "Graph retrieval + causal expansion + trust-case evidence matching",
        "lineage_proof": [r.get("qualname") for r in results[:5]],
        "reconciliation_status": "needs_verification" if not trust_hits else "evidence_available",
        "residual_risk": "medium" if not trust_hits else "low",
        "guardrails": anti if include_guardrails else [],
    }


def _search_index(
    index: Dict,
    query: str,
    k: int = 10,
    expand_depth: int = 1,
    category: str = "",
    domain: str = "",
    persona: str = "balanced",
    response_mode: str = "plain",
    include_evidence: bool = True,
    include_guardrails: bool = False,
) -> Dict:
    """Search a pre-loaded index dict. Use search_index() for the file-path wrapper."""
    base = score_nodes(index, query=query, category=category, domain=domain, persona=persona)
    all_scores = expand_with_graph(index, base, depth=expand_depth)
    nodes = index["nodes"]
    # Filter BEFORE top_k so filtering doesn't silently drop relevant results
    filtered_scores = {}
    for nid, score in all_scores.items():
        n = nodes.get(nid)
        if not n:
            continue
        if category and n.get("category") != category:
            continue
        n_domain = (n.get("semantic_tags", {}) or {}).get("domain")
        if domain and n_domain != domain:
            continue
        filtered_scores[nid] = score
    hits = top_k(filtered_scores, k)
    results = []
    for nid, score in hits:
        n = nodes[nid]
        n_domain = (n.get("semantic_tags", {}) or {}).get("domain")
        results.append(
            {
                "id": nid,
                "score": round(score, 4),
                "name": n.get("name"),
                "qualname": n.get("qualname"),
                "category": n.get("category"),
                "domain": n_domain,
                "source": n.get("source", {}),
                "risk_level": (n.get("semantic_tags", {}) or {}).get("risk_level"),
            }
        )
    out: Dict[str, Any] = {"query": query, "count": len(results), "results": results}
    if include_evidence:
        out["citations"] = [r.get("source", {}) for r in results if r.get("source")]
    out.update(_build_cfo_sections(query=query, results=results, index=index, response_mode=response_mode, include_guardrails=include_guardrails))
    return out


def search_index(
    index_path: str,
    query: str,
    k: int = 10,
    expand_depth: int = 1,
    category: str = "",
    domain: str = "",
    persona: str = "balanced",
    response_mode: str = "plain",
    include_evidence: bool = True,
    include_guardrails: bool = False,
) -> Dict:
    p = Path(index_path)
    if not p.exists():
        return {"error": f"Index not found at '{index_path}'. Build it via: codex_graphrag(action='build') or python scripts/codex_graphrag.py build", "query": query, "count": 0, "results": []}
    index = load_index(p)
    return _search_index(
        index,
        query=query,
        k=k,
        expand_depth=expand_depth,
        category=category,
        domain=domain,
        persona=persona,
        response_mode=response_mode,
        include_evidence=include_evidence,
        include_guardrails=include_guardrails,
    )


def parse_phase(name: str) -> int:
    m = re.search(r"phase[_\s]*(\d+)", name.lower())
    return int(m.group(1)) if m else 10_000


def _suggest_input_stub(node: Dict) -> Dict:
    req = (node.get("io_contract", {}) or {}).get("required_inputs", []) or []
    return {name: "<value>" for name in req}


def _why_included(goal: str, node: Dict) -> str:
    text = " ".join([
        node.get("name", ""),
        node.get("qualname", ""),
        " ".join(node.get("retrieval_aliases", []) or []),
        " ".join(node.get("golden_questions", []) or []),
    ]).lower()
    overlap = sorted(set(tokenize(goal)).intersection(set(tokenize(text))))
    if overlap:
        return f"Keyword overlap: {', '.join(overlap[:8])}"
    return "Graph-neighbor relevance from related symbols."


def _build_ai_review_prompt(goal: str, steps: List[Dict]) -> str:
    lines = [
        f"Goal: {goal}",
        "",
        "Review the proposed workflow. For each step, validate order, dependencies, risks, and missing prerequisites.",
        "Then return:",
        "1) corrected step order,",
        "2) missing steps,",
        "3) risky steps requiring approvals,",
        "4) recommended execution checklist.",
        "",
        "Proposed Steps:",
    ]
    for s in steps:
        lines.append(
            f"- [{s['step']}] {s['qualname']} ({s['category']}, domain={s['domain']}, risk={s['risk_level']}, phase={s['phase']})"
        )
    return "\n".join(lines)


def _is_read_only_step(name: str, qualname: str = "") -> bool:
    """Heuristic detector for read-only operations in workflow recommendations."""
    text = f"{name} {qualname}".strip().lower()
    if not text:
        return False

    mutating_keywords = {
        "create", "add", "update", "delete", "remove", "import", "export", "push",
        "pull", "sync", "apply", "set", "configure", "generate", "build", "run",
        "execute", "write", "save", "deploy", "ingest", "load", "merge", "fix",
    }
    read_only_keywords = {
        "get", "list", "search", "status", "health", "describe", "inspect",
        "preview", "check", "validate", "read", "show", "trace",
    }

    parts = [p for p in re.split(r"[^a-z0-9]+", text) if p]
    if any(part in mutating_keywords for part in parts):
        return False
    if any(part in read_only_keywords for part in parts):
        return True
    return False


def recommend_workflow(
    index_path: str,
    goal: str,
    max_steps: int = 10,
    domain: str = "",
    persona: str = "balanced",
    response_mode: str = "plain",
    include_evidence: bool = True,
    include_guardrails: bool = False,
) -> Dict:
    p = Path(index_path)
    if not p.exists():
        return {"error": f"Index not found at '{index_path}'. Build it via: codex_graphrag(action='build') or python scripts/codex_graphrag.py build", "goal": goal, "recommended_steps": [], "selection_stats": {"workflow_candidates": 0, "tool_candidates": 0, "selected_steps": 0}}
    playbooks = load_playbooks()
    index = load_index(p)
    nodes = index["nodes"]

    wf_hits = _search_index(
        index,
        query=goal,
        k=80,
        expand_depth=2,
        category="workflow",
        domain=domain,
        persona=persona,
        response_mode=response_mode,
        include_evidence=include_evidence,
        include_guardrails=include_guardrails,
    )["results"]
    tool_hits = _search_index(
        index,
        query=goal,
        k=120,
        expand_depth=2,
        category="tool",
        domain=domain,
        persona=persona,
        response_mode=response_mode,
        include_evidence=include_evidence,
        include_guardrails=include_guardrails,
    )["results"]
    merged_hits = wf_hits + tool_hits

    seen_ids = set()
    candidates = []
    for r in merged_hits:
        nid = r["id"]
        if nid in seen_ids:
            continue
        seen_ids.add(nid)
        candidates.append(nodes[nid])

    if len(candidates) < max_steps:
        for nid, n in nodes.items():
            if n.get("category") not in {"tool", "workflow"}:
                continue
            sem = n.get("semantic_tags", {}) or {}
            if domain and sem.get("domain") != domain:
                continue
            goal_tokens = set(tokenize(goal))
            label_tokens = set(tokenize((n.get("qualname", "") + " " + n.get("name", "") + " " + " ".join(n.get("retrieval_aliases", []) or []))))
            if goal_tokens.intersection(label_tokens) and nid not in seen_ids:
                seen_ids.add(nid)
                candidates.append(n)

    if len(candidates) < max_steps and domain:
        patterns = playbooks.get(domain, [])
        for pat in patterns:
            for nid, n in nodes.items():
                if nid in seen_ids:
                    continue
                sem = n.get("semantic_tags", {}) or {}
                if sem.get("domain") != domain:
                    continue
                if n.get("category") not in {"tool", "workflow"}:
                    continue
                if pat in n.get("name", "") or pat in n.get("qualname", ""):
                    seen_ids.add(nid)
                    candidates.append(n)
            if len(candidates) >= max_steps:
                break

    workflows = [n for n in candidates if n.get("category") == "workflow"]
    tools = [n for n in candidates if n.get("category") == "tool"]

    pattern_order = {p: i for i, p in enumerate(playbooks.get(domain, []))}

    def tool_rank(n: Dict) -> Tuple[int, str, str]:
        q = f"{n.get('name', '')} {n.get('qualname', '')}"
        idx = 10_000
        for pat, i in pattern_order.items():
            if pat in q:
                idx = min(idx, i)
        risk = n.get("semantic_tags", {}).get("risk_level", "low")
        return (idx, risk, n.get("qualname", ""))

    workflows_sorted = sorted(workflows, key=lambda n: (parse_phase(n.get("name", "")), n.get("qualname", "")))
    tools_sorted = sorted(tools, key=tool_rank)

    selected = []
    seen_q = set()
    for n in workflows_sorted + tools_sorted:
        qn = n.get("qualname", "")
        if qn and qn not in seen_q:
            selected.append(n)
            seen_q.add(qn)
        if len(selected) >= max_steps:
            break

    steps = []
    for i, n in enumerate(selected, start=1):
        sem = n.get("semantic_tags", {}) or {}
        steps.append(
            {
                "step": i,
                "name": n.get("name"),
                "qualname": n.get("qualname"),
                "category": n.get("category"),
                "domain": sem.get("domain"),
                "risk_level": sem.get("risk_level"),
                "phase": (n.get("workflow_role", {}) or {}).get("phase", "n/a"),
                "suggested_input": _suggest_input_stub(n),
                "why_included": _why_included(goal, n),
            }
        )

    payload: Dict[str, Any] = {
        "goal": goal,
        "selection_stats": {
            "workflow_candidates": len(workflows),
            "tool_candidates": len(tools),
            "selected_steps": len(steps),
        },
        "recommended_steps": steps,
        "context_nodes": [
            {
                "id": n.get("id"),
                "qualname": n.get("qualname"),
                "golden_questions": n.get("golden_questions", []),
                "io_contract": n.get("io_contract", {}),
                "usage_intent": n.get("usage_intent", {}),
            }
            for n in selected
        ],
        "ai_prompt": _build_ai_review_prompt(goal, steps),
    }
    payload["recommended_next_workflow"] = (
        "ClosureLoopFlow" if _classify_intent(goal) == "qa_closure" else "DiscoveryCompressionFlow"
    )
    payload.update(
        _build_cfo_sections(
            query=goal,
            results=[{"category": "evidence"} for _ in index.get("trust_cases", [])[:1]],
            index=index,
            response_mode=response_mode,
            include_guardrails=include_guardrails,
        )
    )
    if include_evidence:
        payload["citations"] = [s.get("qualname") for s in steps[:5]]
    return payload


def review_workflow(
    index_path: str,
    goal: str,
    max_steps: int = 10,
    domain: str = "",
    review_mode: str = "balanced",
    persona: str = "balanced",
    response_mode: str = "plain",
    include_evidence: bool = True,
    include_guardrails: bool = True,
) -> Dict:
    """Generate a structured AI review package for an auto-proposed workflow."""
    p = Path(index_path)
    if not p.exists():
        return {"error": f"Index not found at '{index_path}'. Build it via: codex_graphrag(action='build') or python scripts/codex_graphrag.py build", "goal": goal}
    mode = (review_mode or "balanced").strip().lower()
    if mode not in {"strict", "balanced", "fast"}:
        mode = "balanced"

    proposal = recommend_workflow(
        index_path=index_path,
        goal=goal,
        max_steps=max_steps,
        domain=domain,
        persona=persona,
        response_mode=response_mode,
        include_evidence=include_evidence,
        include_guardrails=include_guardrails,
    )
    steps = proposal.get("recommended_steps", [])

    approvals = []
    missing_inputs = []
    dependency_warnings = []
    ordering_warnings = []

    # Build ordering check from playbooks for the requested domain
    playbook_steps = load_playbooks().get(domain, [])
    step_order = {pat: i for i, pat in enumerate(playbook_steps, start=1)}
    last_rank = 0
    for s in steps:
        name = s.get("name", "")
        rank = step_order.get(name)
        if rank is None:
            # Partial match: e.g. model_discover_data_model contains model_discover
            for pat, idx in step_order.items():
                if pat in name:
                    rank = idx
                    break
        if rank is None:
            rank = last_rank
        if rank < last_rank:
            ordering_warnings.append(f"Step '{name}' appears out of expected order for {domain} flow.")
        last_rank = max(last_rank, rank)

    for s in steps:
        step_no = s.get("step")
        name = s.get("name", "")
        qualname = s.get("qualname", "")
        risk = (s.get("risk_level") or "low").lower()
        inputs = s.get("suggested_input", {}) or {}
        requires_approval = (
            risk in {"medium", "high"}
            or (mode == "strict" and risk == "low" and "load" in name)
        )
        if requires_approval and not _is_read_only_step(name=name, qualname=qualname):
            approvals.append(
                {
                    "step": step_no,
                    "qualname": qualname,
                    "risk_level": risk,
                    "reason": "Potential mutation/configuration impact; approval recommended.",
                }
            )
        missing = [k for k, v in inputs.items() if v == "<value>"]
        if missing:
            missing_inputs.append(
                {
                    "step": step_no,
                    "qualname": s.get("qualname"),
                    "missing_required_inputs": missing,
                }
            )
        if "bus_matrix" in name and not any("relationships" in (k or "") for k in inputs.keys()):
            dependency_warnings.append(
                f"Step '{name}' may require inferred relationships from earlier steps."
            )

        if mode == "strict":
            if "load_dimensional_model" in name and not any("spec" in (k or "") for k in inputs.keys()):
                dependency_warnings.append(
                    f"Step '{name}' should include spec_path/spec_json for deterministic execution."
                )
            if "discover_data_model" in name and "source_type" not in inputs:
                missing_inputs.append(
                    {
                        "step": step_no,
                        "qualname": s.get("qualname"),
                        "missing_required_inputs": ["source_type"],
                    }
                )

    if mode == "fast":
        # Keep fast review concise.
        ordering_warnings = ordering_warnings[:1]
        dependency_warnings = dependency_warnings[:1]
        approvals = approvals[:2]
        missing_inputs = missing_inputs[:3]

    recommended_checklist = [
        "Confirm all required inputs are populated before execution.",
        "Validate prerequisite artifacts exist (relationships/classification/specs).",
        "Get human approval for medium/high risk steps.",
        "Execute steps in proposed order and capture outputs at each step.",
        "Run post-execution validation and compare against expected outputs.",
    ]

    review_report = {
        "goal": goal,
        "domain": domain,
        "review_mode": mode,
        "summary": {
            "total_steps": len(steps),
            "approval_steps": len(approvals),
            "steps_with_missing_inputs": len(missing_inputs),
            "ordering_warnings": len(ordering_warnings),
            "dependency_warnings": len(dependency_warnings),
        },
        "approvals_required": approvals,
        "missing_inputs": missing_inputs,
        "ordering_warnings": ordering_warnings,
        "dependency_warnings": dependency_warnings,
        "recommended_checklist": recommended_checklist,
    }

    ai_review_prompt = "\n".join(
        [
            f"Goal: {goal}",
            f"Review mode: {mode}",
            "",
            "Review the proposed workflow and produce:",
            "1) corrected step order,",
            "2) missing steps and prerequisites,",
            "3) approval gates for risky steps,",
            "4) final execution checklist.",
            "",
            "Proposed workflow JSON:",
            json.dumps(proposal, indent=2),
            "",
            "Precomputed review report JSON:",
            json.dumps(review_report, indent=2),
        ]
    )

    out = {
        "goal": goal,
        "review_mode": mode,
        "proposal": proposal,
        "review_report": review_report,
        "ai_review_prompt": ai_review_prompt,
    }
    out.update(
        _build_cfo_sections(
            query=goal,
            results=proposal.get("recommended_steps", []),
            index=load_index(p),
            response_mode=response_mode,
            include_guardrails=include_guardrails,
        )
    )
    if include_evidence:
        out["citations"] = proposal.get("citations", [])
    return out
