"""Input-validation hardening tests (issue #215).

Covers two defensive fixes to untrusted/malformed input handling:

1. canvas_api.paginated_get — Link-header ``rel="next"`` parsing used
   ``part.split("<")[1]``, which raises IndexError on a malformed part that
   contains ``rel="next"`` but no ``<...>`` wrapper. It also had no cap on
   the number of pages fetched, so a self-referencing (or otherwise
   runaway) ``next`` link would loop forever. Fixed to parse defensively
   with a regex (skipping unmatched parts) and to stop after
   ``MAX_PAGINATION_PAGES``.

2. course_content_cache.dump_pages — used the raw Canvas page ``url`` slug
   as a write-path component with no sanitization, unlike its sibling
   dump_files (which runs names through _safe_filename). Fixed to sanitize
   the slug before building html_path/txt_path so a slug containing path
   separators (e.g. "../") cannot write outside the pages cache directory.

All Canvas API responses are mocked; no network calls are made.
"""

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

import pytest

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

import canvas_api
import course_content_cache as ccc


DOMAIN = "canvas.asu.edu"
HEADERS = {"Authorization": "Bearer fake"}


def _mock_page_response(json_data, link_header=""):
    """Build a minimal mock Canvas paginated-GET response."""
    resp = MagicMock()
    resp.status_code = 200
    resp.json.return_value = json_data
    resp.headers = {"Link": link_header} if link_header else {"Link": ""}
    resp.raise_for_status = MagicMock()
    return resp


# ---------------------------------------------------------------------------
# 1a. paginated_get must not raise on a malformed Link header part
# ---------------------------------------------------------------------------

class TestPaginatedGetMalformedLinkHeader:
    """A Link header part containing rel="next" but no <url> wrapper must be
    skipped defensively instead of raising IndexError."""

    def test_malformed_next_part_without_angle_brackets_does_not_raise(self):
        # Realistic corruption: a well-formed rel="prev" part, followed by a
        # rel="next" part that lost its "<...>" URL wrapper.
        page1 = _mock_page_response(
            [{"id": 1}],
            link_header=(
                f'<https://{DOMAIN}/api/v1/courses/1/pages?page=1>; rel="prev", '
                'rel="next"; foo=bar'
            ),
        )

        with patch("canvas_api._request_with_retry", return_value=page1) as mock_req:
            # Must return normally — no IndexError.
            result = canvas_api.paginated_get(
                f"https://{DOMAIN}/api/v1/courses/1/pages",
                HEADERS,
                canvas_domain=DOMAIN,
            )

        assert result == [{"id": 1}]
        # The malformed "next" part must not be treated as a valid next
        # link — only the first page is fetched.
        assert mock_req.call_count == 1

    def test_well_formed_next_link_still_paginates(self):
        """Happy-path regression guard: a normal two-page Link header must
        still advance to page 2 exactly as before the hardening fix."""
        page1 = _mock_page_response(
            [{"id": 1}],
            link_header=f'<https://{DOMAIN}/api/v1/courses/1/pages?page=2>; rel="next"',
        )
        page2 = _mock_page_response([{"id": 2}], link_header="")

        with patch("canvas_api._request_with_retry", side_effect=[page1, page2]) as mock_req:
            result = canvas_api.paginated_get(
                f"https://{DOMAIN}/api/v1/courses/1/pages",
                HEADERS,
                canvas_domain=DOMAIN,
            )

        assert result == [{"id": 1}, {"id": 2}]
        assert mock_req.call_count == 2

    def test_off_domain_next_link_still_raises(self):
        """Happy-path regression guard: token-validation on next-links
        (_validate_canvas_response_url) must remain intact."""
        page1 = _mock_page_response(
            [{"id": 1}],
            link_header='<https://evil.example/api/v1/next>; rel="next"',
        )

        with patch("canvas_api._request_with_retry", return_value=page1):
            with pytest.raises(ValueError):
                canvas_api.paginated_get(
                    f"https://{DOMAIN}/api/v1/courses/1/pages",
                    HEADERS,
                    canvas_domain=DOMAIN,
                )


# ---------------------------------------------------------------------------
# 1b. paginated_get must not loop forever on a self-referencing next link
# ---------------------------------------------------------------------------

class TestPaginatedGetPageCap:
    """A next link that always points back at the same URL must not hang
    paginated_get forever — it must terminate via the page cap."""

    def test_self_referencing_next_link_terminates_via_cap(self):
        url = f"https://{DOMAIN}/api/v1/courses/1/pages?page=1"
        # Every response reports itself as the next page — a pathological
        # loop if nothing bounds the number of iterations.
        page = _mock_page_response([{"id": 1}], link_header=f'<{url}>; rel="next"')

        with patch("canvas_api._request_with_retry", return_value=page) as mock_req:
            result = canvas_api.paginated_get(url, HEADERS, canvas_domain=DOMAIN)

        # Must terminate (test reaching this line at all is the main
        # assertion) and must do so exactly at the documented cap.
        assert mock_req.call_count == canvas_api.MAX_PAGINATION_PAGES
        assert len(result) == canvas_api.MAX_PAGINATION_PAGES


# ---------------------------------------------------------------------------
# 2. dump_pages must sanitize the page slug before using it as a path
# ---------------------------------------------------------------------------

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


def _mock_plugin_root(tmp_path, monkeypatch):
    """Redirect canvas_api.get_plugin_root to return tmp_path."""
    monkeypatch.setattr(canvas_api, "get_plugin_root", lambda: str(tmp_path))


class TestDumpPagesSlugSanitization:
    def test_traversal_slug_confined_to_pages_dir(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        # A single "../" hop lands in content_cache/ (which already exists,
        # since it's the parent of pages/) — this is a deterministic way to
        # prove containment without depending on deeper paths existing.
        pages_index = [{
            "url": "../escaped-page",
            "updated_at": "2025-01-01T00:00:00Z",
            "title": "Escaped",
            "body": "<p>should stay contained</p>",
        }]

        with patch("canvas_api.get_all_pages", return_value=pages_index):
            count = ccc.dump_pages(config)

        assert count == 1

        content_cache_dir = tmp_path / "courses" / "99001" / "content_cache"
        pages_dir = content_cache_dir / "pages"

        # The raw slug must never have escaped into the parent content_cache
        # directory.
        assert not (content_cache_dir / "escaped-page.html").exists()
        assert not (content_cache_dir / "escaped-page.txt").exists()

        # A sanitized file must exist inside pages_dir instead.
        html_files = list(pages_dir.glob("*.html"))
        txt_files = list(pages_dir.glob("*.txt"))
        assert len(html_files) == 1, f"expected 1 html file inside pages_dir, found {html_files}"
        assert len(txt_files) == 1, f"expected 1 txt file inside pages_dir, found {txt_files}"
        assert html_files[0].read_text() == "<p>should stay contained</p>"

    def test_normal_slug_unaffected(self, tmp_path, monkeypatch):
        """Happy-path regression guard: an ordinary Canvas slug (no path
        separators) must produce byte-identical filenames as before."""
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        pages_index = [{
            "url": "module-1-overview",
            "updated_at": "2025-01-01T00:00:00Z",
            "title": "M1 Overview",
            "body": "<h1>Overview</h1>",
        }]

        with patch("canvas_api.get_all_pages", return_value=pages_index):
            count = ccc.dump_pages(config)

        assert count == 1
        pages_dir = tmp_path / "courses" / "99001" / "content_cache" / "pages"
        assert (pages_dir / "module-1-overview.html").exists()
        assert (pages_dir / "module-1-overview.txt").exists()
