"""Tests for src/heartbeat.py — Periodic health checks."""

import json
import time
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from src.heartbeat import (
    _calendar_daily_summary,
    _calendar_next_reminder,
    _check_calendar_smart,
    _check_email,
    _check_git,
    _check_kb_index,
    _get_extra_instructions,
    _is_quiet_hour,
    _load_state,
    _save_state,
    _should_run,
    run_heartbeat,
    DEFAULT_COOLDOWNS,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def tmp_env(tmp_path, monkeypatch):
    """Redirect PROJECT_ROOT, STATE_FILE, TOOLS_DIR, HEARTBEAT_MD to tmp_path."""
    root = tmp_path / "project"
    root.mkdir()
    tools = root / "tools"
    tools.mkdir()
    mem = root / "memory"
    mem.mkdir()
    personality = root / "personality"
    personality.mkdir()
    state_file = mem / "heartbeat-state.json"

    monkeypatch.setattr("src.heartbeat.PROJECT_ROOT", root)
    monkeypatch.setattr("src.heartbeat.STATE_FILE", state_file)
    monkeypatch.setattr("src.heartbeat.TOOLS_DIR", tools)
    monkeypatch.setattr("src.heartbeat.HEARTBEAT_MD", personality / "HEARTBEAT.md")
    return {
        "root": root, "tools": tools, "memory": mem,
        "state_file": state_file, "personality": personality,
    }


# ---------------------------------------------------------------------------
# _is_quiet_hour
# ---------------------------------------------------------------------------


class TestIsQuietHour:
    """Test quiet hour detection with overnight and daytime ranges."""

    def test_overnight_range_before_midnight(self):
        assert _is_quiet_hour(23, (23, 8)) is True

    def test_overnight_range_after_midnight(self):
        assert _is_quiet_hour(3, (23, 8)) is True

    def test_overnight_range_outside(self):
        assert _is_quiet_hour(12, (23, 8)) is False

    def test_overnight_range_at_end_boundary(self):
        assert _is_quiet_hour(8, (23, 8)) is False

    def test_daytime_range_inside(self):
        assert _is_quiet_hour(12, (9, 17)) is True

    def test_daytime_range_at_start(self):
        assert _is_quiet_hour(9, (9, 17)) is True

    def test_daytime_range_at_end(self):
        assert _is_quiet_hour(17, (9, 17)) is False

    def test_daytime_range_outside(self):
        assert _is_quiet_hour(20, (9, 17)) is False


# ---------------------------------------------------------------------------
# _should_run (cooldowns)
# ---------------------------------------------------------------------------


class TestShouldRun:
    """Test cooldown logic for checks."""

    def test_no_previous_run(self):
        assert _should_run("email", {}, datetime.now(timezone.utc)) is True

    def test_within_cooldown(self):
        now = datetime.now(timezone.utc)
        checks = {"email": (now - timedelta(minutes=10)).isoformat()}
        assert _should_run("email", checks, now) is False

    def test_past_cooldown(self):
        now = datetime.now(timezone.utc)
        checks = {"email": (now - timedelta(minutes=35)).isoformat()}
        assert _should_run("email", checks, now) is True

    def test_zero_cooldown_always_runs(self):
        now = datetime.now(timezone.utc)
        checks = {"calendar": now.isoformat()}
        assert _should_run("calendar", checks, now) is True

    def test_corrupt_timestamp(self):
        now = datetime.now(timezone.utc)
        checks = {"email": "not-a-date"}
        assert _should_run("email", checks, now) is True

    def test_custom_cooldowns(self):
        """Accepts custom cooldowns dict."""
        now = datetime.now(timezone.utc)
        checks = {"email": (now - timedelta(minutes=5)).isoformat()}
        # Default 1800s (30min) — should NOT run
        assert _should_run("email", checks, now) is False
        # Custom 60s — should run (5 min > 60s)
        assert _should_run("email", checks, now, cooldowns={"email": 60}) is True


# ---------------------------------------------------------------------------
# _check_email
# ---------------------------------------------------------------------------


class TestCheckEmail:
    """Test email check via tools/email_check.py with JSON parsing."""

    def test_no_script(self, tmp_env):
        assert _check_email({}) is None

    def test_json_with_unread(self, tmp_env):
        script = tmp_env["tools"] / "email_check.py"
        script.write_text("pass")
        output = json.dumps({
            "ok": True, "unread_count": 2,
            "emails": [
                {"subject": "Meeting azi", "from": "boss@work.com"},
                {"subject": "Factura", "from": "billing@x.com"},
            ]
        })
        mock_result = MagicMock(returncode=0, stdout=output)
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            result = _check_email({})
        assert result == "Email: 2 necitite (Meeting azi, Factura)"

    def test_json_zero_unread(self, tmp_env):
        script = tmp_env["tools"] / "email_check.py"
        script.write_text("pass")
        output = json.dumps({"ok": True, "unread_count": 0, "emails": []})
        mock_result = MagicMock(returncode=0, stdout=output)
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            assert _check_email({}) is None

    def test_json_not_ok(self, tmp_env):
        script = tmp_env["tools"] / "email_check.py"
        script.write_text("pass")
        output = json.dumps({"ok": False, "error": "auth failed"})
        mock_result = MagicMock(returncode=0, stdout=output)
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            assert _check_email({}) is None

    def test_plaintext_fallback(self, tmp_env):
        script = tmp_env["tools"] / "email_check.py"
        script.write_text("pass")
        mock_result = MagicMock(returncode=0, stdout="3 new messages\n")
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            assert _check_email({}) == "Email: 3 new messages"

    def test_plaintext_zero(self, tmp_env):
        script = tmp_env["tools"] / "email_check.py"
        script.write_text("pass")
        mock_result = MagicMock(returncode=0, stdout="0\n")
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            assert _check_email({}) is None

    def test_empty_output(self, tmp_env):
        script = tmp_env["tools"] / "email_check.py"
        script.write_text("pass")
        mock_result = MagicMock(returncode=0, stdout="\n")
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            assert _check_email({}) is None

    def test_nonzero_returncode(self, tmp_env):
        script = tmp_env["tools"] / "email_check.py"
        script.write_text("pass")
        mock_result = MagicMock(returncode=1, stdout="error")
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            assert _check_email({}) is None

    def test_subprocess_exception(self, tmp_env):
        script = tmp_env["tools"] / "email_check.py"
        script.write_text("pass")
        with patch("src.heartbeat.subprocess.run", side_effect=TimeoutError):
            assert _check_email({}) is None


# ---------------------------------------------------------------------------
# _check_calendar
# ---------------------------------------------------------------------------


class TestCheckCalendarSmart:
    """Test smart calendar: daily summary + next-event reminders with dedup."""

    def test_no_script(self, tmp_env):
        assert _check_calendar_smart({}, (23, 8)) is None

    def test_first_run_sends_daily_summary(self, tmp_env):
        """First run after quiet hours should return full day summary."""
        script = tmp_env["tools"] / "calendar_check.py"
        script.write_text("pass")
        output = json.dumps({
            "today": [
                {"summary": "Standup", "time": "09:00"},
                {"summary": "Lunch", "time": "12:00"},
            ],
            "tomorrow": []
        })
        mock_result = MagicMock(returncode=0, stdout=output)
        state = {}
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            result = _check_calendar_smart(state, (23, 8))
        assert "Program azi" in result
        assert "09:00 — Standup" in result
        assert "12:00 — Lunch" in result
        assert state["calendar"]["daily_summary_date"] == date.today().isoformat()

    def test_second_run_sends_next_event_only(self, tmp_env):
        """After daily summary, only remind for next event within 45 min."""
        script = tmp_env["tools"] / "calendar_check.py"
        script.write_text("pass")
        output = json.dumps({
            "upcoming": [
                {"summary": "Meeting", "minutes_until": 30, "time": "10:00"},
            ]
        })
        mock_result = MagicMock(returncode=0, stdout=output)
        state = {"calendar": {"daily_summary_date": date.today().isoformat(), "reminded_events": {}}}
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            result = _check_calendar_smart(state, (23, 8))
        assert "in 30 min — Meeting (10:00)" in result
        assert "Meeting@10:00" in state["calendar"]["reminded_events"]

    def test_dedup_prevents_repeat_reminder(self, tmp_env):
        """Same event should not be reminded twice."""
        script = tmp_env["tools"] / "calendar_check.py"
        script.write_text("pass")
        output = json.dumps({
            "upcoming": [
                {"summary": "Meeting", "minutes_until": 25, "time": "10:00"},
            ]
        })
        mock_result = MagicMock(returncode=0, stdout=output)
        today_str = date.today().isoformat()
        state = {"calendar": {
            "daily_summary_date": today_str,
            "reminded_events": {"Meeting@10:00": today_str}
        }}
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            result = _check_calendar_smart(state, (23, 8))
        assert result is None

    def test_event_beyond_45_min_not_reminded(self, tmp_env):
        """Events more than 45 min away should not trigger a reminder."""
        script = tmp_env["tools"] / "calendar_check.py"
        script.write_text("pass")
        output = json.dumps({
            "upcoming": [
                {"summary": "Later", "minutes_until": 90, "time": "14:00"},
            ]
        })
        mock_result = MagicMock(returncode=0, stdout=output)
        state = {"calendar": {"daily_summary_date": date.today().isoformat(), "reminded_events": {}}}
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            result = _check_calendar_smart(state, (23, 8))
        assert result is None

    def test_no_events_today(self, tmp_env):
        """No events returns None and still marks summary as sent."""
        script = tmp_env["tools"] / "calendar_check.py"
        script.write_text("pass")
        output = json.dumps({"today": [], "tomorrow": []})
        mock_result = MagicMock(returncode=0, stdout=output)
        state = {}
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            result = _check_calendar_smart(state, (23, 8))
        assert result is None
        assert state["calendar"]["daily_summary_date"] == date.today().isoformat()

    def test_old_reminded_events_cleaned(self, tmp_env):
        """Reminded events from previous days are cleaned up."""
        script = tmp_env["tools"] / "calendar_check.py"
        script.write_text("pass")
        output = json.dumps({"today": [{"summary": "X", "time": "09:00"}], "tomorrow": []})
        mock_result = MagicMock(returncode=0, stdout=output)
        state = {"calendar": {
            "daily_summary_date": "2020-01-01",
            "reminded_events": {"OldEvent@09:00": "2020-01-01"}
        }}
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            _check_calendar_smart(state, (23, 8))
        assert "OldEvent@09:00" not in state["calendar"]["reminded_events"]

    def test_subprocess_exception(self, tmp_env):
        script = tmp_env["tools"] / "calendar_check.py"
        script.write_text("pass")
        with patch("src.heartbeat.subprocess.run", side_effect=OSError("fail")):
            assert _check_calendar_smart({}, (23, 8)) is None


# ---------------------------------------------------------------------------
# _check_kb_index
# ---------------------------------------------------------------------------


class TestCheckKbIndex:
    """Test KB index freshness check with auto-reindex."""

    def test_missing_index(self, tmp_env):
        with patch("src.heartbeat._run_reindex") as mock_reindex:
            result = _check_kb_index()
        assert result == "KB: index regenerat"
        mock_reindex.assert_called_once()

    def test_up_to_date(self, tmp_env):
        kb_dir = tmp_env["root"] / "memory" / "kb"
        kb_dir.mkdir(parents=True)
        md_file = kb_dir / "notes.md"
        md_file.write_text("old notes")
        time.sleep(0.05)
        index = kb_dir / "index.json"
        index.write_text("{}")
        assert _check_kb_index() is None

    def test_needs_reindex(self, tmp_env):
        kb_dir = tmp_env["root"] / "memory" / "kb"
        kb_dir.mkdir(parents=True)
        index = kb_dir / "index.json"
        index.write_text("{}")
        time.sleep(0.05)
        md1 = kb_dir / "a.md"
        md1.write_text("new")
        md2 = kb_dir / "b.md"
        md2.write_text("also new")
        with patch("src.heartbeat._run_reindex") as mock_reindex:
            result = _check_kb_index()
        assert result == "KB: 2 fișiere reindexate"
        mock_reindex.assert_called_once()


# ---------------------------------------------------------------------------
# _check_git
# ---------------------------------------------------------------------------


class TestCheckGit:
    """Test git status check."""

    def test_clean(self, tmp_env):
        mock_result = MagicMock(returncode=0, stdout="\n")
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            assert _check_git() is None

    def test_dirty(self, tmp_env):
        mock_result = MagicMock(
            returncode=0,
            stdout=" M file1.py\n?? file2.py\n M file3.py\n",
        )
        with patch("src.heartbeat.subprocess.run", return_value=mock_result):
            assert _check_git() == "Git: 3 uncommitted"

    def test_subprocess_exception(self, tmp_env):
        with patch("src.heartbeat.subprocess.run", side_effect=OSError):
            assert _check_git() is None


# ---------------------------------------------------------------------------
# _get_extra_instructions
# ---------------------------------------------------------------------------


class TestGetExtraInstructions:
    """Test HEARTBEAT.md parsing for extra instructions."""

    def test_no_file(self, tmp_env):
        """Returns None when HEARTBEAT.md doesn't exist."""
        assert _get_extra_instructions() is None

    def test_only_boilerplate(self, tmp_env):
        """Returns None when HEARTBEAT.md has only standard rules."""
        hb = tmp_env["personality"] / "HEARTBEAT.md"
        hb.write_text(
            "# HEARTBEAT.md\n\n"
            "## Reguli\n\n"
            "- **Noapte (23:00-08:00):** Doar HEARTBEAT_OK, nu deranja\n"
            "- **Nu spama:** Dacă nu e nimic, HEARTBEAT_OK\n"
        )
        assert _get_extra_instructions() is None

    def test_with_extra(self, tmp_env):
        """Returns extra instructions when present."""
        hb = tmp_env["personality"] / "HEARTBEAT.md"
        hb.write_text(
            "# HEARTBEAT.md\n\n"
            "## Reguli\n\n"
            "- **Noapte (23:00-08:00):** Doar HEARTBEAT_OK, nu deranja\n\n"
            "## Extra\n\n"
            "- Verifică dacă backup-ul s-a făcut\n"
            "- Raportează uptime-ul serverului\n"
        )
        result = _get_extra_instructions()
        assert result is not None
        assert "backup" in result
        assert "uptime" in result


# ---------------------------------------------------------------------------
# _load_state / _save_state
# ---------------------------------------------------------------------------


class TestState:
    """Test state persistence."""

    def test_load_missing_file(self, tmp_env):
        state = _load_state()
        assert state == {"last_run": None, "checks": {}}

    def test_round_trip(self, tmp_env):
        original = {"last_run": "2025-01-01T00:00:00", "checks": {"email": "2025-01-01T00:00:00"}}
        _save_state(original)
        loaded = _load_state()
        assert loaded == original

    def test_load_corrupt_json(self, tmp_env):
        tmp_env["state_file"].write_text("not valid json {{{")
        state = _load_state()
        assert state == {"last_run": None, "checks": {}}

    def test_save_creates_parent_dir(self, tmp_path, monkeypatch):
        state_file = tmp_path / "deep" / "nested" / "state.json"
        monkeypatch.setattr("src.heartbeat.STATE_FILE", state_file)
        _save_state({"last_run": None, "checks": {}})
        assert state_file.exists()


# ---------------------------------------------------------------------------
# run_heartbeat (integration)
# ---------------------------------------------------------------------------


class TestRunHeartbeat:
    """Test the top-level run_heartbeat orchestrator."""

    def test_all_ok(self, tmp_env):
        with patch("src.heartbeat._check_email", return_value=None), \
             patch("src.heartbeat._check_calendar_smart", return_value=None), \
             patch("src.heartbeat._check_kb_index", return_value=None), \
             patch("src.heartbeat._check_git", return_value=None), \
             patch("src.heartbeat._run_claude_extra", return_value=None):
            result = run_heartbeat()
        assert result == "HEARTBEAT_OK"

    def test_with_results(self, tmp_env):
        with patch("src.heartbeat._check_email", return_value="Email: 2 necitite (X, Y)"), \
             patch("src.heartbeat._check_calendar_smart", return_value=None), \
             patch("src.heartbeat._check_kb_index", return_value="KB: 1 fișiere reindexate"), \
             patch("src.heartbeat._check_git", return_value=None), \
             patch("src.heartbeat._is_quiet_hour", return_value=False), \
             patch("src.heartbeat._run_claude_extra", return_value=None):
            result = run_heartbeat()
        assert result == "Email: 2 necitite (X, Y) | KB: 1 fișiere reindexate"

    def test_quiet_hours_suppresses_all(self, tmp_env):
        """During quiet hours, calendar is also suppressed (no longer critical)."""
        with patch("src.heartbeat._check_email", return_value="Email: 5 necitite (A)"), \
             patch("src.heartbeat._check_calendar_smart", return_value="Calendar stuff") as mock_cal, \
             patch("src.heartbeat._check_kb_index", return_value=None), \
             patch("src.heartbeat._check_git", return_value="Git: 2 uncommitted"), \
             patch("src.heartbeat._is_quiet_hour", return_value=True):
            result = run_heartbeat()
        assert result == "HEARTBEAT_OK"
        mock_cal.assert_not_called()

    def test_config_disables_check(self, tmp_env):
        """Checks can be disabled via config."""
        config = {"heartbeat": {"checks": {"git": False}}}
        with patch("src.heartbeat._check_email", return_value=None), \
             patch("src.heartbeat._check_calendar_smart", return_value=None), \
             patch("src.heartbeat._check_kb_index", return_value=None), \
             patch("src.heartbeat._check_git", return_value="Git: 5 uncommitted") as mock_git, \
             patch("src.heartbeat._run_claude_extra", return_value=None):
            result = run_heartbeat(config)
        mock_git.assert_not_called()
        assert result == "HEARTBEAT_OK"

    def test_config_custom_quiet_hours(self, tmp_env):
        """Quiet hours can be overridden via config."""
        config = {"heartbeat": {"quiet_hours": [0, 1]}}  # only 0-1 is quiet
        with patch("src.heartbeat._check_email", return_value=None), \
             patch("src.heartbeat._check_calendar_smart", return_value=None), \
             patch("src.heartbeat._check_kb_index", return_value=None), \
             patch("src.heartbeat._check_git", return_value="Git: 3 uncommitted"), \
             patch("src.heartbeat._is_quiet_hour", return_value=False), \
             patch("src.heartbeat._run_claude_extra", return_value=None):
            result = run_heartbeat(config)
        assert "Git: 3 uncommitted" in result

    def test_saves_state_after_run(self, tmp_env):
        with patch("src.heartbeat._check_email", return_value=None), \
             patch("src.heartbeat._check_calendar_smart", return_value=None), \
             patch("src.heartbeat._check_kb_index", return_value=None), \
             patch("src.heartbeat._check_git", return_value=None), \
             patch("src.heartbeat._run_claude_extra", return_value=None):
            run_heartbeat()
        state = json.loads(tmp_env["state_file"].read_text())
        assert "last_run" in state
        assert state["last_run"] is not None

    def test_saves_check_timestamps(self, tmp_env):
        with patch("src.heartbeat._check_email", return_value=None), \
             patch("src.heartbeat._check_calendar_smart", return_value=None), \
             patch("src.heartbeat._check_kb_index", return_value=None), \
             patch("src.heartbeat._check_git", return_value=None), \
             patch("src.heartbeat._run_claude_extra", return_value=None):
            run_heartbeat()
        state = json.loads(tmp_env["state_file"].read_text())
        assert "checks" in state
        assert "calendar" in state["checks"]
