"""ops/agent-reconciler.py — token mint + heartbeat unit pins (PAPER-01/02).

The mint and the heartbeat are factored as pure/patchable functions precisely so
they are testable WITHOUT docker or a live host: `mint_user_jwt` takes the DB's
``id|hash`` string, `heartbeat_request` builds the POST, and the impure edges
(`fetch_id_hash`, `post_heartbeat`) are exercised via monkeypatching. Also pins
the survival properties of the shell-side heartbeat wiring (ops/self-heal.sh and
the agent2 cloud-init mirror) and the watcher freshness check.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import importlib.util
import json
from pathlib import Path
from typing import Any

import pytest
import yaml

_REPO = Path(__file__).resolve().parents[4]
_OPS = _REPO / "ops"


def _load_reconciler() -> Any:
    spec = importlib.util.spec_from_file_location("agent_reconciler", _OPS / "agent-reconciler.py")
    assert spec is not None and spec.loader is not None
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)  # import must be side-effect free (no token read, no mkdir)
    return mod


recon = _load_reconciler()


# --------------------------------------------------------------------------
# PAPER-01 — the per-run JWT mint
# --------------------------------------------------------------------------
def _unpad_b64(seg: str) -> bytes:
    return base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))


def test_mint_user_jwt_is_the_woodpecker_user_jwt() -> None:
    tok = recon.mint_user_jwt("42|x4aBcHash")
    h, p, s = tok.split(".")
    # header/payload exactly as Woodpecker mints them (compact separators)
    assert json.loads(_unpad_b64(h)) == {"alg": "HS256", "typ": "JWT"}
    assert json.loads(_unpad_b64(p)) == {"type": "user", "user-id": "42"}
    # the proven-canonical header segment (byte-for-byte woodpecker_api.sh math)
    assert h == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
    # signature = HS256 over "<header>.<payload>" with the DB hash as the key
    expected = hmac.new(b"x4aBcHash", f"{h}.{p}".encode(), hashlib.sha256).digest()
    assert hmac.compare_digest(_unpad_b64(s), expected)
    # urlsafe-b64, no padding anywhere
    assert "=" not in tok and "+" not in tok and "/" not in tok


def test_mint_user_jwt_splits_id_from_hash_once() -> None:
    # a '|' inside the hash must stay in the KEY, not truncate it
    tok = recon.mint_user_jwt("  7|ha|sh|y\n")
    _, p, s = tok.split(".")
    assert json.loads(_unpad_b64(p))["user-id"] == "7"
    msg = tok.rsplit(".", 1)[0].encode()
    assert hmac.compare_digest(_unpad_b64(s), hmac.new(b"ha|sh|y", msg, hashlib.sha256).digest())


def test_get_token_prefers_the_db_mint(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(recon, "fetch_id_hash", lambda: "42|k")
    token, source = recon.get_token()
    assert token == recon.mint_user_jwt("42|k")
    assert source == "db-mint"


def test_get_token_falls_back_to_the_file_only_when_the_db_is_unreachable(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    def boom() -> str:
        raise RuntimeError("db down")

    monkeypatch.setattr(recon, "fetch_id_hash", boom)
    token_file = tmp_path / "reconciler.token"
    token_file.write_text("static-fallback-jwt\n", encoding="utf-8")
    monkeypatch.setattr(recon, "TOKEN_FILE", str(token_file))
    token, source = recon.get_token()
    assert token == "static-fallback-jwt"
    assert "file-fallback" in source  # the used path is reported → gets logged


def test_get_token_raises_when_both_paths_fail(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    def boom() -> str:
        raise RuntimeError("db down")

    monkeypatch.setattr(recon, "fetch_id_hash", boom)
    monkeypatch.setattr(recon, "TOKEN_FILE", str(tmp_path / "absent"))
    with pytest.raises(RuntimeError, match="token unavailable"):
        recon.get_token()


def test_fetch_id_hash_uses_chr124_sql_and_rejects_empty_rows(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    calls: list[list[str]] = []

    class _Done:
        stdout = "14|somehash\n"

    def fake_run(cmd: list[str], **_kw: Any) -> _Done:
        calls.append(cmd)
        return _Done()

    monkeypatch.setattr(recon.subprocess, "run", fake_run)
    assert recon.fetch_id_hash() == "14|somehash"
    (cmd,) = calls
    assert cmd[:3] == ["sudo", "docker", "exec"]
    assert recon.PG_CONTAINER in cmd
    assert any("chr(124)" in part for part in cmd)  # no literal '|' through the layers

    _Done.stdout = "\n"  # user row missing → must raise, so the fallback engages
    with pytest.raises(RuntimeError, match="no id"):
        recon.fetch_id_hash()


# --------------------------------------------------------------------------
# PAPER-02 — the success-only, fail-open heartbeat
# --------------------------------------------------------------------------
def test_heartbeat_request_builds_the_backups_record_shape() -> None:
    url, body, headers = recon.heartbeat_request(
        "agent-reconciler",
        "https://oo/api/org/backup_heartbeats/_json",
        "Basic abc",
        "woodpecker-prod",
        "2026-07-10T12:00:00Z",
        extra={"token": "db-mint"},
    )
    assert url == "https://oo/api/org/backup_heartbeats/_json"
    assert headers == {"Content-Type": "application/json", "Authorization": "Basic abc"}
    (rec,) = json.loads(body)  # OO _json ingest takes a LIST of records
    assert rec == {
        "job": "agent-reconciler",
        "status": "ok",
        "host": "woodpecker-prod",
        "ts": "2026-07-10T12:00:00Z",
        "token": "db-mint",
    }


def _cred_files(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    (tmp_path / "hb.url").write_text("https://oo/api/org/backup_heartbeats/_json\n")
    (tmp_path / "hb.token").write_text("Basic abc\n")
    monkeypatch.setattr(recon, "HEARTBEAT_URL_FILE", str(tmp_path / "hb.url"))
    monkeypatch.setattr(recon, "HEARTBEAT_TOKEN_FILE", str(tmp_path / "hb.token"))
    monkeypatch.setattr(recon, "LOG", str(tmp_path / "log"))


def test_post_heartbeat_posts_and_reports_success(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    _cred_files(monkeypatch, tmp_path)
    seen: list[Any] = []

    class _Resp:
        def close(self) -> None: ...

    def fake_urlopen(req: Any, **_kw: Any) -> _Resp:
        seen.append(req)
        return _Resp()

    monkeypatch.setattr(recon.urllib.request, "urlopen", fake_urlopen)
    assert recon.post_heartbeat(extra={"token": "db-mint"}) is True
    (req,) = seen
    assert req.get_method() == "POST"
    assert json.loads(req.data)[0]["job"] == "agent-reconciler"


def test_post_heartbeat_fails_open_when_cred_files_missing(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    monkeypatch.setattr(recon, "HEARTBEAT_URL_FILE", str(tmp_path / "absent.url"))
    monkeypatch.setattr(recon, "HEARTBEAT_TOKEN_FILE", str(tmp_path / "absent.token"))
    monkeypatch.setattr(recon, "LOG", str(tmp_path / "log"))
    assert recon.post_heartbeat() is False  # no exception — the heal must survive
    assert "WILL page" in (tmp_path / "log").read_text(encoding="utf-8")


def test_post_heartbeat_fails_open_when_the_post_fails(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    _cred_files(monkeypatch, tmp_path)

    def boom(*_a: Any, **_kw: Any) -> None:
        raise OSError("network down")

    monkeypatch.setattr(recon.urllib.request, "urlopen", boom)
    assert recon.post_heartbeat() is False
    assert "heartbeat POST failed" in (tmp_path / "log").read_text(encoding="utf-8")


# --------------------------------------------------------------------------
# shell-side wiring pins (self-heal + cloud-init mirror + watcher check)
# --------------------------------------------------------------------------
def test_self_heal_posts_a_success_heartbeat_last() -> None:
    text = (_OPS / "self-heal.sh").read_text(encoding="utf-8")
    assert "post_heartbeat" in text
    assert text.rstrip().splitlines()[-1] == 'post_heartbeat "self-heal"'
    # endpoint/token are FILE PATHS under /etc/mgf — never inline values
    assert "/etc/mgf/backup-heartbeat.url" in text
    assert "/etc/mgf/backup-heartbeat.token" in text
    for forbidden in ("Bearer ", "Basic ", "https://195."):
        assert forbidden not in text


def test_agent_cloud_init_self_heal_mirror_heartbeats_too() -> None:
    text = (_REPO / "examples" / "woodpecker-agent-cloud-init.yml").read_text(encoding="utf-8")
    assert 'post_heartbeat "self-heal"' in text
    assert "/etc/mgf/backup-heartbeat.url" in text


def test_reconciler_mints_per_run_and_keeps_the_file_as_fallback_only() -> None:
    text = (_OPS / "agent-reconciler.py").read_text(encoding="utf-8")
    assert "def mint_user_jwt" in text
    assert "FALLBACK ONLY" in text  # reconciler.token demoted, not load-bearing
    assert "reconciler.token" in text  # ...but still present for DB-down runs


def test_estate_watch_freshness_check_ships_with_the_paper_02_windows() -> None:
    text = (_OPS / "estate-watch-heartbeats.sh").read_text(encoding="utf-8")
    assert text.startswith("#!/usr/bin/env bash")
    # N≈30 min for the self-heal layer, 26 h for the nightly backups
    assert "agent-reconciler:1800" in text
    assert "self-heal:1800" in text
    for backup_job in ("pg-dump:93600", "devpi-export:93600", "oo-snapshot:93600"):
        assert backup_job in text
    # same no-new-secret files as the emitters; never inline values
    assert "/etc/mgf/backup-heartbeat.url" in text
    assert "/etc/mgf/backup-heartbeat.token" in text
    for forbidden in ("Bearer ", "Basic ", "https://195."):
        assert forbidden not in text
    # a blind watcher must page too, not silently pass
    assert "exit 2" in text


# --------------------------------------------------------------------------
# the self-heal reapers must never race a live pipeline (2026-07-14)
# --------------------------------------------------------------------------
def test_every_self_heal_reaper_is_age_guarded() -> None:
    """A live pipeline's network/volume/exited-container are UNATTACHED between steps.

    An unguarded reaper therefore deletes them out from under work in progress. That is
    what reddened mgf-logship #95 and mgf-hrb-cap #3/#5 (exit 126, `network ... not
    found`, on a */5 tick). PAPER-09 learned this for the Created-container reaper and
    guarded only that one. This pins the property for ALL of them.
    """
    text = (_OPS / "self-heal.sh").read_text(encoding="utf-8")
    assert "REAP_MIN_AGE_S" in text
    # the bare prune is the bug — it must never come back
    assert "docker network prune -f 2>/dev/null" not in text
    assert 'docker network prune -f --filter "until=${REAP_MIN_AGE_S}s"' in text
    # volumes + exited containers go through the age guard, not a bare xargs rm
    assert "docker volume ls --filter name=wp_ --filter dangling=true -q | xargs" not in text
    assert 'older_than_guard "$v" volume' in text
    assert 'older_than_guard "$id" container' in text


def test_cloud_init_self_heal_is_generated_from_the_canonical_script() -> None:
    """One source of truth. A hand-edited cloud-init copy is exactly how the PAPER-09
    fix failed to reach newly-provisioned agents for three days."""
    canonical = (_OPS / "self-heal.sh").read_text(encoding="utf-8").rstrip("\n")
    for rel in (
        Path("examples") / "woodpecker-agent-cloud-init.yml",
        Path("src")
        / "mgf"
        / "cloud_provision"
        / "data"
        / "woodpecker"
        / "woodpecker-agent-cloud-init.yml",
    ):
        doc = yaml.safe_load((_REPO / rel).read_text(encoding="utf-8"))
        embedded = next(
            f["content"] for f in doc["write_files"] if f["path"].endswith("self-heal.sh")
        ).rstrip("\n")
        assert embedded == canonical, f"{rel} has drifted — run scripts/sync_self_heal.py"
    # ...and the agent's cron MUST point STACK_DIR at the agent stack, or the canonical
    # script would cd into the server's /opt/woodpecker, which does not exist there.
    example = (_REPO / "examples" / "woodpecker-agent-cloud-init.yml").read_text(encoding="utf-8")
    assert (
        "WOODPECKER_STACK_DIR=/opt/woodpecker-agent /opt/woodpecker-agent/self-heal.sh" in example
    )
