"""Tests for v7.19 mockup lane-discipline conventions.

Covers:
  - scripts/preflight_checks.check_mockup_lane_discipline
  - scripts/preflight_checks.check_body_no_title_repeat
  - scripts/preflight_checks.check_pre_overflow
  - scripts/canvas_api.create_quiz scoring_policy passthrough
  - scripts/alignment_validator.LANE_EVIDENCE_TYPES enum
  - scripts/push_to_canvas.push_new_mockup_quiz end-to-end (mocked)
"""

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

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

import alignment_validator
import canvas_api
import preflight_checks
import push_to_canvas


def _mock_config():
    return {
        "token": "fake",
        "domain": "canvas.test.edu",
        "course_id": "99999",
        "base_url": "https://canvas.test.edu/api/v1",
        "course_url": "https://canvas.test.edu/api/v1/courses/99999",
        "headers": {"Authorization": "Bearer fake"},
    }


# ── check_mockup_lane_discipline ───────────────────────────────────────────

class TestMockupLaneDiscipline(unittest.TestCase):

    def _ids(self, issues):
        return [i.criterion_id for i in issues]

    def test_detects_compound_noun_jargon(self):
        html = "<p>Use a confidence-interval-interpretation item here.</p>"
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertIn("mockup.lane.compound_noun", self._ids(issues))

    def test_detects_unexplained_CI_abbreviation(self):
        html = "<p>The CI should be 95%.</p>"
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertIn("mockup.lane.unexplained_abbreviation", self._ids(issues))

    def test_allows_CI_when_spelled_out_first(self):
        html = "<p>The confidence interval (CI) should be 95%.</p>"
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertNotIn("mockup.lane.unexplained_abbreviation",
                         self._ids(issues))

    def test_skips_abbreviation_inside_pre_block(self):
        html = "<pre>The CI is reported as 95%.</pre>"
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertNotIn("mockup.lane.unexplained_abbreviation",
                         self._ids(issues))

    def test_skips_abbreviation_inside_em_mlo_quote(self):
        html = '<p>MLO2.2 says: <em>"Compute the CI for the mean."</em></p>'
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertNotIn("mockup.lane.unexplained_abbreviation",
                         self._ids(issues))

    def test_detects_bloom_verb_in_faculty_prose(self):
        html = "<p>This question is at the Apply level.</p>"
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertIn("mockup.lane.bloom_verb", self._ids(issues))

    def test_skips_bloom_verb_inside_em_block(self):
        html = '<p>MLO says: <em>"Apply the formula."</em></p>'
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertNotIn("mockup.lane.bloom_verb", self._ids(issues))

    def test_clean_text_returns_no_issues(self):
        html = ("<p>MLO3.2 says: <em>\"Interpret a regression model with one "
                "categorical predictor.\"</em> A multiple-choice item that "
                "asks students to 'interpret' would fit MLO3.2 better.</p>")
        issues = preflight_checks.check_mockup_lane_discipline(html)
        # Should be clean — no compound nouns, no bare abbreviations.
        self.assertEqual(issues, [])

    def test_all_issues_are_warnings_not_errors(self):
        html = "<p>CI-interpretation item with df issues at Apply level.</p>"
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertTrue(len(issues) > 0)
        for i in issues:
            self.assertEqual(i.severity, "warning")

    def test_maroon_attribution_badge_passes(self):
        html = ('<p><strong style="color:#8C1D40;">Example Generated by '
                'Claude Opus 4.7. Please validate content before using:'
                "</strong></p>")
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertNotIn("mockup.lane.attribution_not_maroon",
                         self._ids(issues))

    def test_black_attribution_badge_warns(self):
        html = ("<p><strong>Example Generated by Claude Opus 4.7. Please "
                "validate content before using:</strong></p>")
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertIn("mockup.lane.attribution_not_maroon",
                      self._ids(issues))

    def test_attribution_warning_is_warning_severity(self):
        html = "<p><strong>Example Generated by Claude.</strong></p>"
        issues = preflight_checks.check_mockup_lane_discipline(html)
        attr = [i for i in issues
                if i.criterion_id == "mockup.lane.attribution_not_maroon"]
        self.assertTrue(attr)
        for i in attr:
            self.assertEqual(i.severity, "warning")

    def test_maroon_attribution_case_insensitive_hex(self):
        # Lowercase hex must also count as maroon.
        html = ('<p><strong style="color:#8c1d40;">Example Generated by '
                "Claude.</strong></p>")
        issues = preflight_checks.check_mockup_lane_discipline(html)
        self.assertNotIn("mockup.lane.attribution_not_maroon",
                         self._ids(issues))


