"""Main architect execution loop.

Orchestrates the full lifecycle: plan → execute → observe → gate.
Provides ``execute()`` (async) and ``execute_sync()`` (blocking) entry points.
"""

from __future__ import annotations

import asyncio
import json
import logging
import signal
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path

from cleave.architect.config import ArchitectConfig
from cleave.architect.dag import DAGStore, Phase
from cleave.architect.errors import (
    ArchitectError,
    BudgetExhaustedError,
    CircuitBreakerError,
    PlanningError,
)
from cleave.architect.gates import evaluate_gate
from cleave.architect.observer import observe_phase
from cleave.architect.planner import plan_phases
from cleave.architect.report import write_plan_review, write_report
from cleave.orchestrator.config import OrchestratorConfig
from cleave.orchestrator.runner import run_sync as orchestrator_run_sync

logger = logging.getLogger(__name__)


@dataclass
class ArchitectResult:
    """Return value from a completed architect run."""

    success: bool
    db_path: Path
    report_path: Path | None = None
    total_cost_usd: float = 0.0
    phases_completed: int = 0
    phases_total: int = 0
    paused: bool = False
    error: str | None = None


def _generate_db_path(config: ArchitectConfig) -> Path:
    """Generate a db path from the directive."""
    # Slugify the directive
    slug = config.directive.lower()
    slug = "".join(c if c.isalnum() or c in ("-", "_", " ") else "" for c in slug)
    slug = "-".join(slug.split())[:40]
    if not slug:
        slug = "architect"

    base = config.repo_path / f".architect-{slug}"
    base.mkdir(parents=True, exist_ok=True)
    return base / "architect.db"


def _build_orchestrator_config(
    phase: Phase,
    config: ArchitectConfig,
) -> OrchestratorConfig:
    """Build an OrchestratorConfig for a phase."""
    # Phase config_json can override specific fields
    overrides: dict = {}
    if phase.config_json:
        try:
            overrides = json.loads(phase.config_json)
        except json.JSONDecodeError:
            logger.warning("Invalid config_json for phase %s, ignoring", phase.id)

    return OrchestratorConfig(
        directive=phase.description,
        repo_path=config.repo_path,
        success_criteria=overrides.get("success_criteria", config.success_criteria),
        model=config.executor_model,
        planner_model=config.executor_planner_model,
        max_budget_usd=config.phase_budget_usd,
        child_budget_usd=config.child_budget_usd,
        timeout_seconds=config.phase_timeout_seconds,
        child_timeout_seconds=config.phase_timeout_seconds // 2,
        max_depth=config.max_depth,
        circuit_breaker_threshold=3,
        max_parallel_children=config.max_parallel_children,
        permission_mode=config.permission_mode,
        allowed_tools=config.allowed_tools,
        mcp_config=config.mcp_config,
        dirty=config.dirty,
    )


async def plan(config: ArchitectConfig) -> ArchitectResult:
    """Plan only — decompose into phases without executing."""
    config.validate()
    claude_cli = config.find_claude_cli()

    db_path = config.db_path or _generate_db_path(config)

    with DAGStore(db_path) as dag:
        dag.set_metadata("config", config.to_dict())
        dag.set_metadata("started_at", datetime.now(UTC).isoformat())
        dag.log_event("plan", "Starting architect planning phase")

        # Invoke planner
        try:
            plan_data = await plan_phases(claude_cli, config)
        except PlanningError as e:
            dag.log_event("error", f"Planning failed: {e}")
            return ArchitectResult(
                success=False, db_path=db_path, error=str(e),
            )

        # Populate DAG
        _populate_dag(dag, plan_data)

        dag.set_metadata("rationale", plan_data.get("rationale", ""))
        dag.log_event("plan", f"Plan complete: {len(plan_data.get('phases', []))} phases")

        phases = dag.list_phases()
        output_dir = db_path.parent

        # Write plan review
        write_plan_review(dag, config, output_dir)
        report_path = write_report(dag, config, output_dir)

        return ArchitectResult(
            success=True,
            db_path=db_path,
            report_path=report_path,
            phases_total=len(phases),
            paused=True,
        )


