#!/usr/bin/env python3
"""
Session Doctor — Basic Unit Tests (Phase 1)
Tests cover:
  1. Detector pattern matching
  2. Classifier error mapping (ERROR_POLICY)
  3. Recoverer strategy selection
  4. StateStore database operations
  5. Notifier alert formatting
"""

import sys
import re
import json
import sqlite3
import tempfile
import unittest
from pathlib import Path
from datetime import datetime
from unittest.mock import MagicMock, patch, call
from io import StringIO

# ─── Add project root to path ─────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))

from session_doctor import (
    Detector,
    Recoverer,
    StateStore,
    Notifier,
    Config,
    Severity,
    Strategy,
    SessionStatus,
    SessionState,
    ErrorEvent,
    ERROR_PATTERNS,
    ERROR_POLICY,
)

# Optional: Classifier may not exist yet in the prototype
try:
    from session_doctor import Classifier
    _CLASSIFIER_AVAILABLE = True
except ImportError:
    Classifier = None
    _CLASSIFIER_AVAILABLE = False


# ─── Helpers ──────────────────────────────────────────────────────────────────

def make_session(session_id="test-session", label="test", status=SessionStatus.OK) -> SessionState:
    return SessionState(session_id=session_id, label=label, status=status)


def make_store(tmp_path: Path = None) -> StateStore:
    """Create an in-memory (or temp file) StateStore for testing."""
    if tmp_path is None:
        tmp_path = Path(tempfile.mkdtemp()) / "test_state.db"
    return StateStore(db_path=tmp_path)


# ═══════════════════════════════════════════════════════════════════════════════
# 1. DETECTOR — Pattern Matching
# ═══════════════════════════════════════════════════════════════════════════════

class TestDetectorPatternMatching(unittest.TestCase):
    """Tests for Detector.classify() — raw text → error_type."""

    def setUp(self):
        self.detector = Detector()

    # ── Positive matches ──────────────────────────────────────────────────────

    def test_classify_http_400(self):
        """HTTP 400 / Bad Request / invalid_request → http_400"""
        cases = [
            "HTTP 400 Bad Request",
            "400 status returned",
            "invalid_request: missing field",
            "Error: Bad Request from upstream",
        ]
        for text in cases:
            with self.subTest(text=text):
                self.assertEqual(self.detector.classify(text), "http_400", text)

    def test_classify_token_limit(self):
        """Context/token overflow messages → token_limit"""
        cases = [
            "context length exceeded",
            "max_tokens limit reached",
            "too long for model context window",
            "token limit: 4096 tokens",
        ]
        for text in cases:
            with self.subTest(text=text):
                self.assertEqual(self.detector.classify(text), "token_limit", text)

    def test_classify_json_parse(self):
        """JSON / parse errors → json_parse"""
        cases = [
            "SyntaxError: Unexpected token in JSON",
            "invalid.json format",
            "JSON parse error at line 3",
            "parse error: malformed response",
        ]
        for text in cases:
            with self.subTest(text=text):
                self.assertEqual(self.detector.classify(text), "json_parse", text)

    def test_classify_timeout(self):
        """Timeout signals → timeout"""
        cases = [
            "Request timed out after 30s",
            "ETIMEDOUT: connection refused",
            "deadline exceeded",
            "timeout waiting for LLM response",
        ]
        for text in cases:
            with self.subTest(text=text):
                self.assertEqual(self.detector.classify(text), "timeout", text)

    def test_classify_tool_repeat(self):
        """Repeated tool call marker → tool_repeat"""
        cases = [
            "REPEATED_TOOL_CALL detected",
            "repeated_tool_call: same call seen 3 times",
        ]
        for text in cases:
            with self.subTest(text=text):
                self.assertEqual(self.detector.classify(text), "tool_repeat", text)

    def test_classify_rate_limit(self):
        """Rate limit responses → rate_limit"""
        cases = [
            "429 Too Many Requests",
            "rate limit exceeded",
            "too_many_requests, retry after 60s",
        ]
        for text in cases:
            with self.subTest(text=text):
                self.assertEqual(self.detector.classify(text), "rate_limit", text)

    def test_classify_auth_error(self):
        """Auth/forbidden errors → auth_error"""
        cases = [
            "401 Unauthorized",
            "403 Forbidden",
            "Unauthorized: invalid API key",
        ]
        for text in cases:
            with self.subTest(text=text):
                self.assertEqual(self.detector.classify(text), "auth_error", text)

    def test_classify_budget_over(self):
        """Budget exceeded signals → budget_over"""
        cases = [
            "budget exceeded $2.00",
            "over_budget: cost limit reached",
            "cost limit hit",
        ]
        for text in cases:
            with self.subTest(text=text):
                self.assertEqual(self.detector.classify(text), "budget_over", text)

    # ── Negative matches ──────────────────────────────────────────────────────

    def test_classify_returns_none_on_normal_text(self):
        """No error keywords → None"""
        benign = [
            "Session started successfully",
            "Tool call completed",
            "Response received in 1.2s",
            "",
            "Everything is fine.",
        ]
        for text in benign:
            with self.subTest(text=text):
                self.assertIsNone(self.detector.classify(text))

    def test_classify_case_insensitive(self):
        """Pattern matching is case-insensitive."""
        self.assertEqual(self.detector.classify("RATE LIMIT"), "rate_limit")
        self.assertEqual(self.detector.classify("Token Limit"), "token_limit")
        self.assertEqual(self.detector.classify("bad request"), "http_400")

    def test_classify_returns_first_match(self):
        """When multiple patterns could match, returns the first one defined."""
        # "401" matches auth_error, "timeout" matches timeout.
        # The order depends on ERROR_PATTERNS dict insertion order.
        # We just verify a single string IS classified (not None).
        result = self.detector.classify("401 request timed out")
        self.assertIsNotNone(result)
        self.assertIn(result, ERROR_PATTERNS.keys())

    def test_all_error_pattern_keys_covered(self):
        """Every key in ERROR_PATTERNS has a test above and can match something."""
        detector = Detector()
        sample_inputs = {
            "http_400":    "400 Bad Request",
            "token_limit": "context length exceeded",
            "json_parse":  "invalid JSON",
            "timeout":     "connection timed out",
            "tool_repeat": "REPEATED_TOOL_CALL",
            "rate_limit":  "429 rate limit",
            "auth_error":  "401 Unauthorized",
            "budget_over": "budget exceeded",
        }
        for key, text in sample_inputs.items():
            self.assertIn(key, ERROR_PATTERNS, f"Missing pattern: {key}")
            self.assertEqual(detector.classify(text), key, f"Pattern {key} failed on '{text}'")


