"""Installing, gating and rolling back — against a real directory on disk.

Nothing here is mocked out at the filesystem boundary. Releases are built,
signed, extracted, switched and reverted for real, and the assertions are about
what is on disk afterwards. An installer verified against a mock is an installer
nobody has run.

The application inside each test release is a stand-in that answers `migrate`
and `check` and can be told to fail either one. That is enough: the installer's
job is to decide what to do about those answers, not to know what they mean.
"""

from __future__ import annotations

import tarfile
from pathlib import Path

import pytest

from app.release import package
from app.release.installer import Installer, read_env_file
from app.release.layout import Layout
from app.release.manifest import Signature, signing_string
from app.release.restart import NoRestart, RestartResult

FAKE_APP = '''\
import os
import sys

command = sys.argv[1] if len(sys.argv) > 1 else "run"
marker = os.environ.get("FAKE_MARKER")

if marker:
    with open(marker, "a", encoding="utf-8") as handle:
        handle.write(command + "\\n")

if command in os.environ.get("FAKE_FAIL", "").split(","):
    print(command + ": deliberate failure", file=sys.stderr)
    raise SystemExit(1)

print(command + ": ok")
'''


# ── Building test releases ────────────────────────────────────────────────


def make_source(root: Path, marker: str = "") -> Path:
    root.mkdir(parents=True, exist_ok=True)
    (root / "app").mkdir(exist_ok=True)
    (root / "pyproject.toml").write_text('requires-python = ">=3.12"\n', encoding="utf-8")
    (root / "app" / "__init__.py").write_text("", encoding="utf-8")
    (root / "app" / "__main__.py").write_text(FAKE_APP, encoding="utf-8")
    (root / "app" / "marker.py").write_text(f"MARKER = {marker!r}\n", encoding="utf-8")

    return root


def build_signed(tmp_path: Path, version: str, key, marker: str = "") -> Path:
    """A release archive with a valid detached signature beside it."""
    source = make_source(tmp_path / f"src-{version}", marker or version)
    artifact = package.build(source, version, tmp_path / "dist")

    artifact.signature_path.write_text(
        Signature(
            manifest=artifact.signing_string,
            signature=key.sign(artifact.signing_string),
        ).to_json(),
        encoding="utf-8",
    )

    return artifact.path


class Restarts:
    """Records restarts, and can be told to fail."""

    def __init__(self, ok: bool = True) -> None:
        self.ok = ok
        self.count = 0

    def restart(self) -> RestartResult:
        self.count += 1

        return RestartResult(self.ok, "restarted" if self.ok else "service would not start")


def probe(ready: bool, detail: str = ""):
    """A stand-in for the HTTP readiness check.

    Takes the expected version, like the real one — the argument exists because
    "is something listening?" cannot tell a restarted service from the old
    process still holding the port.
    """
    return lambda expected: (ready, detail or ("ready" if ready else "never became ready"))


@pytest.fixture
def deployment(tmp_path: Path) -> Layout:
    layout = Layout(tmp_path / "deploy")
    layout.prepare()
    (layout.shared / ".env").write_text(
        f"FAKE_MARKER={tmp_path / 'steps.txt'}\n", encoding="utf-8"
    )

    return layout


def installer_for(layout: Layout, key, **kwargs) -> Installer:
    kwargs.setdefault("restart", NoRestart())
    kwargs.setdefault("probe", probe(True))

    return Installer(layout=layout, public_key=key.public_pem, **kwargs)


# ── Tests ─────────────────────────────────────────────────────────────────