async def execute(config: ArchitectConfig) -> ArchitectResult:
    """Plan and execute all phases."""
    config.validate()
    claude_cli = config.find_claude_cli()

    db_path = config.db_path or _generate_db_path(config)

    shutdown_event = asyncio.Event()
    loop = asyncio.get_running_loop()

    def _signal_handler() -> None:
        logger.warning("Signal received, initiating clean shutdown...")
        shutdown_event.set()

    loop.add_signal_handler(signal.SIGINT, _signal_handler)
    loop.add_signal_handler(signal.SIGTERM, _signal_handler)

    try:
        return await asyncio.wait_for(
            _execute_inner(config, claude_cli, db_path, shutdown_event),
            timeout=config.timeout_seconds,
        )
    except TimeoutError:
        with DAGStore(db_path) as dag:
            dag.log_event("error", f"Global timeout exceeded: {config.timeout_seconds}s")
            report_path = write_report(dag, config, db_path.parent)
            return ArchitectResult(
                success=False,
                db_path=db_path,
                report_path=report_path,
                total_cost_usd=dag.total_cost(),
                phases_completed=len(dag.list_phases(status="completed")),
                phases_total=len(dag.list_phases()),
                error=f"Global timeout exceeded ({config.timeout_seconds}s)",
            )
    except Exception as e:
        with DAGStore(db_path) as dag:
            dag.log_event("error", f"Unexpected error: {e}")
            report_path = write_report(dag, config, db_path.parent)
            return ArchitectResult(
                success=False,
                db_path=db_path,
                report_path=report_path,
                total_cost_usd=dag.total_cost(),
                phases_completed=len(dag.list_phases(status="completed")),
                phases_total=len(dag.list_phases()),
                error=str(e),
            )
    finally:
        loop.remove_signal_handler(signal.SIGINT)
        loop.remove_signal_handler(signal.SIGTERM)


async def _execute_inner(
    config: ArchitectConfig,
    claude_cli: str,
    db_path: Path,
    shutdown_event: asyncio.Event,
) -> ArchitectResult:
    """Inner execution loop."""
    with DAGStore(db_path) as dag:
        dag.set_metadata("config", config.to_dict())
        dag.set_metadata("started_at", datetime.now(UTC).isoformat())

        # ── PLANNING ──────────────────────────────────────────────
        dag.log_event("plan", "Starting architect planning phase")

        try:
            plan_data = await plan_phases(claude_cli, config)
        except PlanningError as e:
            dag.log_event("error", f"Planning failed: {e}")
            return ArchitectResult(success=False, db_path=db_path, error=str(e))

        _populate_dag(dag, plan_data)
        dag.set_metadata("rationale", plan_data.get("rationale", ""))
        dag.log_event("plan", f"Plan complete: {len(plan_data.get('phases', []))} phases")

        output_dir = db_path.parent

        # ── CONFIRM GATE ──────────────────────────────────────────
        if config.confirm:
            write_plan_review(dag, config, output_dir)
            report_path = write_report(dag, config, output_dir)
            dag.log_event("gate", "Paused at confirm gate")
            return ArchitectResult(
                success=True,
                db_path=db_path,
                report_path=report_path,
                phases_total=len(dag.list_phases()),
                paused=True,
            )

        # ── EXECUTION LOOP ────────────────────────────────────────
        return await _run_execution_loop(dag, config, output_dir, shutdown_event)


async def resume(db_path: Path) -> ArchitectResult:
    """Resume from a checkpoint."""
    if not db_path.exists():
        return ArchitectResult(
            success=False, db_path=db_path, error=f"Database not found: {db_path}"
        )

    with DAGStore(db_path) as dag:
        config_data = dag.get_metadata("config")
        if not config_data:
            return ArchitectResult(
                success=False, db_path=db_path, error="No config found in database"
            )

        config = ArchitectConfig.from_dict(config_data)
        config.confirm = False  # Don't stop again

        dag.log_event("resume", "Resuming architect execution")

        # Reset any paused phases back to pending
        for phase in dag.list_phases(status="paused"):
            dag.update_phase(phase.id, status="pending")
            dag.log_event("resume", f"Reset paused phase '{phase.id}' to pending", phase_id=phase.id)

        shutdown_event = asyncio.Event()
        loop = asyncio.get_running_loop()

        def _signal_handler() -> None:
            logger.warning("Signal received, initiating clean shutdown...")
            shutdown_event.set()

        loop.add_signal_handler(signal.SIGINT, _signal_handler)
        loop.add_signal_handler(signal.SIGTERM, _signal_handler)

        try:
            return await asyncio.wait_for(
                _run_execution_loop(dag, config, db_path.parent, shutdown_event),
                timeout=config.timeout_seconds,
            )
        except TimeoutError:
            dag.log_event("error", f"Global timeout exceeded: {config.timeout_seconds}s")
            report_path = write_report(dag, config, db_path.parent)
            return ArchitectResult(
                success=False,
                db_path=db_path,
                report_path=report_path,
                total_cost_usd=dag.total_cost(),
                phases_completed=len(dag.list_phases(status="completed")),
                phases_total=len(dag.list_phases()),
                error=f"Global timeout exceeded ({config.timeout_seconds}s)",
            )
        finally:
            loop.remove_signal_handler(signal.SIGINT)
            loop.remove_signal_handler(signal.SIGTERM)


