"""Tests for session classifier and velocity calculator."""

from __future__ import annotations

from claude_engram.calibration import CalibrationConfig
from claude_engram.handoff import (
    ContextSample,
    HandoffState,
    SessionType,
    calculate_velocity,
    check_snooze,
    classify_session,
    compute_eta,
)

# ---------------------------------------------------------------------------
# classify_session tests
# ---------------------------------------------------------------------------


class TestClassifySessionGSD:
    """GSD_EXECUTION: git_branch starts with 'gsd/' -> highest priority."""

    def test_gsd_branch_returns_gsd_execution(self):
        result = classify_session([], {}, "gsd/phase-4-beacon")
        assert result == SessionType.GSD_EXECUTION

    def test_gsd_branch_wins_over_coding_tool_count(self):
        """Branch check takes priority over tool counts (first-match-wins)."""
        result = classify_session([], {"Write": 100}, "gsd/phase-4-beacon")
        assert result == SessionType.GSD_EXECUTION

    def test_gsd_branch_wins_over_voyager_memory(self):
        """Branch check takes priority over memory signals."""
        result = classify_session([{"content": "session_type:voyager"}], {}, "gsd/phase-3-memory")
        assert result == SessionType.GSD_EXECUTION

    def test_gsd_branch_prefix_exact(self):
        """Only 'gsd/' prefix, not 'gsd-something'."""
        result = classify_session([], {}, "gsd-not-a-branch")
        assert result != SessionType.GSD_EXECUTION

    def test_gsd_branch_case_insensitive(self):
        """GSD branch detection is case-insensitive."""
        result = classify_session([], {}, "GSD/phase-4")
        assert result == SessionType.GSD_EXECUTION


class TestClassifySessionVoyager:
    """VOYAGER: memories contain voyager signals."""

    def test_session_type_voyager_tag(self):
        result = classify_session([{"content": "session_type:voyager"}], {}, "main")
        assert result == SessionType.VOYAGER

    def test_voyager_keyword_in_content(self):
        result = classify_session([{"content": "voyager convergent iteration 3"}], {}, "main")
        assert result == SessionType.VOYAGER

    def test_research_meta_keyword(self):
        result = classify_session([{"content": "research_meta analysis complete"}], {}, "main")
        assert result == SessionType.VOYAGER


class TestClassifySessionBeacon:
    """BEACON: memories contain beacon/reporter signals."""

    def test_reporter_search_keyword(self):
        result = classify_session([{"content": "reporter_search query"}], {}, "main")
        assert result == SessionType.BEACON

    def test_session_type_beacon_tag(self):
        result = classify_session([{"content": "session_type:beacon"}], {}, "main")
        assert result == SessionType.BEACON

    def test_beacon_keyword_in_content(self):
        result = classify_session([{"content": "beacon grounding pipeline started"}], {}, "main")
        assert result == SessionType.BEACON


class TestClassifySessionCoding:
    """CODING: Write+Edit+MultiEdit count > 5."""

    def test_coding_above_threshold(self):
        result = classify_session([], {"Write": 3, "Edit": 3}, "main")
        assert result == SessionType.CODING

    def test_coding_at_threshold_not_coding(self):
        """Total 5 is not > 5, should NOT be CODING."""
        result = classify_session([], {"Write": 3, "Edit": 2}, "main")
        assert result != SessionType.CODING

    def test_coding_below_threshold(self):
        result = classify_session([], {"Write": 2, "Edit": 2}, "main")
        assert result != SessionType.CODING

    def test_coding_with_multiedit(self):
        """MultiEdit counts toward the write threshold."""
        result = classify_session([], {"Write": 2, "Edit": 2, "MultiEdit": 3}, "main")
        assert result == SessionType.CODING

    def test_coding_write_only_above_threshold(self):
        result = classify_session([], {"Write": 7}, "main")
        assert result == SessionType.CODING