class TestInstalling:
    def test_a_signed_release_installs_and_becomes_live(self, tmp_path, deployment, test_key):
        archive = build_signed(tmp_path, "1.0.0", test_key)

        result = installer_for(deployment, test_key).install(archive)

        assert result.ok, result.summary()
        assert deployment.current_version() == "1.0.0"
        assert (deployment.live_path() / "app" / "__main__.py").is_file()

    def test_it_migrates_before_it_checks(self, tmp_path, deployment, test_key):
        """Order matters: a preflight check against a schema the release has not
        migrated yet would fail on a release that is perfectly fine."""
        installer_for(deployment, test_key).install(build_signed(tmp_path, "1.0.0", test_key))

        steps = (tmp_path / "steps.txt").read_text(encoding="utf-8").split()

        assert steps == ["migrate", "check"]

    def test_the_configuration_reaches_the_subprocesses(self, tmp_path, deployment, test_key):
        """`check` needs the deployment's configuration to mean anything. If the
        installer ran it with a bare environment it would fail on every release,
        for reasons that have nothing to do with the release."""
        installer_for(deployment, test_key).install(build_signed(tmp_path, "1.0.0", test_key))

        # The marker file only exists because FAKE_MARKER came out of shared/.env.
        assert (tmp_path / "steps.txt").is_file()

    def test_a_second_release_supersedes_the_first(self, tmp_path, deployment, test_key):
        installer = installer_for(deployment, test_key)
        installer.install(build_signed(tmp_path, "1.0.0", test_key))
        result = installer.install(build_signed(tmp_path, "1.1.0", test_key))

        assert result.ok
        assert deployment.current_version() == "1.1.0"
        assert deployment.state().previous == "1.0.0"
        assert deployment.installed() == ["1.0.0", "1.1.0"]

    def test_the_running_version_is_not_reinstalled(self, tmp_path, deployment, test_key):
        installer = installer_for(deployment, test_key)
        archive = build_signed(tmp_path, "1.0.0", test_key)
        installer.install(archive)

        assert installer.install(archive).reason == "already_current"

    def test_the_service_is_restarted(self, tmp_path, deployment, test_key):
        restarts = Restarts()
        installer_for(deployment, test_key, restart=restarts).install(
            build_signed(tmp_path, "1.0.0", test_key)
        )

        assert restarts.count == 1

    def test_the_install_is_recorded(self, tmp_path, deployment, test_key):
        installer_for(deployment, test_key).install(build_signed(tmp_path, "1.0.0", test_key))

        state = deployment.state()

        assert state.version == "1.0.0"
        assert state.history[0].action == "install"
        assert state.history[0].outcome == "installed"