async def _run_execution_loop(
    dag: DAGStore,
    config: ArchitectConfig,
    output_dir: Path,
    shutdown_event: asyncio.Event,
) -> ArchitectResult:
    """Main execution loop: pick ready phases, execute, observe, gate."""
    dag.log_event("execute", "Starting execution loop")

    while True:
        if shutdown_event.is_set():
            dag.log_event("error", "Interrupted by signal")
            report_path = write_report(dag, config, output_dir)
            logger.info("Clean shutdown. Resume with: cleave architect --resume %s", dag.db_path)
            return ArchitectResult(
                success=False,
                db_path=dag.db_path,
                report_path=report_path,
                total_cost_usd=dag.total_cost(),
                phases_completed=len(dag.list_phases(status="completed")),
                phases_total=len(dag.list_phases()),
                error="Interrupted by signal — resume with --resume",
            )

        ready = dag.ready_set()

        # Check termination conditions
        if not ready:
            if dag.all_completed():
                break  # Success
            if dag.has_failed():
                # Some phases failed, nothing left to run
                dag.log_event("error", "Execution halted: failed phases with no ready successors")
                break
            # Deadlock: no ready phases but not all completed
            dag.log_event("error", "Deadlock: no ready phases but work remains")
            report_path = write_report(dag, config, output_dir)
            return ArchitectResult(
                success=False,
                db_path=dag.db_path,
                report_path=report_path,
                total_cost_usd=dag.total_cost(),
                phases_completed=len(dag.list_phases(status="completed")),
                phases_total=len(dag.list_phases()),
                error="Deadlock: no phases ready but work remains",
            )

        # v1: execute one phase at a time (sequential)
        phase = ready[0]

        dag.update_phase(phase.id, status="running", started_at=datetime.now(UTC).isoformat())
        dag.log_event("execute", f"Starting phase '{phase.id}'", phase_id=phase.id)
        logger.info("Executing phase: %s (%s)", phase.id, phase.label)

        # Build OrchestratorConfig and run
        orch_config = _build_orchestrator_config(phase, config)
        orch_result = None
        try:
            orch_result = await asyncio.to_thread(orchestrator_run_sync, orch_config)
        except Exception as e:
            dag.update_phase(
                phase.id,
                status="failed",
                completed_at=datetime.now(UTC).isoformat(),
                error_message=str(e),
            )
            dag.log_event("error", f"Phase '{phase.id}' raised: {e}", phase_id=phase.id)
            orch_result = None

        if orch_result is not None:
            # Update phase with orchestrator results
            cost = orch_result.state.total_cost_usd if hasattr(orch_result, "state") else 0.0
            workspace = str(orch_result.workspace_path) if orch_result.workspace_path else ""

            if orch_result.success:
                dag.update_phase(
                    phase.id,
                    status="completed",
                    completed_at=datetime.now(UTC).isoformat(),
                    cost_usd=cost,
                    workspace_path=workspace,
                )
                dag.log_event("execute", f"Phase '{phase.id}' completed (${cost:.2f})", phase_id=phase.id)
            else:
                dag.update_phase(
                    phase.id,
                    status="failed",
                    completed_at=datetime.now(UTC).isoformat(),
                    cost_usd=cost,
                    workspace_path=workspace,
                    error_message=orch_result.error or "Unknown error",
                )
                dag.log_event("error", f"Phase '{phase.id}' failed: {orch_result.error}", phase_id=phase.id)

        # Refresh phase from DB after update
        phase = dag.get_phase(phase.id)
        if phase is None:
            continue

        # ── OBSERVE ───────────────────────────────────────────────
        dag.log_event("observe", f"Observing phase '{phase.id}'", phase_id=phase.id)
        observation = await observe_phase(config.repo_path, orch_result)
        dag.update_phase(phase.id, observation_json=json.dumps(observation))

        # ── GATE ──────────────────────────────────────────────────
        gate_decision = evaluate_gate(phase, observation, config, dag)
        dag.add_checkpoint(
            gate_type=gate_decision.gate_type,
            decision=gate_decision.decision,
            reason=gate_decision.reason,
            phase_id=phase.id,
        )
        dag.log_event(
            "gate",
            f"Gate decision for '{phase.id}': {gate_decision.decision} ({gate_decision.gate_type})",
            phase_id=phase.id,
        )
        logger.info(
            "Gate: %s → %s (%s)", phase.id, gate_decision.decision, gate_decision.reason
        )

        if gate_decision.decision == "pause":
            dag.update_phase(phase.id, status="paused")
            report_path = write_report(dag, config, output_dir)
            return ArchitectResult(
                success=False,
                db_path=dag.db_path,
                report_path=report_path,
                total_cost_usd=dag.total_cost(),
                phases_completed=len(dag.list_phases(status="completed")),
                phases_total=len(dag.list_phases()),
                paused=True,
                error=gate_decision.reason,
            )

        if gate_decision.decision == "fail":
            report_path = write_report(dag, config, output_dir)
            return ArchitectResult(
                success=False,
                db_path=dag.db_path,
                report_path=report_path,
                total_cost_usd=dag.total_cost(),
                phases_completed=len(dag.list_phases(status="completed")),
                phases_total=len(dag.list_phases()),
                error=gate_decision.reason,
            )

        # decision == "continue" → loop

    # ── COMPLETE ──────────────────────────────────────────────────
    all_phases = dag.list_phases()
    completed = [p for p in all_phases if p.status == "completed"]
    failed = [p for p in all_phases if p.status == "failed"]

    success = len(failed) == 0 and len(completed) == len(all_phases)
    dag.set_metadata("completed_at", datetime.now(UTC).isoformat())
    dag.log_event("execute", f"Architect complete: {len(completed)}/{len(all_phases)} phases succeeded")

    report_path = write_report(dag, config, output_dir)

    error = None
    if failed:
        error = f"{len(failed)} phase(s) failed: {', '.join(p.id for p in failed)}"

    return ArchitectResult(
        success=success,
        db_path=dag.db_path,
        report_path=report_path,
        total_cost_usd=dag.total_cost(),
        phases_completed=len(completed),
        phases_total=len(all_phases),
        error=error,
    )


