"""Tests for scripts/audit_conversion_readiness.py

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

Fixture design:
    lecture_capture_data  → all 7 detectors fire, signal == "Build, not port"
    ready_course_data     → zero findings, signal == "Online-ready"
"""

from __future__ import annotations

import os
import sys

import pytest

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

import audit_conversion_readiness as acr


# ===========================================================================
# Shared fixtures
# ===========================================================================

CANVAS_DOMAIN = "canvas.asu.edu"


def _bare_iframe_page(page_id=1, title="Lecture 1") -> dict:
    """A page that is just an iframe with no surrounding text — detector 1 fires."""
    return {
        "id": page_id,
        "title": title,
        "url": f"lecture-{page_id}",
        "body": (
            '<iframe src="https://drive.google.com/file/d/abc123/preview" '
            'width="640" height="480"></iframe>'
        ),
    }


def _drive_embed_page(page_id=2, title="Drive Video") -> dict:
    """A page with a drive.google.com embed — detector 2 fires HIGH."""
    return {
        "id": page_id,
        "title": title,
        "url": f"drive-video-{page_id}",
        "body": (
            "<p>Watch the lecture below:</p>"
            '<iframe src="https://drive.google.com/file/d/xyz/preview"></iframe>'
            "<p>Additional context goes here with more words to avoid bare-embed detection.</p>"
            "<p>Here is some more text to push past 40 characters of non-whitespace content.</p>"
        ),
    }


def _overview_page_no_objectives(page_id=10, title="Module 1 Overview") -> dict:
    """An overview page without any objectives heading."""
    return {
        "id": page_id,
        "title": title,
        "url": "module-1-overview",
        "body": "<h2>Introduction</h2><p>This module covers the basics.</p>",
    }


def _overview_page_with_objectives(page_id=20, title="Module 1 Overview") -> dict:
    """An overview page that has a proper objectives section."""
    return {
        "id": page_id,
        "title": title,
        "url": "module-1-overview",
        "body": (
            "<h2>Learning Objectives</h2>"
            "<ul><li>Analyze case law</li><li>Evaluate statutory interpretation</li></ul>"
        ),
    }


def _mediaplus_page_with_transcript(page_id=30, title="Lesson 2") -> dict:
    """A page with an approved MediaPlus embed and a transcript link."""
    return {
        "id": page_id,
        "title": title,
        "url": "lesson-2",
        "body": (
            "<h2>Learning Objectives</h2>"
            "<p>After this lesson you will be able to:</p>"
            "<ul><li>Explain the rule against perpetuities</li></ul>"
            '<iframe src="https://mediaplus.asu.edu/embed/abc123" width="640"></iframe>'
            '<p><a href="/courses/1/files/99">Download Transcript (.vtt)</a></p>'
            "<p>This page has plenty of text content for learning purposes with details.</p>"
        ),
    }


def _rich_text_page(page_id=40, title="Lesson 3") -> dict:
    """A rich text page with no embeds."""
    return {
        "id": page_id,
        "title": title,
        "url": "lesson-3",
        "body": (
            "<h2>Key Concepts</h2>"
            "<p>This lesson covers constitutional law fundamentals in detail. "
            "There is a substantial amount of reading material here with more than forty "
            "non-whitespace characters easily.</p>"
        ),
    }


# ---------------------------------------------------------------------------
# Lecture-capture course (all detectors should fire)
# ---------------------------------------------------------------------------

@pytest.fixture()
def lecture_capture_data():
    """All 7 detectors should fire for this course."""
    return {
        "canvas_domain": CANVAS_DOMAIN,
        "pages": [
            _bare_iframe_page(1, "Lecture 1"),   # detector 1 (bare embed)
            _drive_embed_page(2, "Drive Video"), # detector 2 (off-host HIGH) + detector 3 (no transcript)
            _overview_page_no_objectives(10),    # detector 4 (overview exists, no objectives)
        ],
        "modules": [{"id": 1, "name": "Module 1"}],
        "discussions": [],  # detector 6
        "assignments": [
            {
                "id": 5,
                "name": "Placeholder",
                "points_possible": 0,
                "submission_types": ["none"],
            }  # detector 7 (gradebook junk)
        ],
        "quizzes": [
            {"id": 6, "title": "Empty Quiz", "question_count": 0}  # detector 7
        ],
        "syllabus": {"html": "   "},  # detector 5 (empty syllabus)
    }


