#!/usr/bin/env python3
"""
reminder.py — deterministic SessionStart reminder for doc-hygiene.

This is the only thing the `SessionStart` hook invokes. It:

  0. No-ops silently unless cwd is inside a real git project (invariant #3
     guard — see `_is_git_project`; flagged for human review).
  1. Resolves the project root from cwd (git root).
  2. Reads lifecycle timestamps from the in-project state store.
  3. Decides — purely, with an injected clock — whether docs are stale and
     whether today's snooze permits a banner.
  4. On a banner decision: emits a `systemMessage` JSON object on stdout
     (a user-facing, zero-token banner) and writes `last_reminded = now`.
  5. On a silent decision: writes nothing and prints nothing.
  6. ALWAYS exits 0 so the session is never blocked.

Design invariants honoured:
  #1  Hook only reminds — spends zero AI tokens, runs no scan / no model,
      mutates nothing except `last_reminded`, never blocks the session.
  #2  Snooze ≤ once per calendar day while stale, keyed on `last_reminded`.
  #3  State read/written only via the in-project StateStore.
  #6  Deterministic-first — no model is imported or invoked here.

The decision is a PURE function (`Reminder.decide`) over
`(last_check, last_reminded, now, threshold_days)` so the snooze and staleness
behaviour are unit-testable with a frozen clock and no real session.
"""

from __future__ import annotations

import json
import sys
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Optional

# ---------------------------------------------------------------------------
# Path bootstrap so `python reminder.py` works regardless of cwd.
# ---------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

from state_store import (  # noqa: E402
    RealClock,
    RealRootFS,
    StateStore,
    resolve_project_root,
)


# ---------------------------------------------------------------------------
# Configuration constant
# ---------------------------------------------------------------------------
# Staleness threshold: days since `last_check` before the reminder fires.
# This is an implementation detail (see design Open Questions), NOT a spec'd
# contract — a per-project override is a later concern. Chosen at the
# motivational, not correctness-critical, end of the 14–30 day range.
DEFAULT_THRESHOLD_DAYS = 21


# ---------------------------------------------------------------------------
# Pure decision (task 4.1 / design D5)
# ---------------------------------------------------------------------------

class Decision(Enum):
    """Outcome of the reminder decision."""
    SILENT = "silent"
    BANNER = "banner"


class Reminder:
    """
    Pure staleness + snooze decision for the SessionStart reminder.

    The decision is a total function of its four inputs and performs no I/O,
    so the snooze boundary (invariant #2) and staleness rule (invariant #1's
    "only reminds") are deterministically testable with an injected clock.

    Semantics (design D5):

        stale = last_check is None OR (now - last_check) > threshold
        if not stale:                          SILENT   # fresh docs
        if last_reminded is None:              BANNER   # stale, never reminded
        if same_calendar_day(last_reminded, now): SILENT  # snoozed today
        BANNER                                          # stale, new day

    Snooze keys on **calendar day** (UTC `.date()`), not a 24h sliding window,
    so two sessions 20 minutes apart that straddle midnight still re-banner the
    second day — matching invariant #2's "at most once per calendar day".
    """

    def __init__(self, threshold_days: int = DEFAULT_THRESHOLD_DAYS) -> None:
        self._threshold = timedelta(days=threshold_days)

    @staticmethod
    def _as_utc(ts: datetime) -> datetime:
        """Normalise a datetime to UTC (treat naive as UTC) for safe compares."""
        if ts.tzinfo is None:
            return ts.replace(tzinfo=timezone.utc)
        return ts.astimezone(timezone.utc)

    def is_stale(self, last_check: Optional[datetime], now: datetime) -> bool:
        """True when docs have never been checked or the check is older than the threshold."""
        if last_check is None:
            return True
        return (self._as_utc(now) - self._as_utc(last_check)) > self._threshold

    def decide(
        self,
        last_check: Optional[datetime],
        last_reminded: Optional[datetime],
        now: datetime,
    ) -> Decision:
        """Return BANNER or SILENT given the state timestamps and the current time."""
        if not self.is_stale(last_check, now):
            return Decision.SILENT
        if last_reminded is None:
            return Decision.BANNER
        # Snooze on calendar day (UTC), not a sliding 24h window.
        if self._as_utc(last_reminded).date() == self._as_utc(now).date():
            return Decision.SILENT
        return Decision.BANNER

    def days_stale(self, last_check: Optional[datetime], now: datetime) -> Optional[int]:
        """Whole days since `last_check`, or None if never checked."""
        if last_check is None:
            return None
        delta = self._as_utc(now) - self._as_utc(last_check)
        return max(delta.days, 0)


