"""Characterization tests for scripts/course_content_cache.py.

Locks the CURRENT behavior of:
- _strip_html: converts HTML to plain text
- _safe_filename: converts names to safe filesystem names
- _cache_dir: returns and creates the content_cache directory
- _load_metadata / _save_metadata: round-trip metadata.json
- dump_pages: fetches pages via canvas_api, writes .html + .txt, updates metadata
- get_status: returns cache metadata counts

All canvas_api calls are mocked. All filesystem I/O uses tmp_path via
monkeypatching canvas_api.get_plugin_root.
"""

import json
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch, call

import pytest

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

import course_content_cache as ccc


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

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))
    # Also patch through the module-local reference
    import canvas_api
    monkeypatch.setattr(canvas_api, "get_plugin_root", lambda: str(tmp_path))


def _cache_root(tmp_path, course_id="99001"):
    return tmp_path / "courses" / str(course_id) / "content_cache"


# ---------------------------------------------------------------------------
# _strip_html
# ---------------------------------------------------------------------------

class TestStripHtml:
    def test_removes_tags(self):
        result = ccc._strip_html("<h1>Hello</h1>")
        assert "<h1>" not in result
        assert "Hello" in result

    def test_removes_script_tags_and_content(self):
        result = ccc._strip_html("<p>Before</p><script>alert('xss')</script><p>After</p>")
        assert "alert" not in result
        assert "Before" in result
        assert "After" in result

    def test_removes_style_tags_and_content(self):
        result = ccc._strip_html("<style>body { color: red; }</style><p>Text</p>")
        assert "color" not in result
        assert "Text" in result

    def test_decodes_nbsp(self):
        result = ccc._strip_html("hello&nbsp;world")
        assert "\xa0" in result or " " in result
        assert "hello" in result
        assert "world" in result

    def test_decodes_amp(self):
        result = ccc._strip_html("A &amp; B")
        assert "&" in result

    def test_decodes_lt_gt(self):
        result = ccc._strip_html("&lt;tag&gt;")
        assert "<" in result
        assert ">" in result

    def test_collapses_whitespace(self):
        result = ccc._strip_html("<p>  lots   of  space  </p>")
        # Should not have multiple consecutive spaces
        assert "  " not in result

    def test_empty_string_returns_empty(self):
        assert ccc._strip_html("") == ""

    def test_none_returns_empty(self):
        assert ccc._strip_html(None) == ""

    def test_plain_text_unchanged_structure(self):
        result = ccc._strip_html("just text")
        assert "just text" in result


# ---------------------------------------------------------------------------
# _safe_filename
# ---------------------------------------------------------------------------

class TestSafeFilename:
    def test_alphanumeric_passes_through(self):
        assert ccc._safe_filename("hello") == "hello"

    def test_spaces_replaced_with_underscore(self):
        result = ccc._safe_filename("my file name")
        assert " " not in result
        assert "_" in result

    def test_special_chars_replaced(self):
        result = ccc._safe_filename("file/with:special?chars")
        assert "/" not in result
        assert "?" not in result

    def test_hyphen_kept(self):
        result = ccc._safe_filename("my-file")
        assert "-" in result

    def test_dot_kept(self):
        result = ccc._safe_filename("file.txt")
        assert "." in result


# ---------------------------------------------------------------------------
# _cache_dir
# ---------------------------------------------------------------------------