# ---------------------------------------------------------------------------
# Ready course (zero findings)
# ---------------------------------------------------------------------------

@pytest.fixture()
def ready_course_data():
    """Zero detectors should fire for this course."""
    return {
        "canvas_domain": CANVAS_DOMAIN,
        "pages": [
            _overview_page_with_objectives(20, "Module 1 Overview"),
            _mediaplus_page_with_transcript(30, "Lesson 2"),
            _rich_text_page(40, "Lesson 3"),
        ],
        "modules": [{"id": 1, "name": "Module 1"}],
        "discussions": [{"id": 10, "title": "Week 1 Discussion"}],
        "assignments": [
            {"id": 7, "name": "Module 1 Artifact", "points_possible": 100, "submission_types": ["online_upload"]}
        ],
        "quizzes": [
            {"id": 8, "title": "Module 1 Quiz", "question_count": 5}
        ],
        "syllabus": {"html": "<p>This course covers constitutional law. CLOs: 1. Analyze...<br>Schedule...</p>"},
    }


# ===========================================================================
# Full-course integration: lecture-capture
# ===========================================================================

class TestLectureCaptureAllDetectorsFire:
    def test_all_seven_detectors_fire(self, lecture_capture_data):
        findings, score, signal = acr.run_audit(lecture_capture_data)
        fired = {f["detector"] for f in findings}
        assert "bare_media_embed" in fired, "detector 1 (bare media embed) must fire"
        assert "offhost_embed" in fired, "detector 2 (off-host embed) must fire"
        assert "caption_gap" in fired, "detector 3 (caption gap) must fire"
        assert "missing_objectives" in fired, "detector 4 (missing objectives) must fire"
        assert "empty_syllabus" in fired, "detector 5 (empty syllabus) must fire"
        assert "no_discussions" in fired, "detector 6 (no discussions) must fire"
        assert "gradebook_junk" in fired, "detector 7 (gradebook junk) must fire"

    def test_signal_is_build_not_port(self, lecture_capture_data):
        findings, score, signal = acr.run_audit(lecture_capture_data)
        assert signal == "Build, not port"

    def test_score_below_80(self, lecture_capture_data):
        """Heavy HIGH/MEDIUM findings should depress the score significantly."""
        findings, score, signal = acr.run_audit(lecture_capture_data)
        assert score < 80

    def test_drive_embed_severity_is_high(self, lecture_capture_data):
        findings, _, _ = acr.run_audit(lecture_capture_data)
        offhost = [f for f in findings if f["detector"] == "offhost_embed"]
        assert any(f["severity"] == "HIGH" for f in offhost)


# ===========================================================================
# Full-course integration: ready course
# ===========================================================================

class TestReadyCourseZeroFindings:
    def test_zero_findings(self, ready_course_data):
        findings, score, signal = acr.run_audit(ready_course_data)
        assert findings == [], f"Expected 0 findings, got: {findings}"

    def test_signal_is_online_ready(self, ready_course_data):
        _, score, signal = acr.run_audit(ready_course_data)
        assert signal == "Online-ready"

    def test_score_is_100(self, ready_course_data):
        _, score, signal = acr.run_audit(ready_course_data)
        assert score == 100


# ===========================================================================
# Detector 1: Bare media-embed pages
# ===========================================================================

