"""DobbyDaemon: central orchestrator that ties all components together."""

from __future__ import annotations

import asyncio
import logging
import os
import signal
from datetime import UTC, datetime
from typing import Any

import aiohttp

from dobby.config import DobbyConfig
from dobby.core.api import EventBus, create_app
from dobby.core.collector import Collector
from dobby.core.dispatcher import Dispatcher
from dobby.core.gpu_monitor import GPUMonitor
from dobby.core.loops.planner_loop import planner_loop
from dobby.core.loops.todo_digest import todo_digest_loop
from dobby.core.prompt_builder import PromptBuilder
from dobby.core.queue import QueueManager
from dobby.models import (
    Priority,
    Result,
    ScheduleType,
    TaskStatus,
    Todo,
)
from dobby.store.sqlite import SqliteStore

logger = logging.getLogger(__name__)


class DobbyDaemon:
    """Central orchestrator: initializes all subsystems and drives the main loop."""

    def __init__(self, config: DobbyConfig) -> None:
        self.config = config
        self.store: SqliteStore | None = None
        self.queue: QueueManager | None = None
        self.dispatcher: Dispatcher | None = None
        self.gpu_monitor: GPUMonitor | None = None
        self.event_bus: EventBus = EventBus()
        self.slack_notifier: Any = None  # Set externally if Slack is configured
        self._running: bool = False
        # Background GPU Phase 2 monitoring tasks: task_id -> asyncio.Task
        self._gpu_tasks: dict[str, asyncio.Task] = {}

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def initialize(self) -> None:
        """Set up all subsystems and recover from any interrupted runs."""
        # 1. Persistent storage
        self.store = SqliteStore(self.config.db_path)
        await self.store.initialize()

        # 2. Queue / scheduling manager
        self.queue = QueueManager(self.store)

        # 3. Claude Code subprocess dispatcher
        self.dispatcher = Dispatcher(
            max_concurrent=self.config.max_concurrent_agents,
            default_timeout=self.config.default_timeout,
            results_dir=self.config.results_dir,
            store=self.store,
        )

        # 4. GPU job monitor
        self.gpu_monitor = GPUMonitor(poll_interval=self.config.gpu_poll_interval)

        # 5. Recovery: any task left in 'running' state from a previous daemon
        #    that crashed must be marked failed so they become re-schedulable.
        await self._recover_running_tasks()

    async def shutdown(self) -> None:
        """Gracefully close all resources."""
        self._running = False
        if self.store is not None:
            await self.store.close()
            self.store = None

    # ------------------------------------------------------------------
    # Main loop
    # ------------------------------------------------------------------

    async def tick(self) -> None:
        """Run a single iteration of the dispatch loop.

        1. Resolve AFTER-type dependency chains.
        2. Pick the next eligible pending task.
        3. Dispatch it, store the result, update task status.
        """
        # 0. Check GPU Phase 2 background tasks for completion
        self._check_gpu_jobs()

        # 0b. Check for stale running tasks
        await self._check_stale_tasks()

        # 1. Promote AFTER tasks whose dependency completed
        await self.queue.check_dependencies()

        # 2. Pick next task (respects project locks and priority)
        task = await self.queue.next_dispatchable_task()
        if task is None:
            return

        # 3. Fetch the project
        project = await self.store.get_project(task.project)
        if project is None:
            logger.warning(
                "Project '%s' not found for task %s — marking failed", task.project, task.id
            )
            await self.store.update_task(task.id, status=TaskStatus.FAILED.value)
            return

        # 4. Worktrees enable parallel execution — no project lock needed
        # (Each task gets its own git worktree branch)
        # 5. Increment attempt number and mark the task as running
        started_at = datetime.now(UTC)
        new_attempt = task.attempt_number + 1
        await self.store.update_task(
            task.id,
            status=TaskStatus.RUNNING.value,
            started_at=started_at.isoformat(),
            attempt_number=new_attempt,
        )

        # 5b. Notify Slack that this task is now running
        await self._notify_running(task)

        try:
            # 5c. Fetch user preferences so the agent respects them
            user_preferences = await self.store.search_memories(type="preference")

            # 6. Run the Claude Code agent
            result = await self.dispatcher.dispatch(
                task,
                project,
                timeout=task.timeout,
                infra_name=task.infra,
                user_preferences=user_preferences if user_preferences else None,
            )
            # Stamp the result with the current attempt number
            result.attempt_number = new_attempt

            current_task = await self.store.get_task(task.id)
            if current_task.status == TaskStatus.CANCELLED:
                if result.status != TaskStatus.CANCELLED.value:
                    result.status = TaskStatus.CANCELLED.value
                if not result.summary:
                    result.summary = "Task cancelled by user"

            # 7. Persist the result
            await self.store.store_result(result)

            # 7b. Check for GPU Phase 1 output (gpu_pid + log_path in metrics)
            gpu_pid = result.metrics.get("gpu_pid")
            gpu_log = result.metrics.get("log_path")
            if gpu_pid is not None and gpu_log is not None and task.infra:
                infra = next(
                    (ic for ic in project.infra if ic.name == task.infra), None
                )
                if infra and infra.host:
                    self._start_gpu_monitor(
                        task.id, infra.host, int(gpu_pid), gpu_log, task.description,
                    )
                    # Keep task in RUNNING while GPU job is monitored
                    return

            # 8. Check batch completion
            if task.batch_id and result.status in ("completed", "failed"):
                await self._check_batch_completion(task.batch_id)

            # 9. Update task status based on what the agent reported
            if current_task.status == TaskStatus.CANCELLED:
                await self.store.update_task(task.id, started_at=None)
            elif task.schedule.type in (ScheduleType.CRON, ScheduleType.INTERVAL):
                # Track consecutive failures for recurring tasks
                updated_task = await self.store.get_task(task.id)
                if result.status == "completed":
                    consecutive_failures = 0
                else:
                    consecutive_failures = updated_task.consecutive_failures + 1

                # Compute next_run_at
                # Temporarily update last_run_at so compute_next_run can use it
                temp_task = await self.store.get_task(task.id)
                temp_task.last_run_at = result.completed_at
                next_run = self.queue.compute_next_run(temp_task)

                update_fields = {
                    "status": TaskStatus.ACTIVE.value,
                    "started_at": None,
                    "last_run_at": result.completed_at.isoformat(),
                    "consecutive_failures": consecutive_failures,
                    "next_run_at": next_run.isoformat() if next_run else None,
                }

                # Auto-pause if too many consecutive failures
                max_f = updated_task.max_failures
                if consecutive_failures >= max_f and max_f > 0:
                    update_fields["status"] = TaskStatus.PAUSED.value
                    logger.warning(
                        "Task %s auto-paused after %d consecutive failures",
                        task.id, consecutive_failures,
                    )

                await self.store.update_task(task.id, **update_fields)
            else:
                final_status = (
                    TaskStatus.COMPLETED if result.status == "completed" else TaskStatus.FAILED
                )
                await self.store.update_task(
                    task.id,
                    status=final_status.value,
                    started_at=None,
                    last_run_at=result.completed_at.isoformat(),
                )
                # Fire notifications for one-off task completion/failure
                await self._notify_task_outcome(task, result)

        except Exception as exc:
            error_detail = str(exc)[:200]
            logger.exception("Dispatch failed for task %s: %s", task.id, exc)
            # Store the error as a result so the dashboard can show it
            try:
                from dobby.models import Result
                error_result = Result(
                    task_id=task.id,
                    status="failed",
                    summary=f"Dispatch error: {error_detail}",
                    started_at=started_at,
                )
                await self.store.store_result(error_result)
            except Exception:
                pass

            existing = await self.store.get_task(task.id)
            if existing.status == TaskStatus.CANCELLED:
                await self.store.update_task(task.id, started_at=None)
            elif task.schedule.type in (ScheduleType.CRON, ScheduleType.INTERVAL):
                consecutive_failures = existing.consecutive_failures + 1
                now_ts = datetime.now(UTC)
                existing.last_run_at = now_ts
                next_run = self.queue.compute_next_run(existing)
                update_fields = {
                    "status": TaskStatus.ACTIVE.value,
                    "started_at": None,
                    "last_run_at": now_ts.isoformat(),
                    "consecutive_failures": consecutive_failures,
                    "next_run_at": next_run.isoformat() if next_run else None,
                }
                max_f = existing.max_failures
                if consecutive_failures >= max_f and max_f > 0:
                    update_fields["status"] = TaskStatus.PAUSED.value
                    logger.warning(
                        "Task %s auto-paused after %d consecutive failures (exception)",
                        task.id, consecutive_failures,
                    )
                await self.store.update_task(task.id, **update_fields)
            else:
                await self.store.update_task(
                    task.id,
                    status=TaskStatus.FAILED.value,
                    started_at=None,
                )

        finally:
            pass  # No project lock to release — worktrees enable parallel execution

    async def run(self) -> None:
        """Full daemon main loop (used by the CLI `dobby daemon` command).

        Initializes subsystems, starts the REST API, then polls for work
        every 5 seconds until a SIGTERM/SIGINT is received.
        """
        await self.initialize()

        # Set up graceful shutdown on POSIX signals
        loop = asyncio.get_running_loop()
        stop_event = asyncio.Event()

        def _handle_signal():
            logger.info("Received shutdown signal")
            stop_event.set()

        for sig in (signal.SIGTERM, signal.SIGINT):
            loop.add_signal_handler(sig, _handle_signal)

        # Start the REST API server
        from aiohttp import web

        from dobby.web.dashboard import setup_dashboard

        # Optionally embed the Slack bot for interactions support
        slack_bot = None
        slack_bot_token = os.environ.get("SLACK_BOT_TOKEN")
        slack_app_token = os.environ.get("SLACK_APP_TOKEN")
        if slack_bot_token and slack_app_token:
            try:
                from dobby.slack.bot import DobbySlackBot
                slack_bot = DobbySlackBot(
                    slack_bot_token=slack_bot_token,
                    slack_app_token=slack_app_token,
                    dobby_url=f"http://127.0.0.1:{self.config.port}",
                )
                logger.info("Slack bot embedded in daemon for interactions support")
            except Exception as exc:
                logger.warning("Failed to create embedded Slack bot: %s", exc)

        app = create_app(
            self.store,
            self.dispatcher,
            results_dir=self.config.results_dir,
            api_key=self.config.api_key,
            event_bus=self.event_bus,
            slack_bot=slack_bot,
            hub_url=self.config.hub_url,
            github_url=self.config.github_url,
        )
        setup_dashboard(app)
        runner = web.AppRunner(app)
        await runner.setup()
        site = web.TCPSite(runner, self.config.host, self.config.port)
        await site.start()
        logger.info("Dobby REST API listening on %s:%s", self.config.host, self.config.port)

        # Start Claude Code remote control session if configured
        remote_session = None
        remote_cfg = getattr(self, "_remote_session_config", None)
        if remote_cfg:
            try:
                from dobby.core.remote_session import RemoteSession
                remote_session = RemoteSession(
                    name=remote_cfg.get("name", "Dobby Hub"),
                    capacity=remote_cfg.get("capacity", 4),
                )
                started = await remote_session.start()
                if started:
                    logger.info("Claude Code remote session active — connect at claude.ai/code")
                    # Store in app for status endpoint
                    app["remote_session"] = remote_session
            except Exception as exc:
                logger.warning("Failed to start remote session: %s", exc)

        # Start embedded Slack bot socket mode handler
        if slack_bot is not None:
            try:
                from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
                socket_handler = AsyncSocketModeHandler(slack_bot.app, slack_app_token)
                asyncio.ensure_future(socket_handler.start_async())
                logger.info("Slack bot socket mode handler started")
            except Exception as exc:
                logger.warning("Failed to start Slack socket mode: %s", exc)

        self._running = True
        # Start the todo reminder/digest background task
        todo_digest_task = asyncio.create_task(
            todo_digest_loop(stop_event, self.store, self.slack_notifier), name="todo-digest"
        )
        # Start the daily planner background task
        planner_bg_task = asyncio.create_task(
            planner_loop(stop_event, self.store), name="planner"
        )
        try:
            while not stop_event.is_set():
                try:
                    await self.tick()
                except Exception as exc:
                    logger.exception("Unhandled error in tick(): %s", exc)
                await asyncio.sleep(5)
        finally:
            todo_digest_task.cancel()
            planner_bg_task.cancel()
            if remote_session:
                await remote_session.stop()
            await runner.cleanup()
            await self.shutdown()
            logger.info("Dobby daemon shut down cleanly")

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    async def _check_batch_completion(self, batch_id: str) -> None:
        """Check if all tasks in a batch are done; if so, log and (optionally) notify."""
        tasks = await self.store.list_tasks_by_batch(batch_id)
        terminal = {TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED}
        if all(t.status in terminal for t in tasks):
            completed = sum(1 for t in tasks if t.status == TaskStatus.COMPLETED)
            failed = sum(1 for t in tasks if t.status == TaskStatus.FAILED)
            logger.info(
                "Batch %s complete: %d/%d succeeded, %d failed",
                batch_id, completed, len(tasks), failed,
            )

    async def _recover_running_tasks(self) -> None:
        """Mark any tasks left in RUNNING state as FAILED (daemon restart recovery)."""
        running_tasks = await self.store.list_tasks(status=TaskStatus.RUNNING.value)
        for task in running_tasks:
            logger.warning(
                "Task %s was in RUNNING state on daemon start — marking failed (daemon_restart)",
                task.id,
            )
            await self.store.update_task(task.id, status=TaskStatus.FAILED.value)

    # ------------------------------------------------------------------
    # Stale-run recovery
    # ------------------------------------------------------------------

    async def _check_stale_tasks(self) -> None:
        """Detect running tasks whose log hasn't been updated recently.

        If a task's agent.log hasn't been modified for longer than
        ``config.stale_timeout`` seconds, check whether the subprocess
        is still alive.  If it isn't (or no PID is tracked), mark the
        task as FAILED.
        """
        running_tasks = await self.store.list_tasks(status=TaskStatus.RUNNING.value)
        now = datetime.now(UTC)
        stale_seconds = self.config.stale_timeout

        for task in running_tasks:
            # Skip GPU-monitored tasks
            if task.id in self._gpu_tasks:
                continue

            log_path = os.path.join(self.config.results_dir, task.id, "agent.log")
            last_update = None
            if os.path.exists(log_path):
                mtime = os.path.getmtime(log_path)
                last_update = datetime.fromtimestamp(mtime, tz=UTC)
            elif task.started_at:
                last_update = task.started_at

            if last_update is None:
                continue

            elapsed = (now - last_update).total_seconds()
            if elapsed < stale_seconds:
                continue

            # Check if subprocess is still alive
            pid = self.dispatcher.running_pids.get(task.id) if self.dispatcher else None
            still_alive = False
            if pid is not None:
                try:
                    os.kill(pid, 0)
                    still_alive = True
                except (ProcessLookupError, PermissionError):
                    pass

            if still_alive:
                logger.warning(
                    "Task %s stale (no log update for %ds) but process %d still alive",
                    task.id, int(elapsed), pid,
                )
                continue

            logger.warning(
                "Task %s stale — no log update for %ds and process not alive; marking failed",
                task.id, int(elapsed),
            )
            await self.store.update_task(
                task.id,
                status=TaskStatus.FAILED.value,
                started_at=None,
            )
            await self.event_bus.publish("task_status", {
                "task_id": task.id,
                "status": "failed",
                "detail": f"stale: no log update for {int(elapsed)}s",
            })
            if self.dispatcher:
                self.dispatcher.running_pids.pop(task.id, None)

    # ------------------------------------------------------------------
    # Notifications (webhook + Slack + SSE)
    # ------------------------------------------------------------------

    async def _notify_running(self, task: Any) -> None:
        """Notify Slack that a task has started running."""
        if self.slack_notifier is None:
            return
        task_dict = task.to_dict() if hasattr(task, "to_dict") else task
        try:
            await self.slack_notifier.notify_running(task_dict)
        except Exception as exc:
            logger.warning("Slack running notification failed: %s", exc)

    async def _notify_task_outcome(self, task: Any, result: Result) -> None:
        """Fire webhook, Slack, and SSE notifications for task completion/failure."""
        task_dict = task.to_dict() if hasattr(task, "to_dict") else task
        result_dict = result.to_dict() if hasattr(result, "to_dict") else result

        # SSE event
        await self.event_bus.publish("task_status", {
            "task_id": task.id,
            "status": result.status,
            "summary": result.summary,
        })

        # Webhook
        await self._fire_webhook(task_dict, result_dict)

        # Slack
        if self.slack_notifier is not None:
            try:
                if result.status == "completed":
                    await self.slack_notifier.notify_completed(task_dict, result_dict)
                else:
                    await self.slack_notifier.notify_failed(task_dict, result_dict)
            except Exception as exc:
                logger.warning("Slack notification failed: %s", exc)

        # Auto-create todos from task outcomes
        await self._auto_create_todos(task, result)

        # Finalize trajectory trace
        trace_id = getattr(task, "trace_id", None) or (
            task.get("trace_id") if isinstance(task, dict) else None
        )
        if trace_id and self.store:
            try:
                from dobby.core.trajectory import trajectory_parser
                cost_info = result.cost or {}
                await trajectory_parser.add_span(
                    self.store, trace_id, kind="notification",
                    metadata={"notified": self.slack_notifier is not None},
                )
                await trajectory_parser.finalize_trace(
                    self.store, trace_id,
                    status=result.status,
                    total_input_tokens=cost_info.get("input_tokens", 0),
                    total_output_tokens=cost_info.get("output_tokens", 0),
                    total_cost_usd=cost_info.get("cost_usd", 0.0),
                )
            except Exception:
                logger.debug("Trace finalization failed", exc_info=True)

    async def _auto_create_todos(self, task: Any, result: Result) -> None:
        """Auto-create todos from task outcomes (PR reviews, failure investigations)."""
        if self.store is None:
            return
        project = task.project if hasattr(task, "project") else task.get("project", "")

        try:
            if result.status == "completed":
                # Check for PR artifacts → create "Review PR" todo
                for artifact in result.artifacts:
                    url = (
                        artifact.url if hasattr(artifact, "url")
                        else (artifact.get("url", "") if isinstance(artifact, dict) else "")
                    )
                    if url and ("pull" in url or "pr" in url.lower()):
                        # Extract PR number from URL
                        pr_num = url.rstrip("/").split("/")[-1]
                        todo = Todo(
                            description=f"Review PR #{pr_num}",
                            project=project,
                            priority=Priority.NORMAL,
                            tags=["pr", "review", "auto"],
                            source_task_id=task.id if hasattr(task, "id") else task.get("id"),
                        )
                        await self.store.create_todo(todo)
                        logger.info("Auto-created todo %s: Review PR #%s", todo.id, pr_num)
                        break  # Only one todo per task
            elif result.status == "failed":
                task_id = task.id if hasattr(task, "id") else task.get("id", "?")
                todo = Todo(
                    description=f"Investigate failure: {task_id}",
                    project=project,
                    priority=Priority.HIGH,
                    tags=["failure", "auto"],
                    source_task_id=task_id,
                )
                await self.store.create_todo(todo)
                logger.info("Auto-created todo %s: Investigate failure %s", todo.id, task_id)
        except Exception as exc:
            logger.warning("Failed to auto-create todo: %s", exc)

    async def _fire_webhook(self, task_dict: dict, result_dict: dict) -> None:
        """POST task outcome to configured webhook URL."""
        webhook_url = self.config.webhook_url
        if not webhook_url:
            return

        payload = {
            "event": (
                "task_completed" if result_dict.get("status") == "completed" else "task_failed"
            ),
            "task": task_dict,
            "result": result_dict,
        }

        try:
            async with aiohttp.ClientSession() as session, session.post(
                webhook_url,
                json=payload,
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                if resp.status >= 400:
                    body = await resp.text()
                    logger.warning(
                        "Webhook POST to %s returned %d: %s",
                        webhook_url, resp.status, body[:200],
                    )
        except Exception as exc:
            logger.warning("Webhook POST to %s failed: %s", webhook_url, exc)

    # ------------------------------------------------------------------
    # GPU Phase 2 monitoring
    # ------------------------------------------------------------------

    def _start_gpu_monitor(
        self,
        task_id: str,
        host: str,
        pid: int,
        log_path: str,
        task_description: str,
    ) -> None:
        """Spawn a background task to poll a GPU job and parse logs when done."""
        coro = self._gpu_monitor_worker(task_id, host, pid, log_path, task_description)
        bg_task = asyncio.create_task(coro, name=f"gpu-monitor-{task_id}")
        self._gpu_tasks[task_id] = bg_task

    async def _gpu_monitor_worker(
        self,
        task_id: str,
        host: str,
        pid: int,
        log_path: str,
        task_description: str,
    ) -> None:
        """Poll GPU PID until done, fetch log, parse into Result."""
        try:
            logger.info("GPU Phase 2: monitoring PID %d on %s for task %s", pid, host, task_id)
            await self.gpu_monitor.wait_for_completion(host, pid, log_path)

            log_content = await self.gpu_monitor.fetch_log(host, log_path)
            prompt = PromptBuilder.build_gpu_parse_prompt(log_content, task_description)

            # Spawn a short Claude session to parse the log
            proc = await asyncio.create_subprocess_exec(
                "claude", "-p", prompt,
                "--output-format", "json",
                "--max-turns", "5",
                "--dangerously-skip-permissions",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.STDOUT,
            )
            stdout, _ = await proc.communicate()
            output = stdout.decode(errors="replace") if stdout else ""
            exit_code = proc.returncode or 0

            result = Collector.parse_output(output, exit_code, task_id)
            result.gpu_info = {"host": host, "pid": pid, "log_path": log_path}
            await self.store.store_result(result)

            final_status = (
                TaskStatus.COMPLETED if result.status == "completed" else TaskStatus.FAILED
            )
            await self.store.update_task(
                task_id,
                status=final_status.value,
                last_run_at=result.completed_at.isoformat(),
            )
            logger.info("GPU Phase 2 complete for task %s: %s", task_id, result.status)

        except Exception as exc:
            logger.exception("GPU Phase 2 failed for task %s: %s", task_id, exc)
            await self.store.update_task(task_id, status=TaskStatus.FAILED.value)
        finally:
            self._gpu_tasks.pop(task_id, None)

    def _check_gpu_jobs(self) -> None:
        """Clean up completed GPU monitoring tasks."""
        done = [tid for tid, t in self._gpu_tasks.items() if t.done()]
        for tid in done:
            task = self._gpu_tasks.pop(tid)
            exc = task.exception() if not task.cancelled() else None
            if exc:
                logger.error("GPU monitor background task for %s raised: %s", tid, exc)