def _populate_dag(dag: DAGStore, plan_data: dict) -> None:
    """Populate the DAG from planner output."""
    phases = plan_data.get("phases", [])

    # Compute waves for wave assignment
    # Build adjacency for wave computation
    id_set = {p["id"] for p in phases}
    depths: dict[str, int] = {}

    def _depth(pid: str, seen: set[str] | None = None) -> int:
        if pid in depths:
            return depths[pid]
        if seen is None:
            seen = set()
        if pid in seen:
            return 0
        seen.add(pid)
        phase_data = next((p for p in phases if p["id"] == pid), None)
        if not phase_data:
            return 0
        deps = [d for d in phase_data.get("depends_on", []) if d in id_set]
        if not deps:
            depths[pid] = 0
            return 0
        d = max(_depth(dep, seen) for dep in deps) + 1
        depths[pid] = d
        return d

    for p in phases:
        _depth(p["id"])

    for p in phases:
        phase = Phase(
            id=p["id"],
            label=p["label"],
            description=p["description"],
            phase_type=p.get("phase_type", "cleave_run"),
            wave=depths.get(p["id"], 0),
        )
        # Store phase-specific success_criteria in config_json
        if p.get("success_criteria"):
            phase.config_json = json.dumps({"success_criteria": p["success_criteria"]})
        dag.add_phase(phase)

    # Add dependencies
    for p in phases:
        for dep in p.get("depends_on", []):
            if dep in id_set:
                dag.add_dependency(p["id"], dep)


def execute_sync(config: ArchitectConfig) -> ArchitectResult:
    """Synchronous wrapper for the async ``execute()`` entry point."""
    return asyncio.run(execute(config))


def resume_sync(db_path: Path) -> ArchitectResult:
    """Synchronous wrapper for the async ``resume()`` entry point."""
    return asyncio.run(resume(db_path))


def plan_sync(config: ArchitectConfig) -> ArchitectResult:
    """Synchronous wrapper for the async ``plan()`` entry point."""
    return asyncio.run(plan(config))