class TestDetector1BareMediaEmbed:
    def test_fires_on_iframe_with_few_chars(self):
        page = {
            "id": 1,
            "title": "Lecture",
            "body": '<iframe src="https://www.youtube.com/embed/abc"></iframe>',
        }
        findings = acr.detect_bare_media_embeds([page])
        assert len(findings) == 1
        assert findings[0]["severity"] == "HIGH"
        assert findings[0]["detector"] == "bare_media_embed"

    def test_fires_on_video_tag_with_few_chars(self):
        page = {
            "id": 2,
            "title": "Video",
            "body": '<video src="https://example.com/video.mp4"></video>',
        }
        findings = acr.detect_bare_media_embeds([page])
        assert len(findings) == 1

    def test_no_fire_when_text_rich(self):
        """More than 40 non-whitespace chars → detector should not fire."""
        body = (
            '<iframe src="https://mediaplus.asu.edu/embed/x"></iframe>'
            "<p>This page has a comprehensive lesson covering the fundamentals of "
            "administrative law including due process and equal protection analysis.</p>"
        )
        page = {"id": 3, "title": "Rich Lesson", "body": body}
        findings = acr.detect_bare_media_embeds([page])
        assert findings == []

    def test_no_fire_when_no_embed(self):
        page = {
            "id": 4,
            "title": "Text Only",
            "body": "<p>Plain text content without any video or iframe elements.</p>",
        }
        findings = acr.detect_bare_media_embeds([page])
        assert findings == []

    def test_boundary_at_exactly_40_chars(self):
        """Exactly 40 non-whitespace chars with an iframe → fires."""
        content = "a" * 40
        page = {
            "id": 5,
            "title": "Boundary",
            "body": f'<iframe src="https://youtube.com/x"></iframe>{content}',
        }
        findings = acr.detect_bare_media_embeds([page])
        assert len(findings) == 1

    def test_boundary_41_chars_no_fire(self):
        """41 non-whitespace chars → should NOT fire."""
        content = "a" * 41
        page = {
            "id": 6,
            "title": "Just Over",
            "body": f'<iframe src="https://youtube.com/x"></iframe>{content}',
        }
        findings = acr.detect_bare_media_embeds([page])
        assert findings == []


# ===========================================================================
# Detector 2: Off-host / fragile video embeds (security edge cases required)
# ===========================================================================