# ═══════════════════════════════════════════════════════════════════════════════
# 2. CLASSIFIER — Error → (Severity, Strategy) Mapping
# ═══════════════════════════════════════════════════════════════════════════════

class TestClassifierErrorMapping(unittest.TestCase):
    """
    Tests for ERROR_POLICY — verifies the correct (Severity, Strategy)
    is returned for each known error type.
    Also covers the Classifier class if it exists, or the policy lookup directly.
    """

    def _policy(self, error_type):
        return ERROR_POLICY.get(error_type)

    def test_all_error_types_have_policy(self):
        """Every pattern in ERROR_PATTERNS maps to a policy."""
        for key in ERROR_PATTERNS:
            self.assertIn(key, ERROR_POLICY, f"No policy for error type: {key}")

    def test_http_400_maps_to_high_reset(self):
        sev, strat = self._policy("http_400")
        self.assertEqual(sev, Severity.HIGH)
        self.assertEqual(strat, Strategy.RESET)

    def test_token_limit_maps_to_high_compact(self):
        sev, strat = self._policy("token_limit")
        self.assertEqual(sev, Severity.HIGH)
        self.assertEqual(strat, Strategy.COMPACT_CONTEXT)

    def test_json_parse_maps_to_medium_retry(self):
        sev, strat = self._policy("json_parse")
        self.assertEqual(sev, Severity.MEDIUM)
        self.assertEqual(strat, Strategy.RETRY_PROMPT_FIX)

    def test_timeout_maps_to_medium_backoff(self):
        sev, strat = self._policy("timeout")
        self.assertEqual(sev, Severity.MEDIUM)
        self.assertEqual(strat, Strategy.BACKOFF)

    def test_tool_repeat_maps_to_high_break_loop(self):
        sev, strat = self._policy("tool_repeat")
        self.assertEqual(sev, Severity.HIGH)
        self.assertEqual(strat, Strategy.BREAK_LOOP)

    def test_rate_limit_maps_to_low_backoff(self):
        sev, strat = self._policy("rate_limit")
        self.assertEqual(sev, Severity.LOW)
        self.assertEqual(strat, Strategy.BACKOFF)

    def test_auth_error_maps_to_critical_halt(self):
        sev, strat = self._policy("auth_error")
        self.assertEqual(sev, Severity.CRITICAL)
        self.assertEqual(strat, Strategy.HALT)

    def test_budget_over_maps_to_critical_halt(self):
        sev, strat = self._policy("budget_over")
        self.assertEqual(sev, Severity.CRITICAL)
        self.assertEqual(strat, Strategy.HALT)

    def test_unknown_error_type_falls_back_to_monitor(self):
        """Unrecognized error types should default to LOW/MONITOR."""
        default = ERROR_POLICY.get("totally_unknown_error", (Severity.LOW, Strategy.MONITOR))
        self.assertEqual(default[0], Severity.LOW)
        self.assertEqual(default[1], Strategy.MONITOR)

    def test_severity_ordering(self):
        """Severity enum values have correct relative ordering."""
        self.assertLess(Severity.LOW.value, Severity.MEDIUM.value)
        self.assertLess(Severity.MEDIUM.value, Severity.HIGH.value)
        self.assertLess(Severity.HIGH.value, Severity.CRITICAL.value)

    def test_classifier_class_maps_error_to_policy(self):
        """If a Classifier class exists, verify its core method."""
        if not _CLASSIFIER_AVAILABLE:
            self.skipTest("Classifier class not yet implemented")
        classifier = Classifier()
        result = classifier.classify("auth_error")
        self.assertIsNotNone(result)
        sev, strat = result
        self.assertEqual(sev, Severity.CRITICAL)
        self.assertEqual(strat, Strategy.HALT)