class TestRefusingToInstall:
    """Everything here must leave the live release untouched."""

    def test_an_unsigned_release_is_refused(self, tmp_path, deployment, test_key):
        archive = build_signed(tmp_path, "1.0.0", test_key)
        archive.with_suffix(archive.suffix + ".sig").unlink()

        result = installer_for(deployment, test_key).install(archive)

        assert not result.ok
        assert result.reason == "unverified"

    def test_a_release_signed_by_the_wrong_key_is_refused(
        self, tmp_path, deployment, test_key, other_key
    ):
        archive = build_signed(tmp_path, "1.0.0", other_key)

        result = installer_for(deployment, test_key).install(archive)

        assert result.reason == "unverified"
        assert deployment.current_version() is None

    def test_verification_fails_closed_without_a_key(self, tmp_path, deployment, test_key):
        """"Cannot verify" must never quietly become "assume fine" — that would
        hand back exactly the property the offline key exists to provide."""
        archive = build_signed(tmp_path, "1.0.0", test_key)

        result = Installer(deployment, public_key=None, probe=probe(True)).install(archive)

        assert result.reason == "unverified"
        assert "no public key" in result.detail.lower()

    def test_a_cms_package_signature_is_refused(self, tmp_path, deployment, test_key):
        """The cross-product replay. The same offline key signs CMS packages, so
        the signature verifies perfectly — only the prefix separates them."""
        archive = build_signed(tmp_path, "1.0.0", test_key)
        digest = package.sha256_of(archive)
        cms_manifest = f"taxpilot-update-v1|1.0.0|{digest}|{archive.stat().st_size}"

        result = installer_for(deployment, test_key).install(
            archive, Signature(cms_manifest, test_key.sign(cms_manifest))
        )

        assert result.reason == "unverified"
        assert "not a TaxPilot AI release" in result.detail

    def test_a_valid_signature_does_not_authenticate_other_bytes(
        self, tmp_path, deployment, test_key
    ):
        """The signature attests to a digest. Without holding the file to that
        claim, one genuine signature would authenticate any archive at all."""
        archive = build_signed(tmp_path, "1.0.0", test_key)
        lying = signing_string("1.0.0", "ab" * 32, archive.stat().st_size)

        result = installer_for(deployment, test_key).install(
            archive, Signature(lying, test_key.sign(lying))
        )

        assert result.reason == "unverified"
        assert "Digest mismatch" in result.detail

    def test_a_size_that_disagrees_with_the_signature_is_refused(
        self, tmp_path, deployment, test_key
    ):
        archive = build_signed(tmp_path, "1.0.0", test_key)
        lying = signing_string("1.0.0", package.sha256_of(archive), 999999)

        result = installer_for(deployment, test_key).install(
            archive, Signature(lying, test_key.sign(lying))
        )

        assert "Size mismatch" in result.detail

    def test_a_missing_archive_is_reported_not_raised(self, tmp_path, deployment, test_key):
        result = installer_for(deployment, test_key).install(tmp_path / "nothing.tar.gz")

        assert result.reason == "missing_archive"

    def test_an_archive_that_escapes_its_directory_is_refused(
        self, tmp_path, deployment, test_key
    ):
        """CVE-2007-4559. A tar member named `../…` writes outside the
        destination, and this code extracts where it can overwrite the live
        application."""
        evil = tmp_path / "dist" / "taxpilot-ai-9.9.9.tar.gz"
        evil.parent.mkdir(parents=True, exist_ok=True)
        outside = tmp_path / "escaped.txt"

        with tarfile.open(evil, "w:gz") as tar:
            member = tarfile.TarInfo("../../escaped.txt")
            member.size = 4
            tar.addfile(member, __import__("io").BytesIO(b"evil"))

        manifest = signing_string("9.9.9", package.sha256_of(evil), evil.stat().st_size)
        result = installer_for(deployment, test_key).install(
            evil, Signature(manifest, test_key.sign(manifest))
        )

        assert not result.ok
        assert not outside.exists()

    def test_an_archive_labelled_differently_from_its_signature_is_refused(
        self, tmp_path, deployment, test_key
    ):
        """The manifest inside says one version; the signature says another.
        Only reachable by signing a mislabelled archive, and refusing keeps the
        state file and the release directory agreeing about what is installed."""
        archive = build_signed(tmp_path, "1.0.0", test_key)
        claim = signing_string("2.0.0", package.sha256_of(archive), archive.stat().st_size)

        result = installer_for(deployment, test_key).install(
            archive, Signature(claim, test_key.sign(claim))
        )

        assert result.reason == "rejected"
        assert "signed as 2.0.0" in result.detail

    def test_a_release_needing_a_newer_python_is_refused(self, tmp_path, deployment, test_key):
        source = make_source(tmp_path / "future")
        (source / "pyproject.toml").write_text('requires-python = ">=99.0"\n', encoding="utf-8")
        artifact = package.build(source, "1.0.0", tmp_path / "dist")
        artifact.signature_path.write_text(
            Signature(artifact.signing_string, test_key.sign(artifact.signing_string)).to_json(),
            encoding="utf-8",
        )

        result = installer_for(deployment, test_key).install(artifact.path)

        assert result.reason == "rejected"
        assert "Python 99.0.0" in result.detail