# ── check_body_no_title_repeat ─────────────────────────────────────────────

class TestBodyNoTitleRepeat(unittest.TestCase):

    def test_fails_on_h1_in_body(self):
        html = "<h1>Anything</h1><p>body</p>"
        issues = preflight_checks.check_body_no_title_repeat(html, title="Foo")
        self.assertTrue(any(
            i.criterion_id == "body.title.h1_present" and i.severity == "error"
            for i in issues
        ))

    def test_warns_on_h2_restating_title(self):
        html = '<h2>Mockup: Module 3 Quiz Alignment Review</h2>'
        issues = preflight_checks.check_body_no_title_repeat(
            html, title="Mockup: Module 3 Quiz Alignment Review")
        self.assertTrue(any(
            i.criterion_id == "body.title.h2_restates_title"
            and i.severity == "warning"
            for i in issues
        ))

    def test_h2_with_different_text_passes(self):
        html = "<h2>For Your Review, Maria</h2>"
        issues = preflight_checks.check_body_no_title_repeat(
            html, title="Mockup: Module 3 Quiz Alignment Review")
        for i in issues:
            self.assertNotEqual(i.criterion_id,
                                "body.title.h2_restates_title")

    def test_no_title_argument_only_fires_h1_rule(self):
        html = "<h2>Same Heading</h2>"
        issues = preflight_checks.check_body_no_title_repeat(html)
        self.assertEqual(issues, [])


# ── check_pre_overflow ─────────────────────────────────────────────────────

class TestPreOverflow(unittest.TestCase):

    def test_warns_on_bare_pre(self):
        html = "<pre>code here</pre>"
        issues = preflight_checks.check_pre_overflow(html)
        self.assertTrue(any(
            i.criterion_id == "body.pre.overflow" for i in issues
        ))

    def test_skips_pre_with_white_space_pre_wrap(self):
        html = '<pre style="white-space:pre-wrap;background:#eee;">code</pre>'
        issues = preflight_checks.check_pre_overflow(html)
        self.assertEqual(issues, [])

    def test_warns_on_pre_with_other_style_but_no_prewrap(self):
        html = '<pre style="background:#eee;">code</pre>'
        issues = preflight_checks.check_pre_overflow(html)
        self.assertTrue(any(
            i.criterion_id == "body.pre.overflow" for i in issues
        ))


# ── canvas_api.create_quiz scoring_policy passthrough ──────────────────────

class TestCreateQuizScoringPolicy(unittest.TestCase):

    @patch("canvas_api._request_with_retry")
    def test_scoring_policy_included_in_payload(self, mock_request):
        response = MagicMock()
        response.status_code = 200
        response.json.return_value = {"id": 11, "title": "T"}
        mock_request.return_value = response

        canvas_api.create_quiz(
            _mock_config(), "T", scoring_policy="keep_highest",
            allowed_attempts=-1,
        )
        payload = mock_request.call_args.kwargs["json"]
        self.assertEqual(payload["quiz"]["scoring_policy"], "keep_highest")

    @patch("canvas_api._request_with_retry")
    def test_scoring_policy_absent_when_not_provided(self, mock_request):
        response = MagicMock()
        response.status_code = 200
        response.json.return_value = {"id": 11, "title": "T"}
        mock_request.return_value = response

        canvas_api.create_quiz(_mock_config(), "T")
        payload = mock_request.call_args.kwargs["json"]
        self.assertNotIn("scoring_policy", payload["quiz"])

    def test_invalid_scoring_policy_raises(self):
        with self.assertRaises(ValueError):
            canvas_api.create_quiz(_mock_config(), "T",
                                   scoring_policy="bogus_policy")


# ── LANE_EVIDENCE_TYPES enum ───────────────────────────────────────────────

class TestLaneEvidenceTypes(unittest.TestCase):

    def test_enum_contains_exactly_five_expected_keys(self):
        expected = {
            "verb_mismatch",
            "evidence_absence",
            "format_mismatch",
            "weight_inversion",
            "practice_loop_missing",
        }
        self.assertEqual(set(alignment_validator.LANE_EVIDENCE_TYPES.keys()),
                         expected)

    def test_every_value_is_nonempty_string(self):
        for key, val in alignment_validator.LANE_EVIDENCE_TYPES.items():
            self.assertIsInstance(val, str)
            self.assertTrue(val.strip(), f"{key} has empty description")


# ── push_new_mockup_quiz end-to-end (mocked) ───────────────────────────────

