"""MCP tools for BLCE plugin — Phase 1-21 + P5 Deployment + Section 7f (84 tools total).

Parsers + Norm + CrossRef + Evidence + Governance + Skills + Workflow +
Orchestrator + Agents + Advanced Parsers + Runtime + Swarm + E2E Chaining +
Consultant Intake + Report Processing + Model Generation + Auto-Build +
Table Analysis + Proposal Generation + BI Export + Review Workflow +
DDL Deployment + Graph Copilot & Reconciliation.
"""
from __future__ import annotations

from typing import Any, Dict, List, Optional


def register_blce_tools(mcp, settings):
    """Register BLCE MCP tools."""

    from .parsers import (
        SQLLogicExtractor, PythonLogicExtractor, ExcelLogicExtractor,
        DAXLogicExtractor, MDXLogicExtractor, PDFLogicExtractor,
    )

    # Shared parser instances
    _sql_extractor = SQLLogicExtractor()
    _python_extractor = PythonLogicExtractor()
    _excel_extractor = ExcelLogicExtractor()
    _dax_extractor = DAXLogicExtractor()
    _mdx_extractor = MDXLogicExtractor()
    _pdf_extractor = PDFLogicExtractor()

    # In-memory artifact cache for the session (keyed by artifact_id)
    _artifact_cache: Dict[str, Dict[str, Any]] = {}

    def _cache_artifact(artifact_dict: Dict[str, Any]) -> None:
        aid = artifact_dict.get("artifact_id", "")
        if aid:
            _artifact_cache[aid] = artifact_dict

    # ---- Tool 1: Parse SQL logic ----

    @mcp.tool()
    def blce_parse_sql_logic(
        sql: str,
        dialect: str = "snowflake",
        source_name: str = "",
    ) -> Dict[str, Any]:
        """Extract business logic from a SQL statement using AST parsing.

        Detects: measures (SUM/COUNT/AVG/etc), filters (WHERE/HAVING),
        joins, grain (GROUP BY), and table dependencies (FROM/CTE).

        sql: The SQL source code to parse.
        dialect: SQL dialect for parsing (snowflake, bigquery, postgres, etc).
        source_name: Optional human-readable name for the source.
        """
        extractor = SQLLogicExtractor(dialect=dialect)
        artifact = extractor.extract(sql, source_name=source_name)
        result = artifact.model_dump()
        _cache_artifact(result)
        return {
            "artifact_id": result["artifact_id"],
            "source_type": result["source_type"],
            "confidence": result["confidence"],
            "grain": result["grain"],
            "measures": len(result["objects"]["measures"]),
            "filters": len(result["objects"]["filters"]),
            "joins": len(result["objects"]["joins"]),
            "grain_columns": len(result["objects"]["grain_columns"]),
            "dependencies": len(result["objects"]["dependencies"]),
            "objects": result["objects"],
        }

    # ---- Tool 2: Parse Python logic ----

    @mcp.tool()
    def blce_parse_python_logic(
        source: str,
        source_name: str = "",
    ) -> Dict[str, Any]:
        """Extract business logic from Python source code (pandas focus).

        Detects: groupby → grain, merge → joins, agg → measures,
        query/filter → filters, read_csv/read_sql → dependencies,
        and embedded SQL strings.

        source: Python source code string OR a file path.
        source_name: Optional human-readable name.
        """
        import os

        if os.path.isfile(source):
            artifact = _python_extractor.extract_file(source)
        else:
            artifact = _python_extractor.extract(source, source_name=source_name)

        result = artifact.model_dump()
        _cache_artifact(result)
        return {
            "artifact_id": result["artifact_id"],
            "source_type": result["source_type"],
            "confidence": result["confidence"],
            "grain": result["grain"],
            "measures": len(result["objects"]["measures"]),
            "filters": len(result["objects"]["filters"]),
            "joins": len(result["objects"]["joins"]),
            "grain_columns": len(result["objects"]["grain_columns"]),
            "dependencies": len(result["objects"]["dependencies"]),
            "embedded_sql_count": result.get("metadata", {}).get("embedded_sql_count", 0),
            "objects": result["objects"],
        }

    # ---- Tool 3: Parse Excel logic ----

    @mcp.tool()
    def blce_parse_excel_logic(
        file_path: str,
    ) -> Dict[str, Any]:
        """Extract business logic from an Excel workbook.

        Detects: aggregation formulas (SUMIFS, COUNTIFS, AVERAGEIFS),
        lookup references (VLOOKUP), conditional logic (IF),
        named ranges, and cross-sheet dependencies.

        file_path: Path to the .xlsx file.
        """
        artifact = _excel_extractor.extract_file(file_path)
        result = artifact.model_dump()
        _cache_artifact(result)
        return {
            "artifact_id": result["artifact_id"],
            "source_type": result["source_type"],
            "confidence": result["confidence"],
            "measures": len(result["objects"]["measures"]),
            "filters": len(result["objects"]["filters"]),
            "dependencies": len(result["objects"]["dependencies"]),
            "formula_count": result.get("metadata", {}).get("formula_count", 0),
            "named_range_count": result.get("metadata", {}).get("named_range_count", 0),
            "objects": result["objects"],
            "metadata": result.get("metadata", {}),
        }

    # ---- Tool 4: Extract business measures ----

    @mcp.tool()
    def blce_extract_business_measures(
        artifact_id: str = "",
        sql: str = "",
        dialect: str = "snowflake",
    ) -> Dict[str, Any]:
        """Extract business measures from a cached artifact or inline SQL.

        Returns all measures with their aggregation type, source columns,
        and confidence score. Use artifact_id to look up a previously
        parsed artifact, or pass SQL directly.

        artifact_id: ID of a previously parsed artifact.
        sql: Alternative — parse SQL inline and extract measures.
        dialect: SQL dialect (used only when sql is provided).
        """
        if artifact_id and artifact_id in _artifact_cache:
            cached = _artifact_cache[artifact_id]
            measures = cached.get("objects", {}).get("measures", [])
        elif sql:
            extractor = SQLLogicExtractor(dialect=dialect)
            artifact = extractor.extract(sql)
            result = artifact.model_dump()
            _cache_artifact(result)
            measures = result["objects"]["measures"]
        else:
            return {"error": "Provide artifact_id or sql"}

        return {
            "measure_count": len(measures),
            "measures": measures,
        }

    # ---- Tool 5: Extract common filters ----

    @mcp.tool()
    def blce_extract_common_filters(
        artifact_ids: str = "",
        sql_list: str = "",
    ) -> Dict[str, Any]:
        """Find common filter patterns across multiple artifacts or SQL statements.

        Helps identify shared WHERE clauses that may represent business rules
        (e.g., status = 'Active', date >= DATEADD(...)).

        artifact_ids: Comma-separated artifact IDs to analyze.
        sql_list: Alternative — pipe-delimited (|||) SQL statements to parse and compare.
        """
        all_filters: List[Dict[str, Any]] = []
        sources: List[str] = []

        if artifact_ids:
            for aid in [a.strip() for a in artifact_ids.split(",") if a.strip()]:
                if aid in _artifact_cache:
                    cached = _artifact_cache[aid]
                    filters = cached.get("objects", {}).get("filters", [])
                    all_filters.extend(filters)
                    sources.append(aid)

        if sql_list:
            for sql in sql_list.split("|||"):
                sql = sql.strip()
                if not sql:
                    continue
                extractor = SQLLogicExtractor()
                artifact = extractor.extract(sql)
                result = artifact.model_dump()
                _cache_artifact(result)
                sources.append(result["artifact_id"])
                all_filters.extend(result["objects"]["filters"])

        if not all_filters:
            return {"common_filters": [], "total_filters": 0, "sources_analyzed": len(sources)}

        # Find common filter columns
        column_counts: Dict[str, int] = {}
        column_examples: Dict[str, List[str]] = {}
        for f in all_filters:
            col = f.get("column", "") or f.get("source_clause", "")
            if col:
                column_counts[col] = column_counts.get(col, 0) + 1
                if col not in column_examples:
                    column_examples[col] = []
                clause = f.get("source_clause", "")
                if clause and clause not in column_examples[col]:
                    column_examples[col].append(clause)

        # Filters appearing in more than one source are "common"
        common = []
        for col, count in sorted(column_counts.items(), key=lambda x: -x[1]):
            common.append({
                "column": col,
                "occurrences": count,
                "examples": column_examples.get(col, [])[:3],
                "is_common": count > 1,
            })

        return {
            "total_filters": len(all_filters),
            "sources_analyzed": len(sources),
            "common_filters": [f for f in common if f["is_common"]],
            "all_filters_by_frequency": common[:20],
        }

    # ------------------------------------------------------------------
    # Phase 2: Semantic Normalization Tools
    # ------------------------------------------------------------------

    from .normalizers import MeasureNormalizer, FilterNormalizer, GrainContractBuilder
    from .crossref import CrossReferenceDiscovery
    from .contracts import LogicArtifact, CrossReferenceMapping

    _measure_norm = MeasureNormalizer()
    _filter_norm = FilterNormalizer()
    _grain_builder = GrainContractBuilder()
    _xref_discovery = CrossReferenceDiscovery()

    def _get_cached_artifacts(artifact_ids: str) -> List[LogicArtifact]:
        """Rebuild LogicArtifact objects from cached dicts."""
        arts = []
        for aid in [a.strip() for a in artifact_ids.split(",") if a.strip()]:
            if aid in _artifact_cache:
                arts.append(LogicArtifact(**_artifact_cache[aid]))
        return arts

    # ---- Tool 6: Normalize measures ----

    @mcp.tool()
    def blce_normalize_measures(
        artifact_ids: str,
    ) -> Dict[str, Any]:
        """Deduplicate and normalize measures across multiple parsed artifacts.

        Groups measures by canonical name, merges source columns, detects
        conflicting definitions, and returns NormalizedMeasure entries.

        artifact_ids: Comma-separated artifact IDs from previous parse calls.
        """
        artifacts = _get_cached_artifacts(artifact_ids)
        if not artifacts:
            return {"error": "No cached artifacts found. Parse sources first."}

        normalized = _measure_norm.normalize(artifacts)
        low_conf = _measure_norm.find_low_confidence(artifacts)

        return {
            "normalized_count": len(normalized),
            "low_confidence_count": len(low_conf),
            "measures": [nm.model_dump() for nm in normalized],
            "needs_ai_enrichment": [
                {"name": m.name, "confidence": m.confidence} for m in low_conf
            ],
        }

    # ---- Tool 7: Detect grain contract ----

    @mcp.tool()
    def blce_detect_grain_contract(
        artifact_ids: str,
    ) -> Dict[str, Any]:
        """Propose grain contracts from parsed artifacts.

        Analyzes GROUP BY patterns across artifacts to propose conformed
        grain definitions (e.g., well + month).

        artifact_ids: Comma-separated artifact IDs.
        """
        artifacts = _get_cached_artifacts(artifact_ids)
        if not artifacts:
            return {"error": "No cached artifacts found. Parse sources first."}

        contracts = _grain_builder.build_contracts(artifacts)
        conflicts = _grain_builder.detect_grain_conflicts(artifacts)

        return {
            "contracts_proposed": len(contracts),
            "conflicts_detected": len(conflicts),
            "contracts": [gc.model_dump() for gc in contracts],
            "conflicts": conflicts,
        }

    # ---- Tool 8: AI enrich artifact (stub) ----

    @mcp.tool()
    def blce_ai_enrich_artifact(
        artifact_id: str,
        prompt_hint: str = "",
    ) -> Dict[str, Any]:
        """Enrich a low-confidence artifact using AI analysis.

        For artifacts where the deterministic parser couldn't fully extract
        business logic (confidence < 0.60), this tool sends the raw source
        to an AI model for deeper analysis.

        artifact_id: ID of the artifact to enrich.
        prompt_hint: Optional hint to guide AI analysis.
        """
        if artifact_id not in _artifact_cache:
            return {"error": f"Artifact {artifact_id} not found in cache."}

        cached = _artifact_cache[artifact_id]
        confidence = cached.get("confidence", 0.0)

        if confidence >= 0.60:
            return {
                "status": "skipped",
                "reason": f"Confidence {confidence} already >= 0.60 threshold",
                "artifact_id": artifact_id,
            }

        # Stub: In production, this calls Cortex or another LLM
        return {
            "status": "pending_ai",
            "artifact_id": artifact_id,
            "current_confidence": confidence,
            "raw_source_length": len(cached.get("raw_source", "")),
            "prompt_hint": prompt_hint,
            "message": "AI enrichment requires Cortex integration (Phase 8). "
                       "Artifact flagged for manual review.",
        }

    # ---- Tool 9: Compare logic sources ----

    @mcp.tool()
    def blce_compare_logic_sources(
        artifact_ids: str,
    ) -> Dict[str, Any]:
        """Compare measures across different source types (SQL vs Python vs Excel).

        Finds where the same measure is computed differently across source
        types and highlights discrepancies.

        artifact_ids: Comma-separated artifact IDs to compare.
        """
        artifacts = _get_cached_artifacts(artifact_ids)
        if not artifacts:
            return {"error": "No cached artifacts found. Parse sources first."}

        comparison = _measure_norm.compare_sources(artifacts)
        common_filters = _filter_norm.find_common_filters(artifacts)

        # Summarize
        multi_source = {k: v for k, v in comparison.items() if len(v) > 1}

        return {
            "measures_compared": len(comparison),
            "multi_source_measures": len(multi_source),
            "common_business_filters": len(common_filters),
            "measure_comparison": comparison,
            "common_filters": common_filters,
        }

    # ------------------------------------------------------------------
    # Phase 3: Cross-System Conformance Tools
    # ------------------------------------------------------------------

    # ---- Tool 10: Discover cross-references ----

    @mcp.tool()
    def blce_discover_cross_references(
        artifact_ids: str,
        similarity_threshold: float = 0.75,
    ) -> Dict[str, Any]:
        """Discover cross-system entity mappings from parsed artifacts.

        Compares column names, grain patterns, and measure definitions
        across artifacts from different source systems to propose
        cross-reference mappings.

        artifact_ids: Comma-separated artifact IDs (from different systems).
        similarity_threshold: Minimum name similarity to propose a mapping (0.0-1.0).
        """
        artifacts = _get_cached_artifacts(artifact_ids)
        if not artifacts:
            return {"error": "No cached artifacts found. Parse sources first."}

        disc = CrossReferenceDiscovery(similarity_threshold=similarity_threshold)
        model = disc.discover(artifacts)

        auto_promoted = [m for m in model.mappings if m.confidence >= 0.70]

        return {
            "total_mappings": len(model.mappings),
            "auto_promoted": len(auto_promoted),
            "domains": model.domains,
            "source_systems": model.source_systems,
            "mappings": [m.model_dump() for m in model.mappings],
        }

    # ---- Tool 11: Propose conformed dimensions ----

    @mcp.tool()
    def blce_propose_conformed_dimensions(
        artifact_ids: str,
    ) -> Dict[str, Any]:
        """Propose Kimball conformed dimensions from cross-reference mappings.

        Analyzes high-confidence cross-system mappings and proposes
        conformed dimension designs.

        artifact_ids: Comma-separated artifact IDs.
        """
        artifacts = _get_cached_artifacts(artifact_ids)
        if not artifacts:
            return {"error": "No cached artifacts found. Parse sources first."}

        model = _xref_discovery.discover(artifacts)
        proposals = _xref_discovery.propose_conformed_dimensions(model)

        return {
            "proposals_count": len(proposals),
            "proposals": proposals,
            "underlying_mappings": len(model.mappings),
        }

    # ---- Tool 12: Validate cross-reference ----

    @mcp.tool()
    def blce_validate_cross_reference(
        source_system: str,
        source_key: str,
        target_system: str,
        target_key: str,
        confidence: float = 0.5,
        domain: str = "",
    ) -> Dict[str, Any]:
        """Validate a proposed cross-reference mapping.

        Checks a mapping for completeness and consistency, and indicates
        whether it can be auto-promoted.

        source_system: Name of the source system.
        source_key: Column/entity in source system.
        target_system: Name of the target system.
        target_key: Column/entity in target system.
        confidence: Confidence score (0.0-1.0).
        domain: Business domain (e.g., well, account).
        """
        mapping = CrossReferenceMapping(
            domain=domain,
            source_system=source_system,
            source_key=source_key,
            target_system=target_system,
            target_key=target_key,
            confidence=confidence,
        )
        result = _xref_discovery.validate_mapping(mapping)
        return result

    # ------------------------------------------------------------------
    # Phase 4: Evidence Sampling + DataShield Integration
    # ------------------------------------------------------------------

    from .evidence import EvidenceSampler
    from .shield_bridge import ShieldBridge
    from .governance import GovernanceEngine

    _evidence_sampler = EvidenceSampler()
    _shield_bridge = ShieldBridge()
    _governance_engine = GovernanceEngine()

    # In-memory stores for evidence and governance
    _evidence_store: Dict[str, Dict[str, Any]] = {}
    _governance_store: Dict[str, Dict[str, Any]] = {}

    # ---- Tool 13: Collect evidence sample ----

    @mcp.tool()
    def blce_collect_evidence_sample(
        artifact_id: str,
        date_column: str = "",
        extra_filters: str = "",
    ) -> Dict[str, Any]:
        """Build an evidence-sampling query for a parsed artifact.

        Generates a SQL query that pulls a small sample (max 500 rows,
        last 2 months) from the source tables referenced by the artifact.
        Use the returned SQL to execute against the source database.

        artifact_id: ID of a previously parsed artifact.
        date_column: Optional date column for temporal filtering.
        extra_filters: Optional pipe-delimited extra WHERE clauses.
        """
        if artifact_id not in _artifact_cache:
            return {"error": f"Artifact {artifact_id} not found in cache."}

        art = LogicArtifact(**_artifact_cache[artifact_id])
        filters = [f.strip() for f in extra_filters.split("|") if f.strip()] if extra_filters else None

        sample = _evidence_sampler.collect_sample(
            art, date_column=date_column, extra_filters=filters,
        )
        query_info = _evidence_sampler.build_sample_query(
            art, date_column=date_column, extra_filters=filters,
        )

        result = sample.model_dump()
        _evidence_store[sample.sample_id] = result

        return {
            "sample_id": sample.sample_id,
            "artifact_id": artifact_id,
            "table": sample.table_name,
            "columns": sample.columns,
            "filter_desc": sample.filter_desc,
            "sql": query_info.get("sql", ""),
            "max_rows": _evidence_sampler.max_rows,
            "sample_months": _evidence_sampler.sample_months,
            "status": "query_ready" if "sql" in query_info else "error",
            "backend": _shield_bridge.backend,
        }

    # ---- Tool 14: Mask evidence sample ----

    @mcp.tool()
    def blce_mask_evidence(
        sample_id: str = "",
        artifact_id: str = "",
    ) -> Dict[str, Any]:
        """Mask an evidence sample using DataShield (or built-in stub).

        Scrambles sample data while preserving data shape and types.
        This ensures client data never reaches AI in raw form.

        sample_id: ID of a previously collected sample.
        artifact_id: Alternative — mask the latest sample for this artifact.
        """
        # Find the sample
        sample_dict = None
        if sample_id and sample_id in _evidence_store:
            sample_dict = _evidence_store[sample_id]
        elif artifact_id:
            # Find latest sample for this artifact
            for sid, sd in _evidence_store.items():
                if sd.get("artifact_id") == artifact_id:
                    sample_dict = sd
                    sample_id = sid
                    break

        if not sample_dict:
            return {"error": "Sample not found. Run blce_collect_evidence_sample first."}

        from .contracts import EvidenceSample as ES
        sample = ES(**sample_dict)

        if sample.masked:
            return {
                "status": "already_masked",
                "sample_id": sample_id,
                "backend": _shield_bridge.backend,
            }

        # Since we don't have actual rows in-memory (they'd come from
        # executing the SQL), we demonstrate the masking capability
        # with a synthetic example
        demo_rows = _generate_demo_rows(sample.columns, count=3)
        mask_result = _shield_bridge.mask_sample(sample, demo_rows)

        # Update the store with masked version
        _evidence_store[sample_id] = mask_result["masked_sample"]

        return {
            "status": "masked",
            "sample_id": sample_id,
            "backend": mask_result["backend"],
            "original_rows": len(demo_rows),
            "masked_rows": mask_result["masked_row_count"],
            "datashield_available": _shield_bridge.is_available(),
            "demo_original": demo_rows[:2],
            "demo_masked": mask_result["masked_rows"][:2],
        }

    def _generate_demo_rows(columns: List[str], count: int = 3) -> List[Dict[str, Any]]:
        """Generate synthetic demo rows for masking demonstration."""
        import random
        rows = []
        for i in range(count):
            row = {}
            for col in columns:
                cl = col.lower()
                if cl == "*":
                    continue
                elif "date" in cl or "month" in cl:
                    row[col] = f"2026-0{random.randint(1,2)}-{random.randint(10,28):02d}"
                elif "id" in cl or "key" in cl or "no" in cl:
                    row[col] = random.randint(1000, 9999)
                elif "amount" in cl or "revenue" in cl or "volume" in cl or "price" in cl:
                    row[col] = round(random.uniform(100, 50000), 2)
                elif "name" in cl or "desc" in cl:
                    row[col] = f"Sample_{col}_{i+1}"
                elif "status" in cl:
                    row[col] = random.choice(["Active", "Inactive", "Pending"])
                else:
                    row[col] = f"val_{i+1}"
            if row:
                rows.append(row)
        return rows

    # ---- Tool 15: Verify measure with data ----

    @mcp.tool()
    def blce_verify_measure_with_data(
        artifact_id: str,
        measure_name: str = "",
        date_column: str = "",
    ) -> Dict[str, Any]:
        """Build a verification query for a specific measure against source data.

        Generates a SQL query that computes the measure value grouped by the
        artifact's grain columns.  Execute the SQL against the source database
        to verify the extracted logic.

        artifact_id: ID of a previously parsed artifact.
        measure_name: Name of the measure to verify (or first if omitted).
        date_column: Optional date column for temporal filtering.
        """
        if artifact_id not in _artifact_cache:
            return {"error": f"Artifact {artifact_id} not found in cache."}

        art = LogicArtifact(**_artifact_cache[artifact_id])

        # Find the measure
        measures = art.objects.measures
        if not measures:
            return {"error": "Artifact has no measures to verify."}

        target = None
        if measure_name:
            for m in measures:
                if (m.name or m.alias or "").lower() == measure_name.lower():
                    target = m
                    break
            if not target:
                return {
                    "error": f"Measure '{measure_name}' not found.",
                    "available": [m.name or m.alias for m in measures],
                }
        else:
            target = measures[0]

        from .contracts import Measure as MeasureModel
        result = _evidence_sampler.verify_measure(
            target, art, date_column=date_column,
        )

        return result

    # ------------------------------------------------------------------
    # Phase 5: Governance Classification
    # ------------------------------------------------------------------

    # ---- Tool 16: Classify artifact ----

    @mcp.tool()
    def blce_classify_artifact(
        artifact_id: str = "",
        artifact_ids: str = "",
        client_history_json: str = "",
    ) -> Dict[str, Any]:
        """Classify logic artifact(s) as CORE, CUSTOM, or CANDIDATE.

        CORE: Reusable across clients (standard aggregations, 3+ clients).
        CUSTOM: Client-specific logic (unique naming, 1 client only).
        CANDIDATE: Under review (2 clients, partially matches core patterns).

        artifact_id: Single artifact to classify.
        artifact_ids: Comma-separated artifact IDs for batch classification.
        client_history_json: Optional JSON mapping measure names to client ID lists.
        """
        import json

        # Parse client history if provided
        client_history = None
        if client_history_json:
            try:
                client_history = json.loads(client_history_json)
            except json.JSONDecodeError:
                return {"error": "Invalid JSON in client_history_json."}

        # Collect artifacts
        ids = []
        if artifact_id:
            ids = [artifact_id]
        elif artifact_ids:
            ids = [a.strip() for a in artifact_ids.split(",") if a.strip()]

        if not ids:
            return {"error": "Provide artifact_id or artifact_ids."}

        artifacts = _get_cached_artifacts(",".join(ids))
        if not artifacts:
            return {"error": "No cached artifacts found. Parse sources first."}

        decisions = _governance_engine.classify_batch(
            artifacts, client_history=client_history,
        )

        # Store decisions
        for d in decisions:
            _governance_store[d.decision_id] = d.model_dump()

        return {
            "classified": len(decisions),
            "decisions": [
                {
                    "decision_id": d.decision_id,
                    "artifact_id": d.artifact_id,
                    "classification": d.classification.value,
                    "rationale": d.rationale,
                    "client_count": d.client_count,
                }
                for d in decisions
            ],
        }

    # ---- Tool 17: Governance report ----

    @mcp.tool()
    def blce_governance_report(
        artifact_ids: str = "",
        include_details: bool = True,
    ) -> Dict[str, Any]:
        """Generate a governance summary report for classified artifacts.

        Shows breakdown of CORE vs CUSTOM vs CANDIDATE classifications
        with recommendations for promotion or review.

        artifact_ids: Comma-separated artifact IDs (classifies if not already done).
        include_details: Whether to include per-artifact decision details.
        """
        # If artifact_ids provided but not yet classified, classify first
        if artifact_ids:
            artifacts = _get_cached_artifacts(artifact_ids)
            if artifacts:
                # Check which are already classified
                classified_aids = {
                    d["artifact_id"] for d in _governance_store.values()
                }
                unclassified = [a for a in artifacts if a.artifact_id not in classified_aids]
                if unclassified:
                    new_decisions = _governance_engine.classify_batch(unclassified)
                    for d in new_decisions:
                        _governance_store[d.decision_id] = d.model_dump()

        # Build report from all stored decisions
        all_decisions = []
        for dd in _governance_store.values():
            all_decisions.append(GovernanceDecision(**dd))

        report = _governance_engine.generate_report(all_decisions)

        if not include_details:
            report.pop("decisions", None)

        # Add promotion candidates
        candidates = _governance_engine.find_promotion_candidates(all_decisions)
        report["promotion_candidates"] = [
            {
                "decision_id": c.decision_id,
                "artifact_id": c.artifact_id,
                "client_count": c.client_count,
                "rationale": c.rationale,
            }
            for c in candidates
        ]

        return report

    # ------------------------------------------------------------------
    # Phase 6: Skill Generation
    # ------------------------------------------------------------------

    from .skill_generator import SkillGenerator
    from .workflow_phase import BLCEWorkflowController
    from .contracts import GovernanceDecision as GovDec, SkillManifest

    _skill_generator = SkillGenerator()
    _skill_store: Dict[str, Dict[str, Any]] = {}
    _workflow_controller = BLCEWorkflowController()

    # ---- Tool 18: Generate skill from artifact ----

    @mcp.tool()
    def blce_generate_skill(
        artifact_id: str,
        client_id: str = "",
        domain: str = "",
        write_to_disk: bool = False,
    ) -> Dict[str, Any]:
        """Generate a reusable skill prompt from a CORE-classified artifact.

        Only CORE artifacts produce skills. CUSTOM and CANDIDATE artifacts
        are skipped. The generated skill contains measure definitions, grain
        contracts, filter patterns, and source dependencies in a format
        matching the existing skills/ directory.

        artifact_id: ID of a previously parsed and classified artifact.
        client_id: Optional client identifier.
        domain: Optional business domain override (auto-inferred if empty).
        write_to_disk: Whether to write the skill file to the skills/ directory.
        """
        if artifact_id not in _artifact_cache:
            return {"error": f"Artifact {artifact_id} not found in cache."}

        # Check for governance decision
        decision = None
        for dd in _governance_store.values():
            if dd.get("artifact_id") == artifact_id:
                decision = GovDec(**dd)
                break

        if decision is None:
            # Auto-classify first
            art = LogicArtifact(**_artifact_cache[artifact_id])
            decision = _governance_engine.classify_artifact(art)
            _governance_store[decision.decision_id] = decision.model_dump()

        if decision.classification != ToolClassification.CORE:
            return {
                "status": "skipped",
                "reason": f"Artifact classified as {decision.classification.value}, not CORE",
                "artifact_id": artifact_id,
                "classification": decision.classification.value,
            }

        art = LogicArtifact(**_artifact_cache[artifact_id])
        manifest = _skill_generator.generate_skill(
            art, decision, client_id=client_id, domain=domain,
        )

        if manifest is None:
            return {"error": "Skill generation failed."}

        result = manifest.model_dump()
        _skill_store[manifest.skill_id] = result

        if write_to_disk:
            path = _skill_generator.write_skill(manifest)
            result["written_to"] = path

        return {
            "status": "generated",
            "skill_id": manifest.skill_id,
            "skill_name": manifest.skill_name,
            "skill_path": manifest.skill_path,
            "measures_covered": manifest.measures_covered,
            "grain_columns": manifest.grain_columns,
            "content_length": len(manifest.content),
            "written_to_disk": write_to_disk,
        }

    # ---- Tool 19: List generated skills ----

    @mcp.tool()
    def blce_list_generated_skills() -> Dict[str, Any]:
        """List all BLCE-generated skill prompts.

        Shows skills generated in this session (in-memory) and any
        previously written skill files on disk (blce_*.txt in skills/).
        """
        session_skills = [
            {
                "skill_id": sid,
                "skill_name": sd.get("skill_name", ""),
                "artifact_id": sd.get("artifact_id", ""),
                "measures": len(sd.get("measures_covered", [])),
                "source": "session",
            }
            for sid, sd in _skill_store.items()
        ]

        disk_skills = _skill_generator.list_generated_skills()

        return {
            "session_skills": len(session_skills),
            "disk_skills": len(disk_skills),
            "skills": session_skills,
            "disk_files": disk_skills,
        }

    # ------------------------------------------------------------------
    # Phase 7: Workflow Controller
    # ------------------------------------------------------------------

    # ---- Tool 20: Run BLCE pipeline ----

    @mcp.tool()
    def blce_run_pipeline(
        artifact_ids: str = "",
        skip_phases: str = "",
        client_id: str = "",
    ) -> Dict[str, Any]:
        """Execute the full BLCE pipeline (parse → normalize → xref → evidence → govern → skill).

        Chains all BLCE phases sequentially. If artifact_ids are provided,
        uses cached artifacts and skips the parse phase.

        artifact_ids: Comma-separated artifact IDs to process (skips parse if provided).
        skip_phases: Comma-separated phase names to skip.
        client_id: Client identifier for governance decisions.
        """
        # Gather pre-parsed artifacts
        arts = None
        if artifact_ids:
            arts = _get_cached_artifacts(artifact_ids)
            if not arts:
                return {"error": "No cached artifacts found. Parse sources first."}

        skip = [s.strip() for s in skip_phases.split(",") if s.strip()] if skip_phases else None

        # If we have pre-parsed artifacts, skip the parse phase
        if arts and (not skip or "parse_sources" not in skip):
            skip = (skip or []) + ["parse_sources"]

        summary = _workflow_controller.run_pipeline(
            skip=skip,
            artifacts=arts,
            client_id=client_id,
        )

        return {
            "run_id": summary.run_id,
            "status": "completed" if not summary.errors else "completed_with_errors",
            "duration_seconds": summary.duration_seconds,
            "phases_completed": summary.phases_completed,
            "phases_skipped": summary.phases_skipped,
            "artifacts_extracted": summary.artifacts_extracted,
            "measures_found": summary.measures_found,
            "cross_references": summary.cross_references,
            "errors": summary.errors,
        }

    # ---- Tool 21: Pipeline status ----

    @mcp.tool()
    def blce_pipeline_status(
        run_id: str = "",
    ) -> Dict[str, Any]:
        """Check the status of a BLCE pipeline run.

        run_id: ID of the pipeline run. If empty, lists all runs.
        """
        if run_id:
            return _workflow_controller.get_status(run_id)

        runs = _workflow_controller.list_runs()
        return {
            "total_runs": len(runs),
            "runs": runs,
        }

    # ---- Tool 22: Get run summary ----

    @mcp.tool()
    def blce_get_run_summary(
        run_id: str,
    ) -> Dict[str, Any]:
        """Get the detailed summary of a BLCE pipeline run.

        Returns phase-by-phase results, artifact counts, and any errors.

        run_id: ID of the pipeline run.
        """
        status = _workflow_controller.get_status(run_id)
        if "error" in status:
            return status

        return {
            "run_id": run_id,
            "summary": status.get("summary", {}),
            "phases": status.get("phases", []),
            "context_summary": status.get("context", {}),
        }

    # ---- Tool 23: Run full 16-phase engine ----

    @mcp.tool()
    def blce_run_full_engine(
        source_paths: Optional[List[str]] = None,
        artifact_ids: Optional[List[str]] = None,
        client_id: str = "",
        mode: str = "analyze_only",
        skip_phases: Optional[List[str]] = None,
    ) -> Dict[str, Any]:
        """Run the full 16-phase BLCE engine (orchestrator).

        Extended pipeline: e2e_chain → intake → catalog → parse → normalize →
        hierarchy → cross_reference → evidence → governance → persist →
        bus_matrix → quality → skills → ai_enrich → agent_swarm → artifact_bundle.

        source_paths: File paths to ingest (SQL, Python, Excel, CSV).
        artifact_ids: Pre-parsed artifact IDs from cache (skips intake+parse).
        client_id: Client identifier.
        mode: "analyze_only" (no persist) or "full" (persist to SF/graph).
        skip_phases: Phase names to skip.
        """
        from .orchestrator import BLCEOrchestrator

        orchestrator = BLCEOrchestrator(_workflow_controller.config)

        # Resolve pre-parsed artifacts from cache
        artifacts = None
        if artifact_ids:
            from .contracts import LogicArtifact
            artifacts = []
            for aid in artifact_ids:
                if aid in _artifact_cache:
                    artifacts.append(LogicArtifact(**_artifact_cache[aid]))
                else:
                    return {"error": f"Artifact {aid} not found in session cache"}

        summary = orchestrator.run_full_engine(
            source_paths=source_paths,
            artifacts=artifacts,
            client_id=client_id,
            mode=mode,
            skip_phases=skip_phases,
        )

        return summary.model_dump()

    # ---- Tool 24: Orchestrator status ----

    @mcp.tool()
    def blce_orchestrator_status() -> Dict[str, Any]:
        """Get orchestrator capabilities and phase information.

        Returns the full 16-phase order and agent registry.
        """
        from .orchestrator import BLCE_FULL_PHASE_ORDER
        from .agents import list_agents

        return {
            "phases": BLCE_FULL_PHASE_ORDER,
            "phase_count": len(BLCE_FULL_PHASE_ORDER),
            "agents": list_agents(),
            "base_phases": 6,
            "extended_phases": 10,
        }

    # ---- Tool 25: AI semantic analysis ----

    @mcp.tool()
    def blce_ai_semantic_analysis(
        artifact_id: str,
        confidence_threshold: float = 0.6,
    ) -> Dict[str, Any]:
        """Run AI semantic analysis on a logic artifact.

        Uses ReportSemanticAgent to detect domains, explain measures,
        describe business intent, and boost confidence.

        artifact_id: ID of the artifact to analyze.
        confidence_threshold: Minimum confidence for analysis to apply boost.
        """
        if artifact_id not in _artifact_cache:
            return {"error": f"Artifact {artifact_id} not found in session cache"}

        from .contracts import LogicArtifact
        from .agents import ReportSemanticAgent

        art = LogicArtifact(**_artifact_cache[artifact_id])
        agent = ReportSemanticAgent(confidence_threshold=confidence_threshold)
        analysis = agent.analyze(art)

        return analysis.model_dump()

    # ---- Tool 26: AI cross-reference analysis ----

    @mcp.tool()
    def blce_ai_cross_reference(
        artifact_ids: Optional[List[str]] = None,
        min_confidence: float = 0.5,
    ) -> Dict[str, Any]:
        """Run AI cross-reference analysis across artifacts.

        Uses CrossReferenceMasterDataAgent to find entity matches
        across multiple source artifacts.

        artifact_ids: Artifact IDs to analyze (defaults to all cached).
        min_confidence: Minimum confidence for matches.
        """
        from .contracts import LogicArtifact
        from .agents import CrossReferenceMasterDataAgent

        # Resolve artifacts
        ids = artifact_ids or list(_artifact_cache.keys())
        arts = []
        for aid in ids:
            if aid in _artifact_cache:
                arts.append(LogicArtifact(**_artifact_cache[aid]))
            else:
                return {"error": f"Artifact {aid} not found in session cache"}

        if len(arts) < 2:
            return {"error": "Need at least 2 artifacts for cross-reference analysis"}

        agent = CrossReferenceMasterDataAgent()
        mappings = agent.analyze(arts, min_confidence=min_confidence)

        return {
            "artifacts_analyzed": len(arts),
            "mappings_found": len(mappings),
            "mappings": [m.model_dump() for m in mappings],
        }

    # ---- Tool 27: AI quality policy ----

    @mcp.tool()
    def blce_ai_quality_policy(
        artifact_id: str,
    ) -> Dict[str, Any]:
        """Generate quality expectations from a logic artifact.

        Uses QualityPolicyAgent to infer data quality rules from
        measure types and column patterns.

        artifact_id: ID of the artifact to analyze.
        """
        if artifact_id not in _artifact_cache:
            return {"error": f"Artifact {artifact_id} not found in session cache"}

        from .contracts import LogicArtifact
        from .agents import QualityPolicyAgent

        art = LogicArtifact(**_artifact_cache[artifact_id])
        agent = QualityPolicyAgent()
        policy = agent.analyze(art)

        return policy.model_dump()

    # ---- Tool 28: List agents ----

    @mcp.tool()
    def blce_list_agents() -> Dict[str, Any]:
        """List all registered BLCE AI agents.

        Returns agent names, descriptions, and priority levels.
        """
        from .agents import list_agents

        agents = list_agents()
        return {
            "agent_count": len(agents),
            "agents": agents,
        }

    # ---- Tool 29: Parse DAX logic ----

    @mcp.tool()
    def blce_parse_dax_logic(
        source: str,
        source_name: str = "",
    ) -> Dict[str, Any]:
        """Extract business logic from a DAX expression or measure definition.

        Detects: measures (SUM/CALCULATE/SUMX/etc), filters (FILTER/CALCULATE),
        grain (SUMMARIZE/GROUPBY), table/column dependencies, and variables.

        source: DAX source code string OR a file path ending in .dax or .bim.
        source_name: Optional human-readable name.
        """
        import os

        if os.path.isfile(source):
            artifact = _dax_extractor.extract_file(source)
        else:
            artifact = _dax_extractor.extract(source, source_name=source_name)

        result = artifact.model_dump()
        _cache_artifact(result)
        return {
            "artifact_id": result["artifact_id"],
            "source_type": result["source_type"],
            "confidence": result["confidence"],
            "grain": result["grain"],
            "measures": len(result["objects"]["measures"]),
            "filters": len(result["objects"]["filters"]),
            "grain_columns": len(result["objects"]["grain_columns"]),
            "dependencies": len(result["objects"]["dependencies"]),
            "objects": result["objects"],
        }

    # ---- Tool 30: Parse MDX logic ----

    @mcp.tool()
    def blce_parse_mdx_logic(
        source: str,
        source_name: str = "",
    ) -> Dict[str, Any]:
        """Extract business logic from an MDX query (Analysis Services / SSAS).

        Detects: calculated members (WITH MEMBER), slicer/WHERE filters,
        ON ROWS/COLUMNS grain, cube and dimension dependencies,
        FILTER/CROSSJOIN/NONEMPTY functions.

        source: MDX query string OR a file path ending in .mdx.
        source_name: Optional human-readable name.
        """
        import os

        if os.path.isfile(source):
            artifact = _mdx_extractor.extract_file(source)
        else:
            artifact = _mdx_extractor.extract(source, source_name=source_name)

        result = artifact.model_dump()
        _cache_artifact(result)
        return {
            "artifact_id": result["artifact_id"],
            "source_type": result["source_type"],
            "confidence": result["confidence"],
            "grain": result["grain"],
            "measures": len(result["objects"]["measures"]),
            "filters": len(result["objects"]["filters"]),
            "grain_columns": len(result["objects"]["grain_columns"]),
            "dependencies": len(result["objects"]["dependencies"]),
            "objects": result["objects"],
        }

    # ---- Tool 31: Parse PDF logic ----

    @mcp.tool()
    def blce_parse_pdf_logic(
        source: str,
        source_name: str = "",
    ) -> Dict[str, Any]:
        """Extract business logic from a PDF report or documentation.

        Detects: KPI definitions, formula expressions, percentage/ratio metrics,
        filter conditions, group-by grain, and source system references.

        source: Plain text extracted from PDF OR a file path ending in .pdf.
        source_name: Optional human-readable name.
        """
        import os

        if os.path.isfile(source) and source.lower().endswith(".pdf"):
            artifact = _pdf_extractor.extract_file(source)
        else:
            artifact = _pdf_extractor.extract(source, source_name=source_name)

        result = artifact.model_dump()
        _cache_artifact(result)
        return {
            "artifact_id": result["artifact_id"],
            "source_type": result["source_type"],
            "confidence": result["confidence"],
            "grain": result["grain"],
            "measures": len(result["objects"]["measures"]),
            "filters": len(result["objects"]["filters"]),
            "grain_columns": len(result["objects"]["grain_columns"]),
            "dependencies": len(result["objects"]["dependencies"]),
            "objects": result["objects"],
        }

    # ------------------------------------------------------------------
    # Phase 11-12: Parallel Runtime Tools
    # ------------------------------------------------------------------

    # ---- Tool 32: Run parallel engine ----

    @mcp.tool()
    def blce_run_parallel_engine(
        source_paths: Optional[List[str]] = None,
        artifact_ids: Optional[List[str]] = None,
        client_id: str = "",
        mode: str = "analyze_only",
        skip_phases: Optional[List[str]] = None,
        max_workers: int = 4,
    ) -> Dict[str, Any]:
        """Run the 16-phase BLCE engine with parallel execution of independent phases.

        Uses AgentRuntime + ThreadPoolExecutor to execute phases that have
        no data dependencies concurrently.  Typically reduces total runtime
        by 30-50% compared to sequential execution.

        source_paths: File paths to ingest (SQL, Python, Excel, CSV).
        artifact_ids: Pre-parsed artifact IDs from cache (skips intake+parse).
        client_id: Client identifier.
        mode: "analyze_only" (no persist) or "full" (persist to SF/graph).
        skip_phases: Phase names to skip.
        max_workers: Thread pool size (default 4).
        """
        from .orchestrator import BLCEOrchestrator

        orchestrator = BLCEOrchestrator(_workflow_controller.config)

        # Resolve pre-parsed artifacts from cache
        artifacts = None
        if artifact_ids:
            from .contracts import LogicArtifact
            artifacts = []
            for aid in artifact_ids:
                if aid in _artifact_cache:
                    artifacts.append(LogicArtifact(**_artifact_cache[aid]))
                else:
                    return {"error": f"Artifact {aid} not found in session cache"}

        summary = orchestrator.run_parallel_engine(
            source_paths=source_paths,
            artifacts=artifacts,
            client_id=client_id,
            mode=mode,
            skip_phases=skip_phases,
            max_workers=max_workers,
        )

        return summary.model_dump()

    # ---- Tool 33: Agent runtime status ----

    @mcp.tool()
    def blce_agent_runtime_status() -> Dict[str, Any]:
        """Get agent runtime capabilities and configuration.

        Returns runtime info including max workers, timeout, and
        available task statuses.
        """
        from .agent_runtime import TaskStatus

        return {
            "task_statuses": [s.value for s in TaskStatus],
            "default_max_workers": 4,
            "default_timeout": 300,
            "description": (
                "AgentRuntime executes tasks in parallel using "
                "ThreadPoolExecutor with dependency-aware scheduling "
                "(Kahn's topological sort)."
            ),
        }

    # ---- Tool 34: Message bus drain ----

    @mcp.tool()
    def blce_message_bus_drain(
        topic: str = "",
    ) -> Dict[str, Any]:
        """Drain messages from the BLCE message bus.

        Returns and removes messages, optionally filtered by topic.
        Standard topics: agent.started, agent.completed, agent.failed,
        phase.started, phase.completed.

        topic: Filter by topic (empty = drain all).
        """
        from .message_bus import MessageBus

        bus = MessageBus()
        msgs = bus.drain(topic=topic if topic else None)
        return {
            "drained": len(msgs),
            "topic": topic or "(all)",
            "messages": [m.to_dict() for m in msgs],
        }

    # ---- Tool 35: Parallel phase graph ----

    @mcp.tool()
    def blce_parallel_phase_graph(
        skip_phases: Optional[List[str]] = None,
    ) -> Dict[str, Any]:
        """Show the BLCE phase dependency graph with parallelisable levels.

        Returns the dependency map and topological levels showing which
        phases can execute concurrently.

        skip_phases: Phase names to exclude from the graph.
        """
        from .parallel_pipeline import PHASE_DEPENDENCIES, ParallelPipelineRunner
        from .orchestrator import BLCE_FULL_PHASE_ORDER

        skip_set = set(skip_phases or [])
        levels = ParallelPipelineRunner.resolve_levels(
            list(BLCE_FULL_PHASE_ORDER), skip_set,
        )

        return {
            "dependencies": {
                k: v for k, v in PHASE_DEPENDENCIES.items()
                if k not in skip_set
            },
            "levels": [
                {"level": i, "phases": lvl}
                for i, lvl in enumerate(levels)
            ],
            "level_count": len(levels),
            "total_phases": sum(len(l) for l in levels),
            "max_parallelism": max((len(l) for l in levels), default=0),
        }

    # ------------------------------------------------------------------
    # Phase 13: Multi-Provider Agent Swarm
    # ------------------------------------------------------------------

    from .agent_pool import AgentPool, AdaptiveOrchestrator
    from .conversation_store import ConversationStore

    _agent_pool = AgentPool()
    _conversation_store = ConversationStore()

    # ---- Tool 36: Route task to best provider ----

    @mcp.tool()
    def blce_route_task(
        task_type: str,
        prefer_speed: bool = False,
        prefer_cost: bool = False,
    ) -> Dict[str, Any]:
        """Route a BLCE task to the best-scoring provider using heuristic scoring.

        Scores all available providers (deterministic, claude, codex, gemini)
        and returns the recommended provider with full scoring breakdown.

        task_type: Task category (parse, semantic, cross_reference, quality, etc).
        prefer_speed: Bias toward faster providers.
        prefer_cost: Bias toward cheaper providers.
        """
        context = {}
        if prefer_speed:
            context["prefer_speed"] = True
        if prefer_cost:
            context["prefer_cost"] = True

        scores = _agent_pool.score_providers(task_type, context=context)
        recommended = _agent_pool.route_task(task_type, context=context)

        return {
            "task_type": task_type,
            "recommended_provider": recommended,
            "scores": [
                {
                    "provider": s.provider,
                    "score": round(s.score, 3),
                    "reason": s.reason,
                }
                for s in scores
            ],
        }

    # ---- Tool 37: Agent pool statistics ----

    @mcp.tool()
    def blce_agent_pool_stats() -> Dict[str, Any]:
        """Get provider usage statistics from the agent pool.

        Returns success rates, average durations, and task counts
        per provider.
        """
        stats = _agent_pool.get_provider_stats()
        history = _agent_pool.get_task_history()
        return {
            "provider_stats": stats,
            "total_tasks_executed": len(history),
            "task_history": history[-10:],  # last 10
        }

    # ---- Tool 38: List agent conversations ----

    @mcp.tool()
    def blce_conversation_list(
        agent_name: str = "",
    ) -> Dict[str, Any]:
        """List agent conversations from the conversation store.

        Returns conversation metadata (not full turn content).

        agent_name: Filter by agent name (empty = all agents).
        """
        convs = _conversation_store.list_conversations(
            agent_name=agent_name if agent_name else None,
        )
        return {
            "conversation_count": len(convs),
            "conversations": [
                {
                    "conversation_id": c.conversation_id,
                    "agent_name": c.agent_name,
                    "provider": c.provider,
                    "turns": len(c.turns),
                    "success": c.success,
                    "duration_seconds": round(c.duration_seconds, 2),
                    "created_at": c.created_at,
                }
                for c in convs
            ],
        }

    # ---- Tool 39: Conversation detail ----

    @mcp.tool()
    def blce_conversation_detail(
        conversation_id: str,
    ) -> Dict[str, Any]:
        """Get full detail for a single agent conversation.

        Returns all turns, metadata, and timing information.

        conversation_id: The conversation ID to look up.
        """
        conv = _conversation_store.get_conversation(conversation_id)
        if conv is None:
            return {"error": f"Conversation {conversation_id} not found."}
        return conv.model_dump()

    # ------------------------------------------------------------------
    # Phase 14: E2E→BLCE Chaining + Model Alteration
    # ------------------------------------------------------------------

    from .e2e_handoff import E2EHandoffLoader
    from .model_alteration import ModelAlterationEngine
    from .multi_system_merger import MultiSystemModelMerger
    from .client_interaction import (
        SkillContextInjector, QueryBuilder, ReportSuggester, ModelEvolutionTracker,
    )
    from .client_interaction.skill_context_injector import parse_change_description

    _e2e_loader = E2EHandoffLoader()
    _alteration_engine = ModelAlterationEngine()
    _merger = MultiSystemModelMerger()

    # In-memory stores for handoffs and altered models
    _handoff_store: Dict[str, Dict[str, Any]] = {}
    _altered_model_store: Dict[str, Dict[str, Any]] = {}

    # ---- Tool 40: Load E2E handoff ----

    @mcp.tool()
    def blce_e2e_handoff_load(
        e2e_context_json: str = "",
        run_dir: str = "",
    ) -> Dict[str, Any]:
        """Load E2E pipeline outputs into BLCE as a handoff.

        Parses an E2E workflow context dict or run directory and creates
        an E2EHandoff containing table profiles, classifications,
        relationships, and summaries.

        e2e_context_json: JSON string of an E2E workflow context dict.
        run_dir: Path to an E2E run output directory (alternative).
        """
        import json as _json

        if e2e_context_json:
            try:
                ctx = _json.loads(e2e_context_json)
            except _json.JSONDecodeError:
                return {"error": "Invalid JSON in e2e_context_json."}
            handoff = _e2e_loader.load_from_context(ctx)
        elif run_dir:
            handoff = _e2e_loader.load_from_directory(run_dir)
        else:
            return {"error": "Provide e2e_context_json or run_dir."}

        _handoff_store[handoff.handoff_id] = handoff.model_dump()
        summary = _e2e_loader.get_summary(handoff)

        return {
            "handoff_id": handoff.handoff_id,
            "e2e_run_id": handoff.e2e_run_id,
            "summary": summary,
        }

    # ---- Tool 41: Convert E2E handoff to artifacts ----

    @mcp.tool()
    def blce_e2e_to_artifacts(
        handoff_id: str,
    ) -> Dict[str, Any]:
        """Convert an E2E handoff into BLCE LogicArtifacts.

        Each table profile becomes a LogicArtifact with measures from
        numeric columns, dependencies from relationships, and grain from PKs.

        handoff_id: ID of a previously loaded handoff.
        """
        if handoff_id not in _handoff_store:
            return {"error": f"Handoff {handoff_id} not found. Run blce_e2e_handoff_load first."}

        from .contracts import E2EHandoff as E2HO
        handoff = E2HO(**_handoff_store[handoff_id])
        artifacts = _e2e_loader.convert_to_artifacts(handoff)

        # Cache artifacts
        for art in artifacts:
            d = art.model_dump()
            _cache_artifact(d)

        return {
            "handoff_id": handoff_id,
            "artifacts_created": len(artifacts),
            "artifact_ids": [a.artifact_id for a in artifacts],
            "avg_confidence": round(
                sum(a.confidence for a in artifacts) / max(len(artifacts), 1), 3
            ),
        }

    # ---- Tool 42: Apply model alteration ----

    @mcp.tool()
    def blce_model_alter(
        action: str,
        target: str,
        new_value: str,
        old_value: str = "",
        rationale: str = "",
    ) -> Dict[str, Any]:
        """Apply a tracked alteration to the model.

        Supported actions: alter_table_name, alter_column, alter_classification,
        add_relationship, add_business_measure.

        action: Alteration type.
        target: Target table or table.column path.
        new_value: New value to apply.
        old_value: Original value (for renames).
        rationale: Reason for the change.
        """
        if action == "alter_table_name":
            alt = _alteration_engine.alter_table_name(old_value or target, new_value, rationale)
        elif action == "alter_column":
            parts = target.split(".", 1)
            if len(parts) != 2:
                return {"error": "target must be 'table.column' for alter_column"}
            alt = _alteration_engine.alter_column_business_name(parts[0], parts[1], new_value, rationale)
        elif action == "alter_classification":
            parts = target.split(".", 1)
            if len(parts) != 2:
                return {"error": "target must be 'table.column' for alter_classification"}
            alt = _alteration_engine.alter_classification(parts[0], parts[1], new_value, rationale)
        elif action == "add_relationship":
            parts = target.split(".", 1)
            tgt_parts = new_value.split(".", 1)
            if len(parts) != 2 or len(tgt_parts) != 2:
                return {"error": "target and new_value must be 'table.column'"}
            alt = _alteration_engine.add_relationship(parts[0], parts[1], tgt_parts[0], tgt_parts[1], rationale)
        elif action == "add_business_measure":
            alt = _alteration_engine.add_business_measure(target, old_value or "measure", new_value, rationale)
        else:
            return {"error": f"Unknown action '{action}'. Use: alter_table_name, alter_column, alter_classification, add_relationship, add_business_measure"}

        return {
            "alteration_id": alt.alteration_id,
            "action": alt.action,
            "target": alt.target,
            "old_value": alt.old_value,
            "new_value": alt.new_value,
            "rationale": alt.rationale,
            "total_alterations": len(_alteration_engine.get_alterations()),
        }

    # ---- Tool 43: Alteration summary ----

    @mcp.tool()
    def blce_model_alter_summary() -> Dict[str, Any]:
        """Get the alteration history and summary.

        Returns counts by action type and full alteration log.
        """
        summary = _alteration_engine.get_alteration_summary()
        alterations = _alteration_engine.get_alterations()
        return {
            "summary": summary,
            "alterations": [
                {
                    "alteration_id": a.alteration_id,
                    "action": a.action,
                    "target": a.target,
                    "old_value": a.old_value,
                    "new_value": a.new_value,
                    "rationale": a.rationale,
                }
                for a in alterations
            ],
        }

    # ---- Tool 44: Merge multi-system models ----

    @mcp.tool()
    def blce_merge_systems(
        systems_json: str = "",
    ) -> Dict[str, Any]:
        """Merge models from multiple source systems into a unified model.

        Detects conformed dimensions via column-overlap heuristics and
        produces a UnifiedModel with merged dimensions, separate facts,
        and conflict reports.

        systems_json: JSON dict mapping system names to table lists.
                      E.g. {"erp_a": [{"table_name":"DIM_WELL","columns":[...]}], ...}
        """
        import json as _json
        from .contracts import AlteredModel as AM

        if not systems_json:
            # Use already-registered systems in the merger
            if not _merger._source_models:
                return {"error": "No systems registered. Provide systems_json or register via blce_model_alter first."}
        else:
            try:
                systems = _json.loads(systems_json)
            except _json.JSONDecodeError:
                return {"error": "Invalid JSON in systems_json."}

            for sys_name, tables in systems.items():
                model = AM(source_system=sys_name, tables=tables)
                _merger.add_source_system(sys_name, model)

        report = _merger.get_merge_report()
        return report

    # ------------------------------------------------------------------
    # Phase 15: Client Interaction Layer
    # ------------------------------------------------------------------

    _context_injector = SkillContextInjector(_skill_generator, _alteration_engine, _merger)
    _query_builder = QueryBuilder()
    _report_suggester = ReportSuggester()
    _evolution_tracker = ModelEvolutionTracker(_alteration_engine)

    # Vanna RAG client (optional — used by model_query_builder)
    try:
        from src.vanna_client import get_vanna_client as _get_vanna
        _vanna = _get_vanna()
    except Exception:
        _vanna = None

    # ---- Tool 45: Model ask (context assembly) ----

    @mcp.tool()
    def model_ask(
        question: str,
        client_id: str = "",
    ) -> Dict[str, Any]:
        """Assemble rich context for answering a question about the data model.

        Searches skill manifests, measures, xrefs, alterations, and governance
        decisions to build a ModelContext.  The MCP client uses the returned
        context to formulate a natural-language answer.

        question: The user's question about the data model.
        client_id: Client identifier.
        """
        ctx = _context_injector.assemble_context(
            question,
            client_id=client_id,
            skill_store=_skill_store,
            artifact_cache=_artifact_cache,
            governance_store=_governance_store,
        )
        return ctx.model_dump()

    # ---- Tool 46: Model query builder ----

    @mcp.tool()
    def model_query_builder(
        intent: str,
    ) -> Dict[str, Any]:
        """Generate a SQL query from a natural-language intent.

        Uses Vanna RAG when trained (high-confidence results preferred),
        otherwise falls back to deterministic keyword-based resolution from
        BLCE metadata.

        intent: NL description of desired query (e.g. "total revenue by well by month").
        """
        gq = _query_builder.build_query(
            intent,
            artifact_cache=_artifact_cache,
            skill_store=_skill_store,
            vanna_client=_vanna,
        )
        return gq.model_dump()

    # ---- Tool 47: Model suggest report ----

    @mcp.tool()
    def model_suggest_report(
        report_type: str = "",
    ) -> Dict[str, Any]:
        """Suggest report structures from available BLCE metadata.

        If report_type is empty, suggests all feasible report types
        (summary, trend, comparison, detail, kpi_dashboard).

        report_type: Specific report type to suggest, or empty for all.
        """
        suggestions = _report_suggester.suggest(
            report_type,
            artifact_cache=_artifact_cache,
            skill_store=_skill_store,
        )
        return {
            "suggestions_count": len(suggestions),
            "suggestions": [s.model_dump() for s in suggestions],
        }

    # ---- Tool 48: Model propose change ----

    @mcp.tool()
    def model_propose_change(
        description: str,
    ) -> Dict[str, Any]:
        """Parse a natural-language model change description into a structured proposal.

        Supports: rename table, rename column, reclassify column, add relationship,
        add measure.  Returns parsed actions with risk level and impact summary.

        description: Free-text description of desired changes.
        """
        proposal = parse_change_description(description)
        return proposal.model_dump()

    # ---- Tool 49: Model add source system ----

    @mcp.tool()
    def model_add_source_system(
        system_name: str,
        tables_json: str = "",
    ) -> Dict[str, Any]:
        """Register a source system and detect conformed dimensions.

        Adds a source system to the multi-system merger and returns
        any detected conformed dimensions across registered systems.

        system_name: Name of the source system (e.g. "enertia", "wolfepak").
        tables_json: JSON array of table dicts with table_name and columns.
        """
        import json as _json
        from .contracts import AlteredModel as AM

        tables = []
        if tables_json:
            try:
                tables = _json.loads(tables_json)
            except _json.JSONDecodeError:
                return {"error": "Invalid JSON in tables_json."}

        model = AM(source_system=system_name, tables=tables)
        _merger.add_source_system(system_name, model)

        conformed = _merger.detect_conformed_dimensions()
        return {
            "system_name": system_name,
            "tables_registered": len(tables),
            "total_systems": len(_merger._source_models),
            "conformed_dimensions": conformed,
        }

    # ---- Tool 50: Model evolution history ----

    @mcp.tool()
    def model_evolution_history(
        client_id: str = "",
        since: str = "",
    ) -> Dict[str, Any]:
        """Get the model evolution timeline and summary.

        Returns a chronological list of model changes with summary statistics.

        client_id: Client identifier.
        since: ISO-8601 date — only entries on or after this date.
        """
        timeline = _evolution_tracker.get_timeline(client_id=client_id, since=since)
        summary = _evolution_tracker.get_summary(client_id=client_id)
        return {
            "timeline_count": len(timeline),
            "summary": summary,
            "timeline": [e.model_dump() for e in timeline],
        }

    # ------------------------------------------------------------------
    # Phase 16-19: BLCE Enhancement Tools (Intake + Reports + Model + Build)
    # ------------------------------------------------------------------

    # Session stores for new tools
    _intake_store: Dict[str, Dict[str, Any]] = {}
    _report_store: Dict[str, Dict[str, Any]] = {}
    _notes_store: Dict[str, Dict[str, Any]] = {}
    _proposed_model_store: Dict[str, Dict[str, Any]] = {}
    _ddl_store: Dict[str, Dict[str, Any]] = {}
    _pipeline_store: Dict[str, Dict[str, Any]] = {}
    _wizard_store: Dict[str, Dict[str, Any]] = {}
    _reconciliation_store: Dict[str, Dict[str, Any]] = {}

    def _setting(name: str, default: Any) -> Any:
        if isinstance(settings, dict):
            return settings.get(name, default)
        return getattr(settings, name, default)

    from .excel_wizard import ExcelLogicWizard
    _wizard_engine = ExcelLogicWizard(
        gateway_url=_setting("excel_wizard_gateway_url", ""),
        high_confidence_threshold=float(_setting("excel_wizard_high_confidence_threshold", 0.80)),
        review_threshold=float(_setting("excel_wizard_review_threshold", 0.55)),
        copilot_mode=str(_setting("excel_wizard_copilot_mode", "guarded")),
        max_chunks=int(_setting("excel_wizard_max_chunks", 2000)),
    )

    # ---- Tool 51: Intake questionnaire ----

    @mcp.tool()
    def blce_intake_questionnaire(
        handoff_id: str = "",
        e2e_context_json: str = "",
        erp_type: str = "",
        client_id: str = "",
    ) -> Dict[str, Any]:
        """Generate or process a consultant intake questionnaire.

        Auto-populates from E2E handoff data (detected ERP type, table count,
        detected dimensions, quality issues).  Returns the full questionnaire
        with 7 sections (A-G).

        handoff_id: ID of a previously loaded E2E handoff.
        e2e_context_json: Alternative — raw E2E context JSON.
        erp_type: Override ERP type (auto-detected if empty).
        client_id: Client identifier.
        """
        import json as _json
        from .contracts import E2EHandoff as E2HO
        from .intake_generator import IntakeQuestionnaireGenerator

        # Resolve handoff
        if handoff_id and handoff_id in _handoff_store:
            handoff = E2HO(**_handoff_store[handoff_id])
        elif e2e_context_json:
            try:
                ctx = _json.loads(e2e_context_json)
            except _json.JSONDecodeError:
                return {"error": "Invalid JSON in e2e_context_json."}
            handoff = _e2e_loader.load_from_context(ctx)
        else:
            return {"error": "Provide handoff_id or e2e_context_json."}

        gen = IntakeQuestionnaireGenerator()
        q = gen.generate(handoff, erp_type=erp_type, client_id=client_id)
        _intake_store[q.questionnaire_id] = q.model_dump()

        auto_count = sum(
            1
            for qs in q.sections.values()
            for qq in qs
            if qq.auto_populated
        )

        return {
            "questionnaire_id": q.questionnaire_id,
            "erp_type": q.erp_type,
            "sections": len(q.sections),
            "total_questions": sum(len(qs) for qs in q.sections.values()),
            "auto_populated": auto_count,
        }

    # ---- Tool 52: Process report ----

    @mcp.tool()
    def blce_process_report(
        file_path: str,
    ) -> Dict[str, Any]:
        """Extract KPIs, dimensions, and filters from an Excel or PDF report.

        Parses the report using BLCE parsers, then matches against known
        report patterns (LOS, income statement, balance sheet, etc).

        file_path: Path to .xlsx or .pdf file.
        """
        from .report_processor import ReportProcessor

        rp = ReportProcessor()
        analysis = rp.process_report(file_path)
        _report_store[analysis.analysis_id] = analysis.model_dump()

        return {
            "analysis_id": analysis.analysis_id,
            "report_type": analysis.report_type.value,
            "kpis_extracted": len(analysis.kpis),
            "dimensions_extracted": len(analysis.dimensions),
            "filters_extracted": len(analysis.filters),
            "kpis": [{"name": k.name, "formula": k.formula} for k in analysis.kpis],
            "dimensions": [{"name": d.name} for d in analysis.dimensions],
        }

    # ---- Tool 53: Excel wizard analyze ----

    @mcp.tool()
    def blce_excel_wizard_analyze(
        file_path: str,
        prompt_hint: str = "",
        use_copilot: bool = True,
    ) -> Dict[str, Any]:
        """Analyze a complex Excel report with wizard decomposition + suggestions."""
        analysis = _wizard_engine.analyze_workbook(
            file_path,
            prompt_hint=prompt_hint,
            use_copilot=use_copilot,
        )
        _wizard_store[analysis.analysis_id] = analysis.model_dump()

        by_type: Dict[str, int] = {}
        for s in analysis.suggestions:
            by_type[s.suggestion_type] = by_type.get(s.suggestion_type, 0) + 1

        return {
            "analysis_id": analysis.analysis_id,
            "file_path": analysis.file_path,
            "confidence": analysis.confidence,
            "complexity_score": analysis.complexity_score,
            "suggestion_count": len(analysis.suggestions),
            "suggestions_by_type": by_type,
            "warnings": analysis.warnings,
        }

    # ---- Tool 54: Excel wizard query ----

    @mcp.tool()
    def blce_excel_wizard_query(
        analysis_id: str,
        question: str,
    ) -> Dict[str, Any]:
        """Ask NL questions over workbook decomposition with evidence citations."""
        return _wizard_engine.query_analysis(analysis_id, question)

    # ---- Tool 55: Excel wizard list suggestions ----

    @mcp.tool()
    def blce_excel_wizard_list_suggestions(
        analysis_id: str,
        min_confidence: float = 0.0,
        status: str = "all",
    ) -> Dict[str, Any]:
        """List wizard suggestions with optional confidence and status filters."""
        suggestions = _wizard_engine.list_suggestions(
            analysis_id,
            min_confidence=min_confidence,
            status=status,
        )
        return {
            "analysis_id": analysis_id,
            "count": len(suggestions),
            "suggestions": [s.model_dump() for s in suggestions],
        }

    # ---- Tool 56: Excel wizard review suggestion ----

    @mcp.tool()
    def blce_excel_wizard_review_suggestion(
        analysis_id: str,
        suggestion_id: str,
        action: str,
        reviewer: str = "",
        comments: str = "",
    ) -> Dict[str, Any]:
        """Approve, reject, or defer one wizard suggestion."""
        updated = _wizard_engine.review_suggestion(
            analysis_id,
            suggestion_id,
            action=action,
            reviewer=reviewer,
            comments=comments,
        )
        if not updated:
            return {"error": "Suggestion not found or invalid action."}
        return {
            "analysis_id": analysis_id,
            "suggestion_id": updated.suggestion_id,
            "status": updated.status,
            "reviewer": updated.reviewer,
            "review_comment": updated.review_comment,
        }

    # ---- Tool 57: Excel wizard export ----

    @mcp.tool()
    def blce_excel_wizard_export(
        analysis_id: str,
        format: str = "json",
    ) -> Dict[str, Any]:
        """Export wizard analysis payload."""
        analysis = _wizard_engine.get_analysis(analysis_id)
        if not analysis:
            return {"error": f"analysis_not_found:{analysis_id}"}

        payload = analysis.model_dump()
        if format.lower() == "summary":
            return {
                "analysis_id": analysis.analysis_id,
                "file_path": analysis.file_path,
                "confidence": analysis.confidence,
                "suggestion_count": len(analysis.suggestions),
                "warnings": analysis.warnings,
            }
        return payload

    # ---- Tool 58: Excel vs warehouse reconcile ----

    @mcp.tool()
    def blce_excel_vs_warehouse_reconcile(
        excel_values_json: str,
        warehouse_values_json: str,
        tolerance_pct: float = 0.01,
        analysis_id: str = "",
        report_name: str = "",
    ) -> Dict[str, Any]:
        """Compare Excel aggregates against warehouse values and classify mismatches.

        Inputs accept either:
        - JSON object: {"metric_key": value, ...}
        - JSON array: [{"metric_key": "...", "excel_value"/"warehouse_value"/"value": ...}, ...]
        """
        import json as _json
        from .reconciliation import ExcelWarehouseReconciler

        try:
            excel_payload = _json.loads(excel_values_json)
        except Exception as exc:
            return {"error": f"Invalid excel_values_json: {exc}"}

        try:
            warehouse_payload = _json.loads(warehouse_values_json)
        except Exception as exc:
            return {"error": f"Invalid warehouse_values_json: {exc}"}

        reconciler = ExcelWarehouseReconciler()
        rec = reconciler.reconcile(
            excel_values=excel_payload,
            warehouse_values=warehouse_payload,
            tolerance_pct=tolerance_pct,
            analysis_id=analysis_id,
            report_name=report_name,
        )
        _reconciliation_store[rec.reconciliation_id] = rec.model_dump()

        return {
            "reconciliation_id": rec.reconciliation_id,
            "analysis_id": rec.analysis_id,
            "report_name": rec.report_name,
            "tolerance_pct": rec.tolerance_pct,
            "compared_count": rec.compared_count,
            "matched_count": rec.matched_count,
            "discrepancy_count": rec.discrepancy_count,
            "missing_in_warehouse_count": rec.missing_in_warehouse_count,
            "extra_in_warehouse_count": rec.extra_in_warehouse_count,
            "classifications": rec.classifications,
            "results": [r.model_dump() for r in rec.results],
        }

    # ---- Tool 59: Discrepancy resolver ----

    @mcp.tool()
    def blce_discrepancy_resolver(
        reconciliation_id: str = "",
        reconciliation_json: str = "",
        include_row_details: bool = True,
    ) -> Dict[str, Any]:
        """Build a programmatic remediation plan from classified reconciliation output."""
        import json as _json
        from .contracts import ExcelWarehouseReconciliation
        from .reconciliation import build_resolution_plan

        rec = None
        if reconciliation_id:
            payload = _reconciliation_store.get(reconciliation_id)
            if not payload:
                return {"error": f"reconciliation_id not found: {reconciliation_id}"}
            rec = ExcelWarehouseReconciliation(**payload)
        elif reconciliation_json:
            try:
                rec = ExcelWarehouseReconciliation(**_json.loads(reconciliation_json))
            except Exception as exc:
                return {"error": f"Invalid reconciliation_json: {exc}"}
        else:
            return {"error": "Provide reconciliation_id or reconciliation_json."}

        plan = build_resolution_plan(rec, include_row_details=include_row_details)
        return {
            "reconciliation_id": rec.reconciliation_id,
            "classification_summary": rec.classifications,
            "plan": plan,
        }

    # ---- Tool 60: Live warehouse reconcile from SQL metric queries ----

    @mcp.tool()
    def blce_excel_vs_warehouse_reconcile_live(
        excel_values_json: str,
        metric_queries_json: str,
        connection_name: str = "default",
        database: str = "",
        schema: str = "",
        tolerance_pct: float = 0.01,
        analysis_id: str = "",
        report_name: str = "",
        dry_run: bool = False,
    ) -> Dict[str, Any]:
        """Run warehouse metric SQL queries, then reconcile against Excel values.

        metric_queries_json format:
        - Object: {"Net Revenue": "SELECT ...", "LOE": "SELECT ..."}
        - Array: [{"metric_key":"Net Revenue","sql":"SELECT ..."}, ...]
        """
        import json as _json
        from .reconciliation import ExcelWarehouseReconciler

        try:
            excel_payload = _json.loads(excel_values_json)
        except Exception as exc:
            return {"error": f"Invalid excel_values_json: {exc}"}

        try:
            mq_payload = _json.loads(metric_queries_json)
        except Exception as exc:
            return {"error": f"Invalid metric_queries_json: {exc}"}

        metric_queries: Dict[str, str] = {}
        if isinstance(mq_payload, dict):
            metric_queries = {
                str(k): str(v) for k, v in mq_payload.items()
                if str(k).strip() and str(v).strip()
            }
        elif isinstance(mq_payload, list):
            for row in mq_payload:
                if not isinstance(row, dict):
                    continue
                key = str(row.get("metric_key") or row.get("metric") or row.get("name") or "").strip()
                sql = str(row.get("sql") or "").strip()
                if key and sql:
                    metric_queries[key] = sql
        else:
            return {"error": "metric_queries_json must be a JSON object or array."}

        if not metric_queries:
            return {"error": "No metric queries found in metric_queries_json."}

        # Validate SQL safety: SELECT only, single statement.
        sql_errors = []
        for metric_key, sql in metric_queries.items():
            s = sql.strip().rstrip(";")
            if not s.lower().startswith("select"):
                sql_errors.append(f"{metric_key}: only SELECT statements are allowed")
            if ";" in s:
                sql_errors.append(f"{metric_key}: multiple statements are not allowed")
        if sql_errors:
            return {"error": "Invalid metric SQL", "details": sql_errors}

        warehouse_values: Dict[str, float] = {}
        query_results: List[Dict[str, Any]] = []

        if dry_run:
            query_results = [
                {"metric_key": k, "status": "dry_run", "sql": q}
                for k, q in metric_queries.items()
            ]
            return {
                "status": "dry_run",
                "queries_validated": len(metric_queries),
                "query_results": query_results,
            }

        try:
            from src.data_modeling.snowflake_pool import sf_pool
            conn = sf_pool.get(
                connection_name,
                database=database,
                schema=schema,
            )
        except Exception as exc:
            return {"error": f"Snowflake connection failed: {exc}"}

        for metric_key, sql in metric_queries.items():
            try:
                cur = conn.cursor()
                cur.execute(sql)
                row = cur.fetchone()
                value = row[0] if row else None
                fval = float(value) if value is not None else None
                if fval is not None:
                    warehouse_values[metric_key] = fval
                query_results.append({
                    "metric_key": metric_key,
                    "status": "ok",
                    "value": fval,
                    "sql": sql,
                })
            except Exception as exc:
                query_results.append({
                    "metric_key": metric_key,
                    "status": "error",
                    "error": str(exc),
                    "sql": sql,
                })
            finally:
                try:
                    cur.close()
                except Exception:
                    pass

        reconciler = ExcelWarehouseReconciler()
        rec = reconciler.reconcile(
            excel_values=excel_payload,
            warehouse_values=warehouse_values,
            tolerance_pct=tolerance_pct,
            analysis_id=analysis_id,
            report_name=report_name,
        )
        _reconciliation_store[rec.reconciliation_id] = rec.model_dump()

        return {
            "reconciliation_id": rec.reconciliation_id,
            "analysis_id": rec.analysis_id,
            "report_name": rec.report_name,
            "query_count": len(metric_queries),
            "query_results": query_results,
            "warehouse_values": warehouse_values,
            "compared_count": rec.compared_count,
            "matched_count": rec.matched_count,
            "discrepancy_count": rec.discrepancy_count,
            "missing_in_warehouse_count": rec.missing_in_warehouse_count,
            "extra_in_warehouse_count": rec.extra_in_warehouse_count,
            "classifications": rec.classifications,
            "results": [r.model_dump() for r in rec.results],
        }

    # ---- Tool 61: Auto-build metric SQL from wizard suggestions + reconcile ----

    @mcp.tool()
    def blce_excel_wizard_reconcile_auto(
        analysis_id: str,
        excel_values_json: str,
        source_table: str,
        metric_expression_overrides_json: str = "",
        where_clause: str = "",
        connection_name: str = "default",
        database: str = "",
        schema: str = "",
        tolerance_pct: float = 0.01,
        include_medium_confidence: bool = False,
        dry_run: bool = False,
    ) -> Dict[str, Any]:
        """Auto-generate metric SQL from wizard KPI suggestions, then reconcile live.

        Uses approved suggestions by default, plus high-confidence suggestions.
        Optionally includes medium-confidence suggestions.
        """
        import json as _json
        import re as _re

        if not source_table.strip():
            return {"error": "source_table is required."}

        analysis = _wizard_engine.get_analysis(analysis_id)
        if not analysis:
            return {"error": f"analysis_not_found:{analysis_id}"}

        overrides: Dict[str, str] = {}
        if metric_expression_overrides_json.strip():
            try:
                raw = _json.loads(metric_expression_overrides_json)
                if isinstance(raw, dict):
                    overrides = {str(k): str(v) for k, v in raw.items() if str(k).strip() and str(v).strip()}
                else:
                    return {"error": "metric_expression_overrides_json must be a JSON object."}
            except Exception as exc:
                return {"error": f"Invalid metric_expression_overrides_json: {exc}"}

        def _infer_expr(formula: str, metric_key: str) -> str:
            if not formula:
                return ""
            # Basic SUM/AVG/MIN/MAX/COUNT extractor for SQL-like formulas.
            m = _re.search(
                r"(SUM|AVG|MIN|MAX|COUNT)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)",
                formula,
                flags=_re.IGNORECASE,
            )
            if m:
                return f"{m.group(1).upper()}({m.group(2)})"
            # If no direct SQL-like function, skip and require override.
            return ""

        selected = []
        for s in analysis.suggestions:
            if s.suggestion_type != "kpi_definition":
                continue
            if s.status == "approved":
                selected.append(s)
                continue
            if s.confidence >= float(_setting("excel_wizard_high_confidence_threshold", 0.80)):
                selected.append(s)
                continue
            if include_medium_confidence and s.confidence >= float(_setting("excel_wizard_review_threshold", 0.55)):
                selected.append(s)

        metric_queries: Dict[str, str] = {}
        skipped: List[Dict[str, str]] = []
        for s in selected:
            payload = s.proposed_payload or {}
            metric_key = str(payload.get("name") or s.title or s.suggestion_id).strip()
            expr = overrides.get(metric_key, "")
            if not expr:
                expr = _infer_expr(str(payload.get("formula", "")), metric_key)
            if not expr:
                skipped.append({
                    "metric_key": metric_key,
                    "reason": "No SQL expression inferred; provide override.",
                })
                continue
            sql = f"SELECT {expr} AS value FROM {source_table}"
            if where_clause.strip():
                sql += f" WHERE {where_clause.strip()}"
            metric_queries[metric_key] = sql

        if not metric_queries:
            return {
                "error": "No metric SQL generated from suggestions.",
                "skipped": skipped,
            }

        reconcile_live = blce_excel_vs_warehouse_reconcile_live
        result = reconcile_live(
            excel_values_json=excel_values_json,
            metric_queries_json=_json.dumps(metric_queries),
            connection_name=connection_name,
            database=database,
            schema=schema,
            tolerance_pct=tolerance_pct,
            analysis_id=analysis_id,
            report_name=analysis.workbook_name,
            dry_run=dry_run,
        )
        result["generated_metric_queries"] = metric_queries
        result["skipped_metrics"] = skipped
        result["selected_suggestions"] = len(selected)
        return result

    # ---- Tool 62: Process meeting notes ----

    @mcp.tool()
    def blce_process_meeting_notes(
        text: str = "",
        file_path: str = "",
    ) -> Dict[str, Any]:
        """Extract KPIs, dimensions, filters, and business rules from meeting notes.

        Uses regex-based NL extraction to find patterns like 'revenue by well
        by month excluding plugged wells'.

        text: Meeting notes text content.
        file_path: Alternative — path to a text file.
        """
        from .meeting_notes_processor import MeetingNotesProcessor

        if file_path and not text:
            import pathlib
            try:
                text = pathlib.Path(file_path).read_text(encoding="utf-8")
            except Exception as exc:
                return {"error": f"Cannot read file: {exc}"}

        if not text:
            return {"error": "Provide text or file_path."}

        mnp = MeetingNotesProcessor()
        analysis = mnp.process_notes(text, file_path=file_path)
        _notes_store[analysis.analysis_id] = analysis.model_dump()

        return {
            "analysis_id": analysis.analysis_id,
            "kpis_extracted": len(analysis.kpis),
            "dimensions_extracted": len(analysis.dimensions),
            "filters_extracted": len(analysis.filters),
            "business_rules": len(analysis.business_rules),
            "open_questions": len(analysis.open_questions),
            "kpis": [{"name": k.name, "formula": k.formula} for k in analysis.kpis],
            "dimensions": [{"name": d.name} for d in analysis.dimensions],
            "filters": [{"description": f.description} for f in analysis.filters],
        }

    # ---- Tool 54: Department mapper ----

    @mcp.tool()
    def blce_department_mapper(
        departments: str,
    ) -> Dict[str, Any]:
        """Map organizational departments to data domains.

        Uses heuristic mapping (Finance→revenue/expense/gl,
        Operations→production/well, etc.) to determine which data domains
        each department needs access to.

        departments: Comma-separated department names.
        """
        from .department_mapper import DepartmentMapper

        dept_list = [d.strip() for d in departments.split(",") if d.strip()]
        if not dept_list:
            return {"error": "Provide comma-separated department names."}

        mapper = DepartmentMapper()
        mappings = mapper.map_departments(dept_list)

        return {
            "departments_mapped": len(mappings),
            "mappings": [
                {
                    "department": m.department,
                    "data_domains": m.data_domains,
                }
                for m in mappings
            ],
        }

    # ---- Tool 55: Propose dimensions ----

    @mcp.tool()
    def blce_propose_dimensions(
        handoff_id: str = "",
        erp_type: str = "",
    ) -> Dict[str, Any]:
        """Recommend conformed Kimball dimensions from E2E data and intake.

        Merges dimension candidates from E2E relationships, report analyses,
        meeting notes, and intake questionnaires.

        handoff_id: ID of a previously loaded E2E handoff.
        erp_type: ERP type for template lookup.
        """
        from .contracts import E2EHandoff as E2HO, ReportAnalysis, MeetingNotesAnalysis
        from .model_proposer import KimballModelProposer

        if handoff_id and handoff_id in _handoff_store:
            handoff = E2HO(**_handoff_store[handoff_id])
        else:
            return {"error": "Provide a valid handoff_id. Run blce_e2e_handoff_load first."}

        report_analyses = [ReportAnalysis(**v) for v in _report_store.values()]
        notes_analyses = [MeetingNotesAnalysis(**v) for v in _notes_store.values()]

        proposer = KimballModelProposer()
        model = proposer.propose_model(
            handoff=handoff,
            report_analyses=report_analyses,
            notes_analyses=notes_analyses,
            erp_type=erp_type,
        )
        template_meta = proposer.get_last_template_resolution()

        return {
            "dimensions_proposed": len(model.dimensions),
            "erp_type_input": template_meta.get("erp_type_input", erp_type),
            "erp_type_resolved": template_meta.get("erp_type_resolved", ""),
            "template_used": template_meta.get("template_used", False),
            "template_resolution": template_meta.get("template_resolution", "none"),
            "dimensions": [
                {
                    "name": d.name,
                    "business_name": d.business_name,
                    "source_tables": d.source_tables,
                    "scd_type": d.scd_type,
                    "confidence": d.confidence,
                    "rationale": d.rationale,
                }
                for d in model.dimensions
            ],
        }

    # ---- Tool 56: Propose facts ----

    @mcp.tool()
    def blce_propose_facts(
        handoff_id: str = "",
        erp_type: str = "",
    ) -> Dict[str, Any]:
        """Recommend Kimball fact tables from E2E data, reports, and notes.

        Identifies transaction tables (high row count + dates + measures)
        and KPIs that need dedicated fact tables.

        handoff_id: ID of a previously loaded E2E handoff.
        erp_type: ERP type for template lookup.
        """
        from .contracts import E2EHandoff as E2HO, ReportAnalysis, MeetingNotesAnalysis
        from .model_proposer import KimballModelProposer

        if handoff_id and handoff_id in _handoff_store:
            handoff = E2HO(**_handoff_store[handoff_id])
        else:
            return {"error": "Provide a valid handoff_id."}

        report_analyses = [ReportAnalysis(**v) for v in _report_store.values()]
        notes_analyses = [MeetingNotesAnalysis(**v) for v in _notes_store.values()]

        proposer = KimballModelProposer()
        model = proposer.propose_model(
            handoff=handoff,
            report_analyses=report_analyses,
            notes_analyses=notes_analyses,
            erp_type=erp_type,
        )
        template_meta = proposer.get_last_template_resolution()

        return {
            "facts_proposed": len(model.facts),
            "erp_type_input": template_meta.get("erp_type_input", erp_type),
            "erp_type_resolved": template_meta.get("erp_type_resolved", ""),
            "template_used": template_meta.get("template_used", False),
            "template_resolution": template_meta.get("template_resolution", "none"),
            "facts": [
                {
                    "name": f.name,
                    "business_name": f.business_name,
                    "grain": f.grain,
                    "measures": f.measures[:10],
                    "dimension_fks": f.dimension_fks,
                    "needs_union": f.needs_union,
                    "confidence": f.confidence,
                    "rationale": f.rationale,
                }
                for f in model.facts
            ],
        }

    # ---- Tool 57: Generate bus matrix from proposed model ----

    @mcp.tool()
    def blce_generate_bus_matrix(
        handoff_id: str = "",
        erp_type: str = "",
    ) -> Dict[str, Any]:
        """Generate a proposed Kimball bus matrix from E2E data.

        Shows which dimensions participate in which fact tables,
        highlights conformed dimensions shared across multiple facts.

        handoff_id: ID of a previously loaded E2E handoff.
        erp_type: ERP type for template lookup.
        """
        from .contracts import E2EHandoff as E2HO, ReportAnalysis, MeetingNotesAnalysis
        from .model_proposer import KimballModelProposer

        if handoff_id and handoff_id in _handoff_store:
            handoff = E2HO(**_handoff_store[handoff_id])
        else:
            return {"error": "Provide a valid handoff_id."}

        report_analyses = [ReportAnalysis(**v) for v in _report_store.values()]
        notes_analyses = [MeetingNotesAnalysis(**v) for v in _notes_store.values()]

        proposer = KimballModelProposer()
        model = proposer.propose_model(
            handoff=handoff,
            report_analyses=report_analyses,
            notes_analyses=notes_analyses,
            erp_type=erp_type,
        )
        template_meta = proposer.get_last_template_resolution()

        _proposed_model_store[model.model_id] = model.model_dump()

        return {
            "model_id": model.model_id,
            "erp_type_input": template_meta.get("erp_type_input", erp_type),
            "erp_type_resolved": template_meta.get("erp_type_resolved", ""),
            "template_used": template_meta.get("template_used", False),
            "template_resolution": template_meta.get("template_resolution", "none"),
            "dimensions": len(model.dimensions),
            "facts": len(model.facts),
            "bus_matrix": model.bus_matrix,
            "conformed_dimensions": model.bus_matrix.get("conformed_dimensions", []),
            "measure_dictionary_size": len(model.measure_dictionary),
        }

    # ---- Tool 58: Resolve conflicts ----

    @mcp.tool()
    def blce_resolve_conflicts(
        model_id: str = "",
    ) -> Dict[str, Any]:
        """Detect and resolve measure definition conflicts in a proposed model.

        Finds measures with different formulas across sources and proposes
        resolutions: unified (merge), separate (keep both), or escalated.

        model_id: ID of a previously generated model (from blce_generate_bus_matrix).
        """
        from .contracts import ProposedModel, ExtractedKPI, ReportAnalysis, MeetingNotesAnalysis
        from .conflict_resolver import ConflictResolver

        # Collect all KPIs
        all_kpis = []
        for ra_dict in _report_store.values():
            ra = ReportAnalysis(**ra_dict)
            all_kpis.extend(ra.kpis)
        for na_dict in _notes_store.values():
            na = MeetingNotesAnalysis(**na_dict)
            all_kpis.extend(na.kpis)

        if not all_kpis:
            return {"conflicts": 0, "message": "No KPIs in session to check for conflicts."}

        resolver = ConflictResolver()
        conflicts = resolver.detect_conflicts(all_kpis)
        summary = resolver.resolve_all(conflicts)

        # Update stored model if exists
        if model_id and model_id in _proposed_model_store:
            _proposed_model_store[model_id]["conflicts"] = [
                c.model_dump() for c in conflicts
            ]

        return {
            "summary": summary,
            "conflicts": [
                {
                    "measure_name": c.measure_name,
                    "definitions_count": len(c.definitions),
                    "resolution_type": c.resolution_type,
                    "resolution": c.resolution,
                    "status": c.status,
                }
                for c in conflicts
            ],
        }

    # ---- Tool 59: Generate DDL ----

    @mcp.tool()
    def blce_generate_ddl(
        model_id: str = "",
        schema: str = "EDW",
    ) -> Dict[str, Any]:
        """Generate Snowflake CREATE TABLE DDL from a proposed model.

        Produces DDL for all dimensions (with surrogate keys, SCD2 columns)
        and all facts (with FK columns, measures, metadata columns).

        model_id: ID from blce_generate_bus_matrix.
        schema: Target Snowflake schema (default: EDW).
        """
        from .contracts import ProposedModel
        from .ddl_generator import DDLGenerator

        if model_id not in _proposed_model_store:
            return {"error": f"Model {model_id} not found. Run blce_generate_bus_matrix first."}

        model = ProposedModel(**_proposed_model_store[model_id])
        gen = DDLGenerator()
        ddls = gen.generate_all(model, schema)

        for d in ddls:
            _ddl_store[d.ddl_id] = d.model_dump()

        return {
            "ddl_count": len(ddls),
            "objects": [
                {
                    "ddl_id": d.ddl_id,
                    "name": d.object_name,
                    "type": d.object_type,
                    "sql_length": len(d.ddl_sql),
                    "sql_preview": d.ddl_sql[:200] + "..." if len(d.ddl_sql) > 200 else d.ddl_sql,
                }
                for d in ddls
            ],
        }

    # ---- Tool 60: Generate Wright pipeline ----

    @mcp.tool()
    def blce_generate_wright_pipeline(
        model_id: str = "",
        fact_name: str = "",
        schema: str = "EDW",
    ) -> Dict[str, Any]:
        """Generate Wright 4-object pipeline SQL for a fact table.

        Produces VW_1 (staging), DT_2 (joins), DT_3A (pre-agg), DT_3 (presentation).
        If fact_name is empty, generates pipelines for all facts.

        model_id: ID from blce_generate_bus_matrix.
        fact_name: Specific fact table (or empty for all).
        schema: Target Snowflake schema.
        """
        from .contracts import ProposedModel
        from .wright_pipeline_builder import WrightPipelineBuilder

        if model_id not in _proposed_model_store:
            return {"error": f"Model {model_id} not found."}

        model = ProposedModel(**_proposed_model_store[model_id])
        builder = WrightPipelineBuilder()

        if fact_name:
            target = [f for f in model.facts if f.name == fact_name]
            if not target:
                return {
                    "error": f"Fact '{fact_name}' not found.",
                    "available_facts": [f.name for f in model.facts],
                }
            facts = target
        else:
            facts = model.facts

        pipelines = builder.build_all(facts, model.dimensions, schema)
        for p in pipelines:
            _pipeline_store[p.pipeline_id] = p.model_dump()

        return {
            "pipelines_generated": len(pipelines),
            "pipelines": [
                {
                    "pipeline_id": p.pipeline_id,
                    "fact_name": p.fact_name,
                    "has_union": bool(p.union_template),
                    "objects": ["VW_1", "DT_2", "DT_3A", "DT_3"]
                              + (["UNION"] if p.union_template else []),
                }
                for p in pipelines
            ],
        }

    # ---- Tool 61: Generate UNION ALL template ----

    @mcp.tool()
    def blce_generate_union_template(
        pipeline_id: str,
    ) -> Dict[str, Any]:
        """Get the UNION ALL template SQL for a multi-source Wright pipeline.

        Only available for facts flagged with needs_union=True.

        pipeline_id: ID from blce_generate_wright_pipeline.
        """
        if pipeline_id not in _pipeline_store:
            return {"error": f"Pipeline {pipeline_id} not found."}

        p = _pipeline_store[pipeline_id]
        template = p.get("union_template", "")

        if not template:
            return {
                "pipeline_id": pipeline_id,
                "fact_name": p.get("fact_name", ""),
                "has_union": False,
                "message": "This fact does not require UNION ALL.",
            }

        return {
            "pipeline_id": pipeline_id,
            "fact_name": p.get("fact_name", ""),
            "union_sql": template,
        }

    # ---- Tool 62: Generate semantic layer ----

    @mcp.tool()
    def blce_generate_semantic_layer(
        model_id: str = "",
    ) -> Dict[str, Any]:
        """Generate semantic layer definitions from a proposed model.

        Produces Cortex Analyst-compatible dimension and measure entries
        for all model objects.

        model_id: ID from blce_generate_bus_matrix.
        """
        from .contracts import ProposedModel
        from .semantic_layer_builder import SemanticLayerBuilder

        if model_id not in _proposed_model_store:
            return {"error": f"Model {model_id} not found."}

        model = ProposedModel(**_proposed_model_store[model_id])
        builder = SemanticLayerBuilder()
        entries = builder.build_semantic_layer(model)
        cortex = builder.to_cortex_config(entries)

        return {
            "entries": len(entries),
            "dimensions": len(cortex.get("dimensions", [])),
            "measures": len(cortex.get("measures", [])),
            "time_dimensions": len(cortex.get("time_dimensions", [])),
            "cortex_config": cortex,
        }

    # ---- Tool 63: Analyze tables (standalone heuristic analysis) ----

    @mcp.tool()
    def blce_analyze_tables(
        table_names: str,
        erp_type: str = "",
    ) -> Dict[str, Any]:
        """Analyze tables using heuristic classification without running the full E2E pipeline.

        Detects ERP type, infers business domains, classifies table roles
        (transaction/reference/bridge/config), finds natural keys, and
        generates plain-English purpose summaries.

        table_names: Comma-separated table names (e.g. "GL_JOURNAL,DIM_WELL,FACT_REVENUE").
        erp_type: Optional ERP type override (auto-detected if empty).
        """
        from src.data_modeling.table_analyzer import TableAnalyzer

        names = [t.strip() for t in table_names.split(",") if t.strip()]
        if not names:
            return {"error": "No table names provided."}

        analyzer = TableAnalyzer(erp_type=erp_type or None)
        result = analyzer.analyze_all(names)

        # Summarise role counts
        role_counts = {}
        for cls in result.classification.values():
            role_counts[cls] = role_counts.get(cls, 0) + 1

        return {
            "tables_analyzed": len(names),
            "erp_type": result.erp_type,
            "role_counts": role_counts,
            "classification": result.classification,
            "domain_groups": result.domain_groups,
            "table_analyses": result.table_analyses,
            "natural_keys": result.natural_keys,
            "purpose_summaries": result.purpose_summaries,
        }

    # ---- Tool 64: Map report to model ----

    @mcp.tool()
    def blce_map_report_to_model(
        model_id: str = "",
        threshold: float = 0.15,
    ) -> Dict[str, Any]:
        """Map extracted report KPIs and dimensions to proposed model objects.

        Scores each extracted KPI against proposed facts and each extracted
        dimension against proposed dimensions. Returns a mapping report with
        confidence scores and unmapped items.

        model_id: ID from blce_generate_bus_matrix.
        threshold: Minimum confidence to count as a mapping (0.0-1.0).
        """
        from .contracts import ProposedModel, ReportAnalysis
        from .report_model_mapper import map_report_to_model

        if model_id not in _proposed_model_store:
            return {"error": f"Model {model_id} not found."}

        model = ProposedModel(**_proposed_model_store[model_id])
        report_analyses = [ReportAnalysis(**v) for v in _report_store.values()]

        if not report_analyses:
            return {"error": "No report analyses loaded. Use blce_process_report first."}

        mapping = map_report_to_model(report_analyses, model, threshold=threshold)

        return {
            "report_id": mapping.report_id,
            "kpi_mappings": [m.model_dump() for m in mapping.kpi_mappings],
            "dimension_mappings": [m.model_dump() for m in mapping.dimension_mappings],
            "unmapped_kpis": mapping.unmapped_kpis,
            "unmapped_dims": mapping.unmapped_dims,
            "coverage_score": mapping.coverage_score,
            "total_kpis": len(mapping.kpi_mappings) + len(mapping.unmapped_kpis),
            "total_dims": len(mapping.dimension_mappings) + len(mapping.unmapped_dims),
        }

    # ---- Tool 65: Detect hierarchies ----

    @mcp.tool()
    def blce_detect_hierarchies(
        table_columns_json: str = "",
    ) -> Dict[str, Any]:
        """Detect hierarchy structures in table columns.

        Finds self-referential FKs, LEVEL_N columns, naming patterns
        (*_GROUP, *_CATEGORY, *_TYPE, *_CLASS), and related table chains.

        table_columns_json: JSON string of {table_name: [col1, col2, ...]}.
        """
        import json
        from src.data_modeling.hierarchy_detector import detect_hierarchies

        if not table_columns_json:
            return {"error": "Provide table_columns_json."}

        try:
            table_columns = json.loads(table_columns_json)
        except json.JSONDecodeError as e:
            return {"error": f"Invalid JSON: {e}"}

        hierarchies = detect_hierarchies(table_columns=table_columns)

        return {
            "tables_with_hierarchies": len(hierarchies),
            "hierarchies": hierarchies,
        }

    # ------------------------------------------------------------------
    # Phase P3: Polish & Integration (tools 66-70)
    # ------------------------------------------------------------------

    # ---- Tool 66: Generate proposal document ----

    @mcp.tool()
    def blce_generate_proposal(
        model_id: str = "",
        client_name: str = "",
        include_quality: bool = True,
        include_timeline: bool = False,
    ) -> Dict[str, Any]:
        """Generate a branded HTML proposal document from a proposed model.

        Produces a client-facing document with Executive Summary, Proposed
        Dimensions, Proposed Facts, Bus Matrix, Measure Validations, Business
        Rules, and Design Rationale sections.

        model_id: ID from blce_generate_bus_matrix.
        client_name: Client name for branding (e.g. "Aethon Energy").
        include_quality: Include measure validation section (default True).
        include_timeline: Include phase timeline section (default False).
        """
        from .contracts import ProposedModel
        from .proposal_generator import ProposalGenerator

        if model_id not in _proposed_model_store:
            return {"error": f"Model {model_id} not found. Run blce_generate_bus_matrix first."}

        model = ProposedModel(**_proposed_model_store[model_id])
        gen = ProposalGenerator()
        path = gen.generate_proposal(
            model, client_name=client_name, run_id=model_id,
        )

        return {
            "path": str(path),
            "sections": 7 if include_quality else 6,
            "dimensions": len(model.dimensions),
            "facts": len(model.facts),
        }

    # ---- Tool 67: Deploy semantic layer to BI tool ----

    @mcp.tool()
    def blce_deploy_semantic_layer(
        model_id: str = "",
        format: str = "cortex",
        database: str = "",
        schema: str = "",
        connection_name: str = "",
        model_name: str = "",
    ) -> Dict[str, Any]:
        """Export the semantic layer to a BI tool format.

        Supports Cortex Analyst YAML, Tableau TDS, and Looker LookML.

        model_id: ID from blce_generate_bus_matrix.
        format: Export format — "cortex", "tableau", or "looker".
        database: Snowflake database (for cortex).
        schema: Snowflake schema (for cortex/tableau).
        connection_name: Connection name (for tableau/looker).
        model_name: Semantic model name (for cortex).
        """
        import os
        from .contracts import ProposedModel
        from .semantic_layer_builder import SemanticLayerBuilder

        if model_id not in _proposed_model_store:
            return {"error": f"Model {model_id} not found."}

        model = ProposedModel(**_proposed_model_store[model_id])
        builder = SemanticLayerBuilder()

        kwargs: Dict[str, str] = {}
        if format == "cortex":
            if model_name:
                kwargs["model_name"] = model_name
            if database:
                kwargs["database"] = database
            if schema:
                kwargs["schema"] = schema
        elif format == "tableau":
            if connection_name:
                kwargs["connection_name"] = connection_name
            if schema:
                kwargs["schema"] = schema
        elif format == "looker":
            if connection_name:
                kwargs["connection_name"] = connection_name

        output = builder.export_all(model, format=format, **kwargs)

        # Write to file
        ext_map = {"cortex": ".yaml", "tableau": ".tds", "looker": ".lkml"}
        ext = ext_map.get(format, ".txt")
        out_dir = os.path.join("data", "artifacts", "semantic")
        os.makedirs(out_dir, exist_ok=True)
        out_path = os.path.join(out_dir, f"{model_id}_{format}{ext}")

        with open(out_path, "w", encoding="utf-8") as f:
            f.write(output)

        entries = builder.build_semantic_layer(model)
        dims = sum(1 for e in entries if e.entry_type == "dimension")
        measures = sum(1 for e in entries if e.entry_type == "measure")

        return {
            "format": format,
            "path": out_path,
            "tables": len({e.table for e in entries if e.table}),
            "measures": measures,
            "dimensions": dims,
        }

    # ---- Tool 68: Review model dashboard ----

    @mcp.tool()
    def blce_review_model(
        model_id: str = "",
    ) -> Dict[str, Any]:
        """Show review dashboard for a proposed model.

        Lists all dimensions and facts with their current review status
        (proposed/reviewed/approved/rejected/built) and pending items.

        model_id: ID from blce_generate_bus_matrix.
        """
        from .contracts import ProposedModel
        from .review_workflow import ReviewWorkflow

        if model_id not in _proposed_model_store:
            return {"error": f"Model {model_id} not found."}

        model = ProposedModel(**_proposed_model_store[model_id])
        wf = ReviewWorkflow()
        summary = wf.review_model(model)

        # Add full item list
        items = []
        for d in model.dimensions:
            items.append({
                "type": "dimension", "id": d.dimension_id,
                "name": d.name, "status": d.review_status,
                "confidence": d.confidence,
            })
        for f in model.facts:
            items.append({
                "type": "fact", "id": f.fact_id,
                "name": f.name, "status": f.review_status,
                "confidence": f.confidence,
            })

        summary["items"] = items
        return summary

    # ---- Tool 69: Approve dimension ----

    @mcp.tool()
    def blce_approve_dimension(
        model_id: str = "",
        dimension_id: str = "",
        reviewer: str = "",
        comments: str = "",
    ) -> Dict[str, Any]:
        """Approve a proposed dimension for DDL generation.

        Transitions the dimension from proposed/reviewed to approved.
        Records an audit entry in the model's review log.

        model_id: ID from blce_generate_bus_matrix.
        dimension_id: The dimension_id to approve.
        reviewer: Name of the reviewer.
        comments: Optional approval comments.
        """
        from .contracts import ProposedModel
        from .review_workflow import ReviewWorkflow

        if model_id not in _proposed_model_store:
            return {"error": f"Model {model_id} not found."}

        model = ProposedModel(**_proposed_model_store[model_id])
        wf = ReviewWorkflow()

        try:
            entry = wf.approve_dimension(model, dimension_id, reviewer, comments)
        except ValueError as e:
            return {"error": str(e)}

        # Persist updated model back
        _proposed_model_store[model_id] = model.model_dump()

        return {
            "review_id": entry.review_id,
            "object_type": entry.object_type,
            "object_name": entry.object_name,
            "status": entry.status.value,
            "reviewer": entry.reviewer,
        }

    # ---- Tool 70: Approve fact ----

    @mcp.tool()
    def blce_approve_fact(
        model_id: str = "",
        fact_id: str = "",
        reviewer: str = "",
        comments: str = "",
    ) -> Dict[str, Any]:
        """Approve a proposed fact table for DDL generation.

        Transitions the fact from proposed/reviewed to approved.
        Records an audit entry in the model's review log.

        model_id: ID from blce_generate_bus_matrix.
        fact_id: The fact_id to approve.
        reviewer: Name of the reviewer.
        comments: Optional approval comments.
        """
        from .contracts import ProposedModel
        from .review_workflow import ReviewWorkflow

        if model_id not in _proposed_model_store:
            return {"error": f"Model {model_id} not found."}

        model = ProposedModel(**_proposed_model_store[model_id])
        wf = ReviewWorkflow()

        try:
            entry = wf.approve_fact(model, fact_id, reviewer, comments)
        except ValueError as e:
            return {"error": str(e)}

        _proposed_model_store[model_id] = model.model_dump()

        return {
            "review_id": entry.review_id,
            "object_type": entry.object_type,
            "object_name": entry.object_name,
            "status": entry.status.value,
            "reviewer": entry.reviewer,
        }

    # ---- Tool 71: Execute DDL on Snowflake ----

    _deployment_store: Dict[str, Dict[str, Any]] = {}

    @mcp.tool()
    def blce_execute_ddl(
        ddl_ids: str = "",
        account: str = "",
        database: str = "",
        schema: str = "",
        dry_run: bool = True,
    ) -> Dict[str, Any]:
        """Execute generated DDL statements on Snowflake.

        Runs DDL from blce_generate_ddl on a target Snowflake account.
        Dimensions are executed before facts for FK dependency order.

        ddl_ids: Comma-separated DDL IDs to execute (from blce_generate_ddl).
        account: Snowflake account identifier.
        database: Target database.
        schema: Target schema.
        dry_run: If true, validate without executing (default: true).
        """
        from .contracts import GeneratedDDL, DeploymentSummary
        from .ddl_executor import DDLExecutor

        if not ddl_ids:
            return {"error": "ddl_ids is required (comma-separated DDL IDs)."}

        ids = [i.strip() for i in ddl_ids.split(",") if i.strip()]
        ddls = []
        missing = []
        for did in ids:
            if did in _ddl_store:
                ddls.append(GeneratedDDL(**_ddl_store[did]))
            else:
                missing.append(did)

        if missing:
            return {"error": f"DDL IDs not found: {', '.join(missing)}"}

        executor = DDLExecutor()
        connection = None

        if not dry_run:
            try:
                from src.data_modeling.snowflake_pool import sf_pool
                connection = sf_pool.get(
                    account or "default",
                    database=database,
                    schema=schema,
                )
            except Exception as exc:
                return {"error": f"Snowflake connection failed: {exc}"}

        results = executor.execute_all(ddls, connection, dry_run=dry_run)

        summary = DeploymentSummary(
            ddl_results=results,
            total_objects_deployed=sum(
                1 for r in results if r.status.value == "success"
            ),
            total_failures=sum(
                1 for r in results if r.status.value == "failed"
            ),
            total_execution_time_ms=sum(r.execution_time_ms for r in results),
        )

        _deployment_store[summary.deployment_id] = summary.model_dump()

        return {
            "deployment_id": summary.deployment_id,
            "executed": len(results),
            "success": summary.total_objects_deployed,
            "failed": summary.total_failures,
            "dry_run": dry_run,
            "execution_time_ms": summary.total_execution_time_ms,
            "results": [
                {
                    "object_name": r.object_name,
                    "object_type": r.object_type,
                    "status": r.status.value,
                    "error": r.error_message or None,
                }
                for r in results
            ],
        }

    # ---- Tool 81: Deployment status ----

    @mcp.tool()
    def blce_deployment_status(
        deployment_id: str = "",
    ) -> Dict[str, Any]:
        """Get deployment summary for a DDL execution run.

        Returns detailed results including per-object status, timing,
        and error messages.

        deployment_id: ID from blce_execute_ddl (optional — returns latest if empty).
        """
        if not _deployment_store:
            return {"error": "No deployments recorded yet."}

        if deployment_id and deployment_id in _deployment_store:
            return _deployment_store[deployment_id]

        if not deployment_id:
            # Return the most recent deployment
            latest_id = list(_deployment_store.keys())[-1]
            return _deployment_store[latest_id]

        return {"error": f"Deployment {deployment_id} not found."}

    # ---- Tool 73: Graph Copilot Extract ----

    @mcp.tool()
    def blce_graph_copilot_extract(
        file_path: str,
        drive_item_id: str = "",
    ) -> Dict[str, Any]:
        """Extract business concepts and formulas from an Excel workbook via MS Graph API.

        Falls back to local openpyxl parsing if Graph API is unavailable.
        Returns extracted formulas, named ranges, pivot tables, and translated BLCE measures.

        file_path: Path to an Excel workbook (.xlsx). Used for local fallback.
        drive_item_id: Optional MS Graph drive item ID for cloud extraction.
        """
        from .graph_copilot import GraphCopilotClient

        client = GraphCopilotClient()
        extraction = client.extract_workbook(file_path, drive_item_id=drive_item_id)
        result = extraction.model_dump()

        # Cache the artifact if generated
        if extraction.artifact:
            _cache_artifact(extraction.artifact.model_dump())

        return {
            "extraction_id": result["extraction_id"],
            "source": result["source"],
            "file_path": result["file_path"],
            "sheets": result["sheets"],
            "formulas_count": len(result["formulas"]),
            "named_ranges_count": len(result["named_ranges"]),
            "pivot_tables_count": len(result["pivot_tables"]),
            "measures_count": len(result["measures"]),
            "has_artifact": result["artifact"] is not None,
            "artifact_id": (
                result["artifact"]["artifact_id"]
                if result["artifact"]
                else None
            ),
        }

    # ---- Tool 74: Excel vs Warehouse Cell-Level Reconcile ----

    @mcp.tool()
    def blce_excel_vs_warehouse_reconcile_cells(
        excel_data: str,
        warehouse_sql: str,
        mapping: str = "{}",
        connection_id: str = "",
        rounding_threshold: float = 0.01,
    ) -> Dict[str, Any]:
        """Compare Excel report values against data warehouse query results (cell-level).

        Returns cell-by-cell reconciliation with discrepancy classification:
        MATCH, ROUNDING, GENUINE_DIFF, MISSING_EXCEL, MISSING_WAREHOUSE.

        excel_data: JSON string with {cells: [{address, value, formula, label}, ...]}.
        warehouse_sql: SQL query to run against the warehouse (or JSON result if pre-fetched).
        mapping: JSON mapping {excel_cell_or_label: warehouse_column}.
        connection_id: Optional connection ID for running warehouse_sql.
        rounding_threshold: Maximum difference to classify as rounding (default 0.01).
        """
        import json

        from .excel_reconciler import ExcelWarehouseReconciler

        try:
            excel_dict = json.loads(excel_data) if isinstance(excel_data, str) else excel_data
        except json.JSONDecodeError:
            return {"error": "Invalid JSON in excel_data parameter."}

        try:
            mapping_dict = json.loads(mapping) if isinstance(mapping, str) else mapping
        except json.JSONDecodeError:
            return {"error": "Invalid JSON in mapping parameter."}

        # Resolve warehouse data: try as JSON first, else it's SQL
        warehouse_dict: Dict[str, Any] = {}
        try:
            warehouse_dict = json.loads(warehouse_sql)
        except (json.JSONDecodeError, TypeError):
            # warehouse_sql is actual SQL — return it for caller to execute
            warehouse_dict = {
                "sql": warehouse_sql,
                "rows": [],
                "columns": [],
                "note": "SQL not executed. Pass pre-fetched JSON result or provide connection_id.",
            }

        reconciler = ExcelWarehouseReconciler(rounding_threshold=rounding_threshold)
        report = reconciler.reconcile(excel_dict, warehouse_dict, mapping_dict)
        result = report.model_dump()

        return {
            "report_id": result["report_id"],
            "total_cells": result["total_cells"],
            "matched": result["matched"],
            "rounding": result["rounding"],
            "genuine_diff": result["genuine_diff"],
            "missing_excel": result["missing_excel"],
            "missing_warehouse": result["missing_warehouse"],
            "rounding_threshold": result["rounding_threshold"],
            "cells": result["cells"],
        }

    # ---- Tool 75: Cell-Level Discrepancy Resolver ----

    @mcp.tool()
    def blce_discrepancy_resolver_cells(
        reconciliation_id: str = "",
        discrepancies: str = "[]",
        artifact_ids: str = "",
    ) -> Dict[str, Any]:
        """Analyze cell-level reconciliation discrepancies and generate programmatic fixes.

        Produces SQL patches or Wright pipeline adjustments for each discrepancy.

        reconciliation_id: ID from blce_excel_vs_warehouse_reconcile_cells.
        discrepancies: JSON array of CellReconciliation dicts (from reconciliation report cells).
        artifact_ids: Comma-separated BLCE artifact IDs for context (optional).
        """
        import json

        from .contracts import CellReconciliation, LogicArtifact, ReconciliationReport
        from .discrepancy_resolver import DiscrepancyResolver

        try:
            disc_list = json.loads(discrepancies) if isinstance(discrepancies, str) else discrepancies
        except json.JSONDecodeError:
            return {"error": "Invalid JSON in discrepancies parameter."}

        # Build a minimal ReconciliationReport from the discrepancy cells
        cells = [CellReconciliation(**d) for d in disc_list]
        report = ReconciliationReport(
            report_id=reconciliation_id or "manual",
            total_cells=len(cells),
            cells=cells,
        )

        # Load referenced artifacts from cache
        artifacts: List[LogicArtifact] = []
        if artifact_ids:
            for aid in [a.strip() for a in artifact_ids.split(",") if a.strip()]:
                cached = _artifact_cache.get(aid)
                if cached:
                    artifacts.append(LogicArtifact(**cached))

        resolver = DiscrepancyResolver()
        resolution = resolver.resolve(report, artifacts=artifacts)
        result = resolution.model_dump()

        return {
            "resolution_id": result["resolution_id"],
            "reconciliation_id": result["reconciliation_id"],
            "total_discrepancies": result["total_discrepancies"],
            "auto_fixable": result["auto_fixable"],
            "manual_review": result["manual_review"],
            "summary": result["summary"],
            "fixes": result["fixes"],
        }

    return {
        "tools_registered": 84,
        "tools": [
            "blce_parse_sql_logic",
            "blce_parse_python_logic",
            "blce_parse_excel_logic",
            "blce_extract_business_measures",
            "blce_extract_common_filters",
            "blce_normalize_measures",
            "blce_detect_grain_contract",
            "blce_ai_enrich_artifact",
            "blce_compare_logic_sources",
            "blce_discover_cross_references",
            "blce_propose_conformed_dimensions",
            "blce_validate_cross_reference",
            "blce_collect_evidence_sample",
            "blce_mask_evidence",
            "blce_verify_measure_with_data",
            "blce_classify_artifact",
            "blce_governance_report",
            "blce_generate_skill",
            "blce_list_generated_skills",
            "blce_run_pipeline",
            "blce_pipeline_status",
            "blce_get_run_summary",
            "blce_run_full_engine",
            "blce_orchestrator_status",
            "blce_ai_semantic_analysis",
            "blce_ai_cross_reference",
            "blce_ai_quality_policy",
            "blce_list_agents",
            "blce_parse_dax_logic",
            "blce_parse_mdx_logic",
            "blce_parse_pdf_logic",
            "blce_run_parallel_engine",
            "blce_agent_runtime_status",
            "blce_message_bus_drain",
            "blce_parallel_phase_graph",
            "blce_route_task",
            "blce_agent_pool_stats",
            "blce_conversation_list",
            "blce_conversation_detail",
            "blce_e2e_handoff_load",
            "blce_e2e_to_artifacts",
            "blce_model_alter",
            "blce_model_alter_summary",
            "blce_merge_systems",
            "model_ask",
            "model_query_builder",
            "model_suggest_report",
            "model_propose_change",
            "model_add_source_system",
            "model_evolution_history",
            "blce_intake_questionnaire",
            "blce_process_report",
            "blce_excel_wizard_analyze",
            "blce_excel_wizard_query",
            "blce_excel_wizard_list_suggestions",
            "blce_excel_wizard_review_suggestion",
            "blce_excel_wizard_export",
            "blce_excel_vs_warehouse_reconcile",
            "blce_discrepancy_resolver",
            "blce_excel_vs_warehouse_reconcile_live",
            "blce_excel_wizard_reconcile_auto",
            "blce_process_meeting_notes",
            "blce_department_mapper",
            "blce_propose_dimensions",
            "blce_propose_facts",
            "blce_generate_bus_matrix",
            "blce_resolve_conflicts",
            "blce_generate_ddl",
            "blce_generate_wright_pipeline",
            "blce_generate_union_template",
            "blce_generate_semantic_layer",
            "blce_analyze_tables",
            "blce_map_report_to_model",
            "blce_detect_hierarchies",
            "blce_generate_proposal",
            "blce_deploy_semantic_layer",
            "blce_review_model",
            "blce_approve_dimension",
            "blce_approve_fact",
            "blce_execute_ddl",
            "blce_deployment_status",
            "blce_graph_copilot_extract",
            "blce_excel_vs_warehouse_reconcile_cells",
            "blce_discrepancy_resolver_cells",
        ],
    }