class TestDetector2OffhostEmbeds:
    def test_drive_google_fires_high(self):
        page = {
            "id": 1,
            "title": "Drive Video",
            "body": '<iframe src="https://drive.google.com/file/d/abc/preview"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert len(findings) == 1
        assert findings[0]["severity"] == "HIGH"
        assert findings[0]["evidence"]["host"] == "drive.google.com"

    def test_dropbox_fires_high(self):
        page = {
            "id": 2,
            "title": "Dropbox",
            "body": '<iframe src="https://dropbox.com/s/abc/video.mp4?dl=0"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert len(findings) == 1
        assert findings[0]["severity"] == "HIGH"

    def test_unknown_offhost_fires_medium(self):
        page = {
            "id": 3,
            "title": "Unknown",
            "body": '<iframe src="https://mypersonalserver.example.com/video.mp4"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert len(findings) == 1
        assert findings[0]["severity"] == "MEDIUM"

    def test_mediaplus_approved_no_fire(self):
        page = {
            "id": 4,
            "title": "MediaPlus",
            "body": '<iframe src="https://mediaplus.asu.edu/embed/abc123"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert findings == []

    def test_youtube_approved_no_fire(self):
        page = {
            "id": 5,
            "title": "YouTube",
            "body": '<iframe src="https://www.youtube.com/embed/abc"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert findings == []

    def test_youtu_be_approved_no_fire(self):
        page = {
            "id": 6,
            "title": "YouTube short",
            "body": '<a href="https://youtu.be/abc123">Watch</a>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert findings == []

    def test_instructuremedia_approved_no_fire(self):
        page = {
            "id": 7,
            "title": "Instructure Media",
            "body": '<iframe src="https://asu.instructuremedia.com/embed/xyz"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert findings == []

    def test_wistia_approved_no_fire(self):
        page = {
            "id": 8,
            "title": "Wistia",
            "body": '<iframe src="https://fast.wistia.com/embed/iframe/abc"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert findings == []

    def test_canvas_domain_approved_no_fire(self):
        """Embeds on the course's own Canvas domain should not fire."""
        page = {
            "id": 9,
            "title": "Canvas Page",
            "body": f'<iframe src="https://{CANVAS_DOMAIN}/courses/123/embed"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert findings == []

    # -----------------------------------------------------------------------
    # SECURITY: exact-host matching — substring tricks must fail closed
    # -----------------------------------------------------------------------

    def test_security_evil_drive_google_subdomain_fires(self):
        """evil-drive.google.com.attacker.com must NOT be treated as drive.google.com."""
        page = {
            "id": 10,
            "title": "Evil",
            "body": '<iframe src="https://evil-drive.google.com.attacker.com/file/d/x/preview"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        # Must fire (it is off-host) — and must NOT be confused for drive.google.com HIGH finding
        # The attacker host is not in _HIGH_FLAGGED_HOSTS, so severity is MEDIUM
        assert len(findings) == 1
        assert findings[0]["evidence"]["host"] == "evil-drive.google.com.attacker.com"
        # Severity should be MEDIUM (unknown host), NOT HIGH (only exact drive.google.com is HIGH)
        assert findings[0]["severity"] == "MEDIUM"

    def test_security_youtube_prefix_attack(self):
        """youtube.com.attacker.com must not match approved youtube.com."""
        page = {
            "id": 11,
            "title": "YouTube lookalike",
            "body": '<iframe src="https://youtube.com.attacker.com/embed/abc"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert len(findings) == 1
        assert findings[0]["evidence"]["host"] == "youtube.com.attacker.com"

    def test_security_canvas_domain_substring_attack(self):
        """xcanvas.asu.edu.evil.com must not match canvas.asu.edu approved domain."""
        page = {
            "id": 12,
            "title": "Canvas lookalike",
            "body": f'<iframe src="https://x{CANVAS_DOMAIN}.evil.com/embed/abc"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert len(findings) == 1
        # Must NOT approve this host
        assert findings[0]["evidence"]["host"] != CANVAS_DOMAIN

    def test_security_relative_urls_not_flagged(self):
        """Relative URLs (no scheme) should not trigger the detector."""
        page = {
            "id": 13,
            "title": "Relative",
            "body": '<iframe src="/courses/1/embed/abc"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert findings == []

    def test_security_javascript_url_not_flagged(self):
        """javascript: URLs should be skipped (non-http/https)."""
        page = {
            "id": 14,
            "title": "JS URL",
            "body": '<a href="javascript:void(0)">click</a>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        assert findings == []

    def test_security_instructuremedia_suffix_exact(self):
        """Only *.instructuremedia.com suffix should be approved, not e.g. evil-instructuremedia.com."""
        page = {
            "id": 15,
            "title": "Fake Instructure",
            # Does NOT end with .instructuremedia.com — it IS instructuremedia.com itself
            # which is not in the approved suffix list (only *.instructuremedia.com is)
            "body": '<iframe src="https://instructuremedia.com/embed/abc"></iframe>',
        }
        findings = acr.detect_offhost_embeds([page], canvas_domain=CANVAS_DOMAIN)
        # instructuremedia.com itself (without subdomain) is not in approved set
        # The suffix check is endswith(".instructuremedia.com") which requires a dot prefix
        assert len(findings) == 1


# ===========================================================================
# Detector 3: Caption/transcript gap
# ===========================================================================

class TestDetector3CaptionGap:
    def test_fires_on_embed_without_transcript(self):
        page = {
            "id": 1,
            "title": "Video no transcript",
            "body": '<iframe src="https://mediaplus.asu.edu/embed/abc"></iframe><p>Watch this.</p>',
        }
        findings = acr.detect_caption_gap([page])
        assert len(findings) == 1
        assert findings[0]["severity"] == "MEDIUM"
        assert findings[0]["detector"] == "caption_gap"

    def test_no_fire_when_transcript_text_present(self):
        page = {
            "id": 2,
            "title": "Video with transcript",
            "body": (
                '<iframe src="https://mediaplus.asu.edu/embed/abc"></iframe>'
                '<p><a href="/files/1">Download Transcript</a></p>'
            ),
        }
        findings = acr.detect_caption_gap([page])
        assert findings == []

    def test_no_fire_when_vtt_link_present(self):
        page = {
            "id": 3,
            "title": "Video with VTT",
            "body": (
                '<iframe src="https://mediaplus.asu.edu/embed/abc"></iframe>'
                '<a href="/files/2/captions.vtt">Captions</a>'
            ),
        }
        findings = acr.detect_caption_gap([page])
        assert findings == []

    def test_no_fire_when_srt_mentioned(self):
        page = {
            "id": 4,
            "title": "Video SRT",
            "body": (
                '<video src="/files/video.mp4"></video>'
                '<p>Download the .srt file for captions.</p>'
            ),
        }
        findings = acr.detect_caption_gap([page])
        assert findings == []

    def test_no_fire_on_no_embed_page(self):
        page = {
            "id": 5,
            "title": "Text Page",
            "body": "<p>Text only, no video here.</p>",
        }
        findings = acr.detect_caption_gap([page])
        assert findings == []

    def test_fires_when_caption_keyword_unrelated_to_embed(self):
        """'caption' appearing in an unrelated context should satisfy the check."""
        page = {
            "id": 6,
            "title": "Caption Context",
            "body": (
                '<iframe src="https://mediaplus.asu.edu/embed/abc"></iframe>'
                '<p>See the image caption below for more details.</p>'
            ),
        }
        # The rule is purely string-based: 'caption' present → no finding
        findings = acr.detect_caption_gap([page])
        assert findings == []


# ===========================================================================
# Detector 4: Missing objectives
# ===========================================================================

class TestDetector4MissingObjectives:
    def test_fires_when_no_overview_pages(self):
        pages = [
            {"id": 1, "title": "Lesson 1", "url": "lesson-1", "body": "<p>Content</p>"},
            {"id": 2, "title": "Assignment", "url": "assignment-1", "body": "<p>Submit here.</p>"},
        ]
        findings = acr.detect_missing_objectives(pages, modules=[{"id": 1}])
        assert len(findings) == 1
        assert findings[0]["severity"] == "HIGH"
        assert "no module overview pages" in findings[0]["title"].lower()

    def test_fires_when_overview_lacks_objectives(self):
        pages = [
            {
                "id": 1,
                "title": "Module 1 Overview",
                "url": "module-1-overview",
                "body": "<h2>Introduction</h2><p>Welcome to module 1.</p>",
            }
        ]
        findings = acr.detect_missing_objectives(pages, modules=[])
        assert len(findings) == 1
        assert findings[0]["severity"] == "HIGH"
        assert "objectives" in findings[0]["title"].lower()

    def test_no_fire_when_overview_has_objectives(self):
        pages = [
            {
                "id": 1,
                "title": "Module 1 Overview",
                "url": "module-1-overview",
                "body": "<h2>Learning Objectives</h2><ul><li>Analyze case law</li></ul>",
            }
        ]
        findings = acr.detect_missing_objectives(pages, modules=[])
        assert findings == []

    def test_no_fire_with_objective_singular(self):
        """'Objective' (singular) should also satisfy the check."""
        pages = [
            {
                "id": 1,
                "title": "Overview",
                "url": "overview",
                "body": "<h2>Module Objective</h2><p>By the end you will...</p>",
            }
        ]
        findings = acr.detect_missing_objectives(pages, modules=[])
        assert findings == []

    def test_fires_only_for_overview_pages_missing_objectives(self):
        """Two overview pages: one with, one without objectives — only the deficient one fires."""
        pages = [
            {
                "id": 1,
                "title": "Module 1 Overview",
                "url": "module-1-overview",
                "body": "<h2>Learning Objectives</h2><ul><li>Analyze</li></ul>",
            },
            {
                "id": 2,
                "title": "Module 2 Overview",
                "url": "module-2-overview",
                "body": "<h2>Introduction</h2><p>Welcome to module 2.</p>",
            },
        ]
        findings = acr.detect_missing_objectives(pages, modules=[])
        assert len(findings) == 1
        ev = findings[0]["evidence"]
        assert ev["page_id"] == 2


# ===========================================================================
# Detector 5: Empty syllabus
# ===========================================================================

class TestDetector5EmptySyllabus:
    def test_fires_on_empty_string(self):
        findings = acr.detect_empty_syllabus({"html": ""})
        assert len(findings) == 1
        assert findings[0]["severity"] == "MEDIUM"

    def test_fires_on_whitespace_only(self):
        findings = acr.detect_empty_syllabus({"html": "   \n\t  "})
        assert len(findings) == 1

    def test_fires_on_empty_html_tags(self):
        """HTML tags with no text content should still be empty after strip."""
        findings = acr.detect_empty_syllabus({"html": "<p>  </p>"})
        assert len(findings) == 1

    def test_no_fire_when_content_present(self):
        findings = acr.detect_empty_syllabus({"html": "<p>Course overview: This course...</p>"})
        assert findings == []

    def test_no_fire_when_syllabus_body_key_used(self):
        """Fallback key 'syllabus_body' should also be recognized."""
        findings = acr.detect_empty_syllabus({"syllabus_body": "<p>Course content here.</p>"})
        assert findings == []

    def test_fires_on_missing_html_key(self):
        findings = acr.detect_empty_syllabus({})
        assert len(findings) == 1


# ===========================================================================
# Detector 6: No discussions
# ===========================================================================

class TestDetector6NoDiscussions:
    def test_fires_on_empty_list(self):
        findings = acr.detect_no_discussions([])
        assert len(findings) == 1
        assert findings[0]["severity"] == "MEDIUM"
        assert findings[0]["detector"] == "no_discussions"

    def test_no_fire_when_discussions_present(self):
        discussions = [
            {"id": 1, "title": "Week 1: Intro Discussion"},
            {"id": 2, "title": "Week 2: Analysis"},
        ]
        findings = acr.detect_no_discussions(discussions)
        assert findings == []

    def test_no_fire_with_single_discussion(self):
        findings = acr.detect_no_discussions([{"id": 1, "title": "One Discussion"}])
        assert findings == []


# ===========================================================================
# Detector 7: Gradebook junk
# ===========================================================================

class TestDetector7GradebookJunk:
    def test_fires_on_zero_point_none_submission(self):
        assignments = [
            {"id": 1, "name": "Placeholder", "points_possible": 0, "submission_types": ["none"]}
        ]
        findings = acr.detect_gradebook_junk(assignments, quizzes=[])
        assert len(findings) == 1
        assert findings[0]["severity"] == "LOW"
        assert findings[0]["detector"] == "gradebook_junk"

    def test_no_fire_on_zero_points_but_real_submission(self):
        """Zero points with a real submission type is not junk (e.g. surveys)."""
        assignments = [
            {"id": 2, "name": "Survey", "points_possible": 0, "submission_types": ["online_text_entry"]}
        ]
        findings = acr.detect_gradebook_junk(assignments, quizzes=[])
        assert findings == []

    def test_no_fire_on_real_gradable_assignment(self):
        assignments = [
            {"id": 3, "name": "Essay", "points_possible": 100, "submission_types": ["online_upload"]}
        ]
        findings = acr.detect_gradebook_junk(assignments, quizzes=[])
        assert findings == []

    def test_fires_on_empty_quiz(self):
        quizzes = [{"id": 10, "title": "Empty Quiz", "question_count": 0}]
        findings = acr.detect_gradebook_junk([], quizzes=quizzes)
        assert len(findings) == 1
        assert findings[0]["severity"] == "LOW"
        assert "empty quiz" in findings[0]["title"].lower()

    def test_no_fire_on_quiz_with_questions(self):
        quizzes = [{"id": 11, "title": "Real Quiz", "question_count": 5}]
        findings = acr.detect_gradebook_junk([], quizzes=quizzes)
        assert findings == []

    def test_fires_multiple_junk_items(self):
        assignments = [
            {"id": 1, "name": "Placeholder 1", "points_possible": 0, "submission_types": ["none"]},
            {"id": 2, "name": "Placeholder 2", "points_possible": 0.0, "submission_types": ["none"]},
        ]
        quizzes = [
            {"id": 10, "title": "Empty Quiz A", "question_count": 0},
        ]
        findings = acr.detect_gradebook_junk(assignments, quizzes=quizzes)
        assert len(findings) == 3


# ===========================================================================
# Scoring / signal
# ===========================================================================

class TestComputeSignal:
    def test_any_high_gives_build_not_port(self):
        findings = [{"severity": "HIGH"}]
        score, signal = acr.compute_signal(findings)
        assert signal == "Build, not port"

    def test_medium_only_gives_conversion_ready_with_cleanup(self):
        findings = [{"severity": "MEDIUM"}]
        score, signal = acr.compute_signal(findings)
        assert signal == "Conversion-ready with cleanup"

    def test_low_only_gives_conversion_ready_with_cleanup(self):
        findings = [{"severity": "LOW"}]
        score, signal = acr.compute_signal(findings)
        assert signal == "Conversion-ready with cleanup"

    def test_zero_findings_gives_online_ready(self):
        score, signal = acr.compute_signal([])
        assert signal == "Online-ready"
        assert score == 100

    def test_score_clamped_to_zero(self):
        # Many HIGH findings should not produce negative score
        findings = [{"severity": "HIGH"}] * 20
        score, signal = acr.compute_signal(findings)
        assert score == 0

    def test_score_formula_high_medium_low(self):
        """1 HIGH + 1 MEDIUM + 1 LOW = 100 - 20 - 10 - 3 = 67."""
        findings = [
            {"severity": "HIGH"},
            {"severity": "MEDIUM"},
            {"severity": "LOW"},
        ]
        score, signal = acr.compute_signal(findings)
        assert score == 67
        assert signal == "Build, not port"  # HIGH present

    def test_score_medium_low_no_high(self):
        """2 MEDIUM + 1 LOW = 100 - 20 - 3 = 77."""
        findings = [
            {"severity": "MEDIUM"},
            {"severity": "MEDIUM"},
            {"severity": "LOW"},
        ]
        score, signal = acr.compute_signal(findings)
        assert score == 77
        assert signal == "Conversion-ready with cleanup"


# ===========================================================================
# JSON summary shape
# ===========================================================================

class TestBuildJsonSummary:
    def test_structure(self):
        findings = [
            {"detector": "bare_media_embed", "severity": "HIGH", "title": "T", "detail": "D", "evidence": {}}
        ]
        summary = acr.build_json_summary("12345", findings, 80, "Build, not port")
        assert summary["course_id"] == "12345"
        assert summary["score"] == 80
        assert summary["signal"] == "Build, not port"
        assert "bare_media_embed" in summary["detectors"]
        assert summary["detectors"]["bare_media_embed"]["count"] == 1

    def test_empty_findings(self):
        summary = acr.build_json_summary("99", [], 100, "Online-ready")
        assert summary["detectors"] == {}
        assert summary["score"] == 100


# ===========================================================================
# Host-guard unit tests (_exact_host, _is_approved_host)
# ===========================================================================

class TestHostGuard:
    def test_exact_host_parses_correctly(self):
        assert acr._exact_host("https://drive.google.com/file/d/x") == "drive.google.com"
        assert acr._exact_host("https://www.youtube.com/embed/x") == "www.youtube.com"
        assert acr._exact_host("https://mediaplus.asu.edu/embed/x") == "mediaplus.asu.edu"

    def test_exact_host_returns_empty_for_relative(self):
        assert acr._exact_host("/courses/1/pages") == ""

    def test_is_approved_host_exact_match(self):
        assert acr._is_approved_host("mediaplus.asu.edu")
        assert acr._is_approved_host("youtube.com")
        assert acr._is_approved_host("www.youtube.com")
        assert acr._is_approved_host("youtu.be")

    def test_is_approved_host_suffix_match(self):
        assert acr._is_approved_host("asu.instructuremedia.com")
        assert acr._is_approved_host("fast.wistia.com")
        assert acr._is_approved_host("embed.wi.st")

    def test_is_approved_host_canvas_domain(self):
        assert acr._is_approved_host("canvas.asu.edu", canvas_domain="canvas.asu.edu")

    def test_is_approved_host_rejects_substring_attacks(self):
        """None of these substring tricks should be approved."""
        attacks = [
            "evil-drive.google.com.attacker.com",
            "youtube.com.attacker.com",
            "xcanvas.asu.edu.evil.com",
            "not-mediaplus.asu.edu",
            "fake-youtu.be",
            "xwistia.com",
        ]
        for host in attacks:
            assert not acr._is_approved_host(host, canvas_domain=CANVAS_DOMAIN), (
                f"Expected {host!r} to be rejected but it was approved"
            )

    def test_is_approved_host_empty_string(self):
        assert not acr._is_approved_host("")

    def test_is_approved_host_none_canvas_domain(self):
        """With no canvas_domain provided, canvas.asu.edu should NOT be auto-approved."""
        assert not acr._is_approved_host("canvas.asu.edu", canvas_domain=None)


# ===========================================================================
# fetch_course_data monkeypatching (no live HTTP)
# ===========================================================================

class TestFetchCourseDataNoNetwork:
    """Verify fetch_course_data orchestrates canvas_api correctly without live HTTP."""

    def test_fetch_calls_canvas_api(self, monkeypatch):
        """fetch_course_data must call canvas_api functions, not make raw HTTP calls."""
        import importlib
        import types

        mock_canvas_api = types.ModuleType("canvas_api")
        mock_canvas_api.get_config = lambda course_id=None: {
            "domain": "canvas.asu.edu",
            "course_id": str(course_id),
            "headers": {},
        }
        mock_canvas_api.list_pages = lambda config: [
            {"id": 1, "title": "Overview", "url": "overview"}
        ]
        mock_canvas_api.get_page = lambda config, slug: {
            "id": 1,
            "title": "Overview",
            "url": "overview",
            "body": "<h2>Learning Objectives</h2><p>Content</p>",
        }
        mock_canvas_api.get_modules = lambda config, **kw: [{"id": 1, "name": "M1"}]
        mock_canvas_api.list_discussions = lambda config: [{"id": 1, "title": "Disc"}]
        mock_canvas_api.list_assignments = lambda config: []
        mock_canvas_api.list_quizzes = lambda config: []
        mock_canvas_api.get_syllabus = lambda config: {"html": "<p>Syllabus content.</p>"}

        monkeypatch.setitem(sys.modules, "canvas_api", mock_canvas_api)

        # Re-import to pick up patched module
        import importlib
        importlib.reload(acr)

        data = acr.fetch_course_data("99999")
        assert data["canvas_domain"] == "canvas.asu.edu"
        assert len(data["pages"]) == 1
        assert data["pages"][0]["title"] == "Overview"
        assert len(data["modules"]) == 1
        assert len(data["discussions"]) == 1
        assert data["syllabus"]["html"].startswith("<p>Syllabus")
