"""Tests for QB3: N+1 page-body fetch collapse via include[]=body.

Verifies:
  1. canvas_api.get_all_pages(include_body=True) issues the index call with
     include[]=body in params and does NOT issue per-slug GETs.
  2. canvas_api.get_all_pages(include_body=False) (default) does NOT send
     the include[] param — existing callers are unaffected.
  3. list_pages alias still works (backward compat).
  4. criterion_evaluator.collect_course_data reads body from the index result
     and issues NO per-page _api_get("pages/...") call.
  5. course_content_cache.dump_pages reads body from the index result and
     issues NO canvas_api.get_page() call.
  6. audit_pages.audit_course reads body from the index result and issues
     NO get_page() call (and no time.sleep per page).
  7. Pages with null/empty body are handled gracefully (no crash, empty body).
"""

import os
import sys
import time
import unittest
from unittest.mock import MagicMock, patch, call

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

import canvas_api


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _mock_config(course_id="99001"):
    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_with_body(slug, body="<p>Hello</p>", title=None):
    """Simulate an index-result page dict that includes body (include[]=body)."""
    return {
        "url": slug,
        "title": title or slug,
        "body": body,
        "updated_at": "2026-01-01T00:00:00Z",
        "published": True,
    }


# ---------------------------------------------------------------------------
# 1. canvas_api.get_all_pages — include_body=True sends include[]=body
# ---------------------------------------------------------------------------

class TestGetAllPagesIncludeBody(unittest.TestCase):

    def test_include_body_true_sends_include_param(self):
        """include_body=True must pass include[]=body to paginated_get."""
        config = _mock_config()
        with patch("canvas_api.paginated_get", return_value=[]) as mock_pg:
            canvas_api.get_all_pages(config, include_body=True)

        mock_pg.assert_called_once()
        _, kwargs = mock_pg.call_args
        params = kwargs.get("params") or (mock_pg.call_args.args[2] if len(mock_pg.call_args.args) > 2 else {})
        # params may be positional or keyword depending on call signature
        call_args = mock_pg.call_args
        all_params = call_args.kwargs.get("params") if call_args.kwargs else None
        if all_params is None and len(call_args.args) >= 3:
            all_params = call_args.args[2]
        self.assertIsNotNone(all_params, "paginated_get must receive params dict")
        self.assertIn("include[]", all_params, "include[] must be in params")
        self.assertEqual(all_params["include[]"], "body")

    def test_include_body_false_does_not_send_include_param(self):
        """include_body=False (default) must NOT send include[]=body."""
        config = _mock_config()
        with patch("canvas_api.paginated_get", return_value=[]) as mock_pg:
            canvas_api.get_all_pages(config, include_body=False)

        call_args = mock_pg.call_args
        all_params = call_args.kwargs.get("params") if call_args.kwargs else None
        if all_params is None and len(call_args.args) >= 3:
            all_params = call_args.args[2]
        if all_params:
            self.assertNotIn("include[]", all_params,
                             "include[] must NOT appear in params when include_body=False")

    def test_default_include_body_is_false(self):
        """Default (no arg) must behave like include_body=False."""
        config = _mock_config()
        with patch("canvas_api.paginated_get", return_value=[]) as mock_pg:
            canvas_api.get_all_pages(config)

        call_args = mock_pg.call_args
        all_params = call_args.kwargs.get("params") if call_args.kwargs else None
        if all_params is None and len(call_args.args) >= 3:
            all_params = call_args.args[2]
        if all_params:
            self.assertNotIn("include[]", all_params)

    def test_returns_page_list(self):
        """Return value should be whatever paginated_get returns."""
        config = _mock_config()
        pages = [_page_with_body("m1-overview"), _page_with_body("m1-lesson")]
        with patch("canvas_api.paginated_get", return_value=pages):
            result = canvas_api.get_all_pages(config, include_body=True)
        self.assertEqual(result, pages)

    def test_list_pages_alias_accepts_include_body(self):
        """list_pages alias must forward include_body keyword arg."""
        config = _mock_config()
        with patch("canvas_api.paginated_get", return_value=[]) as mock_pg:
            canvas_api.list_pages(config, include_body=True)

        call_args = mock_pg.call_args
        all_params = call_args.kwargs.get("params") if call_args.kwargs else None
        if all_params is None and len(call_args.args) >= 3:
            all_params = call_args.args[2]
        self.assertIsNotNone(all_params)
        self.assertIn("include[]", all_params)
        self.assertEqual(all_params["include[]"], "body")

    def test_empty_body_page_returns_none_gracefully(self):
        """A page with body=None in the index result must not crash."""
        config = _mock_config()
        pages = [{"url": "empty-page", "title": "Empty", "body": None, "updated_at": "2026-01-01"}]
        with patch("canvas_api.paginated_get", return_value=pages):
            result = canvas_api.get_all_pages(config, include_body=True)
        self.assertEqual(len(result), 1)
        self.assertIsNone(result[0]["body"])


