"""Tests for scripts/extract_course_layout.py

All tests are unit tests — no network calls.  canvas_api is monkeypatched
wherever extract_layout is exercised.

Coverage:
  - extract_layout: module/SubHeader/role sequence, genericized title patterns
  - genericize_title: Module N, Module N: [Title], peer-review N-1, unchanged
  - detect_role: each keyword → expected role; unknown → "other"
  - page-signature extraction: banner_image first, video_embed first, text first;
    headings captured in order; full body NOT in output
  - syllabus extension: boilerplate vs course-specific tagging; no body text
"""

from __future__ import annotations

import sys
import types
import os

import pytest

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

import extract_course_layout as ecl


# ===========================================================================
# genericize_title
# ===========================================================================

class TestGenericizeTitle:
    def test_module_n_overview(self):
        assert ecl.genericize_title("Module 1 Overview") == "Module N Overview"

    def test_module_n_with_colon(self):
        assert ecl.genericize_title("Module 3: Categorical Predictors") == "Module N: [Title]"

    def test_module_12_with_colon(self):
        assert ecl.genericize_title("Module 12: Foo Bar Baz") == "Module N: [Title]"

    def test_peer_review_n_minus_1(self):
        result = ecl.genericize_title("Peer Review of Module 2 Homework")
        assert result == "Peer Review of Module N-1 Homework"

    def test_peer_review_n_minus_1_higher_number(self):
        result = ecl.genericize_title("Peer Review of Module 10 Homework")
        assert result == "Peer Review of Module N-1 Homework"

    def test_no_module_number_unchanged(self):
        title = "Course Introduction"
        assert ecl.genericize_title(title) == title

    def test_no_module_number_syllabus(self):
        title = "Syllabus and Policies"
        assert ecl.genericize_title(title) == title

    def test_module_n_task_list(self):
        assert ecl.genericize_title("Module 5 Task List") == "Module N Task List"

    def test_module_n_quiz(self):
        assert ecl.genericize_title("Module 7 Quiz") == "Module N Quiz"

    def test_module_n_homework(self):
        assert ecl.genericize_title("Module 4 Homework") == "Module N Homework"

    def test_module_n_lecture_videos(self):
        assert ecl.genericize_title("Module 2 Lecture Videos") == "Module N Lecture Videos"

    def test_case_insensitive_module(self):
        # "module" lowercase should still match
        assert ecl.genericize_title("module 3 overview") == "module N overview"


# ===========================================================================
# detect_role
# ===========================================================================

class TestDetectRole:
    """Each keyword case → expected role; unknown → 'other'."""

    @pytest.mark.parametrize("title,expected", [
        ("Module 1 Overview", "overview"),
        ("Module Overview", "overview"),
        ("Module 3 Task List", "task_list"),
        ("Task List", "task_list"),
        ("Module 1 Lecture Videos", "videos"),
        ("Lecture Video", "videos"),
        ("Module 1 Covered Textbook Sections", "readings"),
        ("Readings and Resources", "readings"),
        ("Module Topic", "topic"),
        ("Topics Covered", "topic"),
        ("Peer Review of Module 2 Homework", "peer_review"),
        ("Peer Review Assignment", "peer_review"),
        ("Module 1 Quiz", "quiz"),
        ("Quiz — Week 3", "quiz"),
        ("Module 4 Homework", "homework"),
        ("Homework 2", "homework"),
        ("Discussion: Week 1", "discussion"),
        ("Module 1 Discussion", "discussion"),
    ])
    def test_keyword_mapping(self, title: str, expected: str):
        assert ecl.detect_role("Page", title) == expected

    def test_unknown_returns_other(self):
        assert ecl.detect_role("Page", "Random Unmatched Title 123") == "other"

    def test_empty_title_returns_other(self):
        assert ecl.detect_role("Page", "") == "other"

    def test_peer_review_wins_over_homework(self):
        # "Peer Review … Homework" title — peer_review keyword comes first in rules
        assert ecl.detect_role("Assignment", "Peer Review of Module 5 Homework") == "peer_review"


# ===========================================================================
# Page-signature extraction
# ===========================================================================

