"""
Tests for scripts/reminder.py

Covers tasks 4.1 and 4.4 of the add-deterministic-core change.

Pins invariants:
  #1  Hook only reminds — no mutation except `last_reminded`, only on banner.
  #2  Snooze ≤ once per calendar day while stale, keyed on `last_reminded`.

sys.path is patched here (no shared conftest) so the test file is
self-contained, as required by the file-ownership constraints.
"""

from __future__ import annotations

import json
import sys
from datetime import datetime, timezone
from io import StringIO
from pathlib import Path

import pytest

# ---------------------------------------------------------------------------
# Path bootstrap — inject scripts/ so we can import reminder + state_store.
# ---------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

import reminder as reminder_mod  # noqa: E402
from reminder import (  # noqa: E402
    Decision,
    Reminder,
    ReminderRunner,
    build_banner,
    main as reminder_main,
)
from state_store import StateStore  # noqa: E402


def _utc(y, m, d, hh=0, mm=0):
    return datetime(y, m, d, hh, mm, tzinfo=timezone.utc)


class FrozenClock:
    """Injected clock returning a fixed `now`."""

    def __init__(self, now: datetime):
        self._now = now

    def now(self) -> datetime:
        return self._now


# ===========================================================================
# Task 4.1 — pure decision function
# ===========================================================================

class TestReminderDecision:

    THRESHOLD = 21

    def _r(self):
        return Reminder(threshold_days=self.THRESHOLD)

    def test_fresh_docs_are_silent(self):
        """last_check within threshold → SILENT (no banner)."""
        now = _utc(2026, 6, 24)
        last_check = _utc(2026, 6, 20)  # 4 days ago, < 21
        assert self._r().decide(last_check, None, now) is Decision.SILENT

    def test_never_checked_is_stale_and_banners(self):
        """last_check unset → stale → BANNER (no prior reminder)."""
        now = _utc(2026, 6, 24)
        assert self._r().decide(None, None, now) is Decision.BANNER

    def test_stale_never_reminded_banners(self):
        """last_check older than threshold, never reminded → BANNER."""
        now = _utc(2026, 6, 24)
        last_check = _utc(2026, 5, 1)  # ~54 days ago
        assert self._r().decide(last_check, None, now) is Decision.BANNER

    def test_stale_reminded_same_day_is_snoozed(self):
        """Stale + reminded earlier today → SILENT (invariant #2 snooze)."""
        now = _utc(2026, 6, 24, 17, 0)
        last_check = _utc(2026, 5, 1)
        last_reminded = _utc(2026, 6, 24, 9, 0)  # earlier same calendar day
        assert self._r().decide(last_check, last_reminded, now) is Decision.SILENT

    def test_stale_reminded_prior_day_rebanners(self):
        """Stale + reminded a prior calendar day → BANNER again."""
        now = _utc(2026, 6, 24, 9, 0)
        last_check = _utc(2026, 5, 1)
        last_reminded = _utc(2026, 6, 23, 23, 0)  # yesterday
        assert self._r().decide(last_check, last_reminded, now) is Decision.BANNER

    def test_calendar_day_boundary_across_midnight(self):
        """
        Discriminating case for calendar-day (not 24h-sliding) snooze:
        reminded 23:50 day1, now 00:10 day2 — only 20 minutes apart but a
        DIFFERENT calendar day → must BANNER. A naive 24h window would snooze.
        """
        last_reminded = _utc(2026, 6, 23, 23, 50)
        now = _utc(2026, 6, 24, 0, 10)
        last_check = _utc(2026, 5, 1)
        assert self._r().decide(last_check, last_reminded, now) is Decision.BANNER

    def test_threshold_boundary_just_inside_is_silent(self):
        """Exactly at the threshold (not strictly greater) → fresh → SILENT."""
        r = self._r()
        now = _utc(2026, 6, 22)
        last_check = _utc(2026, 6, 1)  # exactly 21 days
        assert r.is_stale(last_check, now) is False
        assert r.decide(last_check, None, now) is Decision.SILENT

    def test_days_stale_reporting(self):
        r = self._r()
        assert r.days_stale(None, _utc(2026, 6, 24)) is None
        assert r.days_stale(_utc(2026, 6, 20), _utc(2026, 6, 24)) == 4


# ===========================================================================
# Task 4.4 — runner: mutation discipline + I/O (invariants #1, #2)
# ===========================================================================

