import io
import json
import pathlib
import sys
import types

import pytest


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

import staging_server
import unified_staging_review


VALID_ORIGIN = "http://localhost:8111"
# Default Host header that satisfies _check_host() for port 8111.
VALID_HOST = "127.0.0.1:8111"


@pytest.fixture()
def courses_root(tmp_path, monkeypatch):
    monkeypatch.setattr(staging_server, "COURSES_DIR", tmp_path)
    return tmp_path


class _MockServer:
    """Minimal stand-in for HTTPServer providing server_address."""
    def __init__(self, port=8111):
        self.server_address = ("127.0.0.1", port)


class HandlerHarness(staging_server.StagingHandler):
    def __init__(self, method, path, body=None, headers=None, port=8111):
        payload = b""
        req_headers = dict(headers or {})
        # Inject a valid Host header by default so _check_host() passes.
        # Individual tests that want to test Host rejection set it explicitly.
        req_headers.setdefault("Host", f"127.0.0.1:{port}")
        if body is not None:
            payload = json.dumps(body).encode("utf-8")
            req_headers.setdefault("Content-Type", "application/json")
            req_headers.setdefault("Content-Length", str(len(payload)))
        self.command = method
        self.path = path
        self.headers = req_headers
        self.rfile = io.BytesIO(payload)
        self.wfile = io.BytesIO()
        self.server = _MockServer(port=port)
        self.status = None
        self.response_headers = []

    def send_response(self, code, message=None):
        self.status = code

    def send_header(self, keyword, value):
        self.response_headers.append((keyword, value))

    def end_headers(self):
        pass


def request(method, path, body=None, headers=None):
    handler = HandlerHarness(method, path, body=body, headers=headers)
    getattr(handler, f"do_{method}")()
    return handler.status, handler.wfile.getvalue()


def request_origin_check(headers=None):
    # _check_origin() only checks Origin for write verbs; it does not need server.
    handler = HandlerHarness("PUT", "/api/staging/course1/pages/lesson", headers=headers)
    return handler._check_origin(), handler.status, handler.wfile.getvalue()


def write_staged(root, course_id="course1", obj_type="pages", obj_id="lesson", content="<h1>OK</h1>"):
    path = root / course_id / "staging" / obj_type / f"{obj_id}{staging_server.EXTENSIONS[obj_type]}"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")
    return path


def test_get_traversal_path_gets_404_or_400(courses_root):
    status, _ = request("GET", "/courses/../pages/lesson")
    assert status in {400, 404}


def test_get_api_traversal_gets_404_or_400(courses_root):
    status, _ = request("GET", "/api/staging/../pages/lesson/raw")
    assert status in {400, 404}


def test_put_traversal_gets_400(courses_root):
    status, body = request(
        "PUT",
        "/api/staging/../pages/lesson",
        body={"html": "<p>x</p>"},
        headers={"Origin": VALID_ORIGIN},
    )
    assert status == 400
    assert json.loads(body)["error"] == "Invalid path component"


def test_delete_bad_obj_id_with_slash_gets_400(courses_root):
    status, body = request(
        "DELETE",
        "/api/staging/course1/pages/bad%2Fid",
        headers={"Origin": VALID_ORIGIN},
    )
    assert status == 400
    assert json.loads(body)["error"] == "Invalid path component"


def test_put_no_origin_gets_403(courses_root):
    status, _ = request("PUT", "/api/staging/course1/pages/lesson", body={"html": "<p>x</p>"})
    assert status == 403


def test_put_evil_origin_gets_403(courses_root):
    status, _ = request(
        "PUT",
        "/api/staging/course1/pages/lesson",
        body={"html": "<p>x</p>"},
        headers={"Origin": "https://evil.example"},
    )
    assert status == 403


def test_put_valid_origin_passes_origin_check():
    allowed, status, body = request_origin_check(headers={"Origin": VALID_ORIGIN})
    assert allowed is True
    assert status is None
    assert body == b""


