"""Tests for Feature D: CLO-only decision pathway in mockup quiz builder.

Covers:
  - Schema validation (KEEP/MOVE/DROP accept clo_only: true)
  - Schema rejection (MOVE/DROP without clo_only: true → ValueError)
  - Builder HTML output for all 3 patterns (badge chip, yellow <mark>, closing sentence)
  - Preflight check_clo_only_consistency: clo_missing_callout, clo_missing_chip,
    clo_pathway_unresolved
"""
from __future__ import annotations

import datetime
import os
import sys
from pathlib import Path

import pytest

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

import build_mockup_quiz
import canvas_api
import preflight_checks
import staging_manager
import staging_schema


COURSE_ID = "218764"
FIXTURE_DIR = Path(__file__).parent / "fixtures"


# ── Shared helpers ────────────────────────────────────────────────────────────

def _config(course_id: str = COURSE_ID) -> dict:
    return {
        "token": "fake",
        "domain": "asu-dev.instructure.com",
        "course_id": course_id,
        "base_url": "https://asu-dev.instructure.com/api/v1",
        "course_url": f"https://asu-dev.instructure.com/api/v1/courses/{course_id}",
        "headers": {"Authorization": "Bearer fake"},
    }


@pytest.fixture()
def isolated_root(tmp_path, monkeypatch):
    monkeypatch.setattr(staging_manager, "get_plugin_root", lambda: str(tmp_path))
    monkeypatch.setattr(staging_schema, "get_plugin_root", lambda: str(tmp_path))
    monkeypatch.setattr(canvas_api, "get_plugin_root", lambda: str(tmp_path))
    monkeypatch.setattr(canvas_api, "get_config", lambda course_id=None: _config(str(course_id or COURSE_ID)))
    monkeypatch.setattr(canvas_api, "require_course_id", lambda config: None)
    return tmp_path


def _live_questions_3():
    """Three stub live questions for the CLO fixture (3 decisions)."""
    return [
        {
            "id": 2001,
            "question_name": "CLO Q1",
            "question_text": "<p>Develop basic R code to produce a summary table.</p>",
            "question_type": "multiple_choice_question",
            "points_possible": 1,
            "answers": [{"answer_text": "Option A", "answer_weight": 100}],
        },
        {
            "id": 2002,
            "question_name": "CLO Q2",
            "question_text": "<p>Identify the correct syntax for a for-loop in R.</p>",
            "question_type": "multiple_choice_question",
            "points_possible": 1,
            "answers": [{"answer_text": "Option B", "answer_weight": 100}],
        },
        {
            "id": 2003,
            "question_name": "CLO Q3",
            "question_text": "<p>Which function filters rows in a data frame?</p>",
            "question_type": "multiple_choice_question",
            "points_possible": 1,
            "answers": [{"answer_text": "Option C", "answer_weight": 100}],
        },
    ]


@pytest.fixture()
def staged_clo_quiz(isolated_root, monkeypatch):
    monkeypatch.setattr(canvas_api, "get_quiz_questions", lambda config, quiz_id: _live_questions_3())
    result = build_mockup_quiz.build_staged_quiz(
        COURSE_ID,
        FIXTURE_DIR / "sample_clo_decisions.yaml",
        "mockup-clo-quiz",
    )
    assert result["ok"], result
    return staging_manager.get_staged(COURSE_ID, "quizzes", "mockup-clo-quiz")


# ═══════════════════════════════════════════════════════════════════════════
# Schema validation — accepted patterns
# ═══════════════════════════════════════════════════════════════════════════