# ═══════════════════════════════════════════════════════════════════════════════
# 3. RECOVERER — Strategy Selection
# ═══════════════════════════════════════════════════════════════════════════════

class TestRecovererStrategySelection(unittest.TestCase):
    """
    Tests for Recoverer.recover() — verifies the correct strategy
    is dispatched, logged, and that edge cases (max retries, CRITICAL) work.
    """

    def setUp(self):
        self.tmp_db = Path(tempfile.mkdtemp()) / "recoverer_test.db"
        self.store = make_store(self.tmp_db)
        self.config = Config(
            max_auto_retries=2,
            backoff_base_sec=0.001,   # fast for tests
            backoff_max_sec=0.01,
            compact_keep_last_n=3,
        )
        self.notifier = MagicMock()
        self.recoverer = Recoverer(self.store, self.config, self.notifier)

    def _make_session_with_error(self, error_type="rate_limit", label="test"):
        """Register a session and log a dummy error, return (session, error_id)."""
        session = make_session(label=label)
        self.store.upsert_session(session)
        severity, strategy = ERROR_POLICY[error_type]
        event = ErrorEvent(
            session_id=session.session_id,
            error_type=error_type,
            error_detail="test detail",
            severity=severity,
            strategy=strategy,
        )
        error_id = self.store.log_error(event)
        return session, error_id

    def test_rate_limit_triggers_backoff(self):
        """rate_limit → BACKOFF strategy executes and returns True."""
        session, error_id = self._make_session_with_error("rate_limit")
        result = self.recoverer.recover(session, "rate_limit", error_id)
        self.assertTrue(result)

    def test_token_limit_triggers_compact_context(self):
        """token_limit → COMPACT_CONTEXT returns True."""
        session, error_id = self._make_session_with_error("token_limit")
        result = self.recoverer.recover(session, "token_limit", error_id)
        self.assertTrue(result)

    def test_json_parse_triggers_retry_prompt_fix(self):
        """json_parse → RETRY_PROMPT_FIX returns True."""
        session, error_id = self._make_session_with_error("json_parse")
        result = self.recoverer.recover(session, "json_parse", error_id)
        self.assertTrue(result)

    def test_tool_repeat_triggers_break_loop(self):
        """tool_repeat → BREAK_LOOP returns True."""
        session, error_id = self._make_session_with_error("tool_repeat")
        result = self.recoverer.recover(session, "tool_repeat", error_id)
        self.assertTrue(result)

    def test_auth_error_triggers_halt(self):
        """auth_error (CRITICAL) → HALT sets session status to HALTED."""
        session, error_id = self._make_session_with_error("auth_error")
        result = self.recoverer.recover(session, "auth_error", error_id)
        self.assertTrue(result)
        self.assertEqual(session.status, SessionStatus.HALTED)

    def test_max_retries_escalates_to_halt(self):
        """Non-CRITICAL error after max_auto_retries → escalated to HALT."""
        session, error_id = self._make_session_with_error("rate_limit")
        session.recovery_count = self.config.max_auto_retries  # already maxed out
        result = self.recoverer.recover(session, "rate_limit", error_id)
        self.assertTrue(result)
        # After escalation to HALT, session should be halted
        self.assertEqual(session.status, SessionStatus.HALTED)

    def test_backoff_exponential_increase(self):
        """Backoff wait time grows exponentially with each call."""
        session, _ = self._make_session_with_error("rate_limit")
        # Access internal counter to verify growth
        sid = session.session_id
        self.assertEqual(self.recoverer._backoff_counts.get(sid, 0), 0)

        error_ids = []
        for i in range(3):
            ev = ErrorEvent(session_id=sid, error_type="rate_limit", error_detail="x",
                            severity=Severity.LOW, strategy=Strategy.BACKOFF)
            error_ids.append(self.store.log_error(ev))

        self.recoverer.recover(session, "rate_limit", error_ids[0])
        count_after_1 = self.recoverer._backoff_counts.get(sid, 0)
        self.recoverer.recover(session, "rate_limit", error_ids[1])
        count_after_2 = self.recoverer._backoff_counts.get(sid, 0)
        self.assertEqual(count_after_1, 1)
        self.assertEqual(count_after_2, 2)

    def test_recovery_logged_in_store(self):
        """After recovery, an entry appears in the recoveries table."""
        session, error_id = self._make_session_with_error("json_parse")
        self.recoverer.recover(session, "json_parse", error_id)
        history = self.store.get_recovery_history(limit=10)
        self.assertGreater(len(history), 0)
        # Most recent row should reference our session
        most_recent = history[0]
        self.assertEqual(most_recent[1], session.session_id)

    def test_notifier_called_for_medium_severity(self):
        """Notifier.send() is invoked for MEDIUM+ severity when notifications enabled."""
        self.config.notifications_min_severity = Severity.MEDIUM
        self.config.notifications_enabled = True
        session, error_id = self._make_session_with_error("json_parse")  # MEDIUM
        self.recoverer.recover(session, "json_parse", error_id)
        self.notifier.send.assert_called_once()

    def test_notifier_not_called_for_low_severity(self):
        """Notifier.send() is NOT invoked for LOW severity when threshold is MEDIUM."""
        self.config.notifications_min_severity = Severity.MEDIUM
        self.config.notifications_enabled = True
        session, error_id = self._make_session_with_error("rate_limit")  # LOW
        self.recoverer.recover(session, "rate_limit", error_id)
        self.notifier.send.assert_not_called()

    def test_notifier_skipped_when_disabled(self):
        """Notifier.send() is never called when notifications_enabled=False."""
        self.config.notifications_enabled = False
        session, error_id = self._make_session_with_error("auth_error")  # CRITICAL
        self.recoverer.recover(session, "auth_error", error_id)
        self.notifier.send.assert_not_called()