class TestTheHealthGate:
    """Failures caught here happen while the old release is still live."""

    def test_a_release_whose_migrations_fail_never_goes_live(
        self, tmp_path, deployment, test_key
    ):
        installer = installer_for(deployment, test_key)
        installer.install(build_signed(tmp_path, "1.0.0", test_key))

        (deployment.shared / ".env").write_text("FAKE_FAIL=migrate\n", encoding="utf-8")
        result = installer.install(build_signed(tmp_path, "1.1.0", test_key))

        assert result.reason == "failed_gate"
        assert "Migrations failed" in result.detail
        assert deployment.current_version() == "1.0.0"

    def test_a_release_that_fails_preflight_never_goes_live(
        self, tmp_path, deployment, test_key
    ):
        installer = installer_for(deployment, test_key)
        installer.install(build_signed(tmp_path, "1.0.0", test_key))

        (deployment.shared / ".env").write_text("FAKE_FAIL=check\n", encoding="utf-8")
        result = installer.install(build_signed(tmp_path, "1.1.0", test_key))

        assert result.reason == "failed_gate"
        assert deployment.current_version() == "1.0.0"

    def test_a_rejected_release_leaves_nothing_behind(self, tmp_path, deployment, test_key):
        """A half-installed release directory would be offered by `status` as
        though it were installable, and would be picked up by a later rollback."""
        installer = installer_for(deployment, test_key)
        installer.install(build_signed(tmp_path, "1.0.0", test_key))

        (deployment.shared / ".env").write_text("FAKE_FAIL=check\n", encoding="utf-8")
        installer.install(build_signed(tmp_path, "1.1.0", test_key))

        assert deployment.installed() == ["1.0.0"]

    def test_the_first_install_failing_leaves_an_empty_deployment(
        self, tmp_path, deployment, test_key
    ):
        (deployment.shared / ".env").write_text("FAKE_FAIL=check\n", encoding="utf-8")

        result = installer_for(deployment, test_key).install(
            build_signed(tmp_path, "1.0.0", test_key)
        )

        assert result.reason == "failed_gate"
        assert result.rolled_back is False
        assert deployment.current_version() is None


class TestRollingBackAfterTheSwitch:
    """Failures here happen with the new release already live."""

    def test_a_release_that_will_not_start_is_rolled_back(
        self, tmp_path, deployment, test_key
    ):
        installer_for(deployment, test_key).install(build_signed(tmp_path, "1.0.0", test_key))

        broken = Installer(
            deployment,
            public_key=test_key.public_pem,
            restart=NoRestart(),
            probe=probe(False, "readiness never returned 200"),
        )
        result = broken.install(build_signed(tmp_path, "1.1.0", test_key))

        assert not result.ok
        assert result.rolled_back is True
        assert deployment.current_version() == "1.0.0"

    def test_a_failed_restart_rolls_back_too(self, tmp_path, deployment, test_key):
        installer_for(deployment, test_key).install(build_signed(tmp_path, "1.0.0", test_key))

        restarts = Restarts(ok=False)
        result = installer_for(deployment, test_key, restart=restarts).install(
            build_signed(tmp_path, "1.1.0", test_key)
        )

        assert result.rolled_back is True
        assert deployment.current_version() == "1.0.0"

    def test_a_first_install_that_fails_has_nowhere_to_roll_back_to(
        self, tmp_path, deployment, test_key
    ):
        """Reported honestly rather than claimed as rolled back: there is no
        previous release, and the operator needs to know the deployment is
        pointing at something that does not answer."""
        result = Installer(
            deployment, public_key=test_key.public_pem, probe=probe(False)
        ).install(build_signed(tmp_path, "1.0.0", test_key))

        assert result.rolled_back is False
        assert result.reason == "unhealthy"


