"""Tests for objective_extractor.py (CLO/MLO extraction + Bloom grounding).

Covers:
- CLO extraction bounded to the first list block (no Technology-Requirements bleed)
- the "able to:" sentence-split fallback when there is no <ul>
- MLO line parsing + per-module Bloom = max across MLOs
- graceful degrade on fetch failure / missing outcomes
- overview-page selection (named overview vs first Page item)
"""

import os
import sys
import unittest
from unittest.mock import patch

HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPTS = os.path.join(HERE, "..", "scripts")
sys.path.insert(0, SCRIPTS)

import objective_extractor as OE  # noqa: E402


SYLLABUS_HTML = """
<h2>Course Objectives</h2><p>Specific topics include linear regression.</p>
<h2>Learning Outcomes</h2>
<p>Students completing this course will be able to:</p>
<ul>
  <li>Determine and implement linear-regression-based methods to analyze data</li>
  <li>Conduct residual diagnostics</li>
  <li>Develop basic R code for statistical analyses</li>
</ul>
<h2>Technology Requirements</h2>
<ul><li>Webcam</li><li>RStudio</li><li>Microsoft Office</li></ul>
"""

MODULE_OVERVIEW_HTML = """
<h2>Module 1 Overview</h2>
<p>By the end of this module, students will be able to:</p>
<p>MLO 1.1 Construct simple linear regression and multiple regression models to predict an outcome.
MLO 1.2 Create and run R code to obtain the best-fitting regression model given the data.
MLO 1.3 Interpret the coefficients of a fitted regression model.</p>
"""


# Plain-<li> objectives under a "Learning Objectives" heading, chapter-subgrouped,
# with no "MLO N.N" prefix, followed by a Self-check logistics list (COM 100 shape).
HEADING_LIST_OVERVIEW_HTML = """
<h2><span style="color: #822020;">Module Topics</span></h2>
<p>In this module we will learn how becoming an effective communicator works.</p>
<hr>
<h2><span style="color: #822020;">Learning Objectives</span></h2>
<h3>Chapter 1: Introduction to Human Communication</h3>
<ul>
<li>Explain why it is important to study human communication.</li>
<li>Name and describe the seven primary components of communication.</li>
<li>Formulate your own communication ethic.</li>
</ul>
<h3>Chapter 3: Communicating, Perceiving, and Understanding</h3>
<ul>
<li>Describe the three procedures people use to understand information.</li>
<li>Identify three ways you can improve your perception skills.</li>
</ul>
<hr>
<h2>Self-check</h2>
<p>By the deadlines on the course schedule, I will have:</p>
<ol>
<li>Read chapters 1 &amp; 3</li>
<li>Completed the chapter quizzes in Canvas</li>
</ol>
"""