class TestCacheDir:
    def test_creates_directory(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        path = ccc._cache_dir("99001")
        assert os.path.isdir(path)

    def test_returns_correct_path(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        path = ccc._cache_dir("99001")
        expected = os.path.join(str(tmp_path), "courses", "99001", "content_cache")
        assert path == expected

    def test_subdir_created(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        path = ccc._cache_dir("99001", "pages")
        assert os.path.isdir(path)
        assert path.endswith("pages")


# ---------------------------------------------------------------------------
# _load_metadata / _save_metadata round-trip
# ---------------------------------------------------------------------------

class TestMetadataRoundTrip:
    def test_load_returns_stub_when_missing(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        meta = ccc._load_metadata("99001")
        assert meta["course_id"] == "99001"
        assert meta["last_dump"] is None
        assert meta["pages"] == {}
        assert meta["files"] == {}

    def test_save_and_load_round_trip(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        meta = {
            "course_id": "99001",
            "last_dump": "2025-01-01T00:00:00+00:00",
            "pages": {"slug-1": {"title": "Page One", "size": 100}},
            "files": {},
        }
        ccc._save_metadata("99001", meta)
        loaded = ccc._load_metadata("99001")
        assert loaded["last_dump"] == "2025-01-01T00:00:00+00:00"
        assert "slug-1" in loaded["pages"]
        assert loaded["pages"]["slug-1"]["title"] == "Page One"

    def test_metadata_file_is_valid_json(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        meta = {"course_id": "99001", "last_dump": None, "pages": {}, "files": {}}
        ccc._save_metadata("99001", meta)
        meta_path = ccc._metadata_path("99001")
        with open(meta_path) as f:
            data = json.load(f)
        assert data["course_id"] == "99001"


# ---------------------------------------------------------------------------
# dump_pages
# ---------------------------------------------------------------------------

class TestDumpPages:
    def _page_info(self, slug, updated="2025-01-01T00:00:00Z"):
        return {"url": slug, "updated_at": updated, "title": slug.replace("-", " ").title()}

    def _full_page(self, slug, body="<p>Hello World</p>", title=None):
        return {"url": slug, "body": body, "title": title or slug}

    def test_creates_html_file_per_page(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        pages_index = [self._page_info("module-1-overview")]
        full_page = self._full_page("module-1-overview", "<h1>Overview</h1>", "M1 Overview")

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

        html_path = _cache_root(tmp_path) / "pages" / "module-1-overview.html"
        assert html_path.exists()
        assert count == 1

    def test_creates_txt_file_per_page(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        # body is now returned in the index result (include[]=body)
        pages_index = [{**self._page_info("module-1-overview"), **self._full_page("module-1-overview", "<h1>Overview</h1>", "M1 Overview")}]

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

        txt_path = _cache_root(tmp_path) / "pages" / "module-1-overview.txt"
        assert txt_path.exists()
        content = txt_path.read_text()
        assert "M1 Overview" in content

    def test_txt_contains_stripped_html(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        # body is now returned in the index result (include[]=body)
        pages_index = [{**self._page_info("test-page"), **self._full_page("test-page", "<script>bad()</script><p>Clean text</p>", "Test")}]

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

        txt_path = _cache_root(tmp_path) / "pages" / "test-page.txt"
        content = txt_path.read_text()
        assert "bad()" not in content
        assert "Clean text" in content

    def test_updates_metadata_pages_dict(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        pages_index = [self._page_info("my-page")]
        full_page = self._full_page("my-page", "<p>body</p>", "My Page")

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

        meta = ccc._load_metadata("99001")
        assert "my-page" in meta["pages"]
        entry = meta["pages"]["my-page"]
        assert entry["title"] == "My Page"
        assert "cached_at" in entry
        assert "size" in entry

    def test_updates_last_dump_timestamp(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        with patch("canvas_api.get_all_pages", return_value=[]):
            ccc.dump_pages(config)

        meta = ccc._load_metadata("99001")
        assert meta["last_dump"] is not None

    def test_returns_count_of_pages_cached(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        pages_index = [self._page_info("p1"), self._page_info("p2"), self._page_info("p3")]

        def fake_get_page(cfg, slug):
            return self._full_page(slug, f"<p>Body for {slug}</p>", slug)

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

        assert count == 3

    def test_skips_page_when_slug_missing(self, tmp_path, monkeypatch):
        """A page dict with no 'url' key in the index result should be skipped."""
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        # Page with missing url key — now that we read body from index, a missing
        # url means we can't write the file and should skip gracefully
        pages_index = [{"title": "No URL Page", "body": "<p>content</p>", "updated_at": "2026-01-01"}]

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

        assert count == 0

    def test_since_filter_skips_older_pages(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        # Updated before "since" — should be skipped
        pages_index = [self._page_info("old-page", "2024-01-01T00:00:00Z")]

        with patch("canvas_api.get_all_pages", return_value=pages_index):
            with patch("canvas_api.get_page") as mock_get:
                count = ccc.dump_pages(config, since="2025-01-01T00:00:00Z")

        assert count == 0
        # get_page should NOT have been called for the skipped page
        mock_get.assert_not_called()

    def test_since_filter_includes_newer_pages(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        # Updated AFTER "since" — should be included
        pages_index = [self._page_info("new-page", "2026-01-01T00:00:00Z")]
        full_page = self._full_page("new-page", "<p>New</p>", "New Page")

        with patch("canvas_api.get_all_pages", return_value=pages_index):
            with patch("canvas_api.get_page", return_value=full_page):
                count = ccc.dump_pages(config, since="2025-01-01T00:00:00Z")

        assert count == 1

    def test_empty_page_list_returns_zero(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

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

        assert count == 0

    def test_html_body_written_verbatim(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        config = _make_config("99001")

        body = "<h2>Section Title</h2><p>Some <strong>bold</strong> text.</p>"
        # body is now returned in the index result (include[]=body)
        pages_index = [{**self._page_info("exact-page"), **self._full_page("exact-page", body, "Exact")}]

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

        html_path = _cache_root(tmp_path) / "pages" / "exact-page.html"
        assert html_path.read_text() == body


# ---------------------------------------------------------------------------
# get_status
# ---------------------------------------------------------------------------

class TestGetStatus:
    def test_status_with_empty_cache(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        status = ccc.get_status("99001")
        assert status["course_id"] == "99001"
        assert status["page_count"] == 0
        assert status["file_count"] == 0
        assert status["last_dump"] is None

    def test_status_reflects_cached_pages(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        meta = {
            "course_id": "99001",
            "last_dump": "2025-06-01T00:00:00+00:00",
            "pages": {
                "slug-a": {"title": "A", "cached_at": "...", "size": 100},
                "slug-b": {"title": "B", "cached_at": "...", "size": 200},
            },
            "files": {
                "file1.txt": {"original_name": "file1.txt", "cached_at": "...", "size": 50},
            },
        }
        ccc._save_metadata("99001", meta)

        status = ccc.get_status("99001")
        assert status["page_count"] == 2
        assert status["file_count"] == 1
        assert status["last_dump"] == "2025-06-01T00:00:00+00:00"

    def test_status_cache_dir_field_present(self, tmp_path, monkeypatch):
        _mock_plugin_root(tmp_path, monkeypatch)
        status = ccc.get_status("99001")
        assert "cache_dir" in status
        assert "99001" in status["cache_dir"]
