"""Heartbeat observer: periodic background agent turns in the main session."""

from __future__ import annotations

import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING

from klir.infra.base_observer import BaseObserver
from klir.log_context import set_log_context
from klir.utils.quiet_hours import check_quiet_hour

if TYPE_CHECKING:
    from klir.config import AgentConfig, HeartbeatConfig

logger = logging.getLogger(__name__)

# Callback signature: (chat_id, alert_text)
HeartbeatResultCallback = Callable[[int, str], Awaitable[None]]


class HeartbeatObserver(BaseObserver):
    """Sends periodic heartbeat prompts through the main session.

    Follows the CronObserver lifecycle pattern: start/stop with an asyncio
    background task. Results are delivered via a callback set by
    ``set_result_handler``.
    """

    def __init__(self, config: AgentConfig) -> None:
        super().__init__()
        self._config = config
        self._on_result: HeartbeatResultCallback | None = None
        self._handle_heartbeat: Callable[[int], Awaitable[str | None]] | None = None
        self._is_chat_busy: Callable[[int], bool] | None = None
        self._stale_cleanup: Callable[[], Awaitable[int]] | None = None

    @property
    def _hb(self) -> HeartbeatConfig:
        return self._config.heartbeat

    def set_result_handler(self, handler: HeartbeatResultCallback) -> None:
        """Set callback for delivering alert messages to the user."""
        self._on_result = handler

    def set_heartbeat_handler(
        self,
        handler: Callable[[int], Awaitable[str | None]],
    ) -> None:
        """Set the function that executes a heartbeat turn (orchestrator.handle_heartbeat)."""
        self._handle_heartbeat = handler

    def set_busy_check(self, check: Callable[[int], bool]) -> None:
        """Set the function that checks if a chat has active CLI processes."""
        self._is_chat_busy = check

    def set_stale_cleanup(self, cleanup: Callable[[], Awaitable[int]]) -> None:
        """Set the function that kills stale CLI processes (wall-clock based)."""
        self._stale_cleanup = cleanup

    async def start(self) -> None:
        """Start the heartbeat background loop."""
        if not self._hb.enabled:
            logger.info("Heartbeat disabled in config")
            return
        if self._handle_heartbeat is None:
            logger.error("Heartbeat handler not set, cannot start")
            return
        await super().start()
        logger.info(
            "Heartbeat started (every %dm, quiet %d:00-%d:00)",
            self._hb.interval_minutes,
            self._hb.quiet_start,
            self._hb.quiet_end,
        )

    async def stop(self) -> None:
        """Stop the heartbeat background loop."""
        await super().stop()
        logger.info("Heartbeat stopped")

    async def _run(self) -> None:
        """Sleep -> check -> execute -> repeat."""
        last_wall = time.time()
        try:
            while self._running:
                # Read interval fresh each iteration so config-reload changes take effect.
                interval = self._hb.interval_minutes * 60
                await asyncio.sleep(interval)
                if not self._running or not self._hb.enabled:
                    continue

                # Detect system suspend via wall-clock gap.
                now_wall = time.time()
                wall_elapsed = now_wall - last_wall
                if wall_elapsed > interval * 2:
                    logger.warning(
                        "Wall-clock gap: %.0fs (expected ~%ds) -- system likely suspended",
                        wall_elapsed,
                        interval,
                    )
                last_wall = now_wall

                try:
                    await self._tick()
                except asyncio.CancelledError:
                    raise
                except Exception:
                    logger.exception("Heartbeat tick failed (continuing)")
        except asyncio.CancelledError:
            logger.debug("Heartbeat loop cancelled")

    async def _tick(self) -> None:
        """Run one heartbeat cycle for all allowed users."""
        # Cleanup stale processes first (catches suspend hangovers).
        if self._stale_cleanup:
            try:
                killed = await self._stale_cleanup()
                if killed:
                    logger.info("Cleaned up %d stale process(es)", killed)
            except asyncio.CancelledError:
                raise
            except Exception:
                logger.exception("Stale process cleanup failed")

        is_quiet, now_hour, tz = check_quiet_hour(
            quiet_start=self._hb.quiet_start,
            quiet_end=self._hb.quiet_end,
            user_timezone=self._config.user_timezone,
            global_quiet_start=self._hb.quiet_start,
            global_quiet_end=self._hb.quiet_end,
        )
        if is_quiet:
            logger.debug("Heartbeat skipped: quiet hours (%d:00 %s)", now_hour, tz.key)
            return

        logger.debug("Heartbeat tick: checking %d chat(s)", len(self._config.allowed_user_ids))
        for chat_id in self._config.allowed_user_ids:
            await self._run_for_chat(chat_id)

    async def _run_for_chat(self, chat_id: int) -> None:
        """Execute a single heartbeat for one chat."""
        set_log_context(operation="hb", chat_id=chat_id)

        if self._is_chat_busy and self._is_chat_busy(chat_id):
            logger.debug("Heartbeat skipped: chat is busy")
            return

        if self._handle_heartbeat is None:
            return

        try:
            alert_text = await self._handle_heartbeat(chat_id)
        except asyncio.CancelledError:
            raise
        except Exception:
            logger.exception("Heartbeat execution error")
            return

        if alert_text is None:
            return

        if self._on_result:
            try:
                await self._on_result(chat_id, alert_text)
            except asyncio.CancelledError:
                raise
            except Exception:
                logger.exception("Heartbeat result delivery error")
