"""QB4: test_audit_pages_wcag.py

Verifies that audit_pages now uses WCAGContentParser (wcag_checks.py) as its
detection engine, and that coverage matches or exceeds the old
AccessibilityAuditor fork.  Tests are fully offline — no CANVAS_TOKEN required.

Parity requirements tested:
  [shared]      IMG missing alt text         — both engines caught it
  [shared]      Heading level skip           — both engines caught it
  [audit-only]  Generic link text            — added to WCAGContentParser (QB4)
  [convergence] Low contrast (fg+bg only)    — shared engine more correct;
                old fork fired on color-alone-vs-assumed-white (speculative)
  [wcag-only]   Disallowed elements (marquee)— gained from WCAGContentParser
  [wcag-only]   Invalid tabindex             — gained from WCAGContentParser
  [suppressed]  html lang / viewport         — suppressed for Canvas fragments
                (same rule as preflight_checks._WCAG_FRAGMENT_SUPPRESS)
"""

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

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

import audit_pages
import wcag_checks


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


def _page(slug, body):
    return {"url": slug, "title": slug, "body": body, "updated_at": "2026-01-01"}


def _run(body, slug="test-page"):
    """Run _run_wcag_parser directly — bypasses Canvas API entirely."""
    return audit_pages._run_wcag_parser(body, slug)


def _run_via_audit_course(body, slug="test-page"):
    """Run through audit_course with mocked get_all_pages."""
    pages = [_page(slug, body)]
    with patch("audit_pages.get_all_pages", return_value=pages):
        summary, issues = audit_pages.audit_course(_mock_config())
    return summary, issues


# ── 1. IMG missing alt text (SHARED check) ────────────────────────────────

class TestMissingAltText(unittest.TestCase):

    def test_img_without_alt_fires(self):
        findings = _run('<img src="foo.png"><p>Text</p>')
        self.assertTrue(
            any("missing alt" in f.lower() for f in findings),
            f"Expected a missing-alt finding, got: {findings}"
        )

    def test_img_with_alt_clean(self):
        findings = _run('<img src="foo.png" alt="A diagram showing X">')
        self.assertFalse(
            any("missing alt" in f.lower() for f in findings),
            f"No missing-alt expected, got: {findings}"
        )

    def test_img_decorative_presentation_passes(self):
        """role=presentation is an explicit decorative marker — no finding."""
        findings = _run('<img src="divider.png" role="presentation">')
        self.assertFalse(
            any("missing alt" in f.lower() for f in findings),
            f"Decorative image should not fire, got: {findings}"
        )

    def test_img_decorative_aria_hidden_passes(self):
        """aria-hidden=true is an explicit decorative marker — no finding."""
        findings = _run('<img src="divider.png" aria-hidden="true">')
        self.assertFalse(
            any("missing alt" in f.lower() for f in findings),
            f"aria-hidden image should not fire, got: {findings}"
        )

    def test_via_audit_course(self):
        """Missing alt still surfaces when going through audit_course."""
        summary, issues = _run_via_audit_course(
            '<img src="bar.png"><p>Content</p>', "m1-overview"
        )
        self.assertIn("m1-overview", issues)
        self.assertTrue(
            any("missing alt" in i.lower() for i in issues["m1-overview"])
        )


# ── 2. Heading hierarchy skip (SHARED check) ──────────────────────────────

class TestHeadingSkip(unittest.TestCase):

    def test_h2_to_h4_fires(self):
        findings = _run("<h2>Section</h2><h4>Subsection</h4>")
        self.assertTrue(
            any("heading skip" in f.lower() or "heading" in f.lower() for f in findings),
            f"Expected heading skip, got: {findings}"
        )

    def test_h2_to_h3_clean(self):
        findings = _run("<h2>Section</h2><h3>Subsection</h3>")
        self.assertFalse(
            any("heading skip" in f.lower() for f in findings),
            f"Sequential headings should not fire, got: {findings}"
        )

    def test_h1_to_h3_fires(self):
        findings = _run("<h1>Title</h1><h3>Skipped</h3>")
        self.assertTrue(
            any("heading" in f.lower() for f in findings),
            f"Expected h1->h3 skip finding, got: {findings}"
        )


# ── 3. Generic link text (AUDIT-ONLY check, preserved in WCAGContentParser) ─

