import io
import json
import pathlib
import sys

import pytest


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

import staging_server


VALID_ORIGIN = "http://localhost:8111"


class _MockServer:
    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):
        payload = b""
        req_headers = dict(headers or {})
        req_headers.setdefault("Host", "127.0.0.1:8111")
        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()
        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


def assert_same_status_and_body(method, legacy_path, canonical_path, body=None, headers=None):
    legacy = request(method, legacy_path, body=body, headers=headers)
    canonical = request(method, canonical_path, body=body, headers=headers)
    assert canonical.status == legacy.status
    assert canonical.wfile.getvalue() == legacy.wfile.getvalue()
    return legacy, canonical


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


def staged_path(root, course_id, obj_type, obj_id):
    if obj_type == "syllabus":
        return root / course_id / "staging" / "syllabus" / "syllabus_body.html"
    ext = staging_server.EXTENSIONS[obj_type]
    return root / course_id / "staging" / obj_type / f"{obj_id}{ext}"


def write_staged(root, course_id, obj_type, obj_id, content):
    path = staged_path(root, course_id, obj_type, obj_id)
    path.parent.mkdir(parents=True, exist_ok=True)
    if isinstance(content, str):
        path.write_text(content, encoding="utf-8")
    else:
        path.write_text(json.dumps(content), encoding="utf-8")
    return path


def quiz_payload(title="Route Collapse Quiz"):
    return {
        "quiz": {"title": title, "quiz_type": "practice_quiz", "points_possible": 1},
        "questions": [
            {
                "question_name": "Remember route collapse",
                "question_text": "<p>What stays stable?</p>",
                "question_type": "multiple_choice_question",
                "points_possible": 1,
                "answers": [
                    {"answer_text": "Legacy URLs", "answer_weight": 100},
                    {"answer_text": "Broken callers", "answer_weight": 0},
                ],
            }
        ],
    }


@pytest.mark.parametrize(
    "obj_type,obj_id,legacy_path,content,needle",
    [
        ("pages", "m1-overview", "/courses/course1/pages/m1-overview", "<h1>Page Body</h1>", b"Page Body"),
        ("assignments", "101", "/courses/course1/assignments/101", "<h1>Assignment Body</h1>", b"Assignment Body"),
        ("discussions", "202", "/courses/course1/discussions/202", "<h1>Discussion Body</h1>", b"Discussion Body"),
        ("quizzes", "303", "/courses/course1/quizzes/303", quiz_payload(), b"Route Collapse Quiz"),
        (
            "interactives",
            "m1/card-sort",
            "/courses/course1/interactives/m1/card-sort",
            "<!doctype html><html><body><h1>Interactive Body</h1></body></html>",
            b"Interactive Body",
        ),
    ],
)
def test_phase0_legacy_preview_routes(courses_root, obj_type, obj_id, legacy_path, content, needle):
    write_staged(courses_root, "course1", obj_type, obj_id, content)

    handler = request("GET", legacy_path)

    assert handler.status == 200
    assert needle in handler.wfile.getvalue()


def test_phase0_legacy_syllabus_route_redirects_to_review_anchor(courses_root):
    write_staged(courses_root, "course1", "syllabus", "body", "<h1>Syllabus</h1>")

    handler = request("GET", "/courses/course1/syllabus")

    assert handler.status == 302
    assert ("Location", "/courses/course1/review#syllabus-body") in handler.response_headers
    assert handler.wfile.getvalue() == b""


def test_syllabus_legacy_and_canonical_routes_redirect_to_review_anchor(courses_root):
    write_staged(courses_root, "course1", "syllabus", "body", "<h1>Syllabus</h1>")

    legacy = request("GET", "/courses/course1/syllabus")
    canonical = request("GET", "/courses/course1/preview/syllabus")

    assert legacy.status == 302
    assert canonical.status == 302
    assert ("Location", "/courses/course1/review#syllabus-body") in legacy.response_headers
    assert ("Location", "/courses/course1/review#syllabus-body") in canonical.response_headers
    assert legacy.wfile.getvalue() == b""
    assert canonical.wfile.getvalue() == b""


@pytest.mark.parametrize(
    "obj_type,obj_id,content",
    [
        ("pages", "m1-overview", "<h1>Page Body</h1>"),
        ("assignments", "101", "<h1>Assignment Body</h1>"),
        ("discussions", "202", "<h1>Discussion Body</h1>"),
    ],
)
def test_canonical_preview_route_matches_legacy_for_basic_html_types(courses_root, obj_type, obj_id, content):
    write_staged(courses_root, "course1", obj_type, obj_id, content)

    assert_same_status_and_body(
        "GET",
        f"/courses/course1/{obj_type}/{obj_id}",
        f"/courses/course1/preview/{obj_type}/{obj_id}",
    )