# ---------------------------------------------------------------------------
# 2. criterion_evaluator.collect_course_data — no per-page _api_get
# ---------------------------------------------------------------------------

class TestCriterionEvaluatorNoPerPageGet(unittest.TestCase):

    def _minimal_course_data(self):
        return {"syllabus_body": "<p>Syllabus</p>", "html_url": "https://canvas.test.edu/courses/99001"}

    def _fake_cfg(self, course_id="99001"):
        """Minimal cfg dict that satisfies collect_course_data without a real token."""
        return {
            "domain": "canvas.test.edu",
            "course_id": course_id,
            "course_url": f"https://canvas.test.edu/api/v1/courses/{course_id}",
            "headers": {"Authorization": "Bearer fake-tok"},
        }

    def test_collect_course_data_no_per_page_api_get(self):
        """collect_course_data must NOT call _api_get for individual page bodies."""
        import criterion_evaluator

        pages_with_bodies = [
            _page_with_body("m1-overview", "<p>Overview body</p>", "M1 Overview"),
            _page_with_body("m1-lesson", "<p>Lesson body</p>", "M1 Lesson"),
        ]

        def fake_api_get_all(path, params=None, course_id=None):
            if path == "pages":
                # Must include include[]=body in params
                self.assertIsNotNone(params, "pages call must pass params")
                self.assertIn("include[]", params, "pages call must include body")
                return pages_with_bodies
            return []

        def fake_api_get(path, params=None, course_id=None):
            # Any per-page slug GET would show up here — must NOT happen for pages/*
            if isinstance(path, str) and path.startswith("pages/"):
                self.fail(f"Per-page GET issued for {path!r} — N+1 not eliminated")
            # Allow other GETs (course root, tabs, etc.)
            if path == "" or path is None:
                return self._minimal_course_data()
            if path == "tabs":
                return []
            return None

        with patch.object(criterion_evaluator, "_get_config", return_value=self._fake_cfg()), \
             patch.object(criterion_evaluator, "_api_get_all", side_effect=fake_api_get_all), \
             patch.object(criterion_evaluator, "_api_get", side_effect=fake_api_get):
            result = criterion_evaluator.collect_course_data(course_id="99001")

        # collect_course_data returns 'pages' key (dict of slug → page_data)
        self.assertIn("pages", result)
        self.assertIn("m1-overview", result["pages"])
        self.assertEqual(result["pages"]["m1-overview"]["body"], "<p>Overview body</p>")
        self.assertEqual(result["pages"]["m1-overview"]["title"], "M1 Overview")

    def test_collect_course_data_empty_body_not_crash(self):
        """A page with body=None from the index must produce body='' in page_data, not crash."""
        import criterion_evaluator

        pages_with_bodies = [
            {"url": "empty-page", "title": "Empty Page", "body": None, "updated_at": "2026-01-01"},
        ]

        def fake_api_get_all(path, params=None, course_id=None):
            if path == "pages":
                return pages_with_bodies
            return []

        def fake_api_get(path, params=None, course_id=None):
            if isinstance(path, str) and path.startswith("pages/"):
                self.fail(f"Per-page GET issued for {path!r} — should not happen")
            if path == "" or path is None:
                return self._minimal_course_data()
            if path == "tabs":
                return []
            return None

        with patch.object(criterion_evaluator, "_get_config", return_value=self._fake_cfg()), \
             patch.object(criterion_evaluator, "_api_get_all", side_effect=fake_api_get_all), \
             patch.object(criterion_evaluator, "_api_get", side_effect=fake_api_get):
            result = criterion_evaluator.collect_course_data(course_id="99001")

        # empty-page has body=None → should produce empty string body, not crash
        self.assertIn("empty-page", result["pages"])
        self.assertEqual(result["pages"]["empty-page"]["body"], "")

    def test_collect_course_data_body_read_from_index(self):
        """body field in page_data must come from the index, not a per-page GET."""
        import criterion_evaluator

        index_body = "<h2>Section</h2><p>Content from index response</p>"
        pages_with_bodies = [
            _page_with_body("m1-overview", index_body, "M1 Overview"),
        ]

        def fake_api_get_all(path, params=None, course_id=None):
            if path == "pages":
                return pages_with_bodies
            return []

        def fake_api_get(path, params=None, course_id=None):
            if isinstance(path, str) and path.startswith("pages/"):
                self.fail(f"Per-page GET issued for {path!r}")
            if path == "" or path is None:
                return self._minimal_course_data()
            if path == "tabs":
                return []
            return None

        with patch.object(criterion_evaluator, "_get_config", return_value=self._fake_cfg()), \
             patch.object(criterion_evaluator, "_api_get_all", side_effect=fake_api_get_all), \
             patch.object(criterion_evaluator, "_api_get", side_effect=fake_api_get):
            result = criterion_evaluator.collect_course_data(course_id="99001")

        self.assertEqual(result["pages"]["m1-overview"]["body"], index_body)