def test_get_valid_staged_file_returns_200(courses_root):
    write_staged(courses_root, content="<h1>Valid staged page</h1>")
    status, body = request("GET", "/courses/course1/pages/lesson")
    assert status == 200
    assert b"Valid staged page" in body


def test_get_valid_staged_file_includes_back_to_review(courses_root):
    write_staged(courses_root, content="<h1>Valid staged page</h1>")

    status, body = request("GET", "/courses/course1/pages/lesson")

    assert status == 200
    assert b"Back to Course Review" in body
    assert b'href="/courses/course1/review"' in body


def test_resolve_safe_rejects_symlinked_course_directory_outside_root(tmp_path, monkeypatch):
    """S8: A symlinked course dir whose resolved target is outside courses/ must raise.

    Prior to S8, the symlink bypass clause (`or raw_course_dir.is_symlink()`)
    allowed a symlink to escape the courses/ sandbox.  That clause is now removed.
    """
    courses_root = tmp_path / "courses"
    course_target = tmp_path / "external-course"
    staged = course_target / "staging" / "pages" / "lesson.html"
    staged.parent.mkdir(parents=True)
    staged.write_text("<h1>OK</h1>", encoding="utf-8")
    courses_root.mkdir()
    (courses_root / "course1").symlink_to(course_target, target_is_directory=True)
    monkeypatch.setattr(staging_server, "COURSES_DIR", courses_root)

    with pytest.raises(ValueError):
        staging_server._resolve_safe("course1", "pages", "lesson")


def test_put_valid_path_with_valid_origin_updates_file(courses_root):
    target = courses_root / "course1" / "staging" / "pages" / "lesson.html"
    status, _ = request(
        "PUT",
        "/api/staging/course1/pages/lesson",
        body={"html": "<p>updated</p>"},
        headers={"Origin": VALID_ORIGIN},
    )
    assert status == 200
    assert target.read_text(encoding="utf-8") == "<p>updated</p>"


def test_bind_uses_loopback_address_not_localhost():
    source = pathlib.Path(staging_server.__file__).read_text(encoding="utf-8")
    assert 'HTTPServer(("127.0.0.1", args.port), StagingHandler)' in source
    assert 'HTTPServer(("localhost", args.port), StagingHandler)' not in source


# ── #215: GET /courses/{id}/review?refresh=1 side-effect gate ────────────
#
# generate_review()/fetch_canvas_modules() make a LIVE Canvas API call and
# write courses/{id}/.module_cache.json whenever refresh=True is passed
# through. Before this gate, that was reachable by *any* GET to
# /courses/{id}/review?refresh=1 -- only _check_host() ran, and Host is
# satisfied by construction for any request actually reaching 127.0.0.1:8111
# (e.g. via <img>/<iframe>/fetch from a totally unrelated page the user has
# open). The response is unreadable cross-origin (no ACAO reflected for a
# foreign Origin), so this was amplification/side-effect only -- but it let
# any page force repeated Canvas API calls (token rate-limit burn) and
# create courses/{id}/ dirs for arbitrary regex-valid course ids.
#
# fetch_canvas_modules is monkeypatched to a spy so no real Canvas/network
# access happens; assertions check whether it was called and, crucially,
# what `refresh` value it received (the forced-bypass is the side effect
# being gated -- the plain non-forced fetch on a cold/expired cache is
# pre-existing, throttled behavior out of scope for this fix).

@pytest.fixture()
def review_courses_root(courses_root, monkeypatch):
    """courses_root, but also repointed for unified_staging_review's own
    COURSES_DIR constant (a separate module-level Path, not shared with
    staging_server.COURSES_DIR) so /review never touches the real courses/
    directory on disk."""
    monkeypatch.setattr(unified_staging_review, "COURSES_DIR", courses_root)
    return courses_root


