"""Security regression tests — v7.8.0 audit batch (C1, C2, H1, H2, H3, M1)
and PR1 token-egress regression tests (E1, E2, E3, E4)."""

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

import pytest
import requests as _requests

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

import vision_audit
import canvas_api
import generator
import deploy_interactives
import backup_manager
import staging_manager


# ── C1: token only attaches to the exact Canvas host over HTTPS ──────────────

class TestC1CanvasHostGuard:
    DOMAIN = "canvas.asu.edu"

    def test_exact_host_https_attaches(self):
        assert vision_audit._is_canvas_host(
            "https://canvas.asu.edu/files/1/download", self.DOMAIN)

    def test_lookalike_query_string_rejected(self):
        # The substring-match bug: evil host with canvas.asu.edu in the query.
        assert not vision_audit._is_canvas_host(
            "https://evil.example.com/x?ref=canvas.asu.edu", self.DOMAIN)

    def test_subdomain_spoof_rejected(self):
        assert not vision_audit._is_canvas_host(
            "https://canvas.asu.edu.evil.com/img.png", self.DOMAIN)

    def test_http_scheme_rejected(self):
        assert not vision_audit._is_canvas_host(
            "http://canvas.asu.edu/img.png", self.DOMAIN)


# ── C2: Bearer token never follows an off-domain Canvas-response URL ─────────

class TestC2ResponseUrlGuard:
    DOMAIN = "canvas.asu.edu"

    def test_on_domain_url_passes(self):
        url = "https://canvas.asu.edu/api/v1/courses/1/pages?page=2"
        assert canvas_api._validate_canvas_response_url(
            url, self.DOMAIN, "pagination") == url

    def test_off_domain_link_raises(self):
        with pytest.raises(ValueError):
            canvas_api._validate_canvas_response_url(
                "https://attacker.example.com/steal", self.DOMAIN, "pagination")

    def test_http_downgrade_raises(self):
        with pytest.raises(ValueError):
            canvas_api._validate_canvas_response_url(
                "http://canvas.asu.edu/api/v1/x", self.DOMAIN, "upload confirm")

    def test_empty_url_raises(self):
        with pytest.raises(ValueError):
            canvas_api._validate_canvas_response_url("", self.DOMAIN, "ctx")


# ── H1: interactive data islands cannot break out of <script> ───────────────

class TestH1SafeJson:
    def test_closing_script_tag_neutralized(self):
        out = generator._safe_json({"front": "</script><script>alert(1)</script>"})
        assert "</script>" not in out
        assert "<\\/script>" in out

    def test_html_comment_neutralized(self):
        out = generator._safe_json({"x": "<!-- comment -->"})
        assert "<!--" not in out

    def test_clean_content_round_trips(self):
        import json as _json
        out = generator._safe_json({"front": "Mean", "back": "Average"})
        assert _json.loads(out.replace("<\\/", "</")) == {
            "front": "Mean", "back": "Average"}

    def test_generated_card_has_no_raw_closing_script(self):
        html = generator.GENERATORS["dialog_cards"]({
            "type": "dialog_cards",
            "title": "T",
            "filename": "x.html",
            "instructions": "go",
            "cards": [{"front": "</script><script>x</script>", "back": "b"}],
        })
        # The data island must not contain a raw breakout sequence.
        assert "</script><script>x</script>" not in html


# ── H2: activity metadata is HTML-escaped before reaching Canvas HTML ────────

class TestH2WrapperEscaping:
    def test_title_xss_escaped(self):
        out = deploy_interactives.iframe_html(
            "99", "123",
            {"type": "dialog_cards",
             "title": '<img src=x onerror=alert(1)>',
             "instructions": "plain",
             "filename": "m1-x.html"})
        assert "<img src=x onerror=alert(1)>" not in out
        assert "&lt;img" in out


# ── H3: staging/backup reject path traversal as ok:False, not an exception ──

class TestH3PathTraversal:
    def test_save_backup_traversal_returns_false(self):
        result = backup_manager.save_backup(
            "99999", "pages", "../../../etc/passwd", "content")
        assert result["ok"] is False

    def test_save_backup_invalid_type_returns_false(self):
        result = backup_manager.save_backup(
            "99999", "invalid_type", "x", "content")
        assert result["ok"] is False

    def test_stage_traversal_returns_false(self):
        result = staging_manager.stage(
            "99999", "pages", "../../escape", "<p>x</p>", validate=False)
        assert result["ok"] is False


# ── PR1 / E1: setup_env._fetch_all_courses never follows an off-domain next-link

