"""Tests for scripts/scope_triage.py

All tests are unit tests — no network calls, no real filesystem writes to
course directories. backlog_manager is monkeypatched wherever --emit-backlog
behavior is exercised.
"""

from __future__ import annotations

import json
import os
import sys
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

import scope_triage as st


# ===========================================================================
# assess_scope — core math
# ===========================================================================

class TestAssessScopeCapacityMath:
    """Capacity formula: floor((weeks - 0.5) * topics_per_week).

    Session A: 7.5 weeks → floor(7.0 * 1.0) = 7
    Session C: 15.0 weeks → floor(14.5 * 1.0) = 14
    """

    def test_a_session_capacity(self):
        result = st.assess_scope(topic_count=7, session_type="A", topics_per_week=1.0)
        assert result["capacity"] == 7

    def test_c_session_capacity(self):
        result = st.assess_scope(topic_count=14, session_type="C", topics_per_week=1.0)
        assert result["capacity"] == 14

    def test_b_session_capacity(self):
        # B = 7.5 weeks → floor(7.0 * 1.0) = 7 (same as A)
        result = st.assess_scope(topic_count=5, session_type="B", topics_per_week=1.0)
        assert result["capacity"] == 7

    def test_full_session_capacity(self):
        # full = 15.0 → floor(14.5 * 1.0) = 14
        result = st.assess_scope(topic_count=10, session_type="full", topics_per_week=1.0)
        assert result["capacity"] == 14

    def test_capacity_with_two_topics_per_week(self):
        # A: floor(7.0 * 2.0) = 14
        result = st.assess_scope(topic_count=10, session_type="A", topics_per_week=2.0)
        assert result["capacity"] == 14

    def test_capacity_with_fractional_topics_per_week(self):
        # A: floor(7.0 * 0.5) = 3
        result = st.assess_scope(topic_count=2, session_type="A", topics_per_week=0.5)
        assert result["capacity"] == 3


class TestAssessScopeOverScopeFlag:
    def test_over_scope_false_when_at_capacity(self):
        result = st.assess_scope(topic_count=7, session_type="A")
        assert result["over_scope"] is False
        assert result["over_by"] == 0

    def test_over_scope_false_when_under_capacity(self):
        result = st.assess_scope(topic_count=5, session_type="A")
        assert result["over_scope"] is False
        assert result["over_by"] == 0

    def test_over_scope_true_when_exceeds_capacity(self):
        result = st.assess_scope(topic_count=9, session_type="A")
        assert result["over_scope"] is True
        assert result["over_by"] == 2

    def test_over_by_correct(self):
        # capacity=7, topic_count=10 → over_by=3
        result = st.assess_scope(topic_count=10, session_type="A")
        assert result["over_by"] == 3

    def test_over_by_zero_at_capacity(self):
        result = st.assess_scope(topic_count=7, session_type="A")
        assert result["over_by"] == 0

    def test_over_scope_c_session(self):
        # capacity=14, topic_count=16
        result = st.assess_scope(topic_count=16, session_type="C")
        assert result["over_scope"] is True
        assert result["over_by"] == 2


class TestAssessScopeCandidates:
    """Cut candidates must be limited to explicitly optional topics."""

    def test_candidates_empty_when_no_optional(self):
        result = st.assess_scope(topic_count=9, session_type="A")
        assert result["candidates"] == []

    def test_candidates_empty_when_optional_is_none(self):
        result = st.assess_scope(topic_count=9, session_type="A", optional_topics=None)
        assert result["candidates"] == []

    def test_candidates_contains_only_optional_topics(self):
        optional = ["Module 7: Advanced Topics", "Supplemental Module"]
        result = st.assess_scope(
            topic_count=9,
            session_type="A",
            optional_topics=optional,
        )
        assert result["candidates"] == optional

    def test_candidates_present_even_when_not_over_scope(self):
        # candidates lists optional topics regardless of over_scope flag
        optional = ["Optional Review Module"]
        result = st.assess_scope(
            topic_count=5,
            session_type="A",
            optional_topics=optional,
        )
        assert result["candidates"] == optional
        assert result["over_scope"] is False

    def test_candidates_do_not_include_substantive_topics(self):
        # Only provided optional topics appear — no substantive topics are added
        optional = ["Elective: Case Studies"]
        result = st.assess_scope(
            topic_count=9,
            session_type="A",
            optional_topics=optional,
        )
        assert len(result["candidates"]) == len(optional)
        for candidate in result["candidates"]:
            assert candidate in optional


class TestAssessScopeNoteField:
    def test_note_present_on_over_scope(self):
        result = st.assess_scope(topic_count=9, session_type="A")
        assert result["note"]
        assert len(result["note"]) > 0

    def test_note_contains_instructor_decision_language_on_over_scope(self):
        result = st.assess_scope(topic_count=9, session_type="A")
        note_lower = result["note"].lower()
        # Must mention instructor/faculty ownership of the cut decision
        assert "instructor" in note_lower or "faculty" in note_lower

    def test_note_present_when_in_scope(self):
        result = st.assess_scope(topic_count=5, session_type="A")
        assert result["note"]

    def test_note_different_over_vs_clean(self):
        over = st.assess_scope(topic_count=9, session_type="A")
        clean = st.assess_scope(topic_count=5, session_type="A")
        assert over["note"] != clean["note"]