# ---------------------------------------------------------------------------
# Banner copy (task 4.2)
# ---------------------------------------------------------------------------
#
# DEVIATION NOTE: the `session-reminder` spec requires the banner to "name the
# slash command the user runs". That command (`/os-doc-hygiene:check`) is not
# built in this change, so per the task we advertise that monitoring is active without
# promising an unbuilt command. The staleness phrase IS retained (the spec also
# says the banner states "how stale the docs are"), keyed off `last_check`.

def build_banner(days_stale: Optional[int]) -> str:
    """Compose the deterministic, zero-token banner text."""
    if days_stale is None:
        staleness = "no documentation check has been run yet"
    else:
        staleness = f"docs were last checked {days_stale} day{'s' if days_stale != 1 else ''} ago"
    return f"[doc-hygiene] Documentation health monitoring active — {staleness}."


# ---------------------------------------------------------------------------
# Script runner (task 4.2) — owns all I/O and the try/except envelope.
# ---------------------------------------------------------------------------

class ReminderRunner:
    """
    Wires the pure `Reminder` decision to the in-project state store and stdout.

    All I/O lives here; the `Reminder` decision stays pure. Any failure reading
    state is treated as "never checked" (stale) so the reminder still fires and
    the session is never blocked (invariant #1, spec "Unreadable state never
    blocks the session").
    """

    def __init__(
        self,
        store: StateStore,
        reminder: Optional[Reminder] = None,
        clock: Optional[RealClock] = None,
        out=None,
    ) -> None:
        self._store = store
        self._reminder = reminder or Reminder()
        self._clock = clock or RealClock()
        self._out = out if out is not None else sys.stdout

    def _safe_get(self, key: str) -> Optional[datetime]:
        """Read a timestamp, swallowing any corruption as 'unset'."""
        try:
            return self._store.get_timestamp(key)
        except Exception:
            return None

    def run(self) -> Decision:
        """Execute one reminder pass. Emits a banner if due. Never raises."""
        now = self._clock.now()
        last_check = self._safe_get("last_check")
        last_reminded = self._safe_get("last_reminded")

        decision = self._reminder.decide(last_check, last_reminded, now)
        if decision is Decision.BANNER:
            days = self._reminder.days_stale(last_check, now)
            banner = build_banner(days)
            self._out.write(json.dumps({"systemMessage": banner}))
            # The ONLY mutation (invariant #1): record the snooze stamp.
            self._store.set_timestamp("last_reminded", now)
        return decision


# ---------------------------------------------------------------------------
# Entry point (task 4.2) — always exits 0.
# ---------------------------------------------------------------------------

def _is_git_project(root: Path) -> bool:
    """True only when *root* is a real git project (has a `.git` directory).

    Guard rationale (invariant #3): the SessionStart hook is user-scoped and
    fires in ANY directory. `resolve_project_root` falls back to cwd when no
    `.git` is found, so without this guard, starting a session in `~` or any
    non-project folder would create `<cwd>/.dochygiene/` (e.g. `~/.dochygiene`)
    — a signature invariant #3 names as a violation — and, being un-checkable,
    would banner every calendar day there (the nag-ware the snooze prevents
    only WITHIN a day). We therefore restrict the reminder to git projects.
    Brand-new git projects still banner (never-checked = stale), per spec.

    NOTE FOR REVIEW: this guard is a design/invariant interaction not spelled
    out in the `session-reminder` spec (which assumed a project context). It is
    the conservative reading of invariant #3 + D4; flagged for the human gate.
    """
    return RealRootFS().is_dir(root / ".git")


def main(argv: Optional[list] = None) -> int:
    try:
        root = resolve_project_root(Path.cwd())
        if not _is_git_project(root):
            # Not a git project — stay silent and write nothing (invariant #3).
            return 0
        store = StateStore(root)
        ReminderRunner(store).run()
    except Exception:
        # Belt-and-braces: a reminder must NEVER block or fail a session.
        pass
    return 0


if __name__ == "__main__":
    sys.exit(main())
