"""Fix 2: the unified-review sidepanel reflects the real Canvas module structure.

Staged items are mapped to their real Canvas module (by name + position) instead
of generic "Module N" labels. Mapping rules (first match wins):
  1. meta.target_module_id → that module
  2. meta.kind == "mockup"  → the "Mockups and Suggestions" module
  3. meta.source_canvas_id  → the module containing that content_id
  4. module_number fallback → the module named "Module {N}"
  5. unresolved             → Unmapped bucket

Canvas is NEVER hit in these tests — module lists are injected as fixtures via
the `canvas_modules` param on scan_staged_items / generate_review. Offline
degradation (fetch failure → None) is exercised explicitly.
"""

import os
import pathlib
import sys

import pytest

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

import unified_staging_review as u  # noqa: E402


CANVAS_MODULES = [
    {"id": 10, "name": "Module 1: Foundations", "position": 1, "items": []},
    {
        "id": 11,
        "name": "Module 3: Categorical Predictors",
        "position": 3,
        "items": [
            {"id": 7001, "content_id": 555, "type": "Assignment", "title": "HW"},
            {"id": 7002, "page_url": "m3-lesson", "type": "Page", "title": "Lesson"},
        ],
    },
    {"id": 99, "name": "Mockups and Suggestions", "position": 9, "items": []},
]


# ── map_item_to_canvas_module: rule-by-rule ──────────────────────────────

def test_rule2_mockup_maps_to_mockups_module():
    item = {"meta": {"kind": "mockup"}, "module_number": 999}
    mod = u.map_item_to_canvas_module(item, CANVAS_MODULES)
    assert mod is not None
    assert mod["name"] == "Mockups and Suggestions"


def test_rule1_target_module_id_maps_to_that_module():
    item = {"meta": {"target_module_id": 11}, "module_number": 999}
    mod = u.map_item_to_canvas_module(item, CANVAS_MODULES)
    assert mod is not None
    assert mod["id"] == 11
    assert mod["name"] == "Module 3: Categorical Predictors"


def test_rule3_source_canvas_id_maps_to_containing_module():
    item = {"meta": {"source_canvas_id": 555}, "module_number": 999}
    mod = u.map_item_to_canvas_module(item, CANVAS_MODULES)
    assert mod is not None
    assert mod["id"] == 11


def test_rule4_module_number_fallback_matches_named_module():
    item = {"meta": {}, "module_number": 3}
    mod = u.map_item_to_canvas_module(item, CANVAS_MODULES)
    assert mod is not None
    assert mod["name"] == "Module 3: Categorical Predictors"


def test_rule5_unresolvable_item_returns_none():
    item = {"meta": {}, "module_number": 999}
    assert u.map_item_to_canvas_module(item, CANVAS_MODULES) is None


def test_no_modules_returns_none_offline():
    item = {"meta": {"kind": "mockup"}, "module_number": 3}
    assert u.map_item_to_canvas_module(item, None) is None


def test_rule_precedence_target_module_id_beats_module_number():
    # target_module_id (rule 1) should win over module_number (rule 4).
    item = {"meta": {"target_module_id": 10}, "module_number": 3}
    mod = u.map_item_to_canvas_module(item, CANVAS_MODULES)
    assert mod["id"] == 10


# ── End-to-end: generate_review with injected module list ────────────────

def _stage(tmp_path, course_id, obj_type, obj_id, content, meta=None):
    d = tmp_path / course_id / "staging" / obj_type
    d.mkdir(parents=True, exist_ok=True)
    ext = u.EXTENSIONS[obj_type]
    (d / f"{obj_id}{ext}").write_text(content, encoding="utf-8")
    if meta is not None:
        import json
        (d / f"{obj_id}{ext}.meta.json").write_text(
            json.dumps(meta), encoding="utf-8"
        )


def test_review_uses_real_canvas_module_name(tmp_path, monkeypatch):
    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-real-names"
    _stage(tmp_path, course_id, "pages", "m3-lesson", "<h2>Lesson</h2>")

    html = u.generate_review(course_id, canvas_modules=CANVAS_MODULES)

    assert "Module 3: Categorical Predictors" in html
    # The generic-only label should not be the sidebar header when resolved.
    # (We still allow the substring "Module 3" since the real name contains it.)
    assert "Smoke" not in html  # sanity


def test_review_groups_in_canvas_position_order(tmp_path, monkeypatch):
    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-order"
    # Stage items out of position order; mockup (position 9) and module 1 (position 1).
    _stage(tmp_path, course_id, "pages", "x-mockup-card",
           "<h2>Mockup</h2>", meta={"kind": "mockup"})
    _stage(tmp_path, course_id, "pages", "m1-overview", "<h2>Overview</h2>")

    html = u.generate_review(course_id, canvas_modules=CANVAS_MODULES)

    idx_m1 = html.find("Module 1: Foundations")
    idx_mock = html.find("Mockups and Suggestions")
    assert idx_m1 != -1 and idx_mock != -1
    # Module 1 (position 1) must group before Mockups (position 9).
    assert idx_m1 < idx_mock