# ═══════════════════════════════════════════════════════════════════════════════
# 4. STATE STORE — Database Operations
# ═══════════════════════════════════════════════════════════════════════════════

class TestStateStoreDatabaseOperations(unittest.TestCase):
    """
    Tests for StateStore CRUD operations: sessions, errors, recoveries.
    Uses a temp DB file to avoid touching the live state.db.
    """

    def setUp(self):
        self.tmp_db = Path(tempfile.mkdtemp()) / "test_state.db"
        self.store = StateStore(db_path=self.tmp_db)

    # ── Sessions ──────────────────────────────────────────────────────────────

    def test_upsert_session_inserts_new(self):
        """upsert_session inserts a new session row."""
        s = make_session("sess-001", label="test-label")
        self.store.upsert_session(s)
        rows = self.store.get_all_sessions()
        self.assertEqual(len(rows), 1)
        self.assertEqual(rows[0][0], "sess-001")
        self.assertEqual(rows[0][1], "test-label")

    def test_upsert_session_updates_existing(self):
        """upsert_session updates an existing session without duplicating."""
        s = make_session("sess-001", label="original")
        self.store.upsert_session(s)
        s.label = "updated"
        s.status = SessionStatus.WARN
        s.budget_used = 0.50
        self.store.upsert_session(s)
        rows = self.store.get_all_sessions()
        self.assertEqual(len(rows), 1)
        self.assertEqual(rows[0][1], "updated")
        self.assertEqual(rows[0][2], "warn")
        self.assertAlmostEqual(rows[0][5], 0.50)

    def test_get_all_sessions_empty(self):
        """Empty DB returns an empty list."""
        self.assertEqual(self.store.get_all_sessions(), [])

    def test_get_all_sessions_multiple(self):
        """Multiple sessions are all returned."""
        for i in range(5):
            self.store.upsert_session(make_session(f"sess-{i:03d}"))
        rows = self.store.get_all_sessions()
        self.assertEqual(len(rows), 5)

    # ── Errors ────────────────────────────────────────────────────────────────

    def test_log_error_returns_id(self):
        """log_error returns a positive integer row ID."""
        s = make_session()
        self.store.upsert_session(s)
        event = ErrorEvent(
            session_id=s.session_id,
            error_type="timeout",
            error_detail="timed out waiting",
            severity=Severity.MEDIUM,
            strategy=Strategy.BACKOFF,
        )
        error_id = self.store.log_error(event)
        self.assertIsInstance(error_id, int)
        self.assertGreater(error_id, 0)

    def test_log_error_stores_correct_fields(self):
        """log_error persists all fields correctly."""
        s = make_session("sess-err")
        self.store.upsert_session(s)
        event = ErrorEvent(
            session_id="sess-err",
            error_type="json_parse",
            error_detail="bad json here",
            severity=Severity.MEDIUM,
            strategy=Strategy.RETRY_PROMPT_FIX,
        )
        error_id = self.store.log_error(event)
        conn = sqlite3.connect(str(self.tmp_db))
        row = conn.execute("SELECT * FROM errors WHERE id=?", (error_id,)).fetchone()
        conn.close()
        self.assertEqual(row[1], "sess-err")        # session_id
        self.assertEqual(row[2], "json_parse")       # error_type
        self.assertIn("bad json", row[3])            # error_detail
        self.assertEqual(row[5], "MEDIUM")           # severity
        self.assertEqual(row[6], "RETRY_PROMPT_FIX") # strategy
        self.assertEqual(row[7], "pending")          # default outcome

    def test_update_error_outcome(self):
        """update_error_outcome changes outcome from 'pending' → 'recovered'."""
        s = make_session()
        self.store.upsert_session(s)
        event = ErrorEvent(session_id=s.session_id, error_type="timeout",
                           error_detail="x", severity=Severity.MEDIUM, strategy=Strategy.BACKOFF)
        eid = self.store.log_error(event)
        self.store.update_error_outcome(eid, "recovered")
        conn = sqlite3.connect(str(self.tmp_db))
        outcome = conn.execute("SELECT outcome FROM errors WHERE id=?", (eid,)).fetchone()[0]
        conn.close()
        self.assertEqual(outcome, "recovered")

    def test_get_recent_errors_within_window(self):
        """get_recent_errors counts errors within the time window."""
        s = make_session("sess-window")
        self.store.upsert_session(s)
        for _ in range(3):
            ev = ErrorEvent(session_id="sess-window", error_type="rate_limit",
                            error_detail="429", severity=Severity.LOW, strategy=Strategy.BACKOFF)
            self.store.log_error(ev)
        recent = self.store.get_recent_errors("sess-window", window_sec=60)
        self.assertEqual(recent.get("rate_limit", 0), 3)

    def test_get_recent_errors_empty_for_unknown_session(self):
        """get_recent_errors returns empty dict for unknown session_id."""
        recent = self.store.get_recent_errors("no-such-session", window_sec=300)
        self.assertEqual(recent, {})

    # ── Recoveries ────────────────────────────────────────────────────────────

    def test_log_recovery_returns_id(self):
        """log_recovery returns a positive integer ID."""
        rid = self.store.log_recovery("sess-001", "BACKOFF", success=True, notes="ok")
        self.assertIsInstance(rid, int)
        self.assertGreater(rid, 0)

    def test_log_recovery_success_flag(self):
        """log_recovery stores the success flag correctly (1=True, 0=False)."""
        rid_ok  = self.store.log_recovery("sess-001", "BACKOFF", success=True)
        rid_fail = self.store.log_recovery("sess-001", "HALT",   success=False)
        conn = sqlite3.connect(str(self.tmp_db))
        ok_val   = conn.execute("SELECT success FROM recoveries WHERE id=?", (rid_ok,)).fetchone()[0]
        fail_val = conn.execute("SELECT success FROM recoveries WHERE id=?", (rid_fail,)).fetchone()[0]
        conn.close()
        self.assertEqual(ok_val, 1)
        self.assertEqual(fail_val, 0)

    def test_get_recovery_history_limit(self):
        """get_recovery_history respects the limit parameter."""
        for i in range(10):
            self.store.log_recovery("sess-001", "BACKOFF", success=True, notes=f"run {i}")
        history = self.store.get_recovery_history(limit=5)
        self.assertEqual(len(history), 5)

    def test_db_file_created_on_init(self):
        """StateStore creates the DB file on initialization."""
        new_db = Path(tempfile.mkdtemp()) / "subdir" / "new.db"
        self.assertFalse(new_db.exists())
        StateStore(db_path=new_db)
        self.assertTrue(new_db.exists())