class TestAssessScopeReturnShape:
    def test_all_keys_present(self):
        result = st.assess_scope(topic_count=7, session_type="A")
        expected_keys = {
            "session_type", "weeks", "topics_per_week", "capacity",
            "topic_count", "over_by", "over_scope", "candidates", "note",
        }
        assert set(result.keys()) == expected_keys

    def test_session_type_normalized_uppercase(self):
        result = st.assess_scope(topic_count=5, session_type="a")
        assert result["session_type"] == "A"

    def test_weeks_matches_session(self):
        result = st.assess_scope(topic_count=5, session_type="A")
        assert result["weeks"] == 7.5

    def test_topics_per_week_stored(self):
        result = st.assess_scope(topic_count=5, session_type="A", topics_per_week=2.0)
        assert result["topics_per_week"] == 2.0

    def test_topic_count_stored(self):
        result = st.assess_scope(topic_count=11, session_type="A")
        assert result["topic_count"] == 11


class TestAssessScopeErrors:
    def test_topics_per_week_zero_raises(self):
        with pytest.raises(ValueError, match="topics_per_week must be > 0"):
            st.assess_scope(topic_count=5, session_type="A", topics_per_week=0)

    def test_topics_per_week_negative_raises(self):
        with pytest.raises(ValueError, match="topics_per_week must be > 0"):
            st.assess_scope(topic_count=5, session_type="A", topics_per_week=-1.0)

    def test_unknown_session_type_raises(self):
        with pytest.raises(ValueError):
            st.assess_scope(topic_count=5, session_type="X")


# ===========================================================================
# Config extraction helpers
# ===========================================================================

class TestExtractTopicCount:
    def test_top_level_modules_list(self):
        config = {"modules": [{"name": "M1"}, {"name": "M2"}, {"name": "M3"}]}
        assert st._extract_topic_count(config) == 3

    def test_nested_course_modules(self):
        config = {"course": {"modules": [{"name": "A"}, {"name": "B"}]}}
        assert st._extract_topic_count(config) == 2

    def test_empty_modules_returns_zero(self):
        config = {"modules": []}
        assert st._extract_topic_count(config) == 0

    def test_no_modules_returns_none(self):
        config = {}
        assert st._extract_topic_count(config) is None


class TestExtractOptionalTopics:
    def test_extracts_optional_modules(self):
        config = {
            "modules": [
                {"name": "Module 1", "optional": False},
                {"name": "Module 2", "optional": True},
                {"name": "Module 3 Bonus", "optional": True},
            ]
        }
        result = st._extract_optional_topics(config)
        assert result == ["Module 2", "Module 3 Bonus"]

    def test_no_optional_returns_empty(self):
        config = {
            "modules": [
                {"name": "Module 1"},
                {"name": "Module 2"},
            ]
        }
        result = st._extract_optional_topics(config)
        assert result == []

    def test_uses_title_field_if_no_name(self):
        config = {
            "modules": [
                {"title": "Case Studies", "optional": True},
            ]
        }
        result = st._extract_optional_topics(config)
        assert result == ["Case Studies"]

    def test_empty_modules_returns_empty(self):
        config = {"modules": []}
        result = st._extract_optional_topics(config)
        assert result == []

    def test_missing_modules_returns_empty(self):
        config = {}
        result = st._extract_optional_topics(config)
        assert result == []


# ===========================================================================
# JSON output shape
# ===========================================================================

class TestJsonOutputShape:
    """Verify --json output contains expected keys."""

    def test_json_output_shape(self):
        result = st.assess_scope(topic_count=9, session_type="A")
        # The JSON output is just the assess_scope dict; verify key presence
        assert "over_scope" in result
        assert "over_by" in result
        assert "capacity" in result
        assert "candidates" in result
        assert "note" in result
        # Must be JSON-serializable
        json_str = json.dumps(result, default=str)
        parsed = json.loads(json_str)
        assert parsed["over_scope"] is True
        assert parsed["over_by"] == 2


# ===========================================================================
# Backlog emission shape (mocked backlog_manager)
# ===========================================================================

class TestEmitBacklog:
    """Verify emit_backlog calls backlog_manager.add with the expected shape."""

    def test_emit_backlog_finding_shape(self):
        result = st.assess_scope(topic_count=9, session_type="A")

        mock_bm = MagicMock()
        mock_bm.add.return_value = {"id": "backlog-abc123"}

        # backlog_manager is a module-level attribute in scope_triage — patch it directly
        original_bm = st.backlog_manager
        try:
            st.backlog_manager = mock_bm
            st.emit_backlog("12345", result)
        finally:
            st.backlog_manager = original_bm

        assert mock_bm.add.call_count == 1
        call_args = mock_bm.add.call_args
        course_id_arg = call_args[0][0]
        item_arg = call_args[0][1]

        assert course_id_arg == "12345"
        assert "finding_id" in item_arg
        assert "finding_text" in item_arg
        assert "severity" in item_arg
        assert item_arg["severity"] == "HIGH"
        assert "source" in item_arg
        assert item_arg["source"] == "scope_triage.py"
        assert "evidence" in item_arg
        # evidence must be a JSON string
        evidence = json.loads(item_arg["evidence"])
        assert "session_type" in evidence
        assert "capacity" in evidence
        assert "over_by" in evidence

    def test_emit_backlog_includes_candidates(self):
        result = st.assess_scope(
            topic_count=9,
            session_type="A",
            optional_topics=["Optional Module"],
        )
        mock_bm = MagicMock()
        mock_bm.add.return_value = {"id": "backlog-xyz"}

        original_bm = st.backlog_manager
        try:
            st.backlog_manager = mock_bm
            st.emit_backlog("99999", result)
        finally:
            st.backlog_manager = original_bm

        item_arg = mock_bm.add.call_args[0][1]
        evidence = json.loads(item_arg["evidence"])
        assert "Optional Module" in evidence["candidates"]