class TestSchemaAccepted:
    """render_question_html accepts valid CLO-only decisions without raising."""

    def _live_q(self):
        return {
            "id": 9001,
            "question_name": "Q stub",
            "question_text": "<p>Stub.</p>",
            "question_type": "multiple_choice_question",
            "points_possible": 1,
            "answers": [{"answer_text": "A", "answer_weight": 100}],
        }

    def test_keep_with_clo_only_is_accepted(self):
        decision = {"tag": "KEEP", "clo_only": True, "clo_supports_mlo": 4, "mlos": [], "why": "supports CLO"}
        html = build_mockup_quiz.render_question_html("1", decision, self._live_q())
        assert html  # did not raise

    def test_move_with_clo_only_and_target_module_is_accepted(self):
        decision = {"tag": "MOVE", "clo_only": True, "target_module": 1, "mlos": [], "why": "belongs in M1"}
        html = build_mockup_quiz.render_question_html("1", decision, self._live_q())
        assert html  # did not raise

    def test_drop_with_clo_only_is_accepted(self):
        decision = {"tag": "DROP", "clo_only": True, "mlos": [], "why": "drop from module"}
        html = build_mockup_quiz.render_question_html("1", decision, self._live_q())
        assert html  # did not raise


# ═══════════════════════════════════════════════════════════════════════════
# Schema validation — rejected patterns
# ═══════════════════════════════════════════════════════════════════════════

class TestSchemaRejected:
    """render_question_html raises ValueError when MOVE/DROP lack clo_only: true."""

    def _live_q(self):
        return {
            "id": 9002,
            "question_name": "Q stub",
            "question_text": "<p>Stub.</p>",
            "question_type": "multiple_choice_question",
            "points_possible": 1,
            "answers": [{"answer_text": "A", "answer_weight": 100}],
        }

    def test_move_without_clo_only_raises_value_error(self):
        decision = {"tag": "MOVE", "mlos": [], "why": "move it"}
        with pytest.raises(ValueError, match=r"require `clo_only: true`"):
            build_mockup_quiz.render_question_html("7", decision, self._live_q())

    def test_drop_without_clo_only_raises_value_error(self):
        decision = {"tag": "DROP", "mlos": [], "why": "drop it"}
        with pytest.raises(ValueError, match=r"require `clo_only: true`"):
            build_mockup_quiz.render_question_html("7", decision, self._live_q())

    def test_move_with_clo_only_false_raises_value_error(self):
        """Explicitly setting clo_only: false is equivalent to absent."""
        decision = {"tag": "MOVE", "clo_only": False, "target_module": 2, "mlos": [], "why": "move"}
        with pytest.raises(ValueError, match=r"require `clo_only: true`"):
            build_mockup_quiz.render_question_html("3", decision, self._live_q())

    def test_drop_with_clo_only_false_raises_value_error(self):
        decision = {"tag": "DROP", "clo_only": False, "mlos": [], "why": "drop"}
        with pytest.raises(ValueError, match=r"require `clo_only: true`"):
            build_mockup_quiz.render_question_html("3", decision, self._live_q())


# ═══════════════════════════════════════════════════════════════════════════
# Builder HTML output — Pattern A: KEEP + clo_only + clo_supports_mlo
# ═══════════════════════════════════════════════════════════════════════════

class TestPatternAKeep:
    """KEEP + clo_only: true + clo_supports_mlo: 4 — Pattern A."""

    def test_keep_pattern_has_keep_badge(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][0]  # decision 1 = KEEP
        assert "KEEP" in q["question_text"]
        assert "#78BE20" in q["question_text"]

    def test_keep_pattern_has_clo_only_chip(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][0]
        assert "CLO ONLY" in q["question_text"]
        assert "#FFF8E1" in q["question_text"]

    def test_keep_pattern_has_yellow_mark_callout(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][0]
        assert "background-color: #fff2a8" in q["question_text"]

    def test_keep_pattern_has_flag_for_faculty_prefix(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][0]
        assert "Flag for faculty:" in q["question_text"]

    def test_keep_pattern_closing_sentence_references_mlo(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][0]
        assert "Faculty decision: keep this item as foundational support for MLO 4" in q["question_text"]
        assert "move it to a module where this concept is taught directly" in q["question_text"]

    def test_keep_pattern_q_name_is_keep(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][0]
        assert "KEEP" in q["question_name"]


# ═══════════════════════════════════════════════════════════════════════════
# Builder HTML output — Pattern B: MOVE + clo_only + target_module
# ═══════════════════════════════════════════════════════════════════════════