# ═══════════════════════════════════════════════════════════════════════════════
# 5. NOTIFIER — Alert Formatting
# ═══════════════════════════════════════════════════════════════════════════════

class TestNotifierAlertFormatting(unittest.TestCase):
    """
    Tests for Notifier.send() — verifies alert message contains
    required fields and is written to the output file.
    """

    def setUp(self):
        self.tmp_dir = Path(tempfile.mkdtemp())
        self.notif_log = self.tmp_dir / "test_notifications.log"
        self.notifier = Notifier(output_path=self.notif_log)

    def _send_alert(self, session_id="sess-abc", label="test-agent",
                    error_type="rate_limit", strategy=Strategy.BACKOFF,
                    success=True, notes="backed off 4s"):
        session = make_session(session_id=session_id, label=label)
        session.error_count = 3
        session.budget_used = 0.75
        self.notifier.send(session, error_type, strategy, success, notes)
        return self.notif_log.read_text()

    def test_notification_written_to_file(self):
        """send() writes at least something to the output log file."""
        content = self._send_alert()
        self.assertGreater(len(content.strip()), 0)

    def test_notification_contains_session_id(self):
        """Alert includes the session ID."""
        content = self._send_alert(session_id="sess-xyz")
        self.assertIn("sess-xyz", content)

    def test_notification_contains_session_label(self):
        """Alert includes the session label."""
        content = self._send_alert(label="my-agent")
        self.assertIn("my-agent", content)

    def test_notification_contains_error_type(self):
        """Alert includes the error type."""
        content = self._send_alert(error_type="token_limit")
        self.assertIn("token_limit", content)

    def test_notification_contains_strategy(self):
        """Alert includes the strategy name."""
        content = self._send_alert(strategy=Strategy.COMPACT_CONTEXT)
        self.assertIn("COMPACT_CONTEXT", content)

    def test_notification_success_shows_recovered(self):
        """Successful recovery shows '✅' or 'Recovered'."""
        content = self._send_alert(success=True)
        self.assertTrue("✅" in content or "Recovered" in content)

    def test_notification_failure_shows_failed(self):
        """Failed recovery shows '❌' or 'manual review'."""
        content = self._send_alert(success=False)
        self.assertTrue("❌" in content or "manual review" in content or "Failed" in content)

    def test_notification_contains_budget(self):
        """Alert includes the budget figure."""
        content = self._send_alert()
        self.assertIn("$0.75", content)

    def test_notification_contains_notes(self):
        """Alert includes the notes string."""
        content = self._send_alert(notes="special note here")
        self.assertIn("special note here", content)

    def test_notification_appends_on_multiple_sends(self):
        """Multiple send() calls append to the log, not overwrite."""
        self._send_alert(error_type="timeout")
        self._send_alert(error_type="json_parse")
        content = self.notif_log.read_text()
        self.assertIn("timeout", content)
        self.assertIn("json_parse", content)

    def test_notification_contains_timestamp(self):
        """Alert includes a timestamp (year present as minimum check)."""
        content = self._send_alert()
        current_year = str(datetime.now().year)
        self.assertIn(current_year, content)

    def test_notification_header_present(self):
        """Alert message contains a recognizable header."""
        content = self._send_alert()
        self.assertIn("Session Doctor", content)

    def test_notification_log_created_if_missing(self):
        """Notifier creates the output file if it does not exist."""
        new_log = self.tmp_dir / "nonexistent.log"
        notifier = Notifier(output_path=new_log)
        session = make_session()
        notifier.send(session, "timeout", Strategy.BACKOFF, True, "ok")
        self.assertTrue(new_log.exists())


# ─── Run ──────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    unittest.main(verbosity=2)
