"""Regression guard for GitHub issue #213 -- staged-quiz write race.

Bug: `_handle_quiz_details_put` performs a lock-free read-modify-write
(`_load_json` -> `quiz_block.update(payload)` -> `write_text`) against the
staged quiz JSON file. `handle_api_put`'s full-quiz save path (the PUT with
no subroute, used by the editor's "Save" button) writes the SAME staged
file directly. Because the staging server runs as a `ThreadingHTTPServer`
(concurrent since v7.40.1), a full-quiz PUT and a details PUT issued close
together race: the details handler can load a pre-save snapshot, the
full-quiz PUT then overwrites the file, and the details handler's
stale-based write clobbers the full save.

Fix: a single module-level `threading.Lock` (`_STAGED_WRITE_LOCK`) guards
the write critical section in both writer sites, serializing them.

Approach: rather than racing two real writers against a timing window
(flaky -- the exact interleaving depends on OS scheduling), each test here
holds `_STAGED_WRITE_LOCK` itself and asserts that a writer's HTTP handler
call blocks until the lock is released. This directly proves the writer's
critical section is guarded by the shared lock -- the same property that
prevents the interleaving above -- without depending on scheduling luck.

Run against unmodified code, every test here fails immediately with
AttributeError (no `_STAGED_WRITE_LOCK` exists yet): a clean, deterministic
fail-first rather than a timing-dependent flaky one.
"""
import io
import json
import pathlib
import sys
import threading

import pytest

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

import staging_server  # noqa: E402


VALID_ORIGIN = "http://127.0.0.1:8111"

# Bounded waits keep this deterministic (no sleep-and-hope races) and keep
# the suite from hanging even if a fix attempt introduces a real deadlock:
# join() always returns after the timeout, and the background thread is a
# daemon so it can't block process exit either way.
BLOCKED_WAIT = 0.3
UNBLOCKED_WAIT = 2.0


# ── HandlerHarness reused pattern from tests/test_quiz_details_tabs.py ───────

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}")()
    body_bytes = handler.wfile.getvalue()
    try:
        decoded = json.loads(body_bytes.decode("utf-8")) if body_bytes else None
    except json.JSONDecodeError:
        decoded = None
    return handler.status, decoded


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


def _write_quiz(root, course_id="course1", obj_id="quiz1"):
    quiz = {
        "title": "Sample Quiz",
        "quiz_type": "assignment",
        "points_possible": 10,
        "allowed_attempts": 2,
        "time_limit": None,
        "scoring_policy": "keep_highest",
        "shuffle_answers": True,
        "published": False,
    }
    payload = {"quiz": quiz, "questions": []}
    path = root / course_id / "staging" / "quizzes" / f"{obj_id}.json"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
    return path


def _run_in_thread(fn):
    """Run fn() on a daemon thread; return (thread, result_dict)."""
    result = {}

    def _target():
        result["value"] = fn()

    thread = threading.Thread(target=_target, daemon=True)
    thread.start()
    return thread, result


# ── Sanity: the lock exists and behaves like a lock ──────────────────────────

def test_staged_write_lock_exists_and_is_a_real_lock():
    """Fails with AttributeError on unmodified code (no lock defined yet)."""
    lock = staging_server._STAGED_WRITE_LOCK
    acquired = lock.acquire(blocking=False)
    assert acquired, "lock should be free and acquirable"
    lock.release()


# ── Writer site 1: _handle_quiz_details_put (details PUT) ───────────────────

def test_quiz_details_put_blocks_while_lock_held(courses_root):
    """The details PUT's read-modify-write must run inside
    `_STAGED_WRITE_LOCK` -- holding the lock externally should block it
    until release, and the merge should land once it is admitted."""
    _write_quiz(courses_root)
    payload = {
        "title": "Renamed Under Lock",
        "quiz_type": "assignment",
        "allowed_attempts": 2,
        "scoring_policy": "keep_highest",
        "shuffle_answers": True,
        "published": False,
    }

    lock = staging_server._STAGED_WRITE_LOCK
    lock.acquire()
    try:
        thread, result = _run_in_thread(
            lambda: request(
                "PUT",
                "/api/staging/course1/quizzes/quiz1/details",
                body=payload,
                headers={"Origin": VALID_ORIGIN},
            )
        )
        thread.join(timeout=BLOCKED_WAIT)
        assert thread.is_alive(), (
            "details PUT completed while _STAGED_WRITE_LOCK was held "
            "externally -- its read-modify-write is not serialized by the "
            "shared lock"
        )
    finally:
        lock.release()

    thread.join(timeout=UNBLOCKED_WAIT)
    assert not thread.is_alive(), "details PUT never completed after lock release"
    status, body = result["value"]
    assert status == 200, body
    assert body["ok"] is True


# ── Writer site 2: handle_api_put's full-quiz save path (no subroute) ───────

def test_full_quiz_put_blocks_while_lock_held(courses_root):
    """The full-quiz save PUT (editor's "Save" button, no subroute) writes
    the same staged file as the details PUT -- it must also run inside
    `_STAGED_WRITE_LOCK`."""
    path = _write_quiz(courses_root)
    original = json.loads(path.read_text())
    full_payload = {
        "quiz": {**original["quiz"], "title": "Full Save Under Lock"},
        "questions": original["questions"],
    }

    lock = staging_server._STAGED_WRITE_LOCK
    lock.acquire()
    try:
        thread, result = _run_in_thread(
            lambda: request(
                "PUT",
                "/api/staging/course1/quizzes/quiz1",
                body=full_payload,
                headers={"Origin": VALID_ORIGIN},
            )
        )
        thread.join(timeout=BLOCKED_WAIT)
        assert thread.is_alive(), (
            "full-quiz PUT completed while _STAGED_WRITE_LOCK was held "
            "externally -- its write is not serialized by the shared lock"
        )
    finally:
        lock.release()

    thread.join(timeout=UNBLOCKED_WAIT)
    assert not thread.is_alive(), "full-quiz PUT never completed after lock release"
    status, body = result["value"]
    assert status == 200, body
    assert body["ok"] is True
    on_disk = json.loads(path.read_text())
    assert on_disk["quiz"]["title"] == "Full Save Under Lock"