class TestRollback:
    def test_it_returns_to_the_previous_release(self, tmp_path, deployment, test_key):
        installer = installer_for(deployment, test_key)
        installer.install(build_signed(tmp_path, "1.0.0", test_key))
        installer.install(build_signed(tmp_path, "1.1.0", test_key))

        result = installer.rollback()

        assert result.ok
        assert deployment.current_version() == "1.0.0"

    def test_it_can_go_to_a_named_release(self, tmp_path, deployment, test_key):
        installer = installer_for(deployment, test_key)
        installer.install(build_signed(tmp_path, "1.0.0", test_key))
        installer.install(build_signed(tmp_path, "1.1.0", test_key))
        installer.install(build_signed(tmp_path, "1.2.0", test_key))

        installer.rollback(to="1.0.0")

        assert deployment.current_version() == "1.0.0"

    def test_it_does_not_run_migrations(self, tmp_path, deployment, test_key):
        """There is nothing to run. The schema is already ahead, and the release
        being restored is required to work against it — see the expand/contract
        policy in docs/distribution.md."""
        installer = installer_for(deployment, test_key)
        installer.install(build_signed(tmp_path, "1.0.0", test_key))
        installer.install(build_signed(tmp_path, "1.1.0", test_key))

        steps = tmp_path / "steps.txt"
        steps.unlink()
        installer.rollback()

        assert not steps.exists()

    def test_it_restarts(self, tmp_path, deployment, test_key):
        restarts = Restarts()
        installer = installer_for(deployment, test_key, restart=restarts)
        installer.install(build_signed(tmp_path, "1.0.0", test_key))
        installer.install(build_signed(tmp_path, "1.1.0", test_key))
        before = restarts.count

        installer.rollback()

        assert restarts.count == before + 1

    def test_there_is_nothing_to_roll_back_to_on_a_fresh_deployment(
        self, deployment, test_key
    ):
        result = installer_for(deployment, test_key).rollback()

        assert not result.ok
        assert result.reason == "nothing_to_roll_back_to"

    def test_a_release_that_is_not_installed_is_refused(self, tmp_path, deployment, test_key):
        installer = installer_for(deployment, test_key)
        installer.install(build_signed(tmp_path, "1.0.0", test_key))

        result = installer.rollback(to="4.0.0")

        assert result.reason == "not_installed"
        assert deployment.current_version() == "1.0.0"

    def test_it_reports_success_even_when_the_probe_fails(
        self, tmp_path, deployment, test_key
    ):
        """An operator rolling back is already dealing with a broken service.
        Reporting the rollback as failed when the pointer did move would send
        them looking in the wrong place."""
        installer = installer_for(deployment, test_key)
        installer.install(build_signed(tmp_path, "1.0.0", test_key))
        installer.install(build_signed(tmp_path, "1.1.0", test_key))

        unhealthy = Installer(
            deployment, public_key=test_key.public_pem, probe=probe(False, "still down")
        )
        result = unhealthy.rollback()

        assert result.ok
        assert result.reason == "unverified"
        assert deployment.current_version() == "1.0.0"


