"""Platform Workflow Orchestrator — generalised multi-phase pipeline engine.

Inspired by special_projects/enertia_full_platform_workflow.py but parameterised
so any ERP / database can be processed through the same phases.
"""

from __future__ import annotations

import logging
import json
import threading
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional

from src.persistence.adapter import PersistenceAdapter
from src.persistence.file_adapter import FilePersistenceAdapter

logger = logging.getLogger(__name__)


def _utc_now_iso() -> str:
    """Return a timezone-aware UTC timestamp in ISO-8601 format with Z suffix."""
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

@dataclass
class OrchestratorConfig:
    """Configuration for a platform workflow run."""

    # Source connection
    source_type: str = "snowflake"          # csv | snowflake | sql
    database: str = ""
    schema: str = ""
    tables: str = ""                        # comma-separated or '*'
    csv_folder: Optional[str] = None
    connection_string: Optional[str] = None
    snowflake_connection: Optional[str] = None

    # Sampling
    sample_limit: int = 200
    min_overlap: float = 0.5

    # ERP grouping
    erp: str = "enertia"

    # dbt
    dbt_project_name: Optional[str] = None
    dbt_run: bool = False
    dbt_docs: bool = False

    # Dimension detection
    dimension_threshold: int = 3

    # Focus config for TXN tables
    focus_patterns: Optional[List[str]] = None

    # AI relationship discovery
    skip_ai_relationships: bool = False     # Skip AI relationship discovery phase
    cortex_model: str = "mistral-large"     # Cortex model for AI semantic matching
    trust_enforcement_enabled: bool = True
    trust_enforcement_mode: str = "hard_fail"
    trust_public_key_registry_path: Optional[str] = "data/datashield/trust_public_keys.json"

    # Output
    output_dir: str = "data/workflow_runs"


# ---------------------------------------------------------------------------
# Phase result
# ---------------------------------------------------------------------------

@dataclass
class PhaseResult:
    """Result of a single phase execution."""
    name: str
    status: str = "pending"          # pending | running | completed | skipped | error
    started_at: Optional[str] = None
    ended_at: Optional[str] = None
    duration_seconds: float = 0.0
    output: Dict[str, Any] = field(default_factory=dict)
    error: Optional[str] = None


@dataclass
class RunSummary:
    """Summary of a complete orchestrator run."""
    run_id: str = field(default_factory=lambda: f"run_{uuid.uuid4().hex[:8]}")
    status: str = "pending"
    started_at: Optional[str] = None
    ended_at: Optional[str] = None
    phases: List[PhaseResult] = field(default_factory=list)
    config: Dict[str, Any] = field(default_factory=dict)
    total_duration_seconds: float = 0.0

    def to_dict(self) -> Dict[str, Any]:
        return {
            "run_id": self.run_id,
            "status": self.status,
            "started_at": self.started_at,
            "ended_at": self.ended_at,
            "total_duration_seconds": self.total_duration_seconds,
            "phases": [
                {
                    "name": p.name,
                    "status": p.status,
                    "duration_seconds": p.duration_seconds,
                    "error": p.error,
                    "output_keys": list(p.output.keys()),
                }
                for p in self.phases
            ],
            "config": self.config,
        }


# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------

PHASE_ORDER = [
    "load_metadata",
    "ai_relationship_discovery",
    "group_tables",
    "hierarchy",
    "ai_classify",
    "detect_dimensions",
    "wright",
    "dbt",
    "quality",
    "observability",
    "artifact_bundle",
]