class TestPageSignatureExtraction:
    def test_banner_image_first(self):
        html = '<img src="banner.png" alt="banner"><h2>Learning Objectives</h2><p>body</p>'
        sig = ecl._extract_page_signature(html)
        assert sig["first_element"] == "banner_image"

    def test_video_embed_first(self):
        html = '<iframe src="https://player.vimeo.com/video/1"></iframe><h2>Intro</h2>'
        sig = ecl._extract_page_signature(html)
        assert sig["first_element"] == "video_embed"

    def test_text_first(self):
        html = "<p>Welcome to Module 1.</p><h2>Objectives</h2>"
        sig = ecl._extract_page_signature(html)
        assert sig["first_element"] == "text"

    def test_headings_captured_in_order(self):
        html = (
            "<img src='x.png'>"
            "<h2>Learning Objectives</h2>"
            "<p>some body text</p>"
            "<h2>Topics</h2>"
            "<p>more body</p>"
            "<h3>Sub-topic A</h3>"
        )
        sig = ecl._extract_page_signature(html)
        assert sig["headings"] == ["Learning Objectives", "Topics", "Sub-topic A"]

    def test_full_body_not_in_output(self):
        """Signature must not contain paragraph body text."""
        html = (
            "<img src='banner.png'>"
            "<h2>Learning Objectives</h2>"
            "<p>This is a detailed paragraph that should NEVER appear in the template.</p>"
        )
        sig = ecl._extract_page_signature(html)
        # The only string keys are first_element and headings
        assert set(sig.keys()) == {"first_element", "headings"}
        # headings only contain the h2/h3 text, not paragraph body
        for heading in sig["headings"]:
            assert "detailed paragraph" not in heading
            assert "NEVER appear" not in heading

    def test_empty_body(self):
        sig = ecl._extract_page_signature("")
        assert sig["first_element"] == "text"
        assert sig["headings"] == []

    def test_headings_stripped_of_html(self):
        html = "<h2><strong>Learning Objectives</strong></h2><p>body</p>"
        sig = ecl._extract_page_signature(html)
        assert sig["headings"] == ["Learning Objectives"]

    def test_video_tag_first(self):
        html = "<video controls><source src='x.mp4'></video><h2>Intro</h2>"
        sig = ecl._extract_page_signature(html)
        assert sig["first_element"] == "video_embed"


# ===========================================================================
# Syllabus section parsing
# ===========================================================================

class TestSyllabusSectionParsing:
    def test_section_order_from_h2_headings(self):
        html = (
            "<h2>Course Information</h2><p>text</p>"
            "<h2>Academic Integrity</h2><p>policy</p>"
            "<h2>Grading</h2><p>grades</p>"
        )
        sections = ecl._parse_syllabus_sections(html)
        assert sections == ["Course Information", "Academic Integrity", "Grading"]

    def test_empty_syllabus(self):
        assert ecl._parse_syllabus_sections("") == []

    def test_no_h2_headings(self):
        assert ecl._parse_syllabus_sections("<p>Just a paragraph.</p>") == []

    def test_headings_stripped_of_html(self):
        html = "<h2><em>Course Overview</em></h2><p>body</p>"
        sections = ecl._parse_syllabus_sections(html)
        assert sections == ["Course Overview"]


# ===========================================================================
# extract_layout — full orchestration (no network)
# ===========================================================================

def _make_mock_canvas_api(
    *,
    modules: list[dict],
    module_items: dict[int, list[dict]],
    pages: list[dict],
    page_bodies: dict[str, str],
    syllabus_html: str = "",
) -> types.ModuleType:
    """Build a mock canvas_api module for monkeypatching."""
    mock = types.ModuleType("canvas_api")

    mock.get_config = lambda course_id=None: {
        "token": "fake",
        "domain": "canvas.asu.edu",
        "course_id": str(course_id),
        "base_url": "https://canvas.asu.edu/api/v1",
        "course_url": f"https://canvas.asu.edu/api/v1/courses/{course_id}",
        "headers": {"Authorization": "Bearer fake"},
    }

    mock.get_modules = lambda cfg, **kw: modules

    def _get_module_items(cfg, module_id):
        return module_items.get(int(module_id), [])

    mock.get_module_items = _get_module_items
    mock.list_pages = lambda cfg: pages

    def _get_page(cfg, slug):
        body = page_bodies.get(slug, "")
        return {"url": slug, "body": body} if body else None

    mock.get_page = _get_page
    mock.get_syllabus = lambda cfg: {"html": syllabus_html}
    return mock