class _FetchModulesSpy:
    """Records each fetch_canvas_modules(course_id, refresh=...) call."""

    def __init__(self):
        self.calls = []

    def __call__(self, course_id, refresh=False):
        self.calls.append({"course_id": course_id, "refresh": refresh})
        return None  # offline degradation -- generate_review must still render


def _review_get(path, headers=None):
    status, body = request("GET", path, headers=headers)
    return status, body


def test_review_refresh_foreign_origin_skips_canvas_fetch(review_courses_root, monkeypatch):
    spy = _FetchModulesSpy()
    monkeypatch.setattr(unified_staging_review, "fetch_canvas_modules", spy)

    status, _ = _review_get(
        "/courses/refreshcourse/review?refresh=1",
        headers={"Origin": "https://evil.example"},
    )

    assert status == 200  # page still renders -- gate blocks only the side effect
    assert len(spy.calls) == 1
    assert spy.calls[0]["course_id"] == "refreshcourse"
    assert spy.calls[0]["refresh"] is False, (
        "a foreign Origin must not be able to force the live Canvas re-fetch"
    )


def test_review_refresh_absent_origin_skips_canvas_fetch(review_courses_root, monkeypatch):
    """<img src>/<iframe src>/plain navigation never carry an Origin header
    at all (browsers omit Origin on GET regardless of same- or cross-site),
    so an absent Origin must be treated the same as a foreign one here."""
    spy = _FetchModulesSpy()
    monkeypatch.setattr(unified_staging_review, "fetch_canvas_modules", spy)

    status, _ = _review_get("/courses/refreshcourse/review?refresh=1")

    assert status == 200
    assert len(spy.calls) == 1
    assert spy.calls[0]["refresh"] is False


def test_review_refresh_valid_origin_triggers_canvas_fetch(review_courses_root, monkeypatch):
    spy = _FetchModulesSpy()
    monkeypatch.setattr(unified_staging_review, "fetch_canvas_modules", spy)

    status, _ = _review_get(
        "/courses/refreshcourse/review?refresh=1",
        headers={"Origin": VALID_ORIGIN},
    )

    assert status == 200
    assert len(spy.calls) == 1
    assert spy.calls[0]["refresh"] is True, (
        "a same-origin request must still be able to force the refresh"
    )


def test_review_refresh_valid_origin_wrong_token_skips_canvas_fetch(review_courses_root, monkeypatch):
    monkeypatch.setenv("ASUCB_STAGING_TOKEN", "correct-token")
    spy = _FetchModulesSpy()
    monkeypatch.setattr(unified_staging_review, "fetch_canvas_modules", spy)

    status, _ = _review_get(
        "/courses/refreshcourse/review?refresh=1",
        headers={"Origin": VALID_ORIGIN, "X-ASUCB-Staging-Token": "wrong"},
    )

    assert status == 200
    assert len(spy.calls) == 1
    assert spy.calls[0]["refresh"] is False


def test_review_refresh_valid_origin_and_token_triggers_canvas_fetch(review_courses_root, monkeypatch):
    monkeypatch.setenv("ASUCB_STAGING_TOKEN", "correct-token")
    spy = _FetchModulesSpy()
    monkeypatch.setattr(unified_staging_review, "fetch_canvas_modules", spy)

    status, _ = _review_get(
        "/courses/refreshcourse/review?refresh=1",
        headers={"Origin": VALID_ORIGIN, "X-ASUCB-Staging-Token": "correct-token"},
    )

    assert status == 200
    assert len(spy.calls) == 1
    assert spy.calls[0]["refresh"] is True


def test_review_normal_load_without_refresh_param_is_unaffected(review_courses_root, monkeypatch):
    """Sanity guard: plain page loads (no ?refresh=1 at all) must keep
    working exactly as before -- no Origin is sent or required."""
    spy = _FetchModulesSpy()
    monkeypatch.setattr(unified_staging_review, "fetch_canvas_modules", spy)

    status, _ = _review_get("/courses/refreshcourse/review")

    assert status == 200
    assert len(spy.calls) == 1
    assert spy.calls[0]["refresh"] is False