class SnowflakeClientAdapter:
    """Wraps a raw Snowflake connection to match AI plugin's sf_client interface.

    The AI plugin calls ``sf_client.execute_query()``, ``sf_client.get_tables()``,
    and ``sf_client.get_columns()``.  This adapter bridges the raw
    ``snowflake.connector.Connection`` to that interface.
    """

    def __init__(self, connection):
        self._conn = connection

    def execute_query(self, sql: str, limit: int = 100) -> dict:
        cursor = self._conn.cursor()
        cursor.execute(sql)
        columns = [desc[0] for desc in cursor.description] if cursor.description else []
        rows = cursor.fetchmany(limit)
        return {"columns": columns, "rows": rows}

    def get_tables(self, database: str, schema: str) -> list:
        """Return table metadata matching SnowflakeClient.get_tables() format."""
        cursor = self._conn.cursor()
        cursor.execute(f'SHOW TABLES IN "{database}"."{schema}"')
        desc = [d[0] for d in cursor.description] if cursor.description else []
        results = []
        for row in cursor.fetchall():
            row_dict = dict(zip(desc, row))
            results.append({
                "name": row_dict.get("name", ""),
                "rows": row_dict.get("rows", 0),
            })
        return results

    def get_columns(self, database: str, schema: str, table: str) -> list:
        """Return column metadata matching SnowflakeClient.get_columns() format."""
        cursor = self._conn.cursor()
        cursor.execute(f'SHOW COLUMNS IN "{database}"."{schema}"."{table}"')
        desc = [d[0] for d in cursor.description] if cursor.description else []
        results = []
        for row in cursor.fetchall():
            row_dict = dict(zip(desc, row))
            results.append({
                "name": row_dict.get("column_name", ""),
                "type": row_dict.get("data_type", ""),
                "nullable": row_dict.get("is_nullable", ""),
            })
        return results