class TestExtractLayout:
    """Full extract_layout orchestration tests — no live HTTP."""

    # Fixture course: 2 modules with SubHeaders + standard item types
    MODULES = [
        {"id": 10, "name": "Module 1: Introduction", "position": 1},
        {"id": 20, "name": "Module 2: Deep Dive", "position": 2},
    ]

    MODULE_ITEMS: dict[int, list[dict]] = {
        10: [
            {"type": "Page",       "title": "Module 1 Overview",                "page_url": "m1-overview"},
            {"type": "Page",       "title": "Module 1 Task List",               "page_url": "m1-task-list"},
            {"type": "SubHeader",  "title": "Learning Materials"},
            {"type": "Page",       "title": "Module 1 Lecture Videos",          "page_url": "m1-videos"},
            {"type": "Page",       "title": "Module 1 Covered Textbook Sections", "page_url": "m1-readings"},
            {"type": "SubHeader",  "title": "Graded Assignments"},
            {"type": "Quiz",       "title": "Module 1 Quiz",                    "page_url": ""},
            {"type": "Assignment", "title": "Module 1 Homework",                "page_url": ""},
            {"type": "Assignment", "title": "Peer Review of Module 1 Homework", "page_url": ""},
        ],
        20: [
            {"type": "Page",       "title": "Module 2 Overview",                "page_url": "m2-overview"},
            {"type": "SubHeader",  "title": "Learning Materials"},
            {"type": "Page",       "title": "Module 2 Lecture Videos",          "page_url": "m2-videos"},
        ],
    }

    PAGE_BODIES: dict[str, str] = {
        "m1-overview": (
            '<img src="banner.png" alt="Module banner">'
            "<h2>Learning Objectives</h2>"
            "<p>Students will be able to…</p>"
            "<h2>Topics</h2>"
            "<p>Topic list here.</p>"
        ),
        "m1-task-list": "<p>This week you will…</p><h2>Tasks</h2>",
        "m1-videos": '<iframe src="https://player.vimeo.com/123"></iframe><h2>Video Overview</h2>',
        "m1-readings": "<p>Read chapters 1-3.</p><h2>Readings</h2>",
        "m2-overview": '<img src="banner2.png" alt="Module 2 banner"><h2>Objectives</h2>',
        "m2-videos": "<iframe src='https://youtu.be/abc'></iframe><h2>Lecture</h2>",
    }

    def _extract(self, monkeypatch, include_syllabus=False, syllabus_html="") -> dict:
        mock_api = _make_mock_canvas_api(
            modules=self.MODULES,
            module_items=self.MODULE_ITEMS,
            pages=[],
            page_bodies=self.PAGE_BODIES,
            syllabus_html=syllabus_html,
        )
        monkeypatch.setitem(sys.modules, "canvas_api", mock_api)
        return ecl.extract_layout("12345", include_syllabus=include_syllabus)

    # ── Schema keys ──────────────────────────────────────────────────────────

    def test_schema_top_level_keys(self, monkeypatch):
        layout = self._extract(monkeypatch)
        assert "source_course_id" in layout
        assert "extracted" in layout
        assert "modules" in layout
        assert "page_signatures" in layout
        assert "syllabus" not in layout  # not requested

    def test_source_course_id(self, monkeypatch):
        layout = self._extract(monkeypatch)
        assert layout["source_course_id"] == "12345"

    # ── Module order ─────────────────────────────────────────────────────────

    def test_module_count(self, monkeypatch):
        layout = self._extract(monkeypatch)
        assert len(layout["modules"]) == 2

    def test_module_positions(self, monkeypatch):
        layout = self._extract(monkeypatch)
        assert layout["modules"][0]["position"] == 1
        assert layout["modules"][1]["position"] == 2

    def test_module_name_pattern_with_colon(self, monkeypatch):
        layout = self._extract(monkeypatch)
        assert layout["modules"][0]["name_pattern"] == "Module N: [Title]"
        assert layout["modules"][1]["name_pattern"] == "Module N: [Title]"

    # ── SubHeader placement ──────────────────────────────────────────────────

    def test_subheader_types_in_module_1(self, monkeypatch):
        layout = self._extract(monkeypatch)
        m1_items = layout["modules"][0]["items"]
        subheaders = [it for it in m1_items if it["type"] == "SubHeader"]
        assert len(subheaders) == 2
        assert subheaders[0]["text"] == "Learning Materials"
        assert subheaders[1]["text"] == "Graded Assignments"

    def test_subheader_in_module_2(self, monkeypatch):
        layout = self._extract(monkeypatch)
        m2_items = layout["modules"][1]["items"]
        subheaders = [it for it in m2_items if it["type"] == "SubHeader"]
        assert len(subheaders) == 1
        assert subheaders[0]["text"] == "Learning Materials"

    # ── SubHeader has no 'role' field ─────────────────────────────────────────

    def test_subheader_no_role(self, monkeypatch):
        layout = self._extract(monkeypatch)
        for mod in layout["modules"]:
            for item in mod["items"]:
                if item["type"] == "SubHeader":
                    assert "role" not in item

    # ── Role sequence ────────────────────────────────────────────────────────

    def test_m1_role_sequence(self, monkeypatch):
        layout = self._extract(monkeypatch)
        m1_items = layout["modules"][0]["items"]
        role_seq = [
            it["role"] for it in m1_items if it.get("type") != "SubHeader"
        ]
        assert role_seq == [
            "overview",
            "task_list",
            "videos",
            "readings",
            "quiz",
            "homework",
            "peer_review",
        ]

    # ── Genericized title patterns ────────────────────────────────────────────

    def test_m1_title_patterns(self, monkeypatch):
        layout = self._extract(monkeypatch)
        m1_items = layout["modules"][0]["items"]
        non_sub = [it for it in m1_items if it["type"] != "SubHeader"]
        assert non_sub[0]["title_pattern"] == "Module N Overview"
        assert non_sub[1]["title_pattern"] == "Module N Task List"
        assert non_sub[2]["title_pattern"] == "Module N Lecture Videos"
        assert non_sub[3]["title_pattern"] == "Module N Covered Textbook Sections"
        assert non_sub[4]["title_pattern"] == "Module N Quiz"
        assert non_sub[5]["title_pattern"] == "Module N Homework"
        assert non_sub[6]["title_pattern"] == "Peer Review of Module N-1 Homework"

    # ── Page signatures ──────────────────────────────────────────────────────

    def test_overview_signature_banner_image(self, monkeypatch):
        layout = self._extract(monkeypatch)
        sigs = layout["page_signatures"]
        assert "overview" in sigs
        assert sigs["overview"]["first_element"] == "banner_image"

    def test_overview_signature_headings(self, monkeypatch):
        layout = self._extract(monkeypatch)
        sigs = layout["page_signatures"]
        assert sigs["overview"]["headings"] == ["Learning Objectives", "Topics"]

    def test_video_signature_first_element(self, monkeypatch):
        layout = self._extract(monkeypatch)
        sigs = layout["page_signatures"]
        assert "videos" in sigs
        assert sigs["videos"]["first_element"] == "video_embed"

    def test_signatures_have_no_full_body(self, monkeypatch):
        """Page signature output must only have first_element + headings."""
        layout = self._extract(monkeypatch)
        for role, sig in layout["page_signatures"].items():
            assert set(sig.keys()) == {"first_element", "headings"}, (
                f"Signature for '{role}' has unexpected keys: {set(sig.keys())}"
            )
            # Body prose must not appear in headings
            for h in sig["headings"]:
                assert "Students will be able to" not in h
                assert "chapters" not in h.lower()

    def test_one_signature_per_role(self, monkeypatch):
        """Only one representative page signature per role (first-encountered)."""
        layout = self._extract(monkeypatch)
        sigs = layout["page_signatures"]
        # "overview" appears in both modules — signature should be from module 1
        # (first encountered); first_element is banner_image in m1-overview
        assert sigs.get("overview", {}).get("first_element") == "banner_image"