# ---------------------------------------------------------------------------
# 3. course_content_cache.dump_pages — no get_page() call
# ---------------------------------------------------------------------------

class TestCourseContentCacheNoPerPageGet(unittest.TestCase):

    def test_dump_pages_no_get_page_call(self, tmp_path=None):
        """dump_pages must NOT call canvas_api.get_page() for each page."""
        import tempfile, shutil
        import course_content_cache as ccc

        td = tempfile.mkdtemp()
        try:
            config = _mock_config("99002")
            pages_with_bodies = [
                _page_with_body("m1-overview", "<h1>Overview</h1>", "M1 Overview"),
                _page_with_body("m1-lesson", "<p>Lesson content</p>", "M1 Lesson"),
            ]

            with patch("canvas_api.get_all_pages", return_value=pages_with_bodies) as mock_gal, \
                 patch("canvas_api.get_page") as mock_gp, \
                 patch("canvas_api.get_plugin_root", return_value=td):
                count = ccc.dump_pages(config)

            # Must have called get_all_pages with include_body=True
            mock_gal.assert_called_once_with(config, include_body=True)
            # Must NOT have called get_page at all
            mock_gp.assert_not_called()
            self.assertEqual(count, 2)
        finally:
            shutil.rmtree(td, ignore_errors=True)

    def test_dump_pages_body_written_from_index(self):
        """HTML file content must come from index body, not a separate GET."""
        import tempfile, shutil
        import course_content_cache as ccc

        td = tempfile.mkdtemp()
        try:
            config = _mock_config("99002")
            index_body = "<h2>Direct from index</h2><p>Verified content</p>"
            pages_with_bodies = [_page_with_body("test-slug", index_body, "Test Page")]

            with patch("canvas_api.get_all_pages", return_value=pages_with_bodies), \
                 patch("canvas_api.get_page") as mock_gp, \
                 patch("canvas_api.get_plugin_root", return_value=td):
                ccc.dump_pages(config)

            mock_gp.assert_not_called()
            html_path = os.path.join(td, "courses", "99002", "content_cache", "pages", "test-slug.html")
            self.assertTrue(os.path.exists(html_path))
            with open(html_path) as f:
                content = f.read()
            self.assertEqual(content, index_body)
        finally:
            shutil.rmtree(td, ignore_errors=True)

    def test_dump_pages_null_body_graceful(self):
        """Pages with body=None from the index should be cached as empty string."""
        import tempfile, shutil
        import course_content_cache as ccc

        td = tempfile.mkdtemp()
        try:
            config = _mock_config("99002")
            pages_with_bodies = [
                {"url": "empty-page", "title": "Empty", "body": None, "updated_at": "2026-01-01T00:00:00Z"}
            ]

            with patch("canvas_api.get_all_pages", return_value=pages_with_bodies), \
                 patch("canvas_api.get_page") as mock_gp, \
                 patch("canvas_api.get_plugin_root", return_value=td):
                count = ccc.dump_pages(config)

            mock_gp.assert_not_called()
            # body=None → empty string → get_page skipped → page cached with empty body
            # The page still gets written (empty body is not None, the `or ""` handles it)
            html_path = os.path.join(td, "courses", "99002", "content_cache", "pages", "empty-page.html")
            self.assertTrue(os.path.exists(html_path))
            count_value = count
            self.assertGreaterEqual(count_value, 0)  # does not crash
        finally:
            shutil.rmtree(td, ignore_errors=True)

    def test_dump_pages_since_filter_respected(self):
        """since= filter must still work when body comes from the index."""
        import tempfile, shutil
        import course_content_cache as ccc

        td = tempfile.mkdtemp()
        try:
            config = _mock_config("99002")
            pages_with_bodies = [
                {"url": "old-page", "title": "Old", "body": "<p>old</p>", "updated_at": "2024-01-01T00:00:00Z"},
                {"url": "new-page", "title": "New", "body": "<p>new</p>", "updated_at": "2026-06-01T00:00:00Z"},
            ]

            with patch("canvas_api.get_all_pages", return_value=pages_with_bodies), \
                 patch("canvas_api.get_page") as mock_gp, \
                 patch("canvas_api.get_plugin_root", return_value=td):
                count = ccc.dump_pages(config, since="2025-01-01T00:00:00Z")

            mock_gp.assert_not_called()
            self.assertEqual(count, 1)  # only new-page
            html_old = os.path.join(td, "courses", "99002", "content_cache", "pages", "old-page.html")
            html_new = os.path.join(td, "courses", "99002", "content_cache", "pages", "new-page.html")
            self.assertFalse(os.path.exists(html_old))
            self.assertTrue(os.path.exists(html_new))
        finally:
            shutil.rmtree(td, ignore_errors=True)


