"""A4 acceptance — journal-based crash reclaim.

The last piece of the A4 bar: kill the warden mid-run → on restart a new warden
reclaims the dead one's lease (audited), kills the orphaned child, and the queue
advances. Covers the pid plumbing, the on-disk journal, ``recover_from_journal``,
and the serve-path recover (read journal → audit-reclaim → SIGTERM orphan → clear).
"""

from __future__ import annotations

import json
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any

import pytest
from mgf.fedres import validate_payload
from mgf.fedres.canonical import dumpb

from mgf.test_web.testctl import _recover_from_journal_file
from mgf.test_web.warden import RunRequest, Warden
from mgf.test_web.warden.leases import Lease, now_iso
from mgf.test_web.warden.recorder import LiveRecorder


@pytest.mark.concurrency
def test_pid_is_recorded_on_the_lease_and_dropped_from_the_ndjson(tmp_path: Path) -> None:
    # (a.1) the broker lifts `pid` from any event onto the running lease.
    started = threading.Event()
    release = threading.Event()

    def runner(request: RunRequest, emit: Any) -> str:
        emit({"kind": "test_start", "pid": 424242})
        started.set()
        release.wait(timeout=5)
        emit({"kind": "test_end", "result": "pass"})
        return "pass"

    warden = Warden("proj", runner)
    warden.submit(RunRequest("r1", "T-1"))
    assert started.wait(2)
    for _ in range(200):
        lease = warden.registry.running.get("proj")
        if lease is not None and lease.pid == 424242:
            break
        time.sleep(0.01)
    assert warden.registry.running["proj"].pid == 424242  # the warden learned the child pid
    release.set()
    assert warden.wait_idle()

    # (a.2) the recorder MUST drop `pid` — it's not in the live_event_test grammar, so a
    # written line stays schema-valid (validates via mgf.fedres.validate_payload).
    rec = LiveRecorder(tmp_path, project="proj")
    rec.on_event({"run_id": "r1", "kind": "test_start", "test_id": "t.py::a", "pid": 424242})
    ndjson = tmp_path / "fed-resources" / "live" / "runs" / "r1.ndjson"
    line = json.loads(ndjson.read_text(encoding="utf-8").splitlines()[0])
    assert "pid" not in line, "pid is not in the live_event_test grammar — must be dropped"
    assert validate_payload(line) == []


def test_recover_from_journal_audits_reclaim_without_resurrecting_the_dead_lease() -> None:
    # (b) the bare case: a stale entry, nothing queued → audited reclaim, clean registry.
    warden = Warden("proj", lambda req, emit: "pass")
    reclaimed = warden.recover_from_journal(
        [
            {
                "run_id": "dead",
                "key": "proj",
                "test_id": "T-1",
                "acquired_at": "2020-01-01T00:00:00Z",
                "heartbeat_at": "2020-01-01T00:00:00Z",
                "ttl_s": 30,
                "pid": None,
            }
        ]
    )
    assert reclaimed == ["dead"]
    assert any(e["event"] == "lease_reclaimed" for e in warden.registry.recent)  # audited
    assert warden.registry.running == {}  # the dead lease is NOT resurrected into running


@pytest.mark.concurrency
def test_recover_from_journal_advances_the_queue() -> None:
    # (b) a request queued behind the dead warden's lease starts once recovery frees the key.
    started = threading.Event()
    release = threading.Event()

    def runner(request: RunRequest, emit: Any) -> str:
        emit({"kind": "test_start"})
        started.set()
        release.wait(timeout=5)
        emit({"kind": "test_end", "result": "pass"})
        return "pass"

    warden = Warden("proj", runner)
    warden.registry.queue.append(RunRequest("queued1", "T-2"))  # waiting on key "proj"
    reclaimed = warden.recover_from_journal(
        [{"run_id": "dead", "key": "proj", "test_id": "T-1", "pid": None}]
    )
    assert reclaimed == ["dead"]
    assert started.wait(2)
    assert warden.registry.running["proj"].run_id == "queued1"  # the queue advanced
    assert not warden.registry.queue
    release.set()
    assert warden.wait_idle()


@pytest.mark.integration
def test_recover_kills_the_orphaned_child_and_clears_the_journal(tmp_path: Path) -> None:
    # (c) end-to-end-ish: a real killable child stands in for the dead warden's orphan.
    leases_path = tmp_path / "fed-resources" / "live" / "leases.json"
    leases_path.parent.mkdir(parents=True, exist_ok=True)
    proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
    try:
        entry = {
            "run_id": "dead",
            "key": "proj",
            "test_id": "T-1",
            "acquired_at": now_iso(),
            "heartbeat_at": now_iso(),
            "ttl_s": 30,
            "pid": proc.pid,
        }
        leases_path.write_bytes(dumpb([entry]))

        warden = Warden("proj", lambda req, emit: "pass")
        reclaimed = _recover_from_journal_file(warden, leases_path)

        assert reclaimed == ["dead"]
        assert proc.wait(timeout=5) == -signal.SIGTERM  # the orphan was SIGTERM'd
        assert any(e["event"] == "lease_reclaimed" for e in warden.registry.recent)  # audited
        assert not leases_path.exists()  # the journal was cleared → serve fresh
    finally:
        if proc.poll() is None:  # any failure before the kill: reap so no ResourceWarning
            proc.kill()
            proc.wait()


@pytest.mark.concurrency
def test_journal_callback_fires_on_acquire_and_release() -> None:
    # (d) the journal hook is called (under the lock) on every lease change.
    snapshots: list[list[str]] = []

    def journal(leases: list[Lease]) -> None:
        snapshots.append([lease.run_id for lease in leases])

    started = threading.Event()
    release = threading.Event()

    def runner(request: RunRequest, emit: Any) -> str:
        emit({"kind": "test_start"})
        started.set()
        release.wait(timeout=5)
        emit({"kind": "test_end", "result": "pass"})
        return "pass"

    warden = Warden("proj", runner, journal=journal)
    warden.submit(RunRequest("r1", "T-1"))
    assert started.wait(2)
    assert ["r1"] in snapshots  # journaled on acquire (the running lease)
    release.set()
    assert warden.wait_idle()
    for _ in range(200):
        if [] in snapshots:
            break
        time.sleep(0.01)
    assert [] in snapshots  # journaled on release (the empty running set)