class HeadingListMLOFallbackTest(unittest.TestCase):
    def test_harvests_li_under_learning_objectives_heading(self):
        mlos = OE._mlos_from_heading_list(HEADING_LIST_OVERVIEW_HTML)
        self.assertEqual(len(mlos), 5)  # 3 + 2 chapter bullets, no self-check
        self.assertEqual(mlos[0], "Explain why it is important to study human communication.")

    def test_self_check_list_excluded(self):
        mlos = OE._mlos_from_heading_list(HEADING_LIST_OVERVIEW_HTML)
        joined = " ".join(mlos)
        self.assertNotIn("Read chapters", joined)
        self.assertNotIn("chapter quizzes", joined)

    def test_no_objectives_heading_returns_empty(self):
        self.assertEqual(OE._mlos_from_heading_list("<p>no heading</p><ul><li>x</li></ul>"), [])

    def test_prose_objectives_word_does_not_false_anchor(self):
        # A bare "objectives" word in a body sentence (with a stray list before
        # the real heading) must NOT anchor the harvest -- only a short heading
        # block counts. Regression for the bare-substring false-anchor.
        html = ("<h2>Module Topics</h2>"
                "<p>In this module we explore the objectives of communication.</p>"
                "<ul><li>STRAY bullet that must not be harvested</li></ul>"
                "<h2>Learning Objectives</h2>"
                "<ul><li>Explain the real objective.</li></ul>")
        self.assertEqual(OE._mlos_from_heading_list(html), ["Explain the real objective."])

    def test_self_check_word_in_bullet_does_not_truncate(self):
        # "self-check" inside an objective bullet must NOT cut the harvest -- only
        # a Self-check heading ends the section.
        html = ("<h2>Learning Objectives</h2>"
                "<ul><li>Use the self-check rubric to assess writing.</li>"
                "<li>Apply peer review.</li></ul>"
                "<h2>Self-check</h2><ol><li>Read the chapter.</li></ol>")
        mlos = OE._mlos_from_heading_list(html)
        self.assertEqual(len(mlos), 2)
        self.assertIn("Apply peer review.", mlos)
        self.assertNotIn("Read the chapter.", " ".join(mlos))

    def test_real_heading_preferred_over_short_p(self):
        # A short <p> with a marker word must not out-anchor a real <h> Learning
        # Objectives heading (and harvest a stray list before it).
        html = ("<p>Our objectives:</p>"
                "<ul><li>STRAY topic bullet</li></ul>"
                "<h2>Learning Objectives</h2>"
                "<ul><li>Analyze rhetorical strategies.</li></ul>")
        self.assertEqual(OE._mlos_from_heading_list(html), ["Analyze rhetorical strategies."])

    def test_pseudo_heading_p_span_layout(self):
        # COM 100 Module 7 styles "Learning Objectives"/"Self-Check" as large
        # <p><span> pseudo-headings, not real <h> tags. Both must still work.
        html = ('<p><span style="font-size:1.8em">Learning Objectives</span></p>'
                '<p><strong>Chapter 12: Rhetorical Communication</strong></p>'
                '<ul><li>Describe key issues in rhetoric.</li>'
                '<li>Identify cultural influences.</li></ul>'
                '<p><span style="font-size:24pt">Self-Check</span></p>'
                '<ol><li>Read chapter 12</li></ol>')
        mlos = OE._mlos_from_heading_list(html)
        self.assertEqual(mlos, ["Describe key issues in rhetoric.", "Identify cultural influences."])

    def test_extract_module_mlos_falls_back_to_heading_list(self):
        tree = [{
            "name": "Module 1: Intro",
            "items": [{"type": "Page", "title": "Module 1 Overview", "page_url": "m1"}],
        }]
        with patch.object(OE.canvas_api, "get_page", return_value={"body": HEADING_LIST_OVERVIEW_HTML}):
            out = OE.extract_module_mlos({}, tree=tree)
        self.assertIn("Module 1: Intro", out)
        m = out["Module 1: Intro"]
        self.assertEqual(len(m["mlos"]), 5)
        self.assertEqual(m["bloom_level"], "Create")  # Formulate -> Create wins

    def test_mlo_prefix_path_takes_priority_over_fallback(self):
        # A page with BOTH a real MLO N.N line and a stray <li> list uses the
        # strict MLO path, not the heading-list fallback.
        tree = [{
            "name": "Module 2",
            "items": [{"type": "Page", "title": "Module 2 Overview", "page_url": "m2"}],
        }]
        html = ("<h2>Module 2 Overview</h2>"
                "<ul><li>MLO 2.1 Analyze the dataset.</li></ul>"
                "<h2>Learning Objectives</h2><ul><li>Read the chapter.</li></ul>")
        with patch.object(OE.canvas_api, "get_page", return_value={"body": html}):
            out = OE.extract_module_mlos({}, tree=tree)
        self.assertEqual(out["Module 2"]["mlos"], ["Analyze the dataset"])


class CLOExtractionTest(unittest.TestCase):
    def test_first_list_block_only(self):
        clos = OE._outcomes_from_syllabus_html(SYLLABUS_HTML)
        self.assertEqual(len(clos), 3)
        self.assertIn("Conduct residual diagnostics", clos)
        # Technology Requirements list must NOT bleed in.
        self.assertNotIn("Webcam", " ".join(clos))
        self.assertNotIn("RStudio", " ".join(clos))

    def test_no_outcomes_marker_returns_empty(self):
        self.assertEqual(OE._outcomes_from_syllabus_html("<p>no outcomes here</p>"), [])

    def test_able_to_sentence_fallback_when_no_list(self):
        html = ("<h3>Learning Outcomes</h3><p>Students will be able to: "
                "Analyze data sets. Evaluate competing models. Build a final report.</p>")
        clos = OE._outcomes_from_syllabus_html(html)
        self.assertTrue(any("Analyze data sets" in c for c in clos))
        self.assertTrue(any("Evaluate competing models" in c for c in clos))

    def test_extract_course_clos_classifies_bloom(self):
        with patch.object(OE.canvas_api, "get_syllabus", return_value={"html": SYLLABUS_HTML}):
            out = OE.extract_course_clos({})
        self.assertEqual(out["source"], "syllabus")
        self.assertEqual(len(out["clos"]), 3)
        # "Develop"/"Determine"/"implement" -> at least Apply; bloom_index > 0.
        self.assertGreater(out["bloom_index"], 0)

    def test_extract_course_clos_graceful_on_failure(self):
        with patch.object(OE.canvas_api, "get_syllabus", side_effect=RuntimeError("boom")):
            out = OE.extract_course_clos({})
        self.assertEqual(out["clos"], [])
        self.assertEqual(out["source"], "none")

    def test_extract_course_clos_no_outcomes_found(self):
        with patch.object(OE.canvas_api, "get_syllabus", return_value={"html": "<p>hi</p>"}):
            out = OE.extract_course_clos({})
        self.assertEqual(out["clos"], [])
        self.assertEqual(out["source"], "syllabus_no_outcomes_found")


