"""JournalLightTrigger — debounced light-dream trigger on journal edits.

Rule #13 says every manual user action must be observable to the agent.
For journal prose specifically, that means the entity/triple graph should
learn about the edit quickly, not 6 hours later at the next cron tick.

This module is the **push** side of the journal → graph pipeline. The
``VaultEditWatcher`` is the detector; this is the debouncer + dispatcher.
The cron-driven light dream (``memory-dream-light/schedule.yaml``) is
the **safety net** that re-processes anything the push path missed.

Why debounce:
  * Obsidian autosaves on every keystroke (~500ms interval). Without
    debounce a single paragraph edit would fire ~40 light-dream runs.
  * Users often save a draft, edit, save again — the LLM extraction
    only needs to run once per "I'm done with this thought" pause.
  * Fast tier LLM calls are cheap but not free; over-firing wastes
    tokens and saturates the work queue behind other jobs.

The debounce window is configurable via the memory-dream-light skill
config (``sources.journal_entries.debounce_seconds``, default 45s).
After that quiet period, one light-dream run fires over *all* journal
entries modified in the last ``lookback_hours`` window — not just the
single file that triggered it — so the run stays coherent with the
cron-driven safety net.

The trigger is idempotent: ``gather_recent_journal_entries`` emits
deterministic ``episode_id``s per section, so overlapping push + pull
cycles don't duplicate episodes. Rule 12: every knob lives in YAML.
"""

from __future__ import annotations

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

if TYPE_CHECKING:
    pass

log = logging.getLogger(__name__)

# Minimum time between actual runs even after debounce, to cap throughput
# under pathological edit storms. Skill-driven default; engine floor.
_MIN_COOLDOWN_SECONDS = 10.0


class JournalLightTrigger:
    """Debounces journal-edit events and fires one light-dream run per quiet window.

    Not thread-safe by design — all access must be from the asyncio
    event loop thread. The ``VaultEditWatcher`` schedules callback
    invocations via ``asyncio.create_task`` so callers can ``await``
    ``trigger()`` from any task without blocking the scan loop.
    """

    def __init__(
        self,
        run_callback: Callable[[], Awaitable[None]],
        *,
        debounce_seconds: float = 45.0,
        activity_logger: object | None = None,
    ) -> None:
        self._run = run_callback
        # Caller owns the window size. main.py already gates push_enabled on
        # debounce > 0; anything positive is a legitimate choice (tests use
        # tens of milliseconds; production uses tens of seconds).
        self._debounce = max(float(debounce_seconds), 0.0)
        self._activity = activity_logger
        self._pending: asyncio.Task | None = None
        self._last_fired_monotonic: float = 0.0
        self._last_trigger_path: str | None = None

    def trigger(self, path: Path | str) -> None:
        """Record a journal edit and schedule a debounced run.

        If a run is already pending, cancel it and restart the clock —
        so a burst of edits collapses into one run after the final edit.
        """
        self._last_trigger_path = str(path)
        if self._pending and not self._pending.done():
            self._pending.cancel()
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            # No running loop — tests may construct without starting. The
            # caller should drive ``_fire()`` directly in that case.
            return
        self._pending = loop.create_task(self._wait_and_fire(), name="journal-light-trigger")

    async def _wait_and_fire(self) -> None:
        try:
            await asyncio.sleep(self._debounce)
        except asyncio.CancelledError:
            return

        # Cooldown floor — protects against edit storms that slip through
        # debounce (e.g. separate files each triggering fresh timers).
        now = asyncio.get_event_loop().time()
        since_last = now - self._last_fired_monotonic
        if self._last_fired_monotonic > 0 and since_last < _MIN_COOLDOWN_SECONDS:
            log.debug(
                "journal trigger: cooldown floor active (%.1fs since last run)",
                since_last,
            )
            return
        self._last_fired_monotonic = now

        await self._fire()

    async def _fire(self) -> None:
        """Invoke the run callback. Never raises."""
        try:
            await self._run()
        except Exception:
            log.exception("journal-light-trigger: run callback failed")
            return

        # Emit a visible activity event so the agent + /decisions see the run
        if self._activity is None:
            return
        try:
            self._activity.log(
                category="memory",
                action="journal_light_triggered",
                summary="User journal edit triggered light-dream capture",
                details={
                    "trigger_path": self._last_trigger_path,
                    "debounce_seconds": self._debounce,
                },
                importance="low",
            )
        except Exception:
            log.debug("journal-light-trigger: activity log failed", exc_info=True)

    async def stop(self) -> None:
        """Cancel any pending debounce task. Safe to call at shutdown."""
        if self._pending and not self._pending.done():
            self._pending.cancel()
            try:
                await self._pending
            except (asyncio.CancelledError, Exception):
                pass