class TestReminderRunner:

    def _store(self, tmp_path) -> StateStore:
        # No clock injected on the store; the runner owns the clock.
        return StateStore(tmp_path)

    def test_fresh_run_is_silent_and_mutates_nothing(self, tmp_path):
        now = _utc(2026, 6, 24)
        store = self._store(tmp_path)
        store.set_timestamp("last_check", _utc(2026, 6, 23))  # fresh
        before = store.get_timestamp("last_reminded")

        out = StringIO()
        runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
        decision = runner.run()

        assert decision is Decision.SILENT
        assert out.getvalue() == ""  # no banner emitted
        assert store.get_timestamp("last_reminded") == before  # unchanged

    def test_stale_never_reminded_banners_and_records_last_reminded(self, tmp_path):
        now = _utc(2026, 6, 24, 12, 0)
        store = self._store(tmp_path)
        store.set_timestamp("last_check", _utc(2026, 5, 1))  # stale
        assert store.get_timestamp("last_reminded") is None

        out = StringIO()
        runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
        decision = runner.run()

        assert decision is Decision.BANNER
        payload = json.loads(out.getvalue())
        assert "systemMessage" in payload
        assert "doc-hygiene" in payload["systemMessage"]
        # The ONLY mutation: last_reminded == now.
        assert store.get_timestamp("last_reminded") == now

    def test_stale_reminded_same_day_is_silent(self, tmp_path):
        now = _utc(2026, 6, 24, 17, 0)
        store = self._store(tmp_path)
        store.set_timestamp("last_check", _utc(2026, 5, 1))
        store.set_timestamp("last_reminded", _utc(2026, 6, 24, 9, 0))
        before = store.get_timestamp("last_reminded")

        out = StringIO()
        runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
        decision = runner.run()

        assert decision is Decision.SILENT
        assert out.getvalue() == ""
        assert store.get_timestamp("last_reminded") == before  # not bumped

    def test_stale_reminded_prior_day_rebanners(self, tmp_path):
        now = _utc(2026, 6, 24, 9, 0)
        store = self._store(tmp_path)
        store.set_timestamp("last_check", _utc(2026, 5, 1))
        store.set_timestamp("last_reminded", _utc(2026, 6, 23, 8, 0))

        out = StringIO()
        runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
        decision = runner.run()

        assert decision is Decision.BANNER
        assert "systemMessage" in json.loads(out.getvalue())
        assert store.get_timestamp("last_reminded") == now

    def test_missing_state_treated_as_never_checked(self, tmp_path):
        """No state.json at all → stale → banner, never blocks."""
        now = _utc(2026, 6, 24)
        store = self._store(tmp_path)  # nothing written

        out = StringIO()
        runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
        decision = runner.run()

        assert decision is Decision.BANNER
        assert store.get_timestamp("last_reminded") == now

    def test_corrupt_state_treated_as_never_checked(self, tmp_path):
        """A corrupt timestamp string must not crash; treated as never-checked."""
        now = _utc(2026, 6, 24)
        state_dir = tmp_path / ".cc-os" / "dochygiene"
        state_dir.mkdir(parents=True, exist_ok=True)
        (state_dir / "state.json").write_text(
            json.dumps({"last_check": "not-a-date", "last_reminded": "garbage"})
        )
        store = self._store(tmp_path)

        out = StringIO()
        runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
        decision = runner.run()

        assert decision is Decision.BANNER  # unreadable → stale
        assert "systemMessage" in json.loads(out.getvalue())

    def test_only_last_reminded_is_mutated(self, tmp_path):
        """On banner, last_check / last_clean are untouched (invariant #1)."""
        now = _utc(2026, 6, 24, 12, 0)
        store = self._store(tmp_path)
        check_ts = _utc(2026, 5, 1)
        clean_ts = _utc(2026, 5, 2)
        store.set_timestamp("last_check", check_ts)
        store.set_timestamp("last_clean", clean_ts)

        out = StringIO()
        ReminderRunner(store, clock=FrozenClock(now), out=out).run()

        assert store.get_timestamp("last_check") == check_ts
        assert store.get_timestamp("last_clean") == clean_ts
        assert store.get_timestamp("last_reminded") == now


# ===========================================================================
# Entry-point guard — never create .cc-os/dochygiene/ outside a git project (#3)
# ===========================================================================

class TestGitProjectGuard:

    def test_non_git_cwd_stays_silent_and_writes_nothing(self, tmp_path, monkeypatch, capsys):
        """No .git anywhere → resolve falls back to cwd → reminder no-ops.

        Guards invariant #3: must NOT create <cwd>/.cc-os/dochygiene/ in an
        arbitrary directory (e.g. ~).
        """
        monkeypatch.setattr(reminder_mod.Path, "cwd", staticmethod(lambda: tmp_path))
        rc = reminder_main()
        assert rc == 0
        assert capsys.readouterr().out == ""  # no banner
        assert not (tmp_path / ".cc-os").exists()  # nothing written
        assert not (tmp_path / ".dochygiene").exists()  # nothing written

    def test_git_project_banners_when_never_checked(self, tmp_path, monkeypatch, capsys):
        """A real git project with no prior check → banner (and creates state)."""
        (tmp_path / ".git").mkdir()
        monkeypatch.setattr(reminder_mod.Path, "cwd", staticmethod(lambda: tmp_path))
        rc = reminder_main()
        assert rc == 0
        out = capsys.readouterr().out
        assert "systemMessage" in json.loads(out)
        assert (tmp_path / ".cc-os" / "dochygiene" / "state.json").exists()


# ===========================================================================
# Banner copy
# ===========================================================================

class TestBanner:

    def test_never_checked_copy(self):
        b = build_banner(None)
        assert "doc-hygiene" in b
        assert "no documentation check has been run yet" in b

    def test_pluralisation(self):
        assert "1 day ago" in build_banner(1)
        assert "4 days ago" in build_banner(4)