class MLOExtractionTest(unittest.TestCase):
    def test_mlo_regex_splits_lines(self):
        text = OE._block_text(MODULE_OVERVIEW_HTML)
        mlos = OE._MLO_RE.findall(text)
        self.assertEqual(len(mlos), 3)
        self.assertTrue(mlos[0].startswith("Construct"))

    def test_prose_mlo_reference_not_extracted(self):
        # An inline prose mention of an MLO must NOT become a fake MLO. The old
        # (unanchored) pattern captured "from the syllabus and builds on it".
        tree = [{
            "name": "Module 2",
            "items": [{"type": "Page", "title": "Module 2 Overview", "page_url": "m2"}],
        }]
        html = ("<h2>Module 2 Overview</h2>"
                "<p>This lesson aligns with MLO 1.1 from the syllabus and builds on it.</p>")
        with patch.object(OE.canvas_api, "get_page", return_value={"body": html}):
            out = OE.extract_module_mlos({}, tree=tree)
        self.assertEqual(out, {})  # no line-anchored MLO -> module omitted

    def test_real_mlos_kept_despite_prose_reference(self):
        # Real list-item MLOs are kept even when a prose MLO reference shares the page.
        tree = [{
            "name": "Module 3",
            "items": [{"type": "Page", "title": "Module 3 Overview", "page_url": "m3"}],
        }]
        html = ("<h2>Module 3 Overview</h2>"
                "<ul><li>MLO 3.1 Analyze the dataset.</li>"
                "<li>MLO 3.2 Evaluate the model.</li></ul>"
                "<p>These extend MLO 1.1 from Module 1.</p>")
        with patch.object(OE.canvas_api, "get_page", return_value={"body": html}):
            out = OE.extract_module_mlos({}, tree=tree)
        self.assertIn("Module 3", out)
        self.assertEqual(out["Module 3"]["mlos"], ["Analyze the dataset", "Evaluate the model"])

    def test_mlos_in_separate_table_cells(self):
        # MLOs in sibling <td> cells (no <br>/<li>/<p> between) must each be a
        # line so both extract -- </td>/</th> count as block boundaries.
        text = OE._block_text(
            "<table><tr>"
            "<td>MLO 1.1 Analyze the dataset.</td>"
            "<td>MLO 1.2 Build the model.</td>"
            "</tr></table>"
        )
        mlos = OE._MLO_RE.findall(text)
        self.assertEqual(len(mlos), 2)

    def test_module_bloom_is_max(self):
        # Create (1.2) should win over Interpret (Apply).
        idx, level = OE._classify(["Interpret the model", "Create and run R code"])
        self.assertEqual(level, "Create")

    def test_extract_module_mlos_from_tree(self):
        tree = [{
            "name": "Module 1: Simple Linear Regression",
            "items": [
                {"type": "Page", "title": "Module 1 Overview", "page_url": "module-1-overview"},
                {"type": "Assignment", "title": "Module 1 Homework", "content_id": 7451231},
            ],
        }]
        with patch.object(OE.canvas_api, "get_page", return_value={"body": MODULE_OVERVIEW_HTML}):
            out = OE.extract_module_mlos({}, tree=tree)
        self.assertIn("Module 1: Simple Linear Regression", out)
        m = out["Module 1: Simple Linear Regression"]
        self.assertEqual(len(m["mlos"]), 3)
        self.assertEqual(m["bloom_level"], "Create")

    def test_module_with_no_overview_page_omitted(self):
        tree = [{"name": "Module X", "items": [{"type": "Assignment", "title": "HW", "content_id": 1}]}]
        out = OE.extract_module_mlos({}, tree=tree)
        self.assertEqual(out, {})

    def test_overview_item_prefers_named_overview(self):
        items = [
            {"type": "Page", "title": "Reading List", "page_url": "reading"},
            {"type": "Page", "title": "Module 2 Overview", "page_url": "m2-overview"},
        ]
        ov = OE._overview_item(items)
        self.assertEqual(ov["title"], "Module 2 Overview")

    def test_overview_item_falls_back_to_first_page(self):
        items = [{"type": "Page", "title": "Lesson", "page_url": "lesson"}]
        self.assertEqual(OE._overview_item(items)["title"], "Lesson")

    def test_extract_module_mlos_graceful_on_modules_failure(self):
        with patch.object(OE.canvas_api, "get_modules", side_effect=RuntimeError("boom")):
            self.assertEqual(OE.extract_module_mlos({}), {})


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