# ---------------------------------------------------------------------------
# 4. audit_pages.audit_course — no get_page() call, no sleep per page
# ---------------------------------------------------------------------------

class TestAuditPagesNoPerPageGet(unittest.TestCase):

    def test_audit_course_no_get_page_call(self):
        """audit_course must NOT call get_page() for individual page bodies."""
        import audit_pages

        pages_with_bodies = [
            _page_with_body("m1-overview", "<h2>Overview</h2><p>Clean text.</p>", "M1 Overview"),
            _page_with_body("m1-lesson", '<img alt="diagram"/><p>Lesson</p>', "M1 Lesson"),
        ]

        with patch("audit_pages.get_all_pages", return_value=pages_with_bodies) as mock_gal, \
             patch("canvas_api.get_page") as mock_gp:
            # get_page is no longer imported in audit_pages — patch at canvas_api level
            # to confirm it is never called even if canvas_api itself has it
            summary, issues = audit_pages.audit_course(_mock_config())

        mock_gal.assert_called_once()
        mock_gp.assert_not_called()
        # Both pages had body, so pages scanned = 2
        self.assertEqual(summary["pages"], 2)

    def test_audit_course_get_all_pages_called_with_include_body(self):
        """audit_course must call get_all_pages with include_body=True."""
        import audit_pages

        with patch("audit_pages.get_all_pages", return_value=[]) as mock_gal:
            audit_pages.audit_course(_mock_config())

        mock_gal.assert_called_once()
        call_kwargs = mock_gal.call_args.kwargs
        call_args_pos = mock_gal.call_args.args
        # include_body=True must appear either as kwarg or positional arg
        include_body_value = call_kwargs.get("include_body") or (
            call_args_pos[1] if len(call_args_pos) > 1 else None
        )
        self.assertTrue(
            include_body_value,
            f"get_all_pages must be called with include_body=True; got args={call_args_pos}, kwargs={call_kwargs}"
        )

    def test_audit_course_no_sleep_per_page(self):
        """audit_course must NOT sleep between pages (sleep removed with N+1 loop)."""
        import audit_pages

        pages_with_bodies = [
            _page_with_body(f"page-{i}", f"<p>Content {i}</p>", f"Page {i}")
            for i in range(5)
        ]

        sleep_calls = []

        def fake_sleep(secs):
            sleep_calls.append(secs)

        with patch("audit_pages.get_all_pages", return_value=pages_with_bodies), \
             patch("time.sleep", side_effect=fake_sleep):
            audit_pages.audit_course(_mock_config())

        per_page_sleeps = [s for s in sleep_calls if s == 0.15]
        self.assertEqual(
            len(per_page_sleeps), 0,
            f"time.sleep(0.15) must not be called; got sleep calls: {sleep_calls}"
        )

    def test_audit_course_detects_missing_alt(self):
        """Accessibility findings must still be detected from index bodies."""
        import audit_pages

        pages_with_bodies = [
            _page_with_body("m1-overview", '<img src="foo.png"/><p>Text</p>', "M1 Overview"),
        ]

        with patch("audit_pages.get_all_pages", return_value=pages_with_bodies):
            summary, issues = audit_pages.audit_course(_mock_config())

        self.assertIn("m1-overview", issues)
        self.assertTrue(any("missing alt" in i.lower() for i in issues["m1-overview"]))

    def test_audit_course_empty_body_skipped(self):
        """Pages with null/empty body must be skipped, not crash."""
        import audit_pages

        pages_with_bodies = [
            {"url": "empty-page", "title": "Empty", "body": None, "updated_at": "2026-01-01"},
            {"url": "blank-page", "title": "Blank", "body": "   ", "updated_at": "2026-01-01"},
        ]

        with patch("audit_pages.get_all_pages", return_value=pages_with_bodies):
            summary, issues = audit_pages.audit_course(_mock_config())

        # Both pages have no meaningful body — should not be counted
        self.assertEqual(summary["pages"], 0)
        self.assertEqual(summary["issues"], 0)

    def test_audit_course_page_without_url_skipped(self):
        """Page dicts missing 'url' must be skipped gracefully."""
        import audit_pages

        pages_with_bodies = [
            {"title": "No URL Page", "body": "<p>Content</p>", "updated_at": "2026-01-01"},
            _page_with_body("good-page", "<p>Good</p>", "Good Page"),
        ]

        with patch("audit_pages.get_all_pages", return_value=pages_with_bodies):
            summary, issues = audit_pages.audit_course(_mock_config())

        # Only good-page should be counted
        self.assertEqual(summary["pages"], 1)


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