"""Heartbeat: periodic background loop that checks state and logs observations.

Every pulse is recorded to SQLite for the cardiogram time series.
Deviations (blocked tasks, overdue items, errors) are flagged.
"""

from __future__ import annotations

import asyncio
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING

import aiosqlite

from agntspace.automation.heartbeat_pacing import PacingDecision, compute_sleep

if TYPE_CHECKING:
    from agntspace.agent.orchestrator import Orchestrator
    from agntspace.journal.service import JournalService
    from agntspace.tasks.service import TaskService

log = logging.getLogger(__name__)


class Heartbeat:
    """15-minute background loop that monitors state and logs to journal.

    Every pulse is persisted to SQLite so the UI can render a cardiogram.
    """

    def __init__(
        self,
        journal: JournalService,
        task_service: TaskService,
        db: aiosqlite.Connection | None = None,
        interval_seconds: int = 900,
        llm: object | None = None,
        goal_pursuit_seconds: int | None = None,
    ) -> None:
        self._journal = journal
        self._task_service = task_service
        self._db = db
        self._interval = interval_seconds
        # Goal pursuit cadence is independent of heartbeat cadence so the
        # heartbeat can run fast (responsiveness) while goal pursuit stays
        # deliberate. ``None`` means "match the heartbeat interval" — same
        # behavior as before the split.
        self._goal_pursuit_interval = (
            goal_pursuit_seconds if goal_pursuit_seconds is not None else interval_seconds
        )
        self._last_goal_pursuit_at: datetime | None = None
        self._llm = llm  # LLM provider for connectivity checks
        self._work_queue: object | None = None  # Set post-init by main.py
        self._orchestrator: Orchestrator | None = None  # Set post-init by main.py
        self._skill_loader: object | None = None  # Set post-init; owns pacing config
        self._settings: object | None = None  # Set post-init; owns timezone
        self._coach: object | None = None  # Set post-init; for active-goal count signal
        self._last_pacing: PacingDecision | None = None  # For get_status()
        self._task: asyncio.Task | None = None  # type: ignore[type-arg]
        self._running = False

    async def init_schema(self) -> None:
        """Create heartbeat tables if they don't exist."""
        if not self._db:
            return
        await self._db.executescript("""
            CREATE TABLE IF NOT EXISTS heartbeat_log (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                ts TEXT NOT NULL,
                pulse_type TEXT NOT NULL DEFAULT 'heartbeat',
                status TEXT NOT NULL DEFAULT 'normal',
                observations INTEGER NOT NULL DEFAULT 0,
                active_tasks INTEGER NOT NULL DEFAULT 0,
                blocked_tasks INTEGER NOT NULL DEFAULT 0,
                overdue_tasks INTEGER NOT NULL DEFAULT 0,
                details TEXT
            );
            CREATE INDEX IF NOT EXISTS idx_heartbeat_ts ON heartbeat_log(ts);
        """)
        # Add pulse_type column if upgrading from older schema
        try:
            await self._db.execute("SELECT pulse_type FROM heartbeat_log LIMIT 1")
        except Exception:
            await self._db.execute("ALTER TABLE heartbeat_log ADD COLUMN pulse_type TEXT NOT NULL DEFAULT 'heartbeat'")
        await self._db.commit()

    def start(self) -> None:
        """Start the heartbeat background loop."""
        if self._running:
            return
        self._running = True
        self._task = asyncio.create_task(self._loop())
        log.debug("Heartbeat started (interval: %ds)", self._interval)

    def stop(self) -> None:
        """Stop the heartbeat background loop."""
        self._running = False
        if self._task:
            self._task.cancel()
            self._task = None
        log.debug("Heartbeat stopped")

    async def record_pulse(
        self,
        pulse_type: str,
        details: str | None = None,
        status: str | None = None,
    ) -> None:
        """Record any agent activity as a pulse on the cardiogram.

        Pulse types:
        - heartbeat: regular 15-min check (automatic)
        - chat: agent responded to a message
        - tool_use: agent used a tool (email, vault, task, etc.)
        - automation: briefing, EOD, reflection, memory generation
        - channel: handled a channel message
        - cron: cron job executed
        - error: LLM failure, integration error, etc. (auto-deviation)
        - startup: server startup pulse
        """
        if not self._db:
            return
        # Error pulses are always deviations
        if status is None:
            status = "deviation" if pulse_type == "error" else "normal"
        now = datetime.now(UTC).isoformat()
        try:
            await self._db.execute(
                "INSERT INTO heartbeat_log (ts, pulse_type, status, observations, active_tasks, blocked_tasks, overdue_tasks, details) "
                "VALUES (?, ?, ?, 0, 0, 0, 0, ?)",
                (now, pulse_type, status, details),
            )
            await self._db.commit()
        except Exception:
            log.debug("Failed to record %s pulse", pulse_type, exc_info=True)

    @property
    def is_running(self) -> bool:
        return self._running

    async def run_once(self) -> list[str]:
        """Run a single heartbeat cycle. Returns list of observations logged.

        Opens a ``heartbeat-*`` correlation scope so every observation,
        LLM health check, and outreach dispatched from this cycle
        shares one causal chain id in /decisions (Rule #17 Tier B).
        """
        from agntspace.activity.decisions import (
            correlation_scope,
            new_correlation_id,
        )

        with correlation_scope(new_correlation_id("heartbeat")):
            return await self._do_run_once()

    async def _do_run_once(self) -> list[str]:
        """Internal heartbeat body — caller owns the correlation scope."""
        # Pre-authorize contract actions that internal heartbeat paths need
        # when they write to the vault. The heartbeat runs as a background
        # worker (Tier B), so no API route or agent tool has already gated
        # these checks for us — we declare them upfront so
        # CoachService.record_block / set_pursuit_status (which write with
        # contract_action="create_task") pass the VaultWriter runtime guard.
        # Before this, every goal-block write under the heartbeat scope
        # raised ContractBypassError because only proactive_outreach was
        # authorized. `create_task` covers goal/task file writes; the
        # others cover goal pursuit bookkeeping + milestone updates.
        try:
            from agntspace.activity.decisions import mark_actions_authorized
            mark_actions_authorized([
                "create_task",
                "modify_project",
                "proactive_outreach",
                "write_vault",
            ])
        except Exception:  # noqa: BLE001 — auth pre-stamp is opportunistic
            log.debug("heartbeat pre-authorize failed", exc_info=True)

        observations: list[str] = []
        now = datetime.now(UTC)
        now_str = now.strftime("%H:%M")

        active_count = 0
        blocked_count = 0
        overdue_count = 0

        # Check tasks
        try:
            tasks = self._task_service.list_tasks()
            blocked = [t for t in tasks if t.get("status") == "blocked"]
            in_review = [t for t in tasks if t.get("status") == "review"]
            active = [t for t in tasks if t.get("status") == "in_progress"]

            active_count = len(active)
            blocked_count = len(blocked)

            if blocked:
                names = ", ".join(t.get("title", "?") for t in blocked)
                observations.append(f"Heartbeat: {len(blocked)} blocked task(s): {names}")

            if in_review:
                names = ", ".join(t.get("title", "?") for t in in_review)
                observations.append(f"Heartbeat: {len(in_review)} task(s) awaiting review: {names}")

            if active:
                observations.append(f"Heartbeat: {len(active)} task(s) in progress")

        except Exception:
            log.debug("Heartbeat: task check failed", exc_info=True)

        # Check for overdue tasks
        try:
            tasks = self._task_service.list_tasks()
            today = now.strftime("%Y-%m-%d")
            for t in tasks:
                due = t.get("due", "")
                if due and due < today and t.get("status") not in ("done", "cancelled"):
                    overdue_count += 1
                    observations.append(f"Heartbeat: task '{t.get('title', '?')}' is overdue (due: {due})")
        except Exception:
            pass

        # Check LLM connectivity and update work queue health
        llm_error = None
        if self._llm:
            try:
                from agntspace.llm.base import Message as LLMMessage

                await self._llm.complete(
                    messages=[LLMMessage(role="user", content="ping")],
                    system="Reply with exactly: pong",
                    max_tokens=10,
                    temperature=0,
                )
                # LLM is healthy — notify work queue to drain pending jobs
                if self._work_queue and hasattr(self._work_queue, "set_llm_health"):
                    model_name = getattr(self._llm, "model_name", getattr(self._llm, "_model", ""))
                    self._work_queue.set_llm_health(True, model=model_name)
            except Exception as exc:
                llm_error = str(exc)
                log.info("Heartbeat: LLM check failed: %s", llm_error)

                # If local/Ollama mode, try to auto-start Ollama before giving up
                _model = getattr(self._llm, "_model", "") or ""
                if _model.startswith("ollama/") or _model.startswith("ollama_chat/"):
                    from agntspace.llm.ollama_discovery import ensure_ollama_running
                    if ensure_ollama_running():
                        # Ollama is back — retry the health check once
                        try:
                            await self._llm.complete(
                                messages=[LLMMessage(role="user", content="ping")],
                                system="Reply with exactly: pong",
                                max_tokens=10,
                                temperature=0,
                            )
                            llm_error = None  # recovered
                            if self._work_queue and hasattr(self._work_queue, "set_llm_health"):
                                model_name = getattr(self._llm, "model_name", getattr(self._llm, "_model", ""))
                                self._work_queue.set_llm_health(True, model=model_name)
                            observations.append("Heartbeat: Ollama auto-started and recovered")
                        except Exception:
                            pass  # still failing — fall through to unhealthy

                if llm_error:
                    observations.append(f"Heartbeat: LLM unreachable — {llm_error}")
                    # Mark LLM unhealthy — work queue will defer jobs
                    if self._work_queue and hasattr(self._work_queue, "set_llm_health"):
                        self._work_queue.set_llm_health(False)

        # Determine status
        if llm_error or overdue_count > 0 or blocked_count > 0:
            status = "deviation"
        else:
            status = "normal"

        # Log observations to agent file
        for obs in observations:
            try:
                self._journal.append_observation(obs)
            except Exception:
                log.debug("Heartbeat: failed to log observation", exc_info=True)

        # Record pulse to SQLite
        if self._db:
            details = "; ".join(observations) if observations else None
            try:
                await self._db.execute(
                    "INSERT INTO heartbeat_log (ts, pulse_type, status, observations, active_tasks, blocked_tasks, overdue_tasks, details) "
                    "VALUES (?, 'heartbeat', ?, ?, ?, ?, ?, ?)",
                    (now.isoformat(), status, len(observations), active_count, blocked_count, overdue_count, details),
                )
                await self._db.commit()
            except Exception:
                log.debug("Heartbeat: failed to record pulse", exc_info=True)

        if observations:
            log.info("Heartbeat: %d observations logged (%s)", len(observations), status)
        else:
            log.debug("Heartbeat: pulse at %s — normal", now_str)

        # ── Rule #17 learning loop: SkillSchool zero-effect analysis ──
        # On every heartbeat cycle, check the decision log for skills
        # that produced zero-effect outcomes and surface them as action
        # plan proposals. This is the feedback channel between the
        # zero-effect detector (Agent.invoke_skill) and the tuner.
        if hasattr(self, "_skillschool") and self._skillschool:
            try:
                proposals = await self._skillschool.analyze_zero_effects()
                if proposals:
                    log.info("SkillSchool: %d zero-effect proposals from heartbeat", len(proposals))
                    # Write proposals to the persistent action plan so they
                    # appear in the Simulate/Agents page UI alongside health
                    # check issues. The user sees "Skill 'kb-ingest' had N
                    # zero-effect failures — upgrade tier or add examples."
                    try:
                        from agntspace.agent.action_plan import (
                            append_action_plan_items,
                            skillschool_proposals_to_plan_items,
                        )
                        plan_items = skillschool_proposals_to_plan_items(proposals)
                        vault_dir = getattr(self, "_vault_dir", None)
                        if vault_dir and plan_items:
                            added = await append_action_plan_items(vault_dir, plan_items)
                            if added:
                                log.info("SkillSchool: %d item(s) added to action plan", added)
                    except Exception:
                        log.debug("SkillSchool: action plan write failed", exc_info=True)
                    # Also emit as activity events for the agent's world model
                    if hasattr(self, "_activity") and self._activity:
                        for p in proposals[:5]:
                            self._activity.log(
                                "system", "skill_tune_proposal",
                                p.get("what", "zero-effect skill detected"),
                                p,
                                importance="high" if p.get("severity") == "high" else "medium",
                            )
            except Exception:
                log.debug("SkillSchool analyze_zero_effects failed", exc_info=True)

            # ── Reply-signal analysis — capability hallucinations ──
            # Same flow as zero-effect: SkillSchool reads decision log
            # rows written by reply_guardrails.py, groups by capability,
            # proposes skill tunings. Handlers never mutate replies;
            # this is the learning loop that closes the gap.
            try:
                reply_proposals = await self._skillschool.analyze_reply_signals()
                if reply_proposals:
                    log.info(
                        "SkillSchool: %d reply-signal proposals from heartbeat",
                        len(reply_proposals),
                    )
                    try:
                        from agntspace.agent.action_plan import (
                            append_action_plan_items,
                            skillschool_proposals_to_plan_items,
                        )
                        plan_items = skillschool_proposals_to_plan_items(reply_proposals)
                        vault_dir = getattr(self, "_vault_dir", None)
                        if vault_dir and plan_items:
                            added = await append_action_plan_items(vault_dir, plan_items)
                            if added:
                                log.info(
                                    "SkillSchool: %d reply-signal item(s) added to action plan",
                                    added,
                                )
                    except Exception:
                        log.debug("SkillSchool: reply-signal action plan write failed", exc_info=True)
                    if hasattr(self, "_activity") and self._activity:
                        for p in reply_proposals[:5]:
                            self._activity.log(
                                "system", "skill_tune_proposal",
                                p.get("what", "capability hallucination detected"),
                                p,
                                importance="high" if p.get("severity") == "high" else "medium",
                            )
            except Exception:
                log.debug("SkillSchool analyze_reply_signals failed", exc_info=True)

        # ── Evolution mining (Phase 2 pattern extractor) ──
        # Enqueue a daily mining job that scans accumulated runs, clusters
        # them by (skill, caller, agent, tier), and proposes Reflex /
        # AntiReflex candidates. Deduplicate=True means the work queue
        # collapses bursty enqueues into a single pending job. Throttled
        # by mtime on `_last_evolution_mine` so we don't enqueue every
        # heartbeat — once per configured schedule_interval_hours.
        evolution = getattr(self, "_evolution", None)
        if evolution is not None and self._work_queue is not None:
            try:
                import time as _t
                cfg = evolution.config or {}
                mining_cfg = cfg.get("mining", {}) if isinstance(cfg, dict) else {}
                if bool(mining_cfg.get("enabled", True)):
                    interval_hours = float(mining_cfg.get("schedule_interval_hours", 24))
                    last_ts = getattr(self, "_last_evolution_mine", 0.0)
                    now_t = _t.time()
                    if now_t - last_ts >= interval_hours * 3600:
                        await self._work_queue.enqueue(
                            "evolution_mine_reflexes",
                            {"source": "heartbeat"},
                            deduplicate=True,
                        )
                        self._last_evolution_mine = now_t
                        log.info(
                            "Enqueued evolution_mine_reflexes (interval=%.1fh)",
                            interval_hours,
                        )
            except Exception:
                log.debug("Evolution mining enqueue failed", exc_info=True)

        # ── Graph curator — entity-dedup auto-apply (Tier-B) ──
        # Closes the "100+ comma-prefix duplicates piling up in the
        # review queue" gap. Strategy lives in
        # _meta/graph-curator/config/curator.yaml; the engine reads
        # cadence_hours and throttles via _last_graph_curator_run.
        # Default cadence is 24h so a heartbeat tick every 15 min stays
        # cheap. Disabled in YAML → service short-circuits.
        graph_curator = getattr(self, "_graph_curator", None)
        if graph_curator is not None:
            try:
                import time as _t
                cfg = graph_curator.config()
                if bool(cfg.get("enabled", False)):
                    cadence_hours = float(cfg.get("cadence_hours", 24))
                    last_ts = getattr(self, "_last_graph_curator_run", 0.0)
                    now_t = _t.time()
                    if now_t - last_ts >= cadence_hours * 3600:
                        result = await graph_curator.auto_apply(
                            caller="heartbeat",
                            source="agent_curator",
                        )
                        self._last_graph_curator_run = now_t
                        applied = result.get("applied", 0)
                        if applied:
                            log.info(
                                "Graph curator: auto-applied %d duplicate "
                                "merges (rules=%s)",
                                applied,
                                ",".join(result.get("rules", [])),
                            )
                        else:
                            log.debug(
                                "Graph curator: nothing to apply (%s)",
                                result.get("reason", "ok"),
                            )
            except Exception:
                log.debug("Graph curator auto-apply failed", exc_info=True)

        # ── Version update check (uses 6h cache — no PyPI spam) ──
        try:
            from agntspace.api.routes.health import (
                _check_pypi_latest,
                _get_version,
                _parse_version,
            )
            current = _get_version()
            if current != "dev":
                latest = await _check_pypi_latest()
                if latest and _parse_version(latest) > _parse_version(current):
                    if hasattr(self, "_activity") and self._activity:
                        self._activity.log(
                            "system", "update_available",
                            f"AgntSpace v{latest} is available (running v{current})",
                            {"current": current, "latest": latest},
                            importance="medium",
                        )
        except Exception:
            pass

        # ── Proactive outreach (silence check, important event alerts) ──
        if hasattr(self, "_outreach") and self._outreach:
            try:
                _agent = getattr(self, "_agent", None)
                await self._outreach.check_and_reach_out(agent=_agent)
            except Exception:
                log.debug("Proactive outreach failed", exc_info=True)

        # ── Agent insights generation (proactive awareness) ──
        insights_engine = getattr(self, "_insights_engine", None)
        if insights_engine:
            try:
                snapshot = await insights_engine.generate()
                warning_count = len([i for i in snapshot.insights if i.severity == "warning"])
                if warning_count:
                    observations.append(
                        f"Agent insights: {warning_count} warning(s), "
                        f"{len(snapshot.insights) - warning_count} suggestion(s)"
                    )
            except Exception:
                log.debug("Insights generation failed", exc_info=True)

        # ── Autonomous pipeline triggers (agent-first KB health) ──
        try:
            pipeline_obs = await self._check_autonomous_pipelines()
            observations.extend(pipeline_obs)
        except Exception:
            log.debug("Autonomous pipeline check failed", exc_info=True)

        # ── Autonomous goal pursuit (staggered 30s after other checks) ──
        # Throttled independently of the heartbeat cadence: the heartbeat
        # can tick every 5 min for responsiveness while pursuit keeps its
        # deliberate 15-min spacing (configurable via
        # ``schedule.goal_pursuit_seconds``). Skipping without consuming
        # the 30s stagger keeps fast-tick heartbeats cheap.
        coach = getattr(self, "_coach", None)
        if coach:
            should_run = True
            if self._last_goal_pursuit_at is not None:
                elapsed = (now - self._last_goal_pursuit_at).total_seconds()
                should_run = elapsed >= self._goal_pursuit_interval
            if should_run:
                try:
                    import asyncio as _aio
                    await _aio.sleep(30)  # stagger to avoid LLM contention with outreach
                    goal_obs = await self._pursue_active_goals()
                    observations.extend(goal_obs)
                    # Mark completion AFTER the pursuit tick runs so a crash
                    # inside the dispatch loop doesn't block the next attempt.
                    self._last_goal_pursuit_at = datetime.now(UTC)
                except Exception:
                    log.debug("Goal pursuit check failed", exc_info=True)

        return observations

    async def _check_autonomous_pipelines(self) -> list[str]:
        """Auto-trigger KB ingest/compile when the agent notices pending work.

        This is a key agent-first behavior: the agent doesn't wait for
        the user to click "Run Pipeline" — it notices files in the inbox
        and acts on them autonomously.
        """
        observations: list[str] = []
        kb_service = getattr(self, "_kb_service", None)
        wq = getattr(self, "_work_queue", None)
        if not kb_service or not wq:
            return observations

        # Check each KB for pending inbox files
        try:
            kbs = kb_service.list_knowledge_bases()
        except Exception:
            return observations

        for kb in kbs:
            slug = kb.get("slug", "")
            if not slug:
                continue

            # Check inbox for files that the watcher might have missed
            # (e.g., files added while server was down)
            try:
                inbox_files = kb_service._reader.list_files(
                    f"knowledge/{slug}/inbox", "*"
                )
                pending = [
                    f for f in inbox_files
                    if ".quarantine" not in str(f)
                    and not f.name.startswith(".")
                    and f.stat().st_size > 0
                ]
                if len(pending) > 0:
                    # Enqueue ingest job — deduplicate so we don't spam
                    job_id = await wq.enqueue(
                        "ingest",
                        {"slug": slug, "source": "heartbeat_auto"},
                        deduplicate=True,
                    )
                    if job_id:
                        observations.append(
                            f"Auto-triggered ingest for KB '{slug}' "
                            f"({len(pending)} file(s) pending)"
                        )
                        if hasattr(self, "_activity") and self._activity:
                            self._activity.log(
                                "knowledge", "auto_ingest_triggered",
                                f"Agent auto-triggered ingest for '{slug}' — {len(pending)} files pending",
                                {"kb_slug": slug, "file_count": len(pending)},
                                importance="medium",
                            )
            except Exception:
                pass

        return observations

    def _is_local_mode(self) -> bool:
        """True when running against a local LLM (Ollama). Used for budget
        gating — local mode has zero dollar cost so only the dispatch cap
        and futility gate are meaningful."""
        from agntspace.infra.config import get_settings
        try:
            return get_settings().llm.mode == "local"
        except Exception:
            return False

    async def _pursue_active_goals(self) -> list[str]:
        """Iterate pursuable goals and dispatch the first one that passes all gates.

        A single blocked goal (e.g. trust too low) no longer starves the rest
        of the queue — we record the block reason on the goal (so the UI can
        explain it) and try the next one. Still max 1 dispatch per tick to
        prevent parallel budget drain.

        Budget gating is mode-aware:
        - **Dispatch cap** (``max_dispatches``) — always enforced.
        - **Dollar budget** — cloud mode only (local models cost $0).
        - **Futility gate** (``max_attempts``) — always enforced by
          ``get_pursuable_goals``.
        - **Trust gate** — bypassable per goal via ``trust_override: true``.
        """
        observations: list[str] = []
        coach = getattr(self, "_coach", None)
        wq = getattr(self, "_work_queue", None)
        if not coach or not wq:
            return observations

        try:
            pursuable = coach.get_pursuable_goals()
        except Exception:
            log.debug("Failed to get pursuable goals", exc_info=True)
            return observations

        if not pursuable:
            return observations

        for goal in pursuable:
            outcome, obs = await self._try_pursue_goal(goal)
            if obs:
                observations.append(obs)
            if outcome == "dispatched":
                break
        return observations

    async def _try_pursue_goal(self, goal: dict) -> tuple[str, str | None]:
        """Run gate checks for one goal and enqueue it when all pass.

        Returns ``(outcome, observation)`` where outcome is one of
        ``"dispatched" | "blocked" | "error"``. Blocked goals have their
        last-block reason recorded on the goal file so the UI can explain
        the halt; dispatched goals clear that marker.
        """
        coach = getattr(self, "_coach", None)
        wq = getattr(self, "_work_queue", None)
        cost = getattr(self, "_cost_tracker", None)
        activity = getattr(self, "_activity", None)
        trust_svc = getattr(self, "_trust_service", None)
        contract = getattr(self, "_contract", None)
        if not coach or not wq:
            return "error", None

        slug = goal["slug"]
        title = goal.get("title", slug)
        budget = goal["budget"]
        consumed = goal["budget_consumed"]
        dispatches = goal.get("dispatches", 0)
        max_dispatches = goal.get("max_dispatches", 50)
        trust_override = bool(goal.get("trust_override", False))
        goal_domain = goal.get("domain", "general")
        local_mode = self._is_local_mode()

        def _record_block(reason: str, details: dict, *, importance: str = "medium", event_action: str = "goal_pursuit_blocked", event_category: str = "trust") -> None:
            try:
                coach.record_block(slug, reason, details)
            except Exception:
                log.debug("record_block failed for %s", slug, exc_info=True)
            if activity:
                try:
                    activity.log(
                        event_category, event_action,
                        f"Goal '{slug}' pursuit blocked: {reason}",
                        {"goal_slug": slug, **details},
                        importance=importance,
                    )
                except Exception:
                    log.debug("activity.log failed", exc_info=True)

        # Gate 0: trust level (bypassable per goal via trust_override).
        # Uses check_goal_trust so the goal's own domain drives the gate
        # (not a derived action domain), and the slug carries per-goal
        # evidence so feedback on goal A doesn't shift goal B in the same
        # domain. New goals with no direct signals fall back to the domain
        # aggregate — identical behavior to the pre-per-goal world until
        # goal-specific evidence accumulates.
        if trust_svc and not trust_override:
            proactivity = contract.rules.proactivity if contract else "advisor"
            trust_check = trust_svc.check_goal_trust(
                goal_domain, proactivity, is_autonomous=True, goal_key=slug,
            )
            from agntspace.trust.service import _TRUST_LEVEL_INDEX
            effective_idx = _TRUST_LEVEL_INDEX.get(trust_check.effective_level, 0)
            if effective_idx < 2:  # Below Assistant
                _record_block(
                    "trust_too_low",
                    {
                        "domain": goal_domain,
                        "effective_level": trust_check.effective_level,
                        "domain_trust": trust_check.domain_trust,
                        "proactivity": proactivity,
                        "effective_score": getattr(trust_check, "effective_score", None),
                        "goal_key": slug,
                    },
                )
                return "blocked", (
                    f"Goal '{title}' halted: trust too low in '{goal_domain}' "
                    f"(have {trust_check.effective_level}, need Assistant)"
                )

        # Gate 1: dispatch cap (universal — works in ALL modes)
        if dispatches >= max_dispatches:
            reason = f"Dispatch limit reached ({dispatches}/{max_dispatches})"
            _record_block(
                "dispatch_limit",
                {"dispatches": dispatches, "max_dispatches": max_dispatches},
                importance="high",
                event_action="goal_pursuit_paused",
                event_category="system",
            )
            try:
                coach.set_pursuit_status(slug, "paused", reason=reason)
            except Exception:
                log.debug("set_pursuit_status failed for %s", slug, exc_info=True)
            return "blocked", f"Goal '{title}' paused: {reason}"

        # Gate 2: dollar budget (cloud mode only — local is free).
        # Gap 7 fix: budget == 0 means "no cap set" (the default for goals
        # created without a budget). Skip the check entirely — the
        # dispatch cap is the real gate in that case. Users who want a
        # dollar cap set budget > 0 explicitly.
        if not local_mode and cost and budget > 0:
            try:
                check = await cost.check_goal_budget(slug, budget)
                if not check["allowed"]:
                    _record_block(
                        "budget_exhausted",
                        {"spent": check.get("spent"), "budget": budget},
                        importance="high",
                        event_action="goal_pursuit_paused",
                        event_category="system",
                    )
                    try:
                        coach.set_pursuit_status(slug, "paused", reason="Budget exhausted")
                    except Exception:
                        log.debug("set_pursuit_status failed", exc_info=True)
                    return "blocked", (
                        f"Goal '{title}' budget exhausted "
                        f"(${check['spent']:.2f} / ${budget:.2f})"
                    )
            except Exception:
                log.debug("Goal budget check failed for %s", slug, exc_info=True)

        if local_mode:
            budget_remaining_str = f"local mode (free), dispatch {dispatches + 1}/{max_dispatches}"
        else:
            budget_remaining_str = f"${budget - consumed:.2f} remaining, dispatch {dispatches + 1}/{max_dispatches}"

        # Enqueue pursuit job
        try:
            job_id = await wq.enqueue(
                "pursue_goal",
                {
                    "goal_slug": slug,
                    "goal_title": title,
                    "goal_metric": goal.get("success_metric", ""),
                    "budget_remaining": budget - consumed,
                    "dispatch_number": dispatches + 1,
                    "max_dispatches": max_dispatches,
                    "local_mode": local_mode,
                },
                deduplicate=True,
            )
        except Exception:
            log.debug("Failed to enqueue goal pursuit for %s", slug, exc_info=True)
            return "error", None

        if job_id:
            try:
                coach.clear_block(slug)
            except Exception:
                log.debug("clear_block failed for %s", slug, exc_info=True)
            return "dispatched", (
                f"Dispatched pursuit of goal '{title}' ({budget_remaining_str})"
            )
        return "blocked", f"Goal '{title}' pursuit already queued"

    async def get_history(self, hours: int = 24, limit: int = 200) -> list[dict]:
        """Get heartbeat history for the cardiogram chart."""
        if not self._db:
            return []
        from datetime import timedelta

        cutoff = (datetime.now(UTC) - timedelta(hours=hours)).isoformat()
        cursor = await self._db.execute(
            "SELECT ts, pulse_type, status, observations, active_tasks, blocked_tasks, overdue_tasks "
            "FROM heartbeat_log WHERE ts >= ? ORDER BY ts DESC LIMIT ?",
            (cutoff, limit),
        )
        rows = await cursor.fetchall()
        return [
            {
                "ts": row[0],
                "pulse_type": row[1],
                "status": row[2],
                "observations": row[3],
                "active_tasks": row[4],
                "blocked_tasks": row[5],
                "overdue_tasks": row[6],
            }
            for row in reversed(rows)  # chronological order
        ]

    async def get_deviations(self, limit: int = 50) -> list[dict]:
        """Get recent deviations (non-normal pulses)."""
        if not self._db:
            return []
        cursor = await self._db.execute(
            "SELECT ts, status, observations, blocked_tasks, overdue_tasks, details "
            "FROM heartbeat_log WHERE status != 'normal' ORDER BY ts DESC LIMIT ?",
            (limit,),
        )
        rows = await cursor.fetchall()
        return [
            {
                "ts": row[0],
                "status": row[1],
                "observations": row[2],
                "blocked_tasks": row[3],
                "overdue_tasks": row[4],
                "details": row[5],
            }
            for row in rows
        ]

    async def get_status(self) -> dict:
        """Get current heartbeat status summary."""
        last_pulse = None
        total_pulses = 0
        total_deviations = 0

        if self._db:
            cursor = await self._db.execute(
                "SELECT ts, status FROM heartbeat_log ORDER BY ts DESC LIMIT 1"
            )
            row = await cursor.fetchone()
            if row:
                last_pulse = {"ts": row[0], "status": row[1]}

            cursor = await self._db.execute("SELECT COUNT(*) FROM heartbeat_log")
            total_pulses = (await cursor.fetchone())[0]

            cursor = await self._db.execute(
                "SELECT COUNT(*) FROM heartbeat_log WHERE status != 'normal'"
            )
            total_deviations = (await cursor.fetchone())[0]

        # Include active dispatches from orchestrator
        active_dispatches: dict[str, str] = {}
        if self._orchestrator:
            try:
                active_dispatches = self._orchestrator.get_active_dispatches()
            except Exception:
                pass

        # Goal-pursuit ETA is independent of heartbeat ETA when the two
        # intervals differ. Callers (goals API route, UI) should prefer
        # this block over the heartbeat last_pulse when reporting "when
        # will this goal potentially dispatch" because that's actually
        # what the user is asking about in the pursuit panel.
        goal_pursuit_block: dict = {
            "interval_seconds": self._goal_pursuit_interval,
            "last_pursuit_at": None,
            "next_pursuit_at": None,
        }
        if self._last_goal_pursuit_at is not None:
            from datetime import timedelta
            last_iso = self._last_goal_pursuit_at.isoformat()
            next_dt = self._last_goal_pursuit_at + timedelta(
                seconds=self._goal_pursuit_interval
            )
            goal_pursuit_block["last_pursuit_at"] = last_iso
            goal_pursuit_block["next_pursuit_at"] = next_dt.isoformat()

        # Pacing block — reports the LAST resolved decision so the UI can
        # explain why the next tick is when it is. "disabled" reason means
        # pacing is off and the base interval is in effect (legacy behavior).
        pacing_block: dict = {
            "enabled": False,
            "base_seconds": self._interval,
            "effective_seconds": self._interval,
            "reason": "disabled",
            "multiplier": 1.0,
        }
        if self._last_pacing is not None:
            cfg = self._pacing_config()
            pacing_block = {
                "enabled": bool(cfg and cfg.get("enabled")),
                "base_seconds": self._interval,
                "effective_seconds": self._last_pacing.seconds,
                "reason": self._last_pacing.reason,
                "multiplier": self._last_pacing.multiplier,
            }

        return {
            "running": self._running,
            "interval_seconds": self._interval,
            "goal_pursuit_interval_seconds": self._goal_pursuit_interval,
            "last_pulse": last_pulse,
            "total_pulses": total_pulses,
            "total_deviations": total_deviations,
            "active_dispatches": active_dispatches,
            "goal_pursuit": goal_pursuit_block,
            "pacing": pacing_block,
        }

    def _seconds_until_next_slot(self) -> float:
        """Calculate seconds until the next clock-aligned slot.

        With a 15-minute interval, pulses land on :00, :15, :30, :45.
        With a 30-minute interval, pulses land on :00, :30.
        """
        now = datetime.now(UTC)
        interval_min = self._interval // 60 or 1
        current_min = now.minute
        next_slot = ((current_min // interval_min) + 1) * interval_min
        seconds_into_minute = now.second + now.microsecond / 1_000_000
        wait = (next_slot - current_min) * 60 - seconds_into_minute
        if wait <= 0:
            wait += self._interval
        return wait

    def _now_local(self) -> datetime:
        """Current time in the user's local timezone (naive datetime).

        Falls back to system local time when ScheduleSettings.timezone
        isn't set or parsing fails. Never raises.
        """
        tz_name = ""
        try:
            if self._settings and hasattr(self._settings, "schedule"):
                tz_name = getattr(self._settings.schedule, "timezone", "") or ""
        except Exception:
            tz_name = ""

        if tz_name:
            try:
                from zoneinfo import ZoneInfo
                return datetime.now(ZoneInfo(tz_name)).replace(tzinfo=None)
            except Exception:
                log.debug("Pacing: invalid timezone %r, falling back to system local", tz_name)

        # System local time — works on every platform.
        return datetime.now()

    def _pacing_signals(self) -> dict:
        """Live signals consumed by the pacing decision function.

        Counts are best-effort — pacing falls through to base on any
        error so a broken signal source never blocks the heartbeat.
        """
        pending = 0
        active_goals = 0

        if self._work_queue is not None:
            try:
                status = getattr(self._work_queue, "status", None)
                if callable(status):
                    wq_status = status()
                    # Both dict and sync-only shapes have a 'pending' key.
                    if isinstance(wq_status, dict):
                        pending = int(wq_status.get("pending", 0) or 0)
            except Exception:
                pending = 0

        if self._coach is not None:
            try:
                goals = getattr(self._coach, "list_goals", None)
                if callable(goals):
                    all_goals = goals() or []
                    active_goals = sum(
                        1 for g in all_goals
                        if isinstance(g, dict) and g.get("pursuit_status") == "pursuing"
                    )
            except Exception:
                active_goals = 0

        return {"pending_queue": pending, "active_goals": active_goals}

    def _pacing_config(self) -> dict | None:
        """Load the pacing block from the heartbeat-pulse skill YAML.

        Returns None when the skill loader isn't wired or the skill isn't
        installed — the engine treats None as 'pacing disabled', which
        matches pre-pacing behavior exactly.
        """
        if self._skill_loader is None:
            return None
        try:
            cfg = self._skill_loader.get_skill_config("heartbeat-pulse", None)
        except TypeError:
            # Older SkillLoader signatures require a specific block name.
            try:
                cfg = self._skill_loader.get_skill_config("heartbeat-pulse", "pacing")
                return cfg if isinstance(cfg, dict) else None
            except Exception:
                return None
        except Exception:
            return None

        if not isinstance(cfg, dict):
            return None
        # Accept either the whole skill dict (with a 'pacing:' block) or
        # the pacing block directly — get_skill_config has varied over time.
        return cfg.get("pacing", cfg) if isinstance(cfg.get("pacing"), dict) else cfg

    def _resolve_sleep(self) -> PacingDecision:
        """Compute the next sleep duration, with adaptive pacing if enabled.

        When pacing is off or not configured, returns the clock-aligned
        base sleep so legacy behavior is preserved exactly. When on,
        consults `compute_sleep()` with live signals + the user's local
        time + the YAML config.
        """
        cfg = self._pacing_config()
        if not cfg or not cfg.get("enabled"):
            # Clock-aligned base (legacy behavior).
            wait = self._seconds_until_next_slot()
            decision = PacingDecision(
                seconds=wait,
                reason="base",
                multiplier=1.0,
            )
            self._last_pacing = decision
            return decision

        decision = compute_sleep(
            base_seconds=float(self._interval),
            now_local=self._now_local(),
            cfg=cfg,
            signals=self._pacing_signals(),
        )
        self._last_pacing = decision
        return decision

    async def _loop(self) -> None:
        """Main heartbeat loop — pulses with adaptive pacing when configured.

        When pacing is disabled (default), pulses land on clean clock
        boundaries (e.g. :00, :15, :30, :45). When enabled, sleep duration
        is determined by the pacing decision function, which may slow the
        loop in quiet hours or speed it up when work is pending.
        """
        # Immediate pulse on startup, then defer to pacing.
        try:
            await self.run_once()
        except Exception:
            log.exception("Heartbeat startup pulse failed")

        decision = self._resolve_sleep()
        log.debug(
            "Heartbeat: next pulse in %.0fs (reason=%s, x%.2f)",
            decision.seconds, decision.reason, decision.multiplier,
        )
        await asyncio.sleep(decision.seconds)

        while self._running:
            try:
                await self.run_once()
            except asyncio.CancelledError:
                break
            except Exception:
                log.exception("Heartbeat error")
            decision = self._resolve_sleep()
            await asyncio.sleep(decision.seconds)