class TestPatternBMove:
    """MOVE + clo_only: true + target_module: 1 — Pattern B."""

    def test_move_pattern_has_move_badge(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][1]  # decision 2 = MOVE
        assert "MOVE" in q["question_text"]
        assert "#5C2D91" in q["question_text"]

    def test_move_pattern_has_clo_only_chip(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][1]
        assert "CLO ONLY" in q["question_text"]

    def test_move_pattern_has_yellow_mark_callout(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][1]
        assert "background-color: #fff2a8" in q["question_text"]

    def test_move_pattern_closing_sentence_references_target_module(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][1]
        assert "Faculty decision: move this item to Module 1" in q["question_text"]
        assert "where the supporting MLOs are taught directly" in q["question_text"]

    def test_move_pattern_q_name_is_move(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][1]
        assert "MOVE" in q["question_name"]


# ═══════════════════════════════════════════════════════════════════════════
# Builder HTML output — Pattern C: DROP + clo_only
# ═══════════════════════════════════════════════════════════════════════════

class TestPatternCDrop:
    """DROP + clo_only: true — Pattern C."""

    def test_drop_pattern_has_drop_badge(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][2]  # decision 3 = DROP
        assert "DROP" in q["question_text"]
        assert "#4E4E4E" in q["question_text"]

    def test_drop_pattern_has_clo_only_chip(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][2]
        assert "CLO ONLY" in q["question_text"]

    def test_drop_pattern_has_yellow_mark_callout(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][2]
        assert "background-color: #fff2a8" in q["question_text"]

    def test_drop_pattern_closing_sentence_recommends_drop(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][2]
        assert "Faculty decision: drop this item from Module 2 (recommended)" in q["question_text"]
        assert "move it to another module" in q["question_text"]

    def test_drop_pattern_q_name_is_drop(self, staged_clo_quiz):
        q = staged_clo_quiz["questions"][2]
        assert "DROP" in q["question_name"]


# ═══════════════════════════════════════════════════════════════════════════
# Preflight: check_clo_only_consistency
# ═══════════════════════════════════════════════════════════════════════════