class TestClassifySessionDebug:
    """DEBUG: 2+ memories with error/bug/traceback/exception keywords."""

    def test_debug_two_error_memories(self):
        result = classify_session(
            [
                {"content": "error: socket timeout"},
                {"content": "investigating bug"},
            ],
            {"Write": 1},
            "main",
        )
        assert result == SessionType.DEBUG

    def test_debug_traceback_and_exception(self):
        result = classify_session(
            [
                {"content": "traceback in module X"},
                {"content": "exception handler missing"},
            ],
            {},
            "main",
        )
        assert result == SessionType.DEBUG

    def test_debug_one_error_not_enough(self):
        """Single error memory is not enough for DEBUG."""
        result = classify_session(
            [{"content": "error: something failed"}],
            {},
            "main",
        )
        assert result != SessionType.DEBUG

    def test_debug_case_insensitive(self):
        """Error keyword matching is case-insensitive."""
        result = classify_session(
            [
                {"content": "ERROR: timeout"},
                {"content": "BUG: race condition"},
            ],
            {},
            "main",
        )
        assert result == SessionType.DEBUG


class TestClassifySessionResearch:
    """RESEARCH: default when no other signal matches."""

    def test_empty_inputs_returns_research(self):
        result = classify_session([], {}, "main")
        assert result == SessionType.RESEARCH

    def test_none_branch_returns_research(self):
        result = classify_session([], {}, None)
        assert result == SessionType.RESEARCH

    def test_low_tool_counts_returns_research(self):
        result = classify_session([], {"Write": 2}, "main")
        assert result == SessionType.RESEARCH


class TestClassifySessionEdgeCases:
    """Edge cases for classify_session."""

    def test_empty_all_inputs(self):
        result = classify_session([], {}, None)
        assert result == SessionType.RESEARCH

    def test_none_git_branch(self):
        """None branch doesn't crash."""
        result = classify_session([], {}, None)
        assert result == SessionType.RESEARCH

    def test_mixed_signals_gsd_branch_wins(self):
        """GSD branch wins over CODING tool count and VOYAGER memory."""
        result = classify_session(
            [{"content": "session_type:voyager"}],
            {"Write": 100},
            "gsd/phase-4",
        )
        assert result == SessionType.GSD_EXECUTION

    def test_voyager_wins_over_coding(self):
        """VOYAGER wins over CODING when both match (first-match-wins)."""
        result = classify_session(
            [{"content": "voyager convergent iteration"}],
            {"Write": 10},
            "main",
        )
        assert result == SessionType.VOYAGER

    def test_memories_with_missing_content_key(self):
        """Memory dicts without 'content' key don't crash."""
        result = classify_session([{"other": "value"}], {}, "main")
        assert result == SessionType.RESEARCH

    def test_deterministic_ordering(self):
        """Classification order is GSD > VOYAGER > BEACON > CODING > DEBUG > RESEARCH."""
        # Voyager + Beacon signals -> VOYAGER wins
        result = classify_session(
            [
                {"content": "session_type:voyager"},
                {"content": "reporter_search query"},
            ],
            {},
            "main",
        )
        assert result == SessionType.VOYAGER

    def test_beacon_wins_over_coding(self):
        """BEACON wins over CODING when both match."""
        result = classify_session(
            [{"content": "beacon pipeline"}],
            {"Write": 10},
            "main",
        )
        assert result == SessionType.BEACON

    def test_coding_wins_over_debug(self):
        """CODING wins over DEBUG when both match (first-match-wins)."""
        result = classify_session(
            [
                {"content": "error: something"},
                {"content": "bug: another"},
            ],
            {"Write": 10},
            "main",
        )
        assert result == SessionType.CODING

    def test_never_raises_on_bad_memory(self):
        """Malformed memories (None, missing keys, etc) don't raise."""
        # dict with None value
        result = classify_session(
            [{"content": None}],
            {},
            "main",
        )
        assert isinstance(result, SessionType)


# ---------------------------------------------------------------------------
# calculate_velocity tests
# ---------------------------------------------------------------------------


