"""The off-site backup puller (CMS audit C-1).

The property that matters most: nothing this pulls is trusted until its bytes
match the digest the CMS promised, and nothing that fails that check survives
on disk. A mirror holding a plausible archive that would not restore is worse
than a mirror with a hole in it.
"""

from __future__ import annotations

import hashlib
import hmac
import json

import pytest

from app.backup.puller import BackupPuller, PullError
from app.security.signing import canonical_message
from app.support.http import HttpResponse

SECRET = "puller-secret"
KEY = "tpa_backup_puller"


def listing(*artefacts: tuple[str, bytes]) -> HttpResponse:
    return HttpResponse(200, json.dumps({
        "ok": True,
        "data": [
            {
                "name": name,
                "size_bytes": len(body),
                "sha256": hashlib.sha256(body).hexdigest(),
                "modified_at": "2026-08-04T01:45:00+00:00",
            }
            for name, body in artefacts
        ],
    }).encode())


class FakeCms:
    """Answers like the CMS, records what was asked."""

    def __init__(self, responses: list[HttpResponse]) -> None:
        self.responses = responses
        self.requests: list[dict] = []

    def request(self, method, url, *, headers=None, json=None, data=None, timeout=30.0):
        self.requests.append({"method": method, "url": url, "headers": headers or {}})

        return self.responses.pop(0)


def puller(tmp_path, cms: FakeCms, keep: int = 7) -> BackupPuller:
    return BackupPuller(
        cms_base_url="https://cms.example",
        api_key=KEY,
        api_secret=SECRET,
        destination=tmp_path / "mirror",
        keep_per_family=keep,
        transport=cms,
    )


def test_artefacts_are_fetched_verified_and_written(tmp_path):
    archive, dump = b"zip bytes here", b"dump bytes here"
    cms = FakeCms([
        listing(
            ("files_2026-08-04_014500.zip", archive),
            ("life_associate_2026-08-04_013000.sql.gz", dump),
        ),
        HttpResponse(200, archive),
        HttpResponse(200, dump),
    ])

    result = puller(tmp_path, cms).pull()

    assert result.fetched == [
        "files_2026-08-04_014500.zip",
        "life_associate_2026-08-04_013000.sql.gz",
    ]
    assert (tmp_path / "mirror" / "files_2026-08-04_014500.zip").read_bytes() == archive
    assert (tmp_path / "mirror" / "life_associate_2026-08-04_013000.sql.gz").read_bytes() == dump


def test_every_request_is_signed_for_the_routed_path(tmp_path):
    body = b"bytes"
    cms = FakeCms([listing(("files_a.zip", body)), HttpResponse(200, body)])

    puller(tmp_path, cms).pull()

    for request in cms.requests:
        headers = request["headers"]
        path = request["url"].removeprefix("https://cms.example/")

        expected = hmac.new(
            SECRET.encode(),
            canonical_message(
                headers["X-Timestamp"], headers["X-Nonce"], "GET", path, ""
            ).encode(),
            hashlib.sha256,
        ).hexdigest()

        assert headers["X-Agent-Key"] == KEY
        assert headers["X-Signature"] == expected
        # The cPanel firewall judges the client by this header alone —
        # see user_agent(). Without it, no request reaches the CMS at all.
        assert "compatible; TaxPilot-AI" in headers["User-Agent"]


def test_a_transfer_that_does_not_match_its_digest_is_discarded_and_fatal(tmp_path):
    promised = b"what the CMS hashed"
    cms = FakeCms([
        listing(("files_a.zip", promised)),
        HttpResponse(200, b"what actually arrived"),
    ])

    with pytest.raises(PullError, match="Discarded"):
        puller(tmp_path, cms).pull()

    # Neither under its real name nor as leftover wreckage.
    assert list((tmp_path / "mirror").iterdir()) == []


def test_an_artefact_already_held_intact_is_not_fetched_again(tmp_path):
    body = b"unchanged bytes"
    mirror = tmp_path / "mirror"
    mirror.mkdir()
    (mirror / "files_a.zip").write_bytes(body)

    cms = FakeCms([listing(("files_a.zip", body))])

    result = puller(tmp_path, cms).pull()

    assert result.already_present == ["files_a.zip"]
    assert result.fetched == []
    assert len(cms.requests) == 1  # the listing; no download


def test_a_local_copy_that_rotted_is_refetched(tmp_path):
    good = b"the real archive"
    mirror = tmp_path / "mirror"
    mirror.mkdir()
    (mirror / "files_a.zip").write_bytes(b"bit-rotted bytes")

    cms = FakeCms([listing(("files_a.zip", good)), HttpResponse(200, good)])

    result = puller(tmp_path, cms).pull()

    assert result.fetched == ["files_a.zip"]
    assert (mirror / "files_a.zip").read_bytes() == good


def test_retention_is_per_family_so_dumps_cannot_age_out_document_archives(tmp_path):
    mirror = tmp_path / "mirror"
    mirror.mkdir()

    for day in range(1, 5):
        (mirror / f"life_associate_2026-08-0{day}_013000.sql.gz").write_bytes(b"dump")
    (mirror / "files_2026-08-01_014500.zip").write_bytes(b"the only document archive")

    body = b"fresh"
    cms = FakeCms([listing(("files_2026-08-04_014500.zip", body)), HttpResponse(200, body)])

    result = puller(tmp_path, cms, keep=2).pull()

    names = sorted(p.name for p in mirror.iterdir())

    # Dumps pruned to the newest two; BOTH document archives survive.
    assert names == [
        "files_2026-08-01_014500.zip",
        "files_2026-08-04_014500.zip",
        "life_associate_2026-08-03_013000.sql.gz",
        "life_associate_2026-08-04_013000.sql.gz",
    ]
    assert "life_associate_2026-08-01_013000.sql.gz" in result.pruned


def test_history_beyond_the_keep_window_is_never_downloaded(tmp_path):
    """Found on the first live pull, not by review.

    The CMS listed 17 dumps, the mirror keeps 7 — so every run fetched the
    same 10 stale dumps and pruned them seconds later, forever. What
    retention would delete must not be transferred at all.
    """
    old = [(f"life_associate_2026-07-{day:02d}_013000.sql.gz", b"old dump") for day in range(20, 30)]
    new = [(f"life_associate_2026-08-{day:02d}_013000.sql.gz", b"new dump") for day in range(1, 3)]

    cms = FakeCms([
        listing(*old, *new),
        HttpResponse(200, b"new dump"),
        HttpResponse(200, b"new dump"),
    ])

    result = puller(tmp_path, cms, keep=2).pull()

    assert result.fetched == [name for name, _ in new]
    # One listing plus one download per KEPT artefact — and nothing else.
    assert len(cms.requests) == 3


def test_an_empty_listing_is_a_failure_not_a_quiet_night(tmp_path):
    cms = FakeCms([HttpResponse(200, json.dumps({"ok": True, "data": []}).encode())])

    with pytest.raises(PullError, match="no backup artefacts"):
        puller(tmp_path, cms).pull()


def test_a_refusal_from_the_cms_is_fatal_and_names_the_status(tmp_path):
    cms = FakeCms([HttpResponse(403, json.dumps({
        "ok": False, "error": "permission_denied", "required_permission": "backups.read",
    }).encode())])

    with pytest.raises(PullError, match="403"):
        puller(tmp_path, cms).pull()


def test_the_entry_point_refuses_to_run_unconfigured(tmp_path):
    from app.backup.__main__ import main

    assert main(env={}) == 2