class TestCheckCloOnlyConsistency:
    """check_clo_only_consistency emits correct warnings."""

    def _chip_html(self):
        return (
            '<span style="display:inline-block; border:1px solid #FFC627; background:#FFF8E1; '
            'color:#5C4100; padding:2px 8px; border-radius:4px; font-size:12px;">CLO ONLY</span>'
        )

    def _callout_html(self):
        return (
            '<mark style="background-color: #fff2a8; color: inherit;">'
            '<strong>Flag for faculty:</strong> this item does not align with any Module 2 MLO. '
            'Some rationale text here. Faculty decision: drop this item.</mark>'
        )

    def test_chip_without_callout_emits_clo_missing_callout(self):
        """CLO-ONLY chip present but no yellow mark callout → clo_missing_callout warning."""
        html = f"<p>{self._chip_html()}</p><p>Some rationale without a mark tag.</p>"
        issues = preflight_checks.check_clo_only_consistency(html)
        ids = [i.criterion_id for i in issues]
        assert "clo_missing_callout" in ids
        assert all(i.severity == "warning" for i in issues)

    def test_callout_without_chip_emits_clo_missing_chip(self):
        """Yellow mark callout present but no CLO-ONLY chip → clo_missing_chip warning."""
        html = f"<p>KEEP badge here (no chip)</p>{self._callout_html()}"
        issues = preflight_checks.check_clo_only_consistency(html)
        ids = [i.criterion_id for i in issues]
        assert "clo_missing_chip" in ids
        assert all(i.severity == "warning" for i in issues)

    def test_both_chip_and_callout_no_consistency_issue(self):
        """When both chip and callout are present, no consistency warnings are emitted."""
        html = f"<p>{self._chip_html()}</p>{self._callout_html()}"
        issues = preflight_checks.check_clo_only_consistency(html)
        ids = [i.criterion_id for i in issues]
        assert "clo_missing_callout" not in ids
        assert "clo_missing_chip" not in ids

    def test_neither_chip_nor_callout_is_silent(self):
        """Regular content with neither chip nor callout → no issues."""
        html = "<p>Normal KEEP rationale without CLO pathway.</p>"
        issues = preflight_checks.check_clo_only_consistency(html)
        assert issues == []

    def test_empty_content_is_silent(self):
        issues = preflight_checks.check_clo_only_consistency("")
        assert issues == []

    def test_unresolved_age_over_14_days_emits_warning(self):
        """CLO flag item with decided_at > 14 days ago → clo_pathway_unresolved warning."""
        html = f"<p>{self._chip_html()}</p>{self._callout_html()}"
        old_date = (datetime.date.today() - datetime.timedelta(days=20)).isoformat()
        issues = preflight_checks.check_clo_only_consistency(html, decided_at=old_date)
        ids = [i.criterion_id for i in issues]
        assert "clo_pathway_unresolved" in ids
        assert all(i.severity == "warning" for i in issues)

    def test_unresolved_age_under_14_days_no_warning(self):
        """CLO flag item with decided_at ≤ 14 days ago → no clo_pathway_unresolved."""
        html = f"<p>{self._chip_html()}</p>{self._callout_html()}"
        recent_date = (datetime.date.today() - datetime.timedelta(days=10)).isoformat()
        issues = preflight_checks.check_clo_only_consistency(html, decided_at=recent_date)
        ids = [i.criterion_id for i in issues]
        assert "clo_pathway_unresolved" not in ids

    def test_decided_at_none_skips_age_check(self):
        """When decided_at is None, the unresolved-age check is silently skipped."""
        html = f"<p>{self._chip_html()}</p>{self._callout_html()}"
        issues = preflight_checks.check_clo_only_consistency(html, decided_at=None)
        ids = [i.criterion_id for i in issues]
        assert "clo_pathway_unresolved" not in ids

    def test_unparseable_decided_at_is_silent(self):
        """Unparseable decided_at string is ignored gracefully."""
        html = f"<p>{self._chip_html()}</p>{self._callout_html()}"
        issues = preflight_checks.check_clo_only_consistency(html, decided_at="not-a-date")
        ids = [i.criterion_id for i in issues]
        assert "clo_pathway_unresolved" not in ids

    def test_all_issues_are_warnings_not_errors(self):
        """check_clo_only_consistency never emits blocking errors."""
        # chip-only case
        html = f"<p>{self._chip_html()}</p>"
        issues = preflight_checks.check_clo_only_consistency(html)
        for issue in issues:
            assert issue.severity == "warning", f"Got non-warning severity: {issue.severity}"


# ═══════════════════════════════════════════════════════════════════════════
# End-to-end: staged quiz built from fixture passes no-jargon guard
# ═══════════════════════════════════════════════════════════════════════════

def test_clo_quiz_output_has_no_bloom_jargon(staged_clo_quiz):
    """All questions from sample_clo_decisions.yaml are free of Bloom jargon."""
    import re
    bloom_re = re.compile(
        r"\bRemember level\b|\bApply level\b|\bUnderstand level\b|\bAnalyze level\b"
        r"|\bEvaluate level\b|\bCreate level\b|\bBloom\b|\bcognitive level\b"
        r"|\bcognitive demand\b|\bcognitive load level\b",
        re.I,
    )
    for question in staged_clo_quiz["questions"]:
        q_text = question.get("question_text", "")
        m = bloom_re.search(q_text)
        assert m is None, f"Bloom jargon '{m.group()}' in {question.get('question_name')!r}"


def test_badge_constants_move_drop_clo_only_chip_exist():
    """New badge constants are exported from build_mockup_quiz."""
    assert "#5C2D91" in build_mockup_quiz.MOVE_BADGE
    assert "#4E4E4E" in build_mockup_quiz.DROP_BADGE
    assert "#FFF8E1" in build_mockup_quiz.CLO_ONLY_CHIP
    assert "#FFC627" in build_mockup_quiz.CLO_ONLY_CHIP