class TestCalculateVelocity:
    """calculate_velocity computes EMA-smoothed context velocity."""

    def test_zero_samples_returns_none(self):
        config = CalibrationConfig()
        velocity, eta = calculate_velocity([], config)
        assert velocity is None
        assert eta is None

    def test_one_sample_returns_none(self):
        """Need min_samples (default=2) for valid velocity."""
        config = CalibrationConfig()
        samples = [ContextSample(used_pct=30.0, turn_number=1, timestamp="t1")]
        velocity, eta = calculate_velocity(samples, config)
        assert velocity is None
        assert eta is None

    def test_two_samples_basic_velocity(self):
        """Two samples: velocity = delta_pct / delta_turns."""
        config = CalibrationConfig(ema_alpha=1.0)  # alpha=1 -> no smoothing
        samples = [
            ContextSample(used_pct=30.0, turn_number=1, timestamp="t1"),
            ContextSample(used_pct=35.0, turn_number=2, timestamp="t2"),
        ]
        velocity, eta = calculate_velocity(samples, config)
        assert velocity is not None
        assert abs(velocity - 5.0) < 0.01
        # eta = (75 - 35) / 5.0 = 8.0
        assert eta is not None
        assert abs(eta - 8.0) < 0.01

    def test_ema_smoothing_with_alpha_03(self):
        """5 samples with EMA alpha=0.3: later samples weighted more."""
        config = CalibrationConfig(ema_alpha=0.3)
        samples = [
            ContextSample(used_pct=20.0, turn_number=1, timestamp="t1"),
            ContextSample(used_pct=25.0, turn_number=2, timestamp="t2"),  # v=5
            ContextSample(used_pct=30.0, turn_number=3, timestamp="t3"),  # v=5
            ContextSample(used_pct=38.0, turn_number=4, timestamp="t4"),  # v=8
            ContextSample(used_pct=48.0, turn_number=5, timestamp="t5"),  # v=10
        ]
        velocity, eta = calculate_velocity(samples, config)
        assert velocity is not None
        # EMA: start with 5.0
        # step 2: 0.3*5 + 0.7*5 = 5.0
        # step 3: 0.3*8 + 0.7*5 = 5.9
        # step 4: 0.3*10 + 0.7*5.9 = 7.13
        assert velocity > 5.0  # Accelerating, so EMA > initial
        assert velocity < 10.0  # But not as high as last raw velocity
        assert eta is not None

    def test_constant_velocity_same_pct(self):
        """All samples same pct -> velocity=0, eta=None (no growth)."""
        config = CalibrationConfig(ema_alpha=0.3)
        samples = [
            ContextSample(used_pct=50.0, turn_number=1, timestamp="t1"),
            ContextSample(used_pct=50.0, turn_number=2, timestamp="t2"),
            ContextSample(used_pct=50.0, turn_number=3, timestamp="t3"),
        ]
        velocity, eta = calculate_velocity(samples, config)
        assert velocity is not None
        assert abs(velocity) < 0.001
        # Zero velocity -> no ETA (context not growing)
        assert eta is None

    def test_decreasing_pct_returns_negative_velocity(self):
        """Decreasing pct -> negative velocity, eta=None."""
        config = CalibrationConfig(ema_alpha=1.0)
        samples = [
            ContextSample(used_pct=60.0, turn_number=1, timestamp="t1"),
            ContextSample(used_pct=55.0, turn_number=2, timestamp="t2"),
        ]
        velocity, eta = calculate_velocity(samples, config)
        assert velocity is not None
        assert velocity < 0
        assert eta is None

    def test_non_sequential_turns(self):
        """Non-sequential turn numbers still work (velocity per turn delta)."""
        config = CalibrationConfig(ema_alpha=1.0)
        samples = [
            ContextSample(used_pct=30.0, turn_number=2, timestamp="t1"),
            ContextSample(used_pct=40.0, turn_number=4, timestamp="t2"),
        ]
        velocity, eta = calculate_velocity(samples, config)
        assert velocity is not None
        # delta_pct=10, delta_turns=2, velocity=5.0
        assert abs(velocity - 5.0) < 0.01

    def test_uses_config_target_pct_for_eta(self):
        """ETA calculation uses config.target_pct."""
        config = CalibrationConfig(target_pct=80.0, ema_alpha=1.0)
        samples = [
            ContextSample(used_pct=30.0, turn_number=1, timestamp="t1"),
            ContextSample(used_pct=40.0, turn_number=2, timestamp="t2"),
        ]
        velocity, eta = calculate_velocity(samples, config)
        assert eta is not None
        # eta = (80 - 40) / 10 = 4.0
        assert abs(eta - 4.0) < 0.01

    def test_min_samples_configurable(self):
        """Velocity requires config.min_samples observations."""
        config = CalibrationConfig(min_samples=3)
        samples = [
            ContextSample(used_pct=30.0, turn_number=1, timestamp="t1"),
            ContextSample(used_pct=35.0, turn_number=2, timestamp="t2"),
        ]
        velocity, eta = calculate_velocity(samples, config)
        assert velocity is None  # Only 2 samples, need 3
        assert eta is None