def test_canonical_preview_route_matches_legacy_for_quizzes(courses_root):
    write_staged(courses_root, "course1", "quizzes", "303", quiz_payload())

    assert_same_status_and_body(
        "GET",
        "/courses/course1/quizzes/303",
        "/courses/course1/preview/quizzes/303",
    )


def test_canonical_preview_route_matches_legacy_for_interactives(courses_root):
    write_staged(
        courses_root,
        "course1",
        "interactives",
        "m1/card-sort",
        "<!doctype html><html><body><h1>Interactive Body</h1></body></html>",
    )

    assert_same_status_and_body(
        "GET",
        "/courses/course1/interactives/m1/card-sort",
        "/courses/course1/preview/interactives/m1/card-sort",
    )


def test_invalid_catchall_interactive_key_returns_404(courses_root):
    write_staged(
        courses_root,
        "course1",
        "interactives",
        "m1/card-sort",
        "<!doctype html><html><body><h1>Interactive Body</h1></body></html>",
    )

    handler = request("GET", "/courses/course1/preview/interactives/../secret")

    assert handler.status == 404


@pytest.mark.parametrize(
    "obj_type,obj_id,api_path,content,expected_raw",
    [
        ("pages", "m1-overview", "/api/staging/course1/pages/m1-overview", "<h1>Page Body</h1>", "<h1>Page Body</h1>"),
        ("assignments", "101", "/api/staging/course1/assignments/101", "<h1>Assignment Body</h1>", "<h1>Assignment Body</h1>"),
        ("discussions", "202", "/api/staging/course1/discussions/202", "<h1>Discussion Body</h1>", "<h1>Discussion Body</h1>"),
        ("quizzes", "303", "/api/staging/course1/quizzes/303", quiz_payload(), quiz_payload()),
        (
            "interactives",
            "m1/card-sort",
            "/api/staging/course1/interactives/m1/card-sort",
            "<h1>Interactive Body</h1>",
            "<h1>Interactive Body</h1>",
        ),
        ("syllabus", "body", "/api/staging/course1/syllabus", "<h1>Syllabus</h1>", "<h1>Syllabus</h1>"),
    ],
)
def test_phase0_api_raw_and_meta_contract(courses_root, obj_type, obj_id, api_path, content, expected_raw):
    write_staged(courses_root, "course1", obj_type, obj_id, content)

    raw_handler = request("GET", f"{api_path}/raw")
    meta_handler = request("GET", f"{api_path}/meta")

    assert raw_handler.status == 200
    raw = json.loads(raw_handler.wfile.getvalue())
    assert raw["ok"] is True
    assert raw["data"] == expected_raw
    assert isinstance(raw["mtime"], int)

    assert meta_handler.status == 200
    meta = json.loads(meta_handler.wfile.getvalue())
    assert meta["ok"] is True
    assert isinstance(meta["mtime"], int)


@pytest.mark.parametrize(
    "obj_type,obj_id,api_path,body,expected_text",
    [
        ("pages", "m1-overview", "/api/staging/course1/pages/m1-overview", {"html": "<p>Updated page</p>"}, "<p>Updated page</p>"),
        ("assignments", "101", "/api/staging/course1/assignments/101", {"html": "<p>Updated assignment</p>"}, "<p>Updated assignment</p>"),
        ("discussions", "202", "/api/staging/course1/discussions/202", {"html": "<p>Updated discussion</p>"}, "<p>Updated discussion</p>"),
        ("quizzes", "303", "/api/staging/course1/quizzes/303", quiz_payload("Updated quiz"), '"Updated quiz"'),
        ("interactives", "m1/card-sort", "/api/staging/course1/interactives/m1/card-sort", {"html": "<p>Updated interactive</p>"}, "<p>Updated interactive</p>"),
        ("syllabus", "body", "/api/staging/course1/syllabus", {"html": "<p>Updated syllabus</p>"}, "<p>Updated syllabus</p>"),
    ],
)
def test_phase0_api_put_contract(courses_root, obj_type, obj_id, api_path, body, expected_text):
    handler = request("PUT", api_path, body=body, headers={"Origin": VALID_ORIGIN})

    assert handler.status == 200
    assert json.loads(handler.wfile.getvalue())["ok"] is True
    assert expected_text in staged_path(courses_root, "course1", obj_type, obj_id).read_text(encoding="utf-8")