class TestE1SetupEnvTokenEgress:
    """setup_env._fetch_all_courses must route pagination through canvas_api so
    the Bearer token is never sent to an off-domain Link: rel="next" URL."""

    DOMAIN = "canvas.asu.edu"

    def _make_env(self):
        return {
            "token": "fake-token",
            "domain": self.DOMAIN,
            "course_id": None,
        }

    def _page1_response(self, evil_next=True):
        """A first-page response whose Link header points to evil.example."""
        resp = MagicMock()
        resp.status_code = 200
        resp.json.return_value = [{"id": 1, "name": "Course 1"}]
        if evil_next:
            resp.headers = {
                "Link": '<https://evil.example/api/v1/courses?page=2>; rel="next"',
                "X-Rate-Limit-Remaining": "100",
            }
        else:
            resp.headers = {"Link": ""}
        # paginated_get calls raise_for_status on every page
        resp.raise_for_status = MagicMock()
        return resp

    def test_off_domain_next_link_returns_error_not_courses(self):
        """An evil next-link must surface as (None, error_str), not a data leak."""
        import setup_env

        env = self._make_env()
        good_resp = self._page1_response(evil_next=True)

        with patch("canvas_api._request_with_retry", return_value=good_resp) as mock_req:
            courses, err = setup_env._fetch_all_courses(env)

        assert courses is None, "Should return None on off-domain next-link"
        assert err is not None, "Should surface an error string"
        assert "evil.example" in err or "Unsafe" in err or "expected https" in err

    def test_no_second_get_to_evil_host(self):
        """The HTTP layer must never be called with an evil.example URL."""
        import setup_env

        env = self._make_env()
        good_resp = self._page1_response(evil_next=True)

        with patch("canvas_api._request_with_retry", return_value=good_resp) as mock_req:
            setup_env._fetch_all_courses(env)

        called_urls = [c.args[1] for c in mock_req.call_args_list]
        evil_calls = [u for u in called_urls if "evil.example" in u]
        assert evil_calls == [], f"Bearer token was sent to evil host(s): {evil_calls}"

    def test_clean_pagination_returns_all_courses(self):
        """When next-links stay on-domain, all results are returned correctly."""
        import setup_env

        env = self._make_env()

        page1 = MagicMock()
        page1.status_code = 200
        page1.json.return_value = [{"id": 1}]
        page1.headers = {
            "Link": f'<https://{self.DOMAIN}/api/v1/courses?page=2>; rel="next"',
            "X-Rate-Limit-Remaining": "100",
        }
        page1.raise_for_status = MagicMock()

        page2 = MagicMock()
        page2.status_code = 200
        page2.json.return_value = [{"id": 2}]
        page2.headers = {"Link": ""}
        page2.raise_for_status = MagicMock()

        with patch("canvas_api._request_with_retry", side_effect=[page1, page2]):
            courses, err = setup_env._fetch_all_courses(env)

        assert err is None
        assert len(courses) == 2
        assert courses[0]["id"] == 1
        assert courses[1]["id"] == 2


# ── PR1 / E2: criterion_evaluator._api_get_all never follows off-domain next-link

class TestE2CriterionEvaluatorGetAll:
    """_api_get_all must route through canvas_api.paginated_get; an off-domain
    next-link raises instead of silently truncating with token egress."""

    DOMAIN = "canvas.asu.edu"

    def _make_cfg(self):
        return {
            "domain": self.DOMAIN,
            "course_url": f"https://{self.DOMAIN}/api/v1/courses/1",
            "headers": {"Authorization": "Bearer fake"},
        }

    def test_off_domain_next_link_raises(self):
        """paginated_get raises ValueError on off-domain next-link; it must
        propagate out of _api_get_all (not be swallowed as an empty list)."""
        import criterion_evaluator

        page1 = MagicMock()
        page1.status_code = 200
        page1.json.return_value = [{"id": 1}]
        page1.headers = {
            "Link": '<https://evil.example/api/v1/next>; rel="next"',
            "X-Rate-Limit-Remaining": "100",
        }
        page1.raise_for_status = MagicMock()

        cfg = self._make_cfg()
        with patch("canvas_api._request_with_retry", return_value=page1):
            with patch.object(criterion_evaluator, "_get_config", return_value=cfg):
                with pytest.raises(ValueError, match="(?i)unsafe|evil"):
                    criterion_evaluator._api_get_all("modules", course_id="1")

    def test_no_second_get_to_evil_host(self):
        """The HTTP layer must never be called with an evil.example URL."""
        import criterion_evaluator

        page1 = MagicMock()
        page1.status_code = 200
        page1.json.return_value = [{"id": 1}]
        page1.headers = {
            "Link": '<https://evil.example/api/v1/next>; rel="next"',
            "X-Rate-Limit-Remaining": "100",
        }
        page1.raise_for_status = MagicMock()

        cfg = self._make_cfg()
        with patch("canvas_api._request_with_retry", return_value=page1) as mock_req:
            with patch.object(criterion_evaluator, "_get_config", return_value=cfg):
                try:
                    criterion_evaluator._api_get_all("modules", course_id="1")
                except (ValueError, _requests.RequestException):
                    pass

        called_urls = [c.args[1] for c in mock_req.call_args_list]
        evil_calls = [u for u in called_urls if "evil.example" in u]
        assert evil_calls == [], f"Bearer token was sent to evil host(s): {evil_calls}"