# ---------------------------------------------------------------------------
# compute_eta tests
# ---------------------------------------------------------------------------


class TestComputeEta:
    """compute_eta does simple division with safety guards."""

    def test_positive_velocity_returns_eta(self):
        result = compute_eta(current_pct=50.0, velocity=5.0, target_pct=75.0)
        assert result is not None
        assert abs(result - 5.0) < 0.01  # (75-50)/5 = 5.0

    def test_zero_velocity_returns_none(self):
        result = compute_eta(current_pct=50.0, velocity=0.0, target_pct=75.0)
        assert result is None

    def test_negative_velocity_returns_none(self):
        result = compute_eta(current_pct=50.0, velocity=-2.0, target_pct=75.0)
        assert result is None

    def test_current_above_target_returns_zero_or_negative(self):
        """If already past target, ETA is 0 or negative -> still valid."""
        result = compute_eta(current_pct=80.0, velocity=5.0, target_pct=75.0)
        assert result is not None
        assert result <= 0  # Already past target

    def test_very_small_velocity(self):
        """Very small positive velocity -> very large ETA."""
        result = compute_eta(current_pct=50.0, velocity=0.001, target_pct=75.0)
        assert result is not None
        assert result > 1000


# ---------------------------------------------------------------------------
# check_snooze tests
# ---------------------------------------------------------------------------


class TestCheckSnooze:
    """check_snooze gates re-triggering after 'Keep going'."""

    def test_no_state_returns_false(self):
        """None state -> not snoozed."""
        result = check_snooze(None, current_pct=65.0)
        assert result is False

    def test_no_snooze_set_returns_false(self):
        """State exists but snooze_until_pct is None -> not snoozed."""
        state = HandoffState(
            created_at="t1",
            session_type=SessionType.CODING,
            snooze_until_pct=None,
        )
        result = check_snooze(state, current_pct=65.0)
        assert result is False

    def test_snoozed_below_threshold(self):
        """Current pct below snooze threshold -> still snoozed (True)."""
        state = HandoffState(
            created_at="t1",
            session_type=SessionType.CODING,
            snooze_until_pct=70.0,
        )
        result = check_snooze(state, current_pct=65.0)
        assert result is True

    def test_snooze_expired(self):
        """Current pct at or above snooze threshold -> snooze expired (False)."""
        state = HandoffState(
            created_at="t1",
            session_type=SessionType.CODING,
            snooze_until_pct=70.0,
        )
        result = check_snooze(state, current_pct=72.0)
        assert result is False

    def test_snooze_exact_threshold(self):
        """Current pct exactly at snooze threshold -> snooze expired (False)."""
        state = HandoffState(
            created_at="t1",
            session_type=SessionType.CODING,
            snooze_until_pct=70.0,
        )
        result = check_snooze(state, current_pct=70.0)
        assert result is False
