"""Tests for scripts/termux/stage_runtime_libs.py — cache correctness.

No filesystem mocks: a real tiny .deb fixture (ar archive + data.tar) is
served over loopback HTTP and fetched through pm's hardened Download,
then unpacked through pm's DebPackage — the same production path.
"""
from __future__ import annotations

import hashlib
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

import pytest
from tests.termux_fixtures import build_deb

import sys

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO_ROOT))
sys.path.insert(0, str(REPO_ROOT / "scripts" / "termux"))

import stage_runtime_libs as srl  # noqa: E402

PREFIX = srl.PREFIX_REL


# ---------------------------------------------------------------- fixtures

def _build_deb(path: Path, lib_name: str, content: bytes) -> None:
    build_deb(path, {"Package": f"pkg-{lib_name}", "Version": "1.0"},
              {f"{PREFIX}/lib/{lib_name}": f"FAKE-ELF {lib_name}\n".encode() + content})


class _Server:
    """Serves files from a dir over loopback HTTP."""

    def __init__(self, root: Path) -> None:
        self.root = root
        self.requests = []
        self.available = True
        owner = self

        class H(BaseHTTPRequestHandler):
            def do_GET(self):
                owner.requests.append(self.path)
                if not owner.available:
                    self.send_error(503)
                    return
                f = self.server.root / self.path.lstrip("/")  # type: ignore[attr-defined]
                if not f.is_file():
                    self.send_error(404)
                    return
                body = f.read_bytes()
                self.send_response(200)
                self.send_header("Content-Length", str(len(body)))
                self.send_header("Accept-Ranges", "none")
                self.end_headers()
                self.wfile.write(body)

            def log_message(self, *a):
                pass

        self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), H)
        self.httpd.root = root  # type: ignore[attr-defined]
        self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
        self.thread.start()

    @property
    def url(self) -> str:
        host, port = self.httpd.server_address[:2]
        return f"http://{host}:{port}"

    def stop(self):
        self.httpd.shutdown()
        self.httpd.server_close()
        self.thread.join(timeout=5)


@pytest.fixture()
def lib_source(tmp_path: Path):
    """Two packages; libb.so shared by both (identical bytes). Returns
    (server, table, files) where files maps staged soname -> bytes."""
    src = tmp_path / "debs"
    src.mkdir()
    shared = b"shared-impl-v1"
    _build_deb(src / "liba.deb", "liba.so", b"impl-a")
    _build_deb(src / "libb.deb", "libb.so", shared)
    _build_deb(src / "libc.deb", "libb.so", shared)  # identical collision

    server = _Server(src)
    table = {
        "liba": {"url": f"{server.url}/liba.deb",
                 "sha256": hashlib.sha256((src / "liba.deb").read_bytes()).hexdigest(),
                 "version": "1.0"},
        "libb": {"url": f"{server.url}/libb.deb",
                 "sha256": hashlib.sha256((src / "libb.deb").read_bytes()).hexdigest(),
                 "version": "2.0"},
        "libc": {"url": f"{server.url}/libc.deb",
                 "sha256": hashlib.sha256((src / "libc.deb").read_bytes()).hexdigest(),
                 "version": "3.0"},
    }
    yield server, table
    server.stop()


def _stage(tmp_path: Path, table: dict) -> Path:
    return srl.stage(tmp_path / "payload", table)


# ------------------------------------------------------------------ tests

@pytest.mark.parametrize("corruption", ["missing", "extra", "stale"])
def test_stage_cache_correctness(tmp_path, lib_source, corruption):
    """Initial stage, true cache hit with the source gone, and rebuild on
    missing / extra / corrupted output."""
    server, table = lib_source
    out = _stage(tmp_path, table)
    names = {"liba.so", "libb.so"}
    assert {p.name for p in out.glob("*.so*")} == names
    manifest_path = out.parent / "manifest.json"
    manifest = json.loads(manifest_path.read_text())
    manifest_path.write_bytes(b"\xef\xbb\xbf" + manifest_path.read_bytes())

    # Same URL/table throughout: corruption must invalidate output evidence,
    # not accidentally trigger the independent table-identity check.
    server.available = False
    import shutil
    shutil.rmtree(tmp_path / "payload" / ".work")
    requests = list(server.requests)
    assert srl.stage(tmp_path / "payload", table) == out
    assert server.requests == requests
    server.available = True
    if corruption == "missing":
        (out / "liba.so").unlink()
    elif corruption == "extra":
        (out / "libjunk.so").write_bytes(b"bogus")
    else:
        (out / "libb.so").write_bytes(b"corrupted bytes")
    assert srl.stage(tmp_path / "payload", table) == out
    assert len(server.requests) > len(requests)
    assert {p.name for p in out.glob("*.so*")} == names
    for name in names:
        assert hashlib.sha256((out / name).read_bytes()).hexdigest() == \
            manifest["files"][name]


def test_collision_identical_ok_conflicting_raises(tmp_path, lib_source):
    """Co-installed packages may share a soname only with identical bytes."""
    server, table = lib_source
    out = _stage(tmp_path, table)
    # libb + libc both ship libb.so with identical bytes: merged once.
    assert (out / "libb.so").read_bytes().startswith(b"FAKE-ELF libb.so")

    # Now a conflicting rebuild: same soname, different bytes.
    _build_deb(tmp_path / "debs" / "libc.deb", "libb.so", b"DIFFERENT-IMPL")
    table["libc"]["sha256"] = hashlib.sha256(
        (tmp_path / "debs" / "libc.deb").read_bytes()).hexdigest()

    # Force a miss: current manifest no longer validates for libc's bytes.
    with pytest.raises(srl.StageError, match="soname collision.*libb.so"):
        srl.stage(tmp_path / "payload", table)
    assert not (out.parent / "manifest.json").exists()


def test_rebuild_removes_superseded_license_files(tmp_path, lib_source):
    _, table = lib_source
    out = _stage(tmp_path, table)
    stale = out.parent / "share/doc/obsolete/copyright"
    stale.parent.mkdir(parents=True)
    stale.write_text("old package notice", encoding="utf-8")
    table["liba"]["version"] = "next"
    srl.stage(tmp_path / "payload", table)
    assert not stale.exists()
    assert (out / "liba.so").is_file()


def test_failed_download_identifies_the_pin_without_publishing(tmp_path, lib_source):
    _, table = lib_source
    (tmp_path / "debs/libb.deb").unlink()
    payload = tmp_path / "payload"
    with pytest.raises(srl.StageError) as caught:
        srl.stage(payload, table)
    message = str(caught.value)
    assert "libb" in message and table["libb"]["version"] in message
    assert table["libb"]["url"] in message
    assert "404" in message
    assert not (payload / "runtime-libs/manifest.json").exists()


def test_stale_scratch_cannot_poison_a_rebuilt_cache(tmp_path, lib_source):
    _, table = lib_source
    out = _stage(tmp_path, table)
    expected = (out / "liba.so").read_bytes()
    scratch = tmp_path / "payload/.work/runtime-libs/extract/liba" / PREFIX / "lib/liba.so"
    scratch.write_bytes(b"corrupted scratch bytes")
    (out / "liba.so").unlink()
    srl.stage(tmp_path / "payload", table)
    assert (out / "liba.so").read_bytes() == expected