class TestTheReadinessProbe:
    """A 200 alone does not mean the new release is running.

    Found by running the CLI rather than by reading it: an install with no
    restart configured sat waiting ninety seconds for a service nobody had
    restarted. The same shape with the old process still holding the port is
    the dangerous version — it answers 200, and the install reports success
    while the code it replaced goes on running.
    """

    def test_it_accepts_the_expected_version(self):
        probe = _probe_against({"status": "ok", "version": "1.1.0"})

        assert probe("1.1.0")[0] is True

    def test_it_refuses_a_different_version_on_the_same_port(self):
        """The old process, still listening, still healthy, still wrong."""
        probe = _probe_against({"status": "ok", "version": "1.0.0"}, timeout=0)

        ready, detail = probe("1.1.0")

        assert ready is False
        assert "still running 1.0.0" in detail

    def test_it_accepts_a_release_that_reports_no_version(self):
        """An older build being rolled back to. Refusing it would break the
        rollback path exactly when it is needed."""
        probe = _probe_against({"status": "ok"})

        assert probe("1.1.0")[0] is True

    def test_a_non_200_is_not_ready(self):
        probe = _probe_against({"status": "failing", "version": "1.1.0"}, status=503, timeout=0)

        assert probe("1.1.0")[0] is False

    def test_an_unreachable_service_is_not_ready(self):
        from app.release.installer import http_readiness

        probe = http_readiness(
            "http://127.0.0.1:9/health/ready", timeout=0, sleep=lambda _: None
        )

        assert probe("1.1.0")[0] is False

    def test_no_probe_means_not_verified_rather_than_healthy(
        self, tmp_path, deployment, test_key
    ):
        installer = Installer(deployment, public_key=test_key.public_pem, probe=None)

        result = installer.install(build_signed(tmp_path, "1.0.0", test_key))

        assert result.ok
        assert "not verified" in result.detail

    def test_the_cli_does_not_probe_when_nothing_restarts(self):
        """The pairing that produced the original bug."""
        from app.release.__main__ import _probe

        assert _probe({}, NoRestart()) is None

    def test_the_cli_does_probe_when_a_restart_is_configured(self):
        from app.release.__main__ import _probe
        from app.release.restart import systemd

        assert _probe({}, systemd("taxpilot")) is not None


class TestAnUnverifiedInstallWithNothingToRevertTo:
    def test_the_release_stays_live_and_the_state_says_so(
        self, tmp_path, deployment, test_key
    ):
        """There is nothing better to point at, and unpointing would leave the
        deployment with no application at all. What must not happen is the
        pointer and the state file disagreeing about it."""
        result = Installer(
            deployment, public_key=test_key.public_pem, probe=probe(False, "no answer")
        ).install(build_signed(tmp_path, "1.0.0", test_key))

        assert result.reason == "unhealthy"
        assert result.rolled_back is False
        assert deployment.current_version() == "1.0.0"
        assert deployment.state().version == "1.0.0"
        assert deployment.state().history[0].outcome == "unhealthy"


def _probe_against(payload: dict, status: int = 200, timeout: float = 30):
    """A readiness probe pointed at a one-shot HTTP server serving `payload`."""
    import json
    import threading
    from http.server import BaseHTTPRequestHandler, HTTPServer

    from app.release.installer import http_readiness

    body = json.dumps(payload).encode()

    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):  # noqa: N802
            self.send_response(status)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, *_args):  # noqa: A002
            pass

    server = HTTPServer(("127.0.0.1", 0), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()

    url = f"http://127.0.0.1:{server.server_address[1]}/health/ready"

    return http_readiness(url, timeout=timeout, sleep=lambda _: None)


class TestReadingTheEnvironmentFile:
    def test_it_reads_plain_pairs(self, tmp_path):
        path = tmp_path / ".env"
        path.write_text("A=1\nB=two\n", encoding="utf-8")

        assert read_env_file(path) == {"A": "1", "B": "two"}

    def test_it_strips_matched_quotes(self, tmp_path):
        path = tmp_path / ".env"
        path.write_text("A=\"quoted\"\nB='single'\n", encoding="utf-8")

        assert read_env_file(path) == {"A": "quoted", "B": "single"}

    def test_it_ignores_comments_and_blanks(self, tmp_path):
        path = tmp_path / ".env"
        path.write_text("# a comment\n\nA=1\nnot a pair\n", encoding="utf-8")

        assert read_env_file(path) == {"A": "1"}

    def test_a_value_containing_an_equals_survives(self, tmp_path):
        """Base64 secrets and DSNs are full of them."""
        path = tmp_path / ".env"
        path.write_text("KEY=abc==\nURL=postgresql://u:p@h/db?x=1\n", encoding="utf-8")

        assert read_env_file(path)["KEY"] == "abc=="
        assert read_env_file(path)["URL"] == "postgresql://u:p@h/db?x=1"

    def test_a_missing_file_is_not_an_error(self, tmp_path):
        assert read_env_file(tmp_path / "nope") == {}