def test_review_offline_falls_back_to_generic_module_labels(tmp_path, monkeypatch):
    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-offline"
    _stage(tmp_path, course_id, "pages", "m2-lesson", "<h2>Lesson</h2>")

    # Force the fetcher to report offline (None) → must not raise, must render.
    monkeypatch.setattr(u, "fetch_canvas_modules", lambda *a, **k: None)

    html = u.generate_review(course_id)  # canvas_modules=None → fetch → None

    assert "<!DOCTYPE html" in html
    assert "Module 2" in html  # generic fallback label
    assert "Categorical Predictors" not in html  # no real names without Canvas


def test_fetch_swallows_api_exception_returns_none(tmp_path, monkeypatch):
    # A raising Canvas API call must degrade to None, never propagate.
    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-raises"
    import canvas_api

    def _api_boom(*a, **k):
        raise RuntimeError("API 500")

    monkeypatch.setenv("CANVAS_TOKEN", "x")
    monkeypatch.setattr(
        canvas_api, "get_config",
        lambda course_id=None: {
            "course_id": str(course_id), "domain": "canvas.asu.edu",
            "course_url": "https://canvas.asu.edu/api/v1/courses/" + str(course_id),
            "headers": {},
        },
    )
    monkeypatch.setattr(canvas_api, "get_modules", _api_boom)

    assert u.fetch_canvas_modules(course_id, refresh=True) is None


def test_review_renders_when_fetch_returns_none(tmp_path, monkeypatch):
    # generate_review with a None-returning fetcher must still render fully.
    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-none-fetch"
    _stage(tmp_path, course_id, "pages", "m1-overview", "<h2>Overview</h2>")
    monkeypatch.setattr(u, "fetch_canvas_modules", lambda *a, **k: None)

    html = u.generate_review(course_id)
    assert "<!DOCTYPE html" in html
    assert "Module 1" in html


def test_review_unmapped_bucket_renders_last(tmp_path, monkeypatch):
    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-unmapped"
    _stage(tmp_path, course_id, "pages", "m1-overview", "<h2>Overview</h2>")
    # Unmappable: no m-prefix, no meta → module_number 999.
    _stage(tmp_path, course_id, "pages", "stray-note", "<h2>Stray</h2>")

    html = u.generate_review(course_id, canvas_modules=CANVAS_MODULES)

    idx_m1 = html.find("Module 1: Foundations")
    idx_unmapped = html.find("Unmapped Staged Content")
    assert idx_m1 != -1 and idx_unmapped != -1
    assert idx_m1 < idx_unmapped


def test_review_source_canvas_id_item_groups_under_real_module(tmp_path, monkeypatch):
    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-source"
    # No m-prefix, but meta.source_canvas_id=555 → module 11 (position 3).
    _stage(tmp_path, course_id, "assignments", "hw-alignment",
           "<h2>Homework</h2>", meta={"source_canvas_id": 555, "title": "Homework"})

    html = u.generate_review(course_id, canvas_modules=CANVAS_MODULES)

    assert "Module 3: Categorical Predictors" in html


# ── fetch_canvas_modules: caching + offline ──────────────────────────────

def test_fetch_returns_none_when_no_token(tmp_path, monkeypatch):
    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    monkeypatch.delenv("CANVAS_TOKEN", raising=False)
    assert u.fetch_canvas_modules("c-no-token") is None


def test_fetch_does_not_short_circuit_before_dotenv_import(tmp_path, monkeypatch):
    """Regression: token check must run AFTER `import canvas_api`.

    On a fresh process the token lives in .env, not os.environ —
    canvas_api populates it via load_dotenv() at import time. The old order
    checked os.environ BEFORE importing canvas_api, so it always returned
    None (offline) on a fresh process and degraded mockups to the local
    module_number heuristic.

    To genuinely exercise import ORDER (not just call order), the env var is
    set ONLY as a side effect of the `import canvas_api` statement executing.
    We drop canvas_api from sys.modules and install an import loader whose
    exec_module sets CANVAS_TOKEN (mimicking load_dotenv). With the fix the
    import runs first → token present → fetch proceeds. With the old order
    the token check runs before the import fires the side effect → None.
    """
    import importlib.abc
    import importlib.machinery
    import json as _json
    import types

    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-fresh-process"
    # Fresh process: token is NOT in os.environ yet (only in .env on disk).
    monkeypatch.delenv("CANVAS_TOKEN", raising=False)

    fresh_modules = [
        {"id": 1, "name": "Module 1: Fresh", "position": 1, "items": []},
    ]

    def _get_config(course_id=None):
        return {
            "course_id": str(course_id),
            "domain": "canvas.asu.edu",
            "course_url": "https://canvas.asu.edu/api/v1/courses/" + str(course_id),
            "headers": {},
        }

    class _DotenvLoader(importlib.abc.Loader):
        """Loader that mimics canvas_api's load_dotenv() side effect."""

        def create_module(self, spec):
            return types.ModuleType(spec.name)

        def exec_module(self, module):
            # This fires ONLY when the import statement executes — exactly
            # where load_dotenv() runs in the real canvas_api module.
            os.environ["CANVAS_TOKEN"] = "from-dotenv"
            module.get_config = _get_config
            module.get_modules = lambda *a, **k: fresh_modules

    class _DotenvFinder(importlib.abc.MetaPathFinder):
        def find_spec(self, name, path, target=None):
            if name == "canvas_api":
                return importlib.machinery.ModuleSpec(name, _DotenvLoader())
            return None

    # Drop any already-imported canvas_api so our loader is used.
    monkeypatch.delitem(sys.modules, "canvas_api", raising=False)
    finder = _DotenvFinder()
    sys.meta_path.insert(0, finder)
    try:
        result = u.fetch_canvas_modules(course_id, refresh=True)
    finally:
        sys.meta_path.remove(finder)
        sys.modules.pop("canvas_api", None)
        os.environ.pop("CANVAS_TOKEN", None)

    # The key assertion: fetch proceeded to get_modules instead of
    # short-circuiting to None before the import-driven dotenv load.
    assert result == fresh_modules

    # And the live data was cached.
    written = _json.loads(
        (tmp_path / course_id / ".module_cache.json").read_text(encoding="utf-8")
    )
    assert written["modules"] == fresh_modules