# ── PR1 / E3: criterion_evaluator._api_get rejects off-domain absolute path

class TestE3CriterionEvaluatorGetAbsolutePath:
    """_api_get must validate absolute-URL paths against the Canvas domain
    before attaching auth headers."""

    DOMAIN = "canvas.asu.edu"

    def _make_cfg(self):
        return {
            "domain": self.DOMAIN,
            "course_url": f"https://{self.DOMAIN}/api/v1/courses/1",
            "headers": {"Authorization": "Bearer fake"},
        }

    def test_off_domain_absolute_path_raises(self):
        """An absolute path pointing to a non-Canvas host must raise ValueError."""
        import criterion_evaluator

        cfg = self._make_cfg()
        with patch.object(criterion_evaluator, "_get_config", return_value=cfg):
            with pytest.raises(ValueError, match="(?i)unsafe|evil"):
                criterion_evaluator._api_get("https://evil.example/steal")

    def test_off_domain_absolute_path_no_http_call(self):
        """No HTTP GET must be issued to the evil host."""
        import criterion_evaluator

        cfg = self._make_cfg()
        with patch("canvas_api._request_with_retry") as mock_req:
            with patch.object(criterion_evaluator, "_get_config", return_value=cfg):
                try:
                    criterion_evaluator._api_get("https://evil.example/steal")
                except ValueError:
                    pass

        called_urls = [c.args[1] for c in mock_req.call_args_list]
        evil_calls = [u for u in called_urls if "evil.example" in u]
        assert evil_calls == [], f"HTTP GET was made to evil host: {evil_calls}"

    def test_on_domain_absolute_path_accepted(self):
        """An absolute path on the Canvas domain must be accepted and fetched."""
        import criterion_evaluator

        cfg = self._make_cfg()
        good_resp = MagicMock()
        good_resp.status_code = 200
        good_resp.json.return_value = {"id": 42}

        with patch("canvas_api._request_with_retry", return_value=good_resp):
            with patch.object(criterion_evaluator, "_get_config", return_value=cfg):
                result = criterion_evaluator._api_get(
                    f"https://{self.DOMAIN}/api/v1/courses/1/pages/some-page"
                )

        assert result == {"id": 42}


# ── PR1 / E4: audit_image_alt._get (via build_manifest) never follows off-domain next-link

class TestE4AuditImageAltTokenEgress:
    """The _get closure inside build_manifest must route pagination through
    canvas_api.paginated_get; an off-domain next-link returns [] (existing
    error contract) and never issues a second GET to the evil host."""

    DOMAIN = "canvas.asu.edu"

    def _make_config(self):
        return {
            "domain": self.DOMAIN,
            "headers": {"Authorization": "Bearer fake"},
        }

    def _page1_response(self):
        resp = MagicMock()
        resp.status_code = 200
        resp.json.return_value = [{"id": 10, "url": "test-page", "title": "Test"}]
        resp.headers = {
            "Link": '<https://evil.example/api/v1/next>; rel="next"',
            "X-Rate-Limit-Remaining": "100",
        }
        resp.raise_for_status = MagicMock()
        return resp

    def test_off_domain_next_link_returns_empty_list(self):
        """The _get closure must return [] (not raise) on an off-domain next-link,
        preserving the established skip-on-error contract for manifest building."""
        import canvas_api as _capi

        page1 = self._page1_response()

        # We test the _get closure indirectly via paginated_get's behavior:
        # paginated_get raises ValueError on evil next-link; _get catches → returns [].
        with patch("canvas_api._request_with_retry", return_value=page1):
            # Build the closure by importing the module and calling paginated_get
            # with an evil Link header — it must raise ValueError.
            with pytest.raises(ValueError):
                _capi.paginated_get(
                    f"https://{self.DOMAIN}/api/v1/courses/1/pages",
                    {"Authorization": "Bearer fake"},
                    canvas_domain=self.DOMAIN,
                )

    def test_no_second_get_to_evil_host(self):
        """The HTTP layer must never be called with an evil.example URL during
        paginated_get when the first page's next-link is off-domain."""
        page1 = self._page1_response()

        with patch("canvas_api._request_with_retry", return_value=page1) as mock_req:
            try:
                canvas_api.paginated_get(
                    f"https://{self.DOMAIN}/api/v1/courses/1/pages",
                    {"Authorization": "Bearer fake"},
                    canvas_domain=self.DOMAIN,
                )
            except (ValueError, _requests.RequestException):
                pass

        called_urls = [c.args[1] for c in mock_req.call_args_list]
        evil_calls = [u for u in called_urls if "evil.example" in u]
        assert evil_calls == [], f"Bearer token was sent to evil host(s): {evil_calls}"
