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

Locks the CURRENT behavior of:
- get_env: reads CANVAS_TOKEN / CANVAS_DOMAIN / CANVAS_COURSE_ID from env
- _fetch_all_courses: routes through canvas_api.paginated_get (no direct HTTP)
- sync_registry: fetches courses, merges with existing registry, writes registry.json
- migrate: reads CANVAS_COURSE_ID, builds registry, moves course-config.json, strips env var
- validate_env: checks required env vars, reports registry / config status

All HTTP is mocked via canvas_api.paginated_get / requests.get.
All filesystem I/O is scoped to tmp_path — no writes to the real PLUGIN_ROOT.
"""

import importlib
import json
import os
import sys
import time
import types
import unittest
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 setup_env


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

def _make_course(id_, name="Test Course", code="TEST 101", term="Fall 2025",
                 role="TeacherEnrollment"):
    return {
        "id": id_,
        "name": name,
        "course_code": code,
        "term": {"name": term},
        "enrollments": [{"type": role}],
    }


def _patch_env(monkeypatch, token="tok-test", domain="canvas.asu.edu", course_id=None):
    monkeypatch.setenv("CANVAS_TOKEN", token)
    monkeypatch.setenv("CANVAS_DOMAIN", domain)
    if course_id:
        monkeypatch.setenv("CANVAS_COURSE_ID", str(course_id))
    else:
        monkeypatch.delenv("CANVAS_COURSE_ID", raising=False)


# ---------------------------------------------------------------------------
# get_env
# ---------------------------------------------------------------------------

class TestGetEnv:
    def test_reads_token_from_env(self, monkeypatch):
        monkeypatch.setenv("CANVAS_TOKEN", "my-secret-token")
        monkeypatch.setenv("CANVAS_DOMAIN", "canvas.example.edu")
        monkeypatch.delenv("CANVAS_COURSE_ID", raising=False)
        env = setup_env.get_env()
        assert env["token"] == "my-secret-token"

    def test_reads_domain_from_env(self, monkeypatch):
        monkeypatch.setenv("CANVAS_TOKEN", "tok")
        monkeypatch.setenv("CANVAS_DOMAIN", "canvas.example.edu")
        monkeypatch.delenv("CANVAS_COURSE_ID", raising=False)
        env = setup_env.get_env()
        assert env["domain"] == "canvas.example.edu"

    def test_default_domain_when_unset(self, monkeypatch):
        monkeypatch.setenv("CANVAS_TOKEN", "tok")
        monkeypatch.delenv("CANVAS_DOMAIN", raising=False)
        monkeypatch.delenv("CANVAS_COURSE_ID", raising=False)
        env = setup_env.get_env()
        # Current behavior: defaults to canvas.asu.edu
        assert env["domain"] == "canvas.asu.edu"

    def test_reads_course_id_from_env(self, monkeypatch):
        monkeypatch.setenv("CANVAS_TOKEN", "tok")
        monkeypatch.setenv("CANVAS_DOMAIN", "canvas.test.edu")
        monkeypatch.setenv("CANVAS_COURSE_ID", "99999")
        env = setup_env.get_env()
        assert env["course_id"] == "99999"

    def test_course_id_none_when_unset(self, monkeypatch):
        monkeypatch.setenv("CANVAS_TOKEN", "tok")
        monkeypatch.setenv("CANVAS_DOMAIN", "canvas.test.edu")
        monkeypatch.delenv("CANVAS_COURSE_ID", raising=False)
        env = setup_env.get_env()
        assert env["course_id"] is None

    def test_token_none_when_unset(self, monkeypatch):
        monkeypatch.delenv("CANVAS_TOKEN", raising=False)
        monkeypatch.setenv("CANVAS_DOMAIN", "canvas.test.edu")
        monkeypatch.delenv("CANVAS_COURSE_ID", raising=False)
        env = setup_env.get_env()
        assert env["token"] is None


# ---------------------------------------------------------------------------
# _fetch_all_courses — routes through canvas_api.paginated_get
# ---------------------------------------------------------------------------

class TestFetchAllCourses:
    def test_calls_paginated_get_not_raw_requests(self, monkeypatch):
        _patch_env(monkeypatch)
        courses = [_make_course(1, "BIO 101")]
        with patch("setup_env.canvas_api.paginated_get", return_value=courses) as mock_pg:
            result, err = setup_env._fetch_all_courses(setup_env.get_env())
        assert err is None
        assert result == courses
        mock_pg.assert_called_once()

    def test_passes_canvas_domain_for_link_header_validation(self, monkeypatch):
        # Uses a non-default *allowlisted* domain to prove the configured domain
        # (normalized) is what's passed through, not a hardcoded default.
        _patch_env(monkeypatch, domain="asu-dev.instructure.com")
        env = setup_env.get_env()
        with patch("setup_env.canvas_api.paginated_get", return_value=[]) as mock_pg:
            setup_env._fetch_all_courses(env)
        _, kwargs = mock_pg.call_args
        assert kwargs.get("canvas_domain") == "asu-dev.instructure.com"

    def test_returns_error_string_on_value_error(self, monkeypatch):
        _patch_env(monkeypatch)
        with patch("setup_env.canvas_api.paginated_get",
                   side_effect=ValueError("off-domain redirect")):
            result, err = setup_env._fetch_all_courses(setup_env.get_env())
        assert result is None
        assert "off-domain redirect" in err

    def test_returns_error_string_on_request_exception(self, monkeypatch):
        import requests
        _patch_env(monkeypatch)
        with patch("setup_env.canvas_api.paginated_get",
                   side_effect=requests.RequestException("timeout")):
            result, err = setup_env._fetch_all_courses(setup_env.get_env())
        assert result is None
        assert err is not None

    def test_passes_enrollment_state_active_param(self, monkeypatch):
        _patch_env(monkeypatch)
        with patch("setup_env.canvas_api.paginated_get", return_value=[]) as mock_pg:
            setup_env._fetch_all_courses(setup_env.get_env())
        _, kwargs = mock_pg.call_args
        params = kwargs.get("params", {})
        assert params.get("enrollment_state") == "active"


# ---------------------------------------------------------------------------
# sync_registry
# ---------------------------------------------------------------------------

class TestSyncRegistry:
    def test_no_token_returns_false(self, monkeypatch, tmp_path, capsys):
        monkeypatch.delenv("CANVAS_TOKEN", raising=False)
        monkeypatch.setenv("CANVAS_DOMAIN", "canvas.test.edu")
        monkeypatch.delenv("CANVAS_COURSE_ID", raising=False)
        # Redirect REGISTRY_PATH so we don't touch the real one
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        result = setup_env.sync_registry()
        assert result is False
        out = json.loads(capsys.readouterr().out)
        assert out["ok"] is False

    def test_writes_registry_json(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch)
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))

        courses = [_make_course(101, "BIO 101"), _make_course(202, "ENG 200")]
        with patch("setup_env.canvas_api.paginated_get", return_value=courses):
            result = setup_env.sync_registry()

        assert result is True
        assert registry_path.exists()
        data = json.loads(registry_path.read_text())
        assert "courses" in data
        assert "lastSync" in data
        assert len(data["courses"]) == 2

    def test_registry_courses_sorted_by_name(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch)
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))

        courses = [_make_course(202, "Zebra Studies"), _make_course(101, "Alpha Course")]
        with patch("setup_env.canvas_api.paginated_get", return_value=courses):
            setup_env.sync_registry()

        data = json.loads(registry_path.read_text())
        names = [c["name"] for c in data["courses"]]
        assert names == sorted(names)

    def test_existing_course_last_accessed_preserved(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch)
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))

        # Pre-populate registry with a course that has lastAccessed set
        existing = {
            "lastSync": "2025-01-01T00:00:00+00:00",
            "courses": [{"id": 101, "name": "Old Name", "code": "OLD", "term": "",
                         "enrollmentRole": "TeacherEnrollment",
                         "lastAccessed": "2025-06-01T00:00:00+00:00", "hasLocalData": True}],
            "archived": [],
        }
        registry_path.write_text(json.dumps(existing))

        updated_courses = [_make_course(101, "New Name")]
        with patch("setup_env.canvas_api.paginated_get", return_value=updated_courses):
            setup_env.sync_registry()

        data = json.loads(registry_path.read_text())
        course = data["courses"][0]
        # Name updated
        assert course["name"] == "New Name"
        # lastAccessed preserved from existing
        assert course["lastAccessed"] == "2025-06-01T00:00:00+00:00"

    def test_courses_removed_from_canvas_move_to_archived(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch)
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))

        existing = {
            "lastSync": "2025-01-01T00:00:00+00:00",
            "courses": [
                {"id": 101, "name": "Still Active", "code": "A", "term": "",
                 "enrollmentRole": "TeacherEnrollment", "lastAccessed": None, "hasLocalData": False},
                {"id": 999, "name": "Gone Course", "code": "G", "term": "",
                 "enrollmentRole": "TeacherEnrollment", "lastAccessed": None, "hasLocalData": False},
            ],
            "archived": [],
        }
        registry_path.write_text(json.dumps(existing))

        # Canvas now only returns course 101
        with patch("setup_env.canvas_api.paginated_get",
                   return_value=[_make_course(101, "Still Active")]):
            setup_env.sync_registry()

        data = json.loads(registry_path.read_text())
        active_ids = {c["id"] for c in data["courses"]}
        archived_ids = {c["id"] for c in data["archived"]}
        assert 101 in active_ids
        assert 999 not in active_ids
        assert 999 in archived_ids

    def test_archived_course_reinstated_when_back_in_canvas(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch)
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))

        existing = {
            "lastSync": "2025-01-01T00:00:00+00:00",
            "courses": [],
            "archived": [
                {"id": 555, "name": "Archived Course", "code": "ARC", "term": "",
                 "enrollmentRole": "TeacherEnrollment", "lastAccessed": None, "hasLocalData": False},
            ],
        }
        registry_path.write_text(json.dumps(existing))

        with patch("setup_env.canvas_api.paginated_get",
                   return_value=[_make_course(555, "Restored Course")]):
            setup_env.sync_registry()

        data = json.loads(registry_path.read_text())
        active_ids = {c["id"] for c in data["courses"]}
        assert 555 in active_ids
        # Should not appear in archived after reinstatement
        archived_ids = {c["id"] for c in data["archived"]}
        assert 555 not in archived_ids

    def test_has_local_data_reflects_courses_directory(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch)
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))

        # Create a directory for course 101 under tmp COURSES_DIR
        (tmp_path / "101").mkdir()

        courses = [_make_course(101, "Has Dir"), _make_course(202, "No Dir")]
        with patch("setup_env.canvas_api.paginated_get", return_value=courses):
            setup_env.sync_registry()

        data = json.loads(registry_path.read_text())
        by_id = {c["id"]: c for c in data["courses"]}
        assert by_id[101]["hasLocalData"] is True
        assert by_id[202]["hasLocalData"] is False

    def test_courses_with_no_name_are_skipped(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch)
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))

        courses = [{"id": 1}, _make_course(2, "Real Course")]  # id=1 has no name
        with patch("setup_env.canvas_api.paginated_get", return_value=courses):
            setup_env.sync_registry()

        data = json.loads(registry_path.read_text())
        ids = [c["id"] for c in data["courses"]]
        assert 1 not in ids
        assert 2 in ids

    def test_enrollment_role_extracted_from_first_enrollment(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch)
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))

        course = _make_course(1, "Test", role="StudentEnrollment")
        with patch("setup_env.canvas_api.paginated_get", return_value=[course]):
            setup_env.sync_registry()

        data = json.loads(registry_path.read_text())
        assert data["courses"][0]["enrollmentRole"] == "StudentEnrollment"


# ---------------------------------------------------------------------------
# migrate — CANVAS_COURSE_ID path
# ---------------------------------------------------------------------------

class TestMigrate:
    def test_no_course_id_reports_already_migrated(self, monkeypatch, tmp_path, capsys):
        _patch_env(monkeypatch)  # no CANVAS_COURSE_ID
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))
        result = setup_env.migrate()
        assert result is True
        out = json.loads(capsys.readouterr().out)
        assert out["already_migrated"] is True

    def test_creates_course_directory(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch, course_id="12345")
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))

        with patch("setup_env.canvas_api.paginated_get",
                   return_value=[_make_course(12345, "My Course")]):
            result = setup_env.migrate()

        assert result is True
        assert (tmp_path / "12345").is_dir()
        assert (tmp_path / "12345" / "reports").is_dir()

    def test_writes_registry_json(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch, course_id="12345")
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))

        with patch("setup_env.canvas_api.paginated_get",
                   return_value=[_make_course(12345, "My Course")]):
            setup_env.migrate()

        assert registry_path.exists()
        data = json.loads(registry_path.read_text())
        assert any(c["id"] == 12345 for c in data["courses"])

    def test_marks_current_course_has_local_data(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch, course_id="12345")
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))

        with patch("setup_env.canvas_api.paginated_get",
                   return_value=[_make_course(12345, "My Course")]):
            setup_env.migrate()

        data = json.loads(registry_path.read_text())
        course = next(c for c in data["courses"] if c["id"] == 12345)
        assert course["hasLocalData"] is True
        assert course["lastAccessed"] is not None

    def test_copies_existing_course_config(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch, course_id="12345")
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))

        # Put a course-config.json in cwd (mocked via os.getcwd)
        cwd = tmp_path / "cwd"
        cwd.mkdir()
        config_src = cwd / "course-config.json"
        config_src.write_text('{"course_id": "12345"}')

        with patch("setup_env.canvas_api.paginated_get",
                   return_value=[_make_course(12345, "My Course")]):
            with patch("os.getcwd", return_value=str(cwd)):
                setup_env.migrate()

        dest = tmp_path / "12345" / "course-config.json"
        assert dest.exists()

    def test_removes_canvas_course_id_from_env_file(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch, course_id="12345")
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))

        # Create a .env file in tmp_path (PLUGIN_ROOT)
        env_file = tmp_path / ".env"
        env_file.write_text(
            "CANVAS_TOKEN=tok\n"
            "CANVAS_DOMAIN=canvas.test.edu\n"
            "CANVAS_COURSE_ID=12345\n"
        )

        with patch("setup_env.canvas_api.paginated_get",
                   return_value=[_make_course(12345, "My Course")]):
            setup_env.migrate()

        content = env_file.read_text()
        assert "CANVAS_COURSE_ID" not in content
        assert "CANVAS_TOKEN" in content

    def test_fetch_error_returns_false(self, monkeypatch, tmp_path, capsys):
        _patch_env(monkeypatch, course_id="12345")
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))

        with patch("setup_env.canvas_api.paginated_get",
                   side_effect=ValueError("off-domain")):
            result = setup_env.migrate()

        assert result is False

    def test_no_token_returns_false(self, monkeypatch, tmp_path, capsys):
        monkeypatch.delenv("CANVAS_TOKEN", raising=False)
        monkeypatch.setenv("CANVAS_DOMAIN", "canvas.test.edu")
        monkeypatch.setenv("CANVAS_COURSE_ID", "12345")
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))
        result = setup_env.migrate()
        assert result is False

    # ------------------------------------------------------------------
    # Non-active course (concluded/archived/unenrolled) — orphan-dir fix
    # ------------------------------------------------------------------

    def test_migrate_non_active_course_single_get_succeeds(self, monkeypatch, tmp_path, capsys):
        """When CANVAS_COURSE_ID is absent from the active-course list but the
        individual GET succeeds, migrate() must add a registry entry with the
        real metadata and hasLocalData=True, and the courses/{id}/ dir must exist.
        """
        _patch_env(monkeypatch, course_id="99999")
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))

        # Active-course list does NOT contain 99999
        active_courses = [_make_course(11111, "Active Course")]

        # Single-course GET returns concluded course metadata
        single_resp = MagicMock()
        single_resp.status_code = 200
        single_resp.json.return_value = {
            "id": 99999,
            "name": "Concluded Law Course",
            "course_code": "LAW 524",
            "term": {"name": "Spring 2024"},
            "enrollments": [{"type": "TeacherEnrollment"}],
        }
        single_resp.headers = {}

        def _fake_request_with_retry(method, url, headers, **kwargs):
            # Only intercept the single-course URL; let any other call through
            if "courses/99999" in url:
                return single_resp
            raise AssertionError(f"Unexpected URL in test: {url}")

        with patch("setup_env.canvas_api.paginated_get", return_value=active_courses):
            with patch("setup_env.canvas_api._request_with_retry",
                       side_effect=_fake_request_with_retry):
                result = setup_env.migrate()

        assert result is True

        # The course directory must have been created
        assert (tmp_path / "99999").is_dir()

        # The registry must contain an entry for 99999
        data = json.loads(registry_path.read_text())
        ids = [c["id"] for c in data["courses"]]
        assert 99999 in ids

        entry = next(c for c in data["courses"] if c["id"] == 99999)
        assert entry["hasLocalData"] is True
        assert entry["lastAccessed"] is not None
        assert entry["name"] == "Concluded Law Course"
        assert entry["code"] == "LAW 524"

    def test_migrate_non_active_course_single_get_fails_uses_stub(
        self, monkeypatch, tmp_path, capsys
    ):
        """When CANVAS_COURSE_ID is absent from the active-course list AND the
        individual GET fails (non-200 or exception), migrate() must still add a
        stub registry entry with hasLocalData=True, and must not crash.
        """
        _patch_env(monkeypatch, course_id="88888")
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))

        # Active-course list does NOT contain 88888
        active_courses = [_make_course(11111, "Active Course")]

        # Single-course GET returns a non-200 (course truly gone / forbidden)
        fail_resp = MagicMock()
        fail_resp.status_code = 404
        fail_resp.headers = {}

        def _fake_request_with_retry(method, url, headers, **kwargs):
            if "courses/88888" in url:
                return fail_resp
            raise AssertionError(f"Unexpected URL in test: {url}")

        with patch("setup_env.canvas_api.paginated_get", return_value=active_courses):
            with patch("setup_env.canvas_api._request_with_retry",
                       side_effect=_fake_request_with_retry):
                result = setup_env.migrate()

        assert result is True

        # The course directory must have been created
        assert (tmp_path / "88888").is_dir()

        # The registry must contain a stub entry for 88888
        data = json.loads(registry_path.read_text())
        ids = [c["id"] for c in data["courses"]]
        assert 88888 in ids

        entry = next(c for c in data["courses"] if c["id"] == 88888)
        assert entry["hasLocalData"] is True
        assert entry["lastAccessed"] is not None
        # Stub name should reference the course_id
        assert "88888" in entry["name"]

    def test_migrate_non_active_course_single_get_raises_uses_stub(
        self, monkeypatch, tmp_path, capsys
    ):
        """When CANVAS_COURSE_ID is absent from the active-course list AND the
        individual GET raises an exception (network error), migrate() must still
        add a stub registry entry and must not crash.
        """
        import requests as requests_lib

        _patch_env(monkeypatch, course_id="77777")
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))
        monkeypatch.setattr(setup_env, "PLUGIN_ROOT", str(tmp_path))

        active_courses = [_make_course(11111, "Active Course")]

        def _fake_request_with_retry(method, url, headers, **kwargs):
            if "courses/77777" in url:
                raise requests_lib.RequestException("timeout")
            raise AssertionError(f"Unexpected URL in test: {url}")

        with patch("setup_env.canvas_api.paginated_get", return_value=active_courses):
            with patch("setup_env.canvas_api._request_with_retry",
                       side_effect=_fake_request_with_retry):
                result = setup_env.migrate()

        assert result is True
        assert (tmp_path / "77777").is_dir()

        data = json.loads(registry_path.read_text())
        ids = [c["id"] for c in data["courses"]]
        assert 77777 in ids

        entry = next(c for c in data["courses"] if c["id"] == 77777)
        assert entry["hasLocalData"] is True
        assert "77777" in entry["name"]


# ---------------------------------------------------------------------------
# validate_env
# ---------------------------------------------------------------------------

class TestValidateEnv:
    def test_missing_token_returns_false(self, monkeypatch, tmp_path, capsys):
        monkeypatch.delenv("CANVAS_TOKEN", raising=False)
        monkeypatch.setenv("CANVAS_DOMAIN", "canvas.test.edu")
        monkeypatch.delenv("CANVAS_COURSE_ID", raising=False)
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        result = setup_env.validate_env()
        assert result is False
        out = json.loads(capsys.readouterr().out)
        assert "CANVAS_TOKEN" in out["missing"]

    def test_all_credentials_set_returns_true(self, monkeypatch, tmp_path, capsys):
        _patch_env(monkeypatch)
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        result = setup_env.validate_env()
        assert result is True
        out = json.loads(capsys.readouterr().out)
        assert out["ok"] is True

    def test_registry_found_status(self, monkeypatch, tmp_path, capsys):
        _patch_env(monkeypatch)
        registry_path = tmp_path / "registry.json"
        registry_path.write_text('{"courses": []}')
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        setup_env.validate_env()
        out = json.loads(capsys.readouterr().out)
        assert out["registry"] == "found"

    def test_registry_not_found_status(self, monkeypatch, tmp_path, capsys):
        _patch_env(monkeypatch)
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "no-registry.json"))
        setup_env.validate_env()
        out = json.loads(capsys.readouterr().out)
        assert out["registry"] == "not_found"

    def test_legacy_course_id_triggers_migration_needed(self, monkeypatch, tmp_path, capsys):
        _patch_env(monkeypatch, course_id="99999")
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        setup_env.validate_env()
        out = json.loads(capsys.readouterr().out)
        assert out.get("migration_needed") is True
        assert out.get("legacy_course_id") == "99999"

    def test_valid_course_config_reported(self, monkeypatch, tmp_path, capsys):
        _patch_env(monkeypatch)
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        # Put a valid course-config.json in cwd
        cwd = tmp_path / "cwd"
        cwd.mkdir()
        config = cwd / "course-config.json"
        config.write_text('{"config_version": "1.0"}')
        with patch("os.getcwd", return_value=str(cwd)):
            setup_env.validate_env()
        out = json.loads(capsys.readouterr().out)
        assert out["course_config"] == "valid"

    def test_invalid_json_config_reported(self, monkeypatch, tmp_path, capsys):
        _patch_env(monkeypatch)
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(tmp_path / "registry.json"))
        cwd = tmp_path / "cwd"
        cwd.mkdir()
        (cwd / "course-config.json").write_text("not-json{{{")
        with patch("os.getcwd", return_value=str(cwd)):
            setup_env.validate_env()
        out = json.loads(capsys.readouterr().out)
        assert out["course_config"] == "invalid_json"


# ---------------------------------------------------------------------------
# Registry staleness — lastSync field is present
# ---------------------------------------------------------------------------

class TestRegistryLastSync:
    def test_last_sync_is_iso_timestamp(self, monkeypatch, tmp_path):
        _patch_env(monkeypatch)
        registry_path = tmp_path / "registry.json"
        monkeypatch.setattr(setup_env, "REGISTRY_PATH", str(registry_path))
        monkeypatch.setattr(setup_env, "COURSES_DIR", str(tmp_path))

        with patch("setup_env.canvas_api.paginated_get", return_value=[]):
            setup_env.sync_registry()

        data = json.loads(registry_path.read_text())
        ts = data["lastSync"]
        # Should be parseable as ISO 8601
        from datetime import datetime
        parsed = datetime.fromisoformat(ts.replace("Z", "+00:00"))
        assert parsed is not None