def test_mockup_item_maps_to_mockups_module_over_module_number(
    tmp_path, monkeypatch
):
    """User-visible regression: a staged mockup item with module_number=2 must
    group under the "Mockups and Suggestions" Canvas module (rule 2), NOT
    "Module 2" (rule 4 fallback). This is the behavior the token-order bug
    silently broke — without Canvas modules, every mockup fell back to
    "Module N".
    """
    # Unit level: rule 2 beats rule 4 when both could match.
    item = {"meta": {"kind": "mockup"}, "module_number": 2}
    mod = u.map_item_to_canvas_module(item, CANVAS_MODULES)
    assert mod is not None
    assert mod["name"] == "Mockups and Suggestions"

    # End-to-end: the staged item renders under the Mockups module heading,
    # not a generic "Module 2" heading.
    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-mockup-grouping"
    _stage(
        tmp_path, course_id, "assignments", "m2-hw-mockup",
        "<h2>Homework</h2>",
        meta={"kind": "mockup", "module_number": 2, "title": "M2 HW Mockup"},
    )

    html = u.generate_review(course_id, canvas_modules=CANVAS_MODULES)

    assert "Mockups and Suggestions" in html
    # The generic "Module 2" group header must not appear for this item.
    assert "Module 2" not in html


def test_fetch_serves_fresh_cache_without_calling_canvas(tmp_path, monkeypatch):
    import json
    import datetime as _dt

    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-cache"
    cache_dir = tmp_path / course_id
    cache_dir.mkdir(parents=True)
    cached_modules = [{"id": 1, "name": "Module 1: Cached", "position": 1, "items": []}]
    (cache_dir / ".module_cache.json").write_text(
        json.dumps({
            "fetched_at": _dt.datetime.now().timestamp(),
            "modules": cached_modules,
        }),
        encoding="utf-8",
    )

    # If Canvas were called this would explode — assert it is NOT called.
    import canvas_api

    def _should_not_be_called(*a, **k):
        raise AssertionError("Canvas should not be hit when cache is fresh")

    monkeypatch.setattr(canvas_api, "get_modules", _should_not_be_called)
    monkeypatch.setenv("CANVAS_TOKEN", "x")

    result = u.fetch_canvas_modules(course_id)
    assert result == cached_modules


def test_fetch_ignores_stale_cache_and_writes_fresh(tmp_path, monkeypatch):
    import json

    monkeypatch.setattr(u, "COURSES_DIR", tmp_path)
    course_id = "c-stale"
    cache_dir = tmp_path / course_id
    cache_dir.mkdir(parents=True)
    # fetched_at = epoch 0 → far older than the 1h TTL.
    (cache_dir / ".module_cache.json").write_text(
        json.dumps({"fetched_at": 0, "modules": [{"id": 9, "name": "old"}]}),
        encoding="utf-8",
    )

    fresh = [{"id": 1, "name": "Module 1: Fresh", "position": 1, "items": []}]
    import canvas_api
    monkeypatch.setattr(canvas_api, "get_modules", lambda *a, **k: fresh)
    monkeypatch.setattr(
        canvas_api, "get_config",
        lambda course_id=None: {
            "course_id": str(course_id), "domain": "canvas.asu.edu",
            "course_url": "https://canvas.asu.edu/api/v1/courses/" + str(course_id),
            "headers": {},
        },
    )
    monkeypatch.setenv("CANVAS_TOKEN", "x")

    result = u.fetch_canvas_modules(course_id)
    assert result == fresh

    # Cache file rewritten with the fresh modules.
    written = json.loads((cache_dir / ".module_cache.json").read_text(encoding="utf-8"))
    assert written["modules"] == fresh
    assert written["fetched_at"] > 0