@pytest.mark.parametrize(
    "obj_type,obj_id,api_path,content",
    [
        ("pages", "m1-overview", "/api/staging/course1/pages/m1-overview", "<h1>Page Body</h1>"),
        ("quizzes", "303", "/api/staging/course1/quizzes/303", quiz_payload()),
        ("syllabus", "body", "/api/staging/course1/syllabus", "<h1>Syllabus</h1>"),
    ],
)
def test_phase0_api_delete_contract(courses_root, obj_type, obj_id, api_path, content):
    target = write_staged(courses_root, "course1", obj_type, obj_id, content)

    handler = request("DELETE", api_path, headers={"Origin": VALID_ORIGIN})

    assert handler.status == 200
    assert json.loads(handler.wfile.getvalue())["ok"] is True
    assert not target.exists()


@pytest.mark.parametrize(
    "obj_type,obj_id,api_path,content",
    [
        ("pages", "m1-overview", "/api/staging/course1/pages/m1-overview", "<h1>Page Body</h1>"),
        ("assignments", "101", "/api/staging/course1/assignments/101", "<h1>Assignment Body</h1>"),
        ("discussions", "202", "/api/staging/course1/discussions/202", "<h1>Discussion Body</h1>"),
        ("quizzes", "303", "/api/staging/course1/quizzes/303", quiz_payload()),
        ("interactives", "m1/card-sort", "/api/staging/course1/interactives/m1/card-sort", "<h1>Interactive Body</h1>"),
        ("syllabus", "body", "/api/staging/course1/syllabus", "<h1>Syllabus</h1>"),
    ],
)
def test_delete_removes_sidecars_for_all_staging_types(courses_root, obj_type, obj_id, api_path, content):
    target = write_staged(courses_root, "course1", obj_type, obj_id, content)
    sidecars = []
    for suffix in (".issues.json", ".vision.json", ".meta.json"):
        for candidate in (target.parent / (target.name + suffix), target.parent / (target.stem + suffix)):
            candidate.parent.mkdir(parents=True, exist_ok=True)
            candidate.write_text("{}", encoding="utf-8")
            sidecars.append(candidate)

    handler = request("DELETE", api_path, headers={"Origin": VALID_ORIGIN})

    assert handler.status == 200
    assert json.loads(handler.wfile.getvalue())["ok"] is True
    assert not target.exists()
    for sidecar in sidecars:
        assert not sidecar.exists()


@pytest.mark.parametrize(
    "path",
    [
        "/courses/course1/pages/m1-overview",
        "/courses/course1/preview/pages/m1-overview",
    ],
)
def test_origin_guard_403s_put_without_allowed_origin_on_legacy_and_canonical_preview_routes(courses_root, path):
    handler = request("PUT", path, body={"html": "<p>x</p>"})

    assert handler.status == 403
    assert json.loads(handler.wfile.getvalue())["error"] == "Forbidden origin"


@pytest.mark.parametrize(
    "path",
    [
        "/courses/../preview/pages/lesson",
        "/courses/course1/preview/pages/bad%2Fid",
        "/courses/course1/preview/interactives/../secret",
    ],
)
def test_canonical_preview_routes_block_traversal(courses_root, path):
    handler = request("GET", path)

    assert handler.status in {400, 404}


def test_fake_registry_entry_exposes_canonical_preview_route(courses_root, monkeypatch):
    def resolve_widget(course_id, key):
        if not staging_server.COURSE_ID_RE.fullmatch(course_id or ""):
            raise ValueError("Invalid path component")
        if not staging_server.OBJ_ID_RE.fullmatch(key or ""):
            raise ValueError("Invalid path component")
        course_dir = staging_server._resolve_course_dir(course_id)
        candidate = (course_dir / "staging" / "widgets" / f"{key}.html").resolve(strict=False)
        if not candidate.is_relative_to(course_dir):
            raise ValueError("Invalid path component")
        return candidate

    monkeypatch.setitem(
        staging_server.STAGING_TYPES,
        "widgets",
        staging_server.StagingType(
            name="widgets",
            staging_pattern="staging/widgets/{key}.html",
            extension=".html",
            key_mode="single",
            resolve=resolve_widget,
            load=lambda path: path.read_text(encoding="utf-8"),
            wrapper=lambda content, course_id, key: f"<h1>Widget Preview</h1>{content}",
            validate_save=lambda data: None,
            write=lambda path, data: path.write_text(data.get("html", ""), encoding="utf-8"),
            delete_sidecars=True,
        ),
    )
    widget = courses_root / "course1" / "staging" / "widgets" / "demo.html"
    widget.parent.mkdir(parents=True, exist_ok=True)
    widget.write_text("<p>Fake Widget</p>", encoding="utf-8")

    handler = request("GET", "/courses/course1/preview/widgets/demo")

    assert handler.status == 200
    assert b"Widget Preview" in handler.wfile.getvalue()
    assert b"Fake Widget" in handler.wfile.getvalue()