class TestPushNewMockupQuiz(unittest.TestCase):

    def _staged_payload(self):
        return {
            "title": "Mockup: Module 3 Quiz Alignment Review",
            "description": "<p>For Your Review, Maria</p>",
            "quiz_type": "assignment",
            "allowed_attempts": -1,
            "scoring_policy": "keep_highest",
            "questions": [
                {"question_name": "Q1", "question_text": "Stem 1",
                 "points_possible": 1,
                 "answers": [{"answer_text": "A", "answer_weight": 100}]},
                {"question_name": "Q2", "question_text": "Stem 2",
                 "points_possible": 1,
                 "answers": [{"answer_text": "B", "answer_weight": 100}]},
            ],
        }

    @patch("push_to_canvas.canvas_api")
    @patch("push_to_canvas.staging_manager")
    @patch("push_to_canvas.timeline_logger")
    def test_end_to_end_success(self, mock_tl, mock_stage, mock_canvas):
        mock_stage.get_staged.return_value = self._staged_payload()
        mock_canvas.get_config.return_value = _mock_config()
        mock_canvas.require_course_id.return_value = None
        mock_canvas.create_quiz.return_value = {"id": 555, "title": "ok"}
        mock_canvas.add_quiz_question.return_value = {"id": 1}
        mock_canvas.add_module_item.return_value = {"id": 777}
        mock_canvas.get_quiz.return_value = {"id": 555}

        result = push_to_canvas.push_new_mockup_quiz(
            "99999", "m3-quiz-revised-mockup", "2937808",
        )

        self.assertTrue(result["ok"])
        self.assertEqual(result["quiz_id"], 555)
        self.assertEqual(result["question_count"], 2)
        self.assertTrue(result["staging_cleared"])
        # create_quiz called with scoring_policy from payload
        ck = mock_canvas.create_quiz.call_args.kwargs
        self.assertEqual(ck["scoring_policy"], "keep_highest")
        # add_quiz_question called twice
        self.assertEqual(mock_canvas.add_quiz_question.call_count, 2)
        # module item added
        mock_canvas.add_module_item.assert_called_once()
        # staging cleared
        mock_stage.clear_staged.assert_called_once_with(
            "99999", "quizzes", "m3-quiz-revised-mockup")
        # timeline event emitted
        mock_tl.emit_event.assert_called()
        evt_args = mock_tl.emit_event.call_args
        self.assertEqual(evt_args.args[1], "mockup_quiz_created")

    @patch("push_to_canvas.canvas_api")
    @patch("push_to_canvas.staging_manager")
    def test_missing_staged_payload_returns_error(self, mock_stage, mock_canvas):
        mock_stage.get_staged.return_value = None
        mock_canvas.get_config.return_value = _mock_config()
        mock_canvas.require_course_id.return_value = None

        result = push_to_canvas.push_new_mockup_quiz(
            "99999", "nope", "1234",
        )
        self.assertFalse(result["ok"])
        self.assertIn("no staged quiz", result["error"])

    @patch("push_to_canvas.canvas_api")
    @patch("push_to_canvas.staging_manager")
    def test_dry_run_does_not_call_canvas_writes(self, mock_stage, mock_canvas):
        mock_stage.get_staged.return_value = self._staged_payload()
        mock_canvas.get_config.return_value = _mock_config()
        mock_canvas.require_course_id.return_value = None

        result = push_to_canvas.push_new_mockup_quiz(
            "99999", "key", "1234", dry_run=True,
        )
        self.assertTrue(result["ok"])
        self.assertTrue(result["dry_run"])
        mock_canvas.create_quiz.assert_not_called()
        mock_canvas.add_quiz_question.assert_not_called()
        mock_canvas.add_module_item.assert_not_called()


class TestPushNewMockupAssignment(unittest.TestCase):

    @patch("push_to_canvas.canvas_api")
    @patch("push_to_canvas.staging_manager")
    @patch("push_to_canvas.timeline_logger")
    def test_end_to_end_success(self, mock_tl, mock_stage, mock_canvas):
        mock_stage.get_staged.return_value = {
            "name": "Mockup: Module 3 Homework",
            "description": "<p>body</p>",
            "points_possible": 10,
            "submission_types": ["online_text_entry"],
        }
        mock_canvas.get_config.return_value = _mock_config()
        mock_canvas.require_course_id.return_value = None
        mock_canvas.create_assignment.return_value = {"id": 444}
        mock_canvas.add_module_item.return_value = {"id": 888}

        result = push_to_canvas.push_new_mockup_assignment(
            "99999", "m3-hw-mockup", "2937808",
        )
        self.assertTrue(result["ok"])
        self.assertEqual(result["assignment_id"], 444)
        self.assertTrue(result["staging_cleared"])
        mock_stage.clear_staged.assert_called_once_with(
            "99999", "assignments", "m3-hw-mockup")


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