class TestGenericLinkText(unittest.TestCase):
    """This was audit-only (not in WCAGContentParser before QB4).

    QB4 adds it to WCAGContentParser so all consumers gain it.
    This test verifies the audit-only check still fires after unification.
    """

    def test_click_here_fires(self):
        findings = _run('<a href="http://example.com">click here</a>')
        self.assertTrue(
            any("click here" in f.lower() for f in findings),
            f"Expected generic link text finding for 'click here', got: {findings}"
        )

    def test_here_fires(self):
        findings = _run('<a href="http://example.com">here</a>')
        self.assertTrue(
            any("'here'" in f for f in findings),
            f"Expected generic link text for 'here', got: {findings}"
        )

    def test_read_more_fires(self):
        findings = _run('<a href="/details">read more</a>')
        self.assertTrue(
            any("read more" in f.lower() for f in findings),
            f"Expected generic link text for 'read more', got: {findings}"
        )

    def test_more_fires(self):
        findings = _run('<a href="/details">more</a>')
        self.assertTrue(
            any("'more'" in f for f in findings),
            f"Expected generic link text for 'more', got: {findings}"
        )

    def test_link_fires(self):
        findings = _run('<a href="/x">link</a>')
        self.assertTrue(
            any("'link'" in f for f in findings),
            f"Expected generic link text for 'link', got: {findings}"
        )

    def test_descriptive_link_clean(self):
        findings = _run('<a href="/syllabus">View the course syllabus</a>')
        self.assertFalse(
            any("generic link" in f.lower() for f in findings),
            f"Descriptive link should not fire, got: {findings}"
        )

    def test_empty_link_does_not_fire_as_generic(self):
        """An empty anchor is a different issue (missing text), not generic text."""
        findings = _run('<a href="/x"></a>')
        self.assertFalse(
            any("generic link text" in f.lower() for f in findings),
            f"Empty link should not trigger 'generic link text', got: {findings}"
        )

    def test_generic_link_also_fires_in_wcag_parser_directly(self):
        """Confirms WCAGContentParser itself now carries the generic-link check."""
        p = wcag_checks.WCAGContentParser("test")
        p.feed('<a href="http://example.com">click here</a>')
        p.close()
        p.finalize()
        codes = [f["code"] for f in p.failures]
        self.assertIn(
            "WCAG 2.4.4", codes,
            f"WCAGContentParser should emit WCAG 2.4.4 for 'click here', got codes: {codes}"
        )

    def test_via_audit_course(self):
        """Generic link text surfaces through the full audit_course path."""
        summary, issues = _run_via_audit_course(
            '<p><a href="/x">click here</a> for more info.</p>', "m1-overview"
        )
        self.assertIn("m1-overview", issues)
        self.assertTrue(
            any("click here" in i.lower() for i in issues["m1-overview"])
        )


# ── 4. Low contrast (CONVERGENCE — shared engine more correct) ────────────

class TestLowContrast(unittest.TestCase):
    """The fork checked color-against-assumed-white-bg (speculative).
    The shared engine requires explicit fg+bg declared inline.

    These tests document the CONVERGENCE:
    - explicit fg+bg → both old and new fire (shared behavior)
    - color-only, no bg → old fired (false alarm), new correctly suppresses
    """

    def test_low_contrast_explicit_fg_bg_fires(self):
        """Both fg and bg declared → contrast check fires if ratio < 4.5:1."""
        # #777 on #888 → very low contrast
        findings = _run('<p style="color:#777777;background-color:#888888">x</p>')
        self.assertTrue(
            any("contrast" in f.lower() for f in findings),
            f"Expected contrast finding for explicit low-contrast colors, got: {findings}"
        )

    def test_high_contrast_explicit_clean(self):
        """High contrast fg+bg declared → no finding."""
        findings = _run('<p style="color:#000000;background-color:#ffffff">x</p>')
        self.assertFalse(
            any("contrast" in f.lower() for f in findings),
            f"High contrast should not fire, got: {findings}"
        )

    def test_color_only_no_bg_suppressed(self):
        """Color-only inline style (no background-color) → NO finding.

        The fork incorrectly fired here by assuming white background.
        The shared engine correctly suppresses: unknown background means
        we cannot determine the actual ratio.  This is a CONVERGENCE
        (improvement, not regression).
        """
        findings = _run('<p style="color:#777777">text without bg</p>')
        self.assertFalse(
            any("contrast" in f.lower() for f in findings),
            f"Color-only style should NOT fire (speculative); got: {findings}"
        )

    def test_large_text_heading_3to1_threshold(self):
        """h1/h2/h3 with borderline contrast: must pass 3:1 but fail 4.5:1.

        Verifies the audit-only large-text threshold is preserved in the
        shared engine (contrast_issue now accepts tag= parameter).

        #777777 on #ffffff = ~4.48:1 — just under the 4.5:1 normal-text
        threshold but well above the 3.0:1 large-text threshold.
        Verified: wcag_checks.contrast_ratio((119,119,119),(255,255,255)) ≈ 4.478
        """
        # h2 → large text threshold (3:1); ~4.48:1 should PASS for h2
        findings_h2 = _run('<h2 style="color:#777777;background-color:#ffffff">heading</h2>')
        self.assertFalse(
            any("contrast" in f.lower() for f in findings_h2),
            f"h2 at ~4.48:1 should pass large-text 3:1 threshold, got: {findings_h2}"
        )
        # p → normal text threshold (4.5:1); ~4.48:1 should FAIL for p
        findings_p = _run('<p style="color:#777777;background-color:#ffffff">normal text</p>')
        self.assertTrue(
            any("contrast" in f.lower() for f in findings_p),
            f"p at ~4.48:1 should fail normal 4.5:1 threshold, got: {findings_p}"
        )


