"""Tests for scripts/apply_image_alt.py.

Run with: pytest tests/test_apply_image_alt.py -q
"""

import json
import pathlib
import sys
import types
from unittest.mock import MagicMock

ROOT = pathlib.Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
sys.path.insert(0, str(SCRIPTS))

_STUB_MODULES = ["canvas_api", "backup_manager", "staging_manager",
                 "push_to_canvas", "apply_image_alt"]


def _write_manifest(course_id, images):
    vdir = ROOT / "courses" / str(course_id) / "reports" / "vision"
    vdir.mkdir(parents=True, exist_ok=True)
    (vdir / "alt-manifest.json").write_text(
        json.dumps({"course_id": str(course_id), "images": images}), encoding="utf-8"
    )
    return vdir


def _write_classifications(course_id, entries):
    vdir = ROOT / "courses" / str(course_id) / "reports" / "vision"
    vdir.mkdir(parents=True, exist_ok=True)
    (vdir / "alt-classifications.json").write_text(json.dumps(entries), encoding="utf-8")


def _rec(image_id, object_type, object_id, src, page_url="https://canvas.asu.edu/x"):
    return {
        "image_id": image_id, "object_type": object_type, "object_id": object_id,
        "src": src, "page_url": page_url,
    }


class TestApplyImageAlt:
    def setup_method(self):
        # Save the real modules so teardown RESTORES them — popping instead
        # leaves other test files' canvas_api references mismatched against a
        # re-imported copy, so their monkeypatches miss and real network calls
        # leak out (104-failure cascade). apply_image_alt re-imports fresh.
        self._saved = {m: sys.modules.get(m) for m in _STUB_MODULES}
        sys.modules.pop("apply_image_alt", None)

    def teardown_method(self):
        for m in _STUB_MODULES:
            saved = self._saved.get(m)
            if saved is not None:
                sys.modules[m] = saved
            else:
                sys.modules.pop(m, None)

    # -- stub infra: models the real staging -> push_to_canvas flow ------------

    def _stub(self, page_body=None, assignment_body=None, discussion_body=None,
              push_applies=True):
        """Wire canvas_api + staging_manager + push_to_canvas stubs to a shared
        state. staging_manager.stage records the body; push_to_canvas.push_*
        (when push_applies) copies staged -> live, so the per-image verify that
        re-fetches via get_* sees the real effect. push_applies=False models a
        failed write (live body unchanged -> verify must fail)."""
        state = {"pages": page_body, "assignments": assignment_body,
                 "discussions": discussion_body}
        staged = {}

        stub_canvas = types.ModuleType("canvas_api")
        stub_canvas.get_config = MagicMock(return_value={
            "course_id": "77777", "domain": "canvas.asu.edu", "headers": {}})
        stub_canvas.get_page = MagicMock(side_effect=lambda cfg, slug: {"body": state["pages"]})
        stub_canvas.get_assignment = MagicMock(
            side_effect=lambda cfg, aid: {"description": state["assignments"]})
        stub_canvas.get_discussion = MagicMock(
            side_effect=lambda cfg, tid: {"message": state["discussions"]})
        sys.modules["canvas_api"] = stub_canvas

        stub_backup = types.ModuleType("backup_manager")
        stub_backup.save_backup = MagicMock(return_value={"ok": True, "path": "/fake/backup"})
        sys.modules["backup_manager"] = stub_backup

        stub_staging = types.ModuleType("staging_manager")
        stub_staging.stage = MagicMock(
            side_effect=lambda cid, otype, oid, body: staged.__setitem__(otype, body)
            or {"ok": True})
        sys.modules["staging_manager"] = stub_staging

        def _apply(otype):
            if push_applies and otype in staged:
                state[otype] = staged[otype]
            return {"ok": True, "backup_path": "/fake/backup"}
        stub_push = types.ModuleType("push_to_canvas")
        stub_push.push_page = MagicMock(side_effect=lambda cfg, slug, cid: _apply("pages"))
        stub_push.push_assignment = MagicMock(
            side_effect=lambda cfg, aid, cid: _apply("assignments"))
        stub_push.push_discussion = MagicMock(
            side_effect=lambda cfg, tid, cid: _apply("discussions"))
        sys.modules["push_to_canvas"] = stub_push

        self._state = state
        return stub_canvas, stub_staging, stub_push

    def _cleanup_course(self, course_id):
        import shutil
        d = ROOT / "courses" / str(course_id)
        if d.exists():
            shutil.rmtree(d)

    # -- decorative injection ------------------------------------------------

    def test_decorative_injection_removes_stale_alt(self):
        cid = "77701"
        self._cleanup_course(cid)
        body = '<p><img src="https://canvas.asu.edu/files/99/preview" alt="banner.jpg"></p>'
        _write_manifest(cid, [_rec("img1", "page", "m1-lesson", "https://canvas.asu.edu/files/99/preview")])
        _write_classifications(cid, [{"image_id": "img1", "classification": "decorative"}])
        self._stub(page_body=body)

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=False, confirm_verb="push",
                                      clear_cache=False, as_json=True)

        assert result["totals"]["errors"] == 0
        assert result["totals"]["images_changed"] == 1
        assert result["objects"][0]["changes"][0]["applied"] == "decorative"
        assert result["objects"][0]["verified"] is True
        # the LIVE body now carries the decorative markup, stale alt gone
        assert 'role="presentation"' in self._state["pages"]
        assert 'alt="banner.jpg"' not in self._state["pages"]
        self._cleanup_course(cid)

    def test_relevant_injection_sets_proposed_alt_no_role(self):
        cid = "77702"
        self._cleanup_course(cid)
        body = '<p><img src="https://canvas.asu.edu/files/50/preview" alt="chart.png"></p>'
        _write_manifest(cid, [_rec("img2", "page", "m1-lesson", "https://canvas.asu.edu/files/50/preview")])
        _write_classifications(cid, [{"image_id": "img2", "classification": "relevant",
                                       "proposed_alt": "Bar chart of Q3 revenue by region"}])
        self._stub(page_body=body)

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=False, confirm_verb="push",
                                      clear_cache=False, as_json=True)

        assert result["objects"][0]["changes"][0]["alt"] == "Bar chart of Q3 revenue by region"
        assert 'alt="Bar chart of Q3 revenue by region"' in self._state["pages"]
        assert "role=" not in self._state["pages"]  # role never added on relevant
        self._cleanup_course(cid)

    def test_idempotent_double_run(self):
        cid = "77703"
        self._cleanup_course(cid)
        body = '<p><img src="https://canvas.asu.edu/files/60/preview" alt="x.png"></p>'
        _write_manifest(cid, [_rec("img3", "page", "m1-lesson", "https://canvas.asu.edu/files/60/preview")])
        _write_classifications(cid, [{"image_id": "img3", "classification": "decorative"}])
        self._stub(page_body=body)

        import apply_image_alt
        r1 = apply_image_alt.run(cid, dry_run=False, confirm_verb="push",
                                  clear_cache=False, as_json=True)
        assert r1["totals"]["images_changed"] == 1
        first_body = self._state["pages"]

        r2 = apply_image_alt.run(cid, dry_run=False, confirm_verb="push",
                                  clear_cache=False, as_json=True)
        # Second run: already decorative -> no changes, body byte-stable.
        assert r2["objects"][0]["images_changed"] == 0
        assert self._state["pages"] == first_body
        self._cleanup_course(cid)

    def test_src_matching_robust_to_query_params(self):
        cid = "77704"
        self._cleanup_course(cid)
        body = '<p><img src="https://canvas.asu.edu/files/70/preview?verifier=abc" alt="x.png"></p>'
        _write_manifest(cid, [_rec("img4", "page", "m1-lesson", "https://canvas.asu.edu/files/70/preview")])
        _write_classifications(cid, [{"image_id": "img4", "classification": "decorative"}])
        self._stub(page_body=body)

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=False, confirm_verb="push",
                                      clear_cache=False, as_json=True)
        assert result["objects"][0]["images_changed"] == 1
        self._cleanup_course(cid)

    def test_bare_file_id_src_matches(self):
        """Codex finding 3: /files/{id} with no trailing slash must match."""
        cid = "77713"
        self._cleanup_course(cid)
        body = '<p><img src="https://canvas.asu.edu/courses/1/files/71" alt="x.png"></p>'
        _write_manifest(cid, [_rec("imgb", "page", "m1-lesson", "https://canvas.asu.edu/files/71/preview")])
        _write_classifications(cid, [{"image_id": "imgb", "classification": "decorative"}])
        self._stub(page_body=body)

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=False, confirm_verb="push",
                                      clear_cache=False, as_json=True)
        assert result["objects"][0]["images_changed"] == 1
        self._cleanup_course(cid)

    def test_only_classified_image_changes_among_two(self):
        cid = "77705"
        self._cleanup_course(cid)
        body = (
            '<p><img src="https://canvas.asu.edu/files/80/preview" alt="a.png">'
            '<img src="https://canvas.asu.edu/files/81/preview" alt="b.png"></p>'
        )
        _write_manifest(cid, [_rec("img5", "page", "m1-lesson", "https://canvas.asu.edu/files/80/preview")])
        _write_classifications(cid, [{"image_id": "img5", "classification": "decorative"}])
        self._stub(page_body=body)

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=False, confirm_verb="push",
                                      clear_cache=False, as_json=True)
        assert result["objects"][0]["images_changed"] == 1
        # the untouched image keeps its original alt in the live body (real assert)
        assert 'alt="b.png"' in self._state["pages"]
        assert 'role="presentation"' in self._state["pages"]  # only img5 got it
        assert self._state["pages"].count('role="presentation"') == 1
        self._cleanup_course(cid)

    # -- gating ---------------------------------------------------------------

    def test_dry_run_never_pushes(self):
        cid = "77706"
        self._cleanup_course(cid)
        body = '<p><img src="https://canvas.asu.edu/files/90/preview" alt="a.png"></p>'
        _write_manifest(cid, [_rec("img6", "page", "m1-lesson", "https://canvas.asu.edu/files/90/preview")])
        _write_classifications(cid, [{"image_id": "img6", "classification": "decorative"}])
        _, stub_staging, stub_push = self._stub(page_body=body)

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=True, confirm_verb=None,
                                      clear_cache=False, as_json=True)
        assert not stub_push.push_page.called
        assert not stub_staging.stage.called
        assert result["objects"][0]["images_changed"] == 1
        assert self._state["pages"] == body  # live body untouched
        self._cleanup_course(cid)

    def test_bad_confirm_verb_refused(self):
        cid = "77707"
        self._cleanup_course(cid)
        _write_manifest(cid, [])
        _, _, stub_push = self._stub(page_body="")

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=False, confirm_verb="yes",
                                      clear_cache=False, as_json=True)
        assert "error" in result
        assert not stub_push.push_page.called
        self._cleanup_course(cid)

    def test_good_confirm_verb_pushes_and_verifies(self):
        cid = "77708"
        self._cleanup_course(cid)
        body = '<p><img src="https://canvas.asu.edu/files/91/preview" alt="a.png"></p>'
        _write_manifest(cid, [_rec("img7", "page", "m1-lesson", "https://canvas.asu.edu/files/91/preview")])
        _write_classifications(cid, [{"image_id": "img7", "classification": "decorative"}])
        _, _, stub_push = self._stub(page_body=body)

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=False, confirm_verb="push",
                                      clear_cache=False, as_json=True)
        assert stub_push.push_page.called
        assert result["objects"][0]["verified"] is True
        self._cleanup_course(cid)

    def test_verify_mismatch_hard_fails_with_backup_path(self):
        cid = "77709"
        self._cleanup_course(cid)
        body = '<p><img src="https://canvas.asu.edu/files/92/preview" alt="a.png"></p>'
        _write_manifest(cid, [_rec("img8", "page", "m1-lesson", "https://canvas.asu.edu/files/92/preview")])
        _write_classifications(cid, [{"image_id": "img8", "classification": "decorative"}])
        # push_applies=False: push reports ok but the live body never changes,
        # so the per-image re-fetch verify must fail.
        self._stub(page_body=body, push_applies=False)

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=False, confirm_verb="push",
                                      clear_cache=False, as_json=True)
        entry = result["objects"][0]
        assert entry["verified"] is False
        assert "backup" in entry["error"]
        assert result["totals"]["errors"] == 1
        self._cleanup_course(cid)

    def test_unclassified_image_skipped(self):
        cid = "77710"
        self._cleanup_course(cid)
        body = '<p><img src="https://canvas.asu.edu/files/93/preview" alt="a.png"></p>'
        _write_manifest(cid, [_rec("img9", "page", "m1-lesson", "https://canvas.asu.edu/files/93/preview")])
        _write_classifications(cid, [])  # no classification entry for img9
        self._stub(page_body=body)

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=True, confirm_verb=None,
                                      clear_cache=False, as_json=True)
        assert result["objects"] == []
        assert result["totals"]["images_changed"] == 0
        self._cleanup_course(cid)

    def test_quiz_image_without_question_id_logged_and_skipped(self):
        cid = "77711"
        self._cleanup_course(cid)
        _write_manifest(cid, [_rec("imgq", "quiz", "555", "https://canvas.asu.edu/files/94/preview")])
        _write_classifications(cid, [{"image_id": "imgq", "classification": "relevant",
                                       "proposed_alt": "desc"}])
        self._stub(page_body="")

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=True, confirm_verb=None,
                                      clear_cache=False, as_json=True)
        assert result["objects"] == []
        assert any("question_id" in s for s in result["skipped"])
        self._cleanup_course(cid)

    def test_clear_cache_removes_images_keeps_json(self):
        cid = "77712"
        self._cleanup_course(cid)
        vdir = _write_manifest(cid, [])
        img_dir = vdir / "images"
        img_dir.mkdir(parents=True, exist_ok=True)
        (img_dir / "abc.png").write_bytes(b"12345")
        self._stub(page_body="")

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=True, confirm_verb=None,
                                      clear_cache=True, as_json=True)
        assert not img_dir.exists() or not any(img_dir.iterdir())
        assert (vdir / "alt-manifest.json").exists()
        assert result["cache_cleared"]["files_removed"] == 1
        assert result["cache_cleared"]["bytes_freed"] == 5
        self._cleanup_course(cid)


    def test_file_id_regex_no_overmatch(self):
        """Codex re-review #2: /files/123abc must NOT match as file id 123."""
        cid = "77714"
        self._cleanup_course(cid)
        body = '<p><img src="https://x.edu/files/123abc/preview" alt="a.png"></p>'
        _write_manifest(cid, [_rec("imgo", "page", "m1-lesson", "https://x.edu/files/123/preview")])
        _write_classifications(cid, [{"image_id": "imgo", "classification": "decorative"}])
        self._stub(page_body=body)

        import apply_image_alt
        result = apply_image_alt.run(cid, dry_run=False, confirm_verb="push",
                                      clear_cache=False, as_json=True)
        assert result["objects"][0]["images_changed"] == 0  # no false match
        self._cleanup_course(cid)