# ===========================================================================
# Syllabus extension
# ===========================================================================

class TestSyllabusExtension:
    """Syllabus section tagging — boilerplate vs course-specific."""

    # Minimal boilerplate HTML that matches the actual asset format
    _SYLLABUS_HTML = (
        "<h2>Course Information</h2><p>Course-specific info here.</p>"
        "<h2>Academic Integrity</h2><p>Policy text here.</p>"
        "<h2>Course Overview</h2><p>Course-specific overview.</p>"
        "<h2>Syllabus Disclaimer</h2><p>Disclaimer text.</p>"
        "<h2>Grading</h2><p>Grading breakdown.</p>"
    )

    def _extract_with_syllabus(self, monkeypatch, syllabus_html=None) -> dict:
        """Run extract_layout with include_syllabus=True and stubbed boilerplate."""
        # Stub the boilerplate_sections function to return known boilerplate keys
        mock_boilerplate = types.ModuleType("syllabus_boilerplate")
        mock_boilerplate.boilerplate_sections = lambda: {
            "Academic Integrity": "<h2>Academic Integrity</h2><p>…</p>",
            "Syllabus Disclaimer": "<h2>Syllabus Disclaimer</h2><p>…</p>",
            "Copyright": "<h2>Copyright</h2><p>…</p>",
        }
        monkeypatch.setitem(sys.modules, "syllabus_boilerplate", mock_boilerplate)

        html = syllabus_html if syllabus_html is not None else self._SYLLABUS_HTML

        mock_api = _make_mock_canvas_api(
            modules=[],
            module_items={},
            pages=[],
            page_bodies={},
            syllabus_html=html,
        )
        monkeypatch.setitem(sys.modules, "canvas_api", mock_api)
        return ecl.extract_layout("99999", include_syllabus=True)

    def test_syllabus_key_present(self, monkeypatch):
        layout = self._extract_with_syllabus(monkeypatch)
        assert "syllabus" in layout

    def test_section_order_preserved(self, monkeypatch):
        layout = self._extract_with_syllabus(monkeypatch)
        assert layout["syllabus"]["section_order"] == [
            "Course Information",
            "Academic Integrity",
            "Course Overview",
            "Syllabus Disclaimer",
            "Grading",
        ]

    def test_boilerplate_sections_tagged(self, monkeypatch):
        layout = self._extract_with_syllabus(monkeypatch)
        bp = layout["syllabus"]["boilerplate_sections"]
        assert "Academic Integrity" in bp
        assert "Syllabus Disclaimer" in bp

    def test_course_specific_sections_tagged(self, monkeypatch):
        layout = self._extract_with_syllabus(monkeypatch)
        cs = layout["syllabus"]["course_specific_sections"]
        assert "Course Information" in cs
        assert "Course Overview" in cs
        assert "Grading" in cs

    def test_boilerplate_sections_not_in_course_specific(self, monkeypatch):
        layout = self._extract_with_syllabus(monkeypatch)
        bp = set(layout["syllabus"]["boilerplate_sections"])
        cs = set(layout["syllabus"]["course_specific_sections"])
        assert bp.isdisjoint(cs), "A section appears in both boilerplate and course-specific"

    def test_no_course_specific_body_text(self, monkeypatch):
        """Template must record titles/structure only — no body text."""
        layout = self._extract_with_syllabus(monkeypatch)
        syl = layout["syllabus"]
        # section_order is a list of titles
        for title in syl["section_order"]:
            assert isinstance(title, str)
            # None of the actual body content should appear in titles
            assert "Policy text here" not in title
            assert "Grading breakdown" not in title
            assert "Course-specific" not in title
        # boilerplate_sections and course_specific_sections are also title lists
        for title in syl["boilerplate_sections"] + syl["course_specific_sections"]:
            assert isinstance(title, str)
            assert "Policy text here" not in title

    def test_empty_syllabus_graceful(self, monkeypatch):
        layout = self._extract_with_syllabus(monkeypatch, syllabus_html="")
        syl = layout["syllabus"]
        assert syl["section_order"] == []
        assert syl["boilerplate_sections"] == []
        assert syl["course_specific_sections"] == []

    def test_syllabus_absent_when_not_requested(self, monkeypatch):
        mock_api = _make_mock_canvas_api(
            modules=[], module_items={}, pages={}, page_bodies={},
            syllabus_html="<h2>Something</h2><p>body</p>",
        )
        monkeypatch.setitem(sys.modules, "canvas_api", mock_api)
        layout = ecl.extract_layout("11111", include_syllabus=False)
        assert "syllabus" not in layout


# ===========================================================================
# --save path
# ===========================================================================

class TestSavePath:
    """Verify --save writes to the expected output directory."""

    def test_save_writes_file(self, tmp_path, monkeypatch):
        """extract_layout + save should write to extracted/COURSEID-layout.json."""
        import extract_course_layout as ecl_local

        # Point PLUGIN_ROOT at tmp_path so we don't touch real templates dir
        monkeypatch.setattr(ecl_local, "PLUGIN_ROOT", tmp_path)

        mock_api = _make_mock_canvas_api(
            modules=[], module_items={}, pages=[], page_bodies={},
        )
        monkeypatch.setitem(sys.modules, "canvas_api", mock_api)

        layout = ecl_local.extract_layout("55555")
        out_dir = tmp_path / "templates" / "canvas-templates" / "extracted"
        out_dir.mkdir(parents=True, exist_ok=True)
        out_path = out_dir / "55555-layout.json"
        import json as _json
        out_path.write_text(_json.dumps(layout, indent=2), encoding="utf-8")

        assert out_path.exists()
        data = _json.loads(out_path.read_text())
        assert data["source_course_id"] == "55555"