# ── 5. WCAG-only checks now available (gained from WCAGContentParser) ──────

class TestWcagOnlyGains(unittest.TestCase):
    """Checks that did NOT exist in the old fork but now fire via WCAGContentParser."""

    def test_marquee_disallowed(self):
        """<marquee> is WCAG 2.2.2 (was not in AccessibilityAuditor fork)."""
        findings = _run("<marquee>spinning text</marquee>")
        self.assertTrue(
            any("WCAG 2.2.2" in f or "marquee" in f.lower() for f in findings),
            f"Expected marquee/WCAG 2.2.2 finding, got: {findings}"
        )

    def test_bad_tabindex(self):
        """tabindex > 0 is WCAG 2.4.3 (was not in AccessibilityAuditor fork)."""
        findings = _run('<div tabindex="3">item</div>')
        self.assertTrue(
            any("WCAG 2.4.3" in f or "tabindex" in f.lower() for f in findings),
            f"Expected tabindex/WCAG 2.4.3 finding, got: {findings}"
        )


# ── 6. Fragment suppression (html lang / viewport suppressed) ─────────────

class TestFragmentSuppression(unittest.TestCase):

    def test_no_html_lang_suppressed(self):
        """Canvas body fragments have no <html lang> — must NOT fire 3.1.1."""
        findings = _run("<h2>Title</h2><p>Some content</p>")
        self.assertFalse(
            any("3.1.1" in f or "html lang" in f.lower() for f in findings),
            f"WCAG 3.1.1 must be suppressed for Canvas body fragments, got: {findings}"
        )

    def test_no_viewport_suppressed(self):
        """Canvas body fragments have no viewport meta — must NOT fire 1.4.10."""
        findings = _run("<h2>Title</h2><p>Some content</p>")
        self.assertFalse(
            any("1.4.10" in f or "viewport" in f.lower() for f in findings),
            f"WCAG 1.4.10 must be suppressed for Canvas body fragments, got: {findings}"
        )


# ── 7. Clean page produces no findings ────────────────────────────────────

class TestCleanPage(unittest.TestCase):

    def test_good_page_no_findings(self):
        """A well-formed accessible Canvas page body produces no findings.

        Note: _run_wcag_parser automatically prepends <!-- canvas-wrapped -->
        so h2 is a valid heading start (Canvas uses h1 for the page title
        in its chrome, not in the page body).
        """
        html = """
        <h2>Module Overview</h2>
        <p>Welcome to this module.</p>
        <img src="diagram.png" alt="A diagram showing the architecture">
        <h3>Key Concepts</h3>
        <p>Review the <a href="/resources">course resources</a> for details.</p>
        """
        findings = _run(html)
        self.assertEqual(
            findings, [],
            f"Clean page should produce no findings, got: {findings}"
        )


# ── 8. Output contract compatibility with test_pages_include_body.py ──────

class TestOutputContractCompat(unittest.TestCase):
    """Ensure that the audit_course return contract is unchanged.

    test_pages_include_body.py asserts:
      summary["pages"] == 2
      "missing alt" in i.lower() for i in issues["m1-overview"]
    These must still pass.
    """

    def test_summary_keys_present(self):
        summary, issues = _run_via_audit_course("<p>clean content</p>", "clean-page")
        self.assertIn("pages", summary)
        self.assertIn("issues", summary)
        self.assertIn("pages_with_issues", summary)

    def test_issues_dict_keyed_by_slug(self):
        summary, issues = _run_via_audit_course(
            '<img src="x.png"><p>text</p>', "m1-overview"
        )
        self.assertIn("m1-overview", issues)

    def test_missing_alt_string_compat(self):
        """The missing-alt string must contain 'missing alt' (lowercase check)."""
        summary, issues = _run_via_audit_course(
            '<img src="x.png"><p>text</p>', "m1-overview"
        )
        self.assertTrue(
            any("missing alt" in i.lower() for i in issues.get("m1-overview", [])),
            f"Expected 'missing alt' in findings, got: {issues.get('m1-overview')}"
        )


# ── 9. No real token needed ────────────────────────────────────────────────

class TestNoTokenRequired(unittest.TestCase):
    """All tests above must run without CANVAS_TOKEN in env."""

    def test_run_wcag_parser_no_canvas_token(self):
        import os
        env_copy = {k: v for k, v in os.environ.items() if k != "CANVAS_TOKEN"}
        # If this call raises due to missing token, the test fails.
        result = audit_pages._run_wcag_parser("<p>hello</p>", "test")
        self.assertIsInstance(result, list)

    def test_audit_course_no_canvas_token(self):
        """audit_course with mocked get_all_pages requires no real token."""
        pages = [_page("p1", "<img src='x.png'><p>text</p>")]
        with patch("audit_pages.get_all_pages", return_value=pages):
            summary, issues = audit_pages.audit_course(_mock_config())
        self.assertIsNotNone(summary)


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