class PlatformOrchestrator:
    """Reusable multi-phase platform workflow engine.

    Each phase is a method named ``phase_<name>`` that receives the
    accumulated context dict and returns a result dict.
    """

    def __init__(
        self,
        config: OrchestratorConfig,
        persistence: Optional[PersistenceAdapter] = None,
    ):
        self.config = config
        self.persistence = persistence or FilePersistenceAdapter(
            base_dir=str(Path(config.output_dir) / "runs")
        )
        self._context: Dict[str, Any] = {}
        self._summary: Optional[RunSummary] = None
        self._sf_source_conn = None  # Persistent Snowflake source connection
        self._trust_events: List[Dict[str, Any]] = []
        # Heartbeat state for long-running phases
        self._current_phase: Optional[str] = None
        self._phase_start: float = 0.0

    def _load_trust_registry(self) -> Dict[str, str]:
        path = self.config.trust_public_key_registry_path
        if not path:
            return {}
        p = Path(path)
        if not p.exists():
            return {}
        try:
            data = json.loads(p.read_text(encoding="utf-8"))
            if isinstance(data, dict):
                return {str(k): str(v) for k, v in data.items()}
        except Exception:
            return {}
        return {}

    def _enforce_trust(
        self,
        *,
        phase_name: str,
        lane: str,
    ) -> None:
        """Enforce trust attestation for a protected lane."""
        if not self.config.trust_enforcement_enabled:
            return

        from src.datashield.policy_engine import enforce_attestation
        from src.datashield.attestation import persist_attestation_event
        from src.datashield.types import TrustLane

        attestation = self._context.get("trust_attestation")
        registry = self._load_trust_registry()
        lane_enum = TrustLane(lane)
        result = enforce_attestation(attestation, lane=lane_enum, public_key_registry=registry)

        event = {
            "phase": phase_name,
            "lane": lane_enum.value,
            "result": result,
            "timestamp": _utc_now_iso(),
        }
        self._trust_events.append(event)
        self._context["trust_events"] = self._trust_events
        try:
            base = str(Path(self.config.output_dir).parent / "datashield")
            persisted = persist_attestation_event(event, base_dir=base)
            event["persisted_at"] = persisted
        except Exception as exc:
            logger.warning("Failed to persist trust event: %s", exc)

        if result.get("ok"):
            return

        if self.config.trust_enforcement_mode == "warn":
            logger.warning("Trust enforcement warning (%s): %s", phase_name, result)
            return

        raise RuntimeError(f"Trust enforcement failed in {phase_name}: {result.get('code')}")

    # ------------------------------------------------------------------
    # Connection management
    # ------------------------------------------------------------------

    def get_source_connection(self):
        """Get or create a persistent Snowflake source connection.

        Creates ONE connection on first call and reuses it for the entire
        pipeline run.  This eliminates repeated SSO browser prompts.
        """
        if self._sf_source_conn is not None:
            # Verify still alive
            try:
                self._sf_source_conn.cursor().execute("SELECT 1")
                return self._sf_source_conn
            except Exception:
                self._sf_source_conn = None

        if self.config.source_type != "snowflake":
            return None

        from src.data_modeling.snowflake_pool import sf_pool
        from src.data_modeling.snowflake_utils import ensure_snowflake_env_from_cli

        if self.config.snowflake_connection:
            ensure_snowflake_env_from_cli(
                connection_name=self.config.snowflake_connection,
                allow_override=True,
            )
            self._sf_source_conn = sf_pool.get(
                self.config.snowflake_connection,
                database=self.config.database,
                schema=self.config.schema,
            )
        else:
            self._sf_source_conn = sf_pool.get_from_env(
                database=self.config.database,
                schema=self.config.schema,
            )

        logger.info("Persistent source connection established")
        return self._sf_source_conn

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def run(
        self,
        phases: Optional[List[str]] = None,
        skip: Optional[List[str]] = None,
        checkpoint_dir: Optional[str] = None,
        resume_from: Optional[str] = None,
    ) -> RunSummary:
        """Execute phases sequentially with optional skip flags.

        Args:
            phases: Explicit list of phase names to run (default: all).
            skip: Phase names to skip.
            checkpoint_dir: Directory for phase checkpoints (enables resume).
                Each completed phase saves its context so the pipeline can
                be restarted from the point of failure.
            resume_from: Path to a checkpoint directory from a previous run.
                Phases that already completed are skipped and their context
                is restored.

        Returns:
            RunSummary with per-phase results.
        """
        phase_list = phases or list(PHASE_ORDER)
        skip_set = set(skip or [])

        # --- Resume support: load context from previous checkpoint ---
        completed_phases: Dict[str, PhaseResult] = {}
        if resume_from:
            completed_phases = self._load_checkpoint(resume_from)
            logger.info("Resuming from checkpoint: %d phases already done",
                        len(completed_phases))

        # Establish persistent source connection ONCE at pipeline start
        if self.config.source_type == "snowflake":
            try:
                self.get_source_connection()
                logger.info("Source connection ready -- SSO will NOT be prompted again")
            except Exception as exc:
                logger.warning("Could not pre-establish source connection: %s", exc)

        summary = RunSummary(config=self.config.__dict__.copy())
        summary.started_at = _utc_now_iso()
        summary.status = "running"
        self._summary = summary

        # Resolve checkpoint directory
        ckpt_dir = None
        if checkpoint_dir:
            import pathlib
            ckpt_dir = pathlib.Path(checkpoint_dir)
            ckpt_dir.mkdir(parents=True, exist_ok=True)

        t0 = time.time()

        # Heartbeat thread — logs every 30s during silent phases
        _heartbeat_stop = threading.Event()

        def _heartbeat():
            while not _heartbeat_stop.wait(30):
                if self._current_phase:
                    elapsed = time.time() - self._phase_start
                    logger.info("  [heartbeat] Phase %s running (%.0fs elapsed)",
                                self._current_phase, elapsed)

        hb = threading.Thread(target=_heartbeat, daemon=True, name="orchestrator-heartbeat")
        hb.start()

        _completed_normally = False
        try:
            for phase_name in phase_list:
                if phase_name in skip_set:
                    pr = PhaseResult(name=phase_name, status="skipped")
                    summary.phases.append(pr)
                    continue

                # Check if already completed in a previous run (resume mode)
                if phase_name in completed_phases:
                    prev = completed_phases[phase_name]
                    pr = PhaseResult(
                        name=phase_name,
                        status="completed",
                        started_at=prev.started_at,
                        ended_at=prev.ended_at,
                        duration_seconds=prev.duration_seconds,
                        output=prev.output,
                    )
                    summary.phases.append(pr)
                    self._context[phase_name] = prev.output
                    logger.info("Phase %s: restored from checkpoint (%.1fs)",
                                phase_name, prev.duration_seconds)
                    continue

                handler = getattr(self, f"phase_{phase_name}", None)
                if handler is None:
                    pr = PhaseResult(name=phase_name, status="error", error=f"Unknown phase: {phase_name}")
                    summary.phases.append(pr)
                    continue

                pr = PhaseResult(name=phase_name, status="running")
                pr.started_at = _utc_now_iso()
                pt0 = time.time()
                self._current_phase = phase_name
                self._phase_start = pt0
                logger.info("Phase %s: starting...", phase_name)

                try:
                    result = handler(self._context)
                    pr.output = result or {}
                    pr.status = "completed"
                    self._context[phase_name] = result
                except Exception as exc:
                    pr.status = "error"
                    pr.error = str(exc)
                    logger.error("Phase %s failed: %s", phase_name, exc)
                finally:
                    self._current_phase = None

                pr.duration_seconds = round(time.time() - pt0, 2)
                pr.ended_at = _utc_now_iso()
                summary.phases.append(pr)
                logger.info("Phase %s: %s (%.1fs)", phase_name, pr.status, pr.duration_seconds)

                # Checkpoint after each phase (even failed ones, for diagnostics)
                if ckpt_dir:
                    self._save_checkpoint(ckpt_dir, phase_name, pr)
            _completed_normally = True
        finally:
            _heartbeat_stop.set()

            # Finalize summary — always runs, even on interrupt/exception
            summary.total_duration_seconds = round(time.time() - t0, 2)
            summary.ended_at = _utc_now_iso()
            if _completed_normally:
                summary.status = (
                    "completed"
                    if all(p.status in ("completed", "skipped") for p in summary.phases)
                    else "partial"
                )
            else:
                summary.status = "partial"

            # Persist final summary
            try:
                self.persistence.save_run(summary.run_id, summary.to_dict())
            except Exception as exc:
                logger.error("Failed to persist final run summary: %s", exc)

            # Refresh artifact run_summary.json written by phase_artifact_bundle
            # (that copy was written mid-run with status "running")
            try:
                artifact_summary = (
                    Path(self.config.output_dir) / "artifacts"
                    / summary.run_id / "run_summary.json"
                )
                if artifact_summary.exists():
                    artifact_summary.write_text(
                        json.dumps(summary.to_dict(), indent=2, default=str),
                        encoding="utf-8",
                    )
            except Exception as exc:
                logger.warning("Could not update artifact run_summary: %s", exc)

        return summary

    # ------------------------------------------------------------------
    # Checkpoint helpers
    # ------------------------------------------------------------------

    def _save_checkpoint(self, ckpt_dir, phase_name: str, pr: PhaseResult) -> None:
        """Save a single phase result + context to a checkpoint file."""
        import json as _json
        import pathlib

        ckpt_dir = pathlib.Path(ckpt_dir)
        data = {
            "name": pr.name,
            "status": pr.status,
            "started_at": pr.started_at,
            "ended_at": pr.ended_at,
            "duration_seconds": pr.duration_seconds,
            "error": pr.error,
            "output": pr.output,
        }
        path = ckpt_dir / f"{phase_name}.json"
        try:
            path.write_text(_json.dumps(data, indent=2, default=str), encoding="utf-8")
        except Exception as exc:
            logger.warning("Could not save checkpoint for %s: %s", phase_name, exc)

    def _load_checkpoint(self, ckpt_dir: str) -> Dict[str, PhaseResult]:
        """Load all completed phase results from checkpoint directory."""
        import json as _json
        import pathlib

        ckpt_path = pathlib.Path(ckpt_dir)
        completed: Dict[str, PhaseResult] = {}
        if not ckpt_path.is_dir():
            logger.warning("Checkpoint directory not found: %s", ckpt_dir)
            return completed

        for f in sorted(ckpt_path.glob("*.json")):
            try:
                data = _json.loads(f.read_text(encoding="utf-8"))
                if data.get("status") == "completed":
                    pr = PhaseResult(
                        name=data["name"],
                        status="completed",
                        started_at=data.get("started_at"),
                        ended_at=data.get("ended_at"),
                        duration_seconds=data.get("duration_seconds", 0),
                        output=data.get("output", {}),
                        error=data.get("error"),
                    )
                    completed[data["name"]] = pr
            except Exception as exc:
                logger.warning("Could not load checkpoint %s: %s", f.name, exc)

        return completed

    @property
    def context(self) -> Dict[str, Any]:
        return self._context

    # ------------------------------------------------------------------
    # Built-in phases (each can be overridden by subclasses)
    # ------------------------------------------------------------------

    def phase_load_metadata(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase 0: Load metadata / samples from source.

        Passes ``return_samples=True`` so that downstream phases
        (ai_classify, quality_from_classification) have access to
        raw sample data and INFORMATION_SCHEMA column metadata.
        """
        from databridge_data_modeling.mcp_adapter import _discover_data_model

        result = _discover_data_model(
            source_type=self.config.source_type,
            csv_paths=None,
            csv_folder=self.config.csv_folder,
            connection_string=self.config.connection_string,
            database=self.config.database,
            schema=self.config.schema,
            tables=self.config.tables,
            sample_limit=self.config.sample_limit,
            min_overlap=self.config.min_overlap,
            persist=True,
            snowflake_connection=self.config.snowflake_connection,
            return_samples=True,
            sf_connection=self._sf_source_conn,
        )
        return result

    def phase_ai_relationship_discovery(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase: AI-powered relationship discovery using DataShield AI plugin.

        Runs the AI relationship pipeline (deterministic FK, value overlap,
        Cortex AI semantic matching) and merges results into load_metadata.
        Falls back gracefully to existing simple inference if AI plugin unavailable.
        """
        if self.config.skip_ai_relationships:
            return {"skipped": True, "reason": "skip_ai_relationships=True"}

        if self.config.source_type != "snowflake":
            return {"skipped": True, "reason": "AI relationship discovery requires Snowflake"}

        meta = ctx.get("load_metadata", {})
        if not meta.get("tables"):
            return {"skipped": True, "reason": "no tables from load_metadata"}

        # Enforce lane: client-owned raw Cortex path.
        self._enforce_trust(
            phase_name="ai_relationship_discovery",
            lane="client_cortex_raw",
        )

        # Try to import the AI plugin (Pro license gate)
        try:
            from private_plugins.databridge_pro.datashield_ai_plugin.service import (
                analyze_schema_for_orchestrator,
            )
        except ImportError:
            return {"skipped": True, "reason": "AI plugin not available (Pro license required)"}

        # Get persistent Snowflake connection
        conn = self.get_source_connection()
        if conn is None:
            return {"skipped": True, "reason": "no Snowflake connection available"}

        sf_adapter = SnowflakeClientAdapter(conn)

        # Run AI relationship pipeline
        ai_result = analyze_schema_for_orchestrator(
            sf_client=sf_adapter,
            database=self.config.database,
            schema=self.config.schema,
            model=self.config.cortex_model,
        )

        new_rels = ai_result.get("relationships", [])
        naming_patterns = ai_result.get("naming_patterns", [])

        # Merge with existing relationships from load_metadata (dedup)
        existing_rels = meta.get("relationships", [])
        existing_keys = {
            (r.get("source_table", ""), r.get("source_column", ""), r.get("target_table", ""))
            for r in existing_rels
        }

        merged_count = 0
        for rel in new_rels:
            key = (rel.get("source_table", ""), rel.get("source_column", ""), rel.get("target_table", ""))
            if key not in existing_keys:
                existing_rels.append(rel)
                existing_keys.add(key)
                merged_count += 1

        # Write merged relationships back so downstream phases benefit
        meta["relationships"] = existing_rels

        # Count by match_type
        type_counts: Dict[str, int] = {}
        for rel in new_rels:
            mt = rel.get("method", rel.get("match_type", "unknown"))
            type_counts[mt] = type_counts.get(mt, 0) + 1

        return {
            "total_relationships": len(existing_rels),
            "new_relationships": len(new_rels),
            "merged": merged_count,
            "duplicates_skipped": len(new_rels) - merged_count,
            "by_match_type": type_counts,
            "naming_patterns_found": len(naming_patterns),
        }

    def phase_group_tables(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase 1: Group tables by ERP prefix."""
        from src.plugins.ce.table_grouping.strategy import PrefixGroupingStrategy, BUILTIN_MAPS

        tables = ctx.get("load_metadata", {}).get("tables", [])
        if not tables:
            return {"skipped": True, "reason": "no tables from load_metadata"}
        if self.config.erp.lower() not in BUILTIN_MAPS:
            return {"skipped": True, "reason": f"no prefix map for ERP '{self.config.erp}'"}
        strategy = PrefixGroupingStrategy.from_builtin(self.config.erp)
        groups = strategy.group(tables)
        return {"groups": groups, "total_tables": len(tables)}

    def phase_hierarchy(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase 2: Build hierarchy from discovered tables (stub — requires hierarchy module)."""
        return {"status": "placeholder", "note": "Use create_hierarchy or import_flexible_hierarchy via MCP"}

    def phase_ai_classify(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase 3: AI classification via DataShield auto_classify_columns.

        Reads ``sample_tables`` and ``column_metadata`` from the
        ``load_metadata`` phase and runs per-column classification +
        PII masking for every table.
        """
        from collections import Counter
        from src.datashield.classifier import auto_classify_columns
        from src.datashield.policy_engine import mask_rows
        from src.datashield.types import ColumnClassification

        meta = ctx.get("load_metadata", {})
        sample_tables = meta.get("sample_tables", {})
        column_metadata = meta.get("column_metadata", [])

        if not sample_tables:
            return {"skipped": True, "reason": "no sample_tables in load_metadata (run with return_samples=True)"}

        # Build lookup: table -> [{"name": col, "data_type": dtype}]
        col_meta_by_table: Dict[str, List[Dict[str, str]]] = {}
        for cm in column_metadata:
            tbl = cm["TABLE_NAME"]
            col_meta_by_table.setdefault(tbl, []).append({
                "name": cm["COLUMN_NAME"],
                "data_type": cm.get("DATA_TYPE", "VARCHAR"),
            })

        classifications: Dict[str, List[dict]] = {}
        classification_counts: Counter = Counter()
        masked_samples: List[dict] = []

        for table, data in sample_tables.items():
            # Column metadata: prefer INFORMATION_SCHEMA, fallback to sample columns
            cols = col_meta_by_table.get(table, [
                {"name": c, "data_type": "VARCHAR"} for c in data.get("columns", [])
            ])

            # Transpose rows into {col_name: [values]} for classifier
            rows = data.get("rows", [])
            sample_vals: Dict[str, List[Any]] = {}
            for row in rows:
                for col, val in row.items():
                    sample_vals.setdefault(col, []).append(val)

            rules = auto_classify_columns(cols, sample_data=sample_vals, row_count=len(rows))
            classifications[table] = [r.model_dump() for r in rules]

            for rule in rules:
                classification_counts[rule.classification.value] += 1

            # Mask samples — baseline PII + redact SENSITIVE_PII columns
            masked = mask_rows(rows, can_view_pii=False)
            pii_cols = {r.column_name for r in rules if r.classification == ColumnClassification.SENSITIVE_PII}
            for idx, row in enumerate(masked):
                redacted = {}
                for col, val in row.items():
                    if col in pii_cols and val is not None:
                        redacted[col] = "***REDACTED***"
                    else:
                        redacted[col] = val
                masked_samples.append({"table_name": table, "row_index": idx, "data": redacted})

        return {
            "classifications": classifications,
            "masked_samples": masked_samples,
            "masked_samples_count": len(masked_samples),
            "classification_counts": dict(classification_counts),
            "tables_classified": len(classifications),
        }

    def phase_detect_dimensions(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase 4: Detect dimension vs fact tables.

        Three strategies in priority order:
        1. DimensionDetector from FK relationships (value-overlap or combined).
        2. Column-name FK inference → DimensionDetector (no sample data needed).
        3. TableAnalyzer heuristics (P0.2 fallback, no relationships needed).
        """
        rels = ctx.get("load_metadata", {}).get("relationships", [])

        if rels:
            # Primary: FK-based classification
            from src.data_modeling.dimension_detector import DimensionDetector

            detector = DimensionDetector()
            classification = detector.classify_tables(rels, threshold=self.config.dimension_threshold)
            return {
                "classification": classification,
                "relationship_count": len(rels),
                "method": "dimension_detector",
            }

        # Middle fallback: column-name FK inference
        meta = ctx.get("load_metadata", {})
        sample_tables = meta.get("sample_tables", {})
        table_columns = {
            t: data.get("columns", [])
            for t, data in sample_tables.items()
        } if sample_tables else {}

        if not table_columns:
            # Try from table list + column_metadata
            col_meta = meta.get("column_metadata", [])
            for cm in col_meta:
                tbl = cm.get("TABLE_NAME", "")
                col = cm.get("COLUMN_NAME", "")
                if tbl and col:
                    table_columns.setdefault(tbl, []).append(col)

        if table_columns:
            from src.data_modeling.column_fk_inferer import infer_relationships_by_name
            from src.data_modeling.focus_config import InferenceConfig
            from src.data_modeling.erp_configs.registry import get_erp_config

            cfg = None
            erp_entry = get_erp_config(self.config.erp)
            if erp_entry:
                cfg = erp_entry.inference

            name_rels = infer_relationships_by_name(table_columns, config=cfg)
            if name_rels:
                from src.data_modeling.dimension_detector import DimensionDetector

                detector = DimensionDetector()
                classification = detector.classify_tables(
                    name_rels, threshold=self.config.dimension_threshold,
                )
                return {
                    "classification": classification,
                    "relationship_count": len(name_rels),
                    "method": "column_name_inference",
                }

        # Final fallback: heuristic table analysis (no relationships needed)
        from src.data_modeling.table_analyzer import TableAnalyzer

        table_names = meta.get("tables", [])
        if not table_names:
            return {"skipped": True, "reason": "no tables"}

        analyzer = TableAnalyzer(
            erp_type=self.config.erp,
            table_profiles=meta.get("table_profiles", []),
            column_metadata=meta.get("column_metadata", []),
        )
        result = analyzer.analyze_all(table_names)

        return {
            "classification": result.classification,
            "table_analyses": result.table_analyses,
            "domain_groups": result.domain_groups,
            "natural_keys": result.natural_keys,
            "purpose_summaries": result.purpose_summaries,
            "erp_type": result.erp_type,
            "method": result.method,
        }

    def phase_wright(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase 5: Generate Wright pipelines (stub — requires hierarchy)."""
        return {"status": "placeholder", "note": "Use wright_from_hierarchy via MCP"}

    def phase_dbt(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase 6: Scaffold dbt project (stub — triggered by config)."""
        if not self.config.dbt_project_name:
            return {"skipped": True, "reason": "no dbt_project_name configured"}
        return {"status": "placeholder", "note": "dbt scaffolding handled in load_metadata phase when dbt_project_name is set"}

    def phase_quality(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase 7: Generate quality expectations from classifications."""
        classification = ctx.get("detect_dimensions", {}).get("classification", {})
        if not classification:
            return {"skipped": True, "reason": "no classification data from detect_dimensions"}

        try:
            from src.data_quality.classification_bridge import ClassificationExpectationBridge
            bridge = ClassificationExpectationBridge()
            suites_info = []
            for table, cls_type in classification.items():
                col_map = {}
                if cls_type == "dimension":
                    col_map[f"{table}_KEY"] = "identifier"
                elif cls_type == "fact":
                    col_map[f"{table}_AMOUNT"] = "measure"
                if col_map:
                    suite = bridge.generate_suite(
                        suite_name=f"auto_{table}",
                        classifications=col_map,
                        table_name=table,
                        database=self.config.database,
                        schema_name=self.config.schema,
                    )
                    suites_info.append({"table": table, "expectations": len(suite.expectations)})
            return {"suites_generated": len(suites_info), "suites": suites_info}
        except Exception as exc:
            return {"status": "error", "error": str(exc)}

    def phase_observability(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase 8: Set up observability baselines (stub)."""
        return {"status": "placeholder", "note": "Use obs_record_metric via MCP"}

    def phase_artifact_bundle(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
        """Phase 9: Generate artifact bundle for the run."""
        try:
            from src.artifacts.bundle import ArtifactBundle

            bundle = ArtifactBundle(
                run_id=self._summary.run_id if self._summary else "unknown",
                output_dir=Path(self.config.output_dir) / "artifacts",
            )
            if self._summary:
                bundle.add("run_summary", self._summary.to_dict(), fmt="json")
                if self._trust_events:
                    bundle.add("trust_events", {"events": self._trust_events}, fmt="json")
                bundle.generate_summary_html(self._summary.to_dict())
            manifest = bundle.generate_manifest()
            return {"manifest": str(manifest), "artifacts": len(bundle.artifacts)}
        except Exception as exc:
            return {"error": str(exc)}
