"""The release CLI.

    python -m app.release build    --version 1.1.0 --out dist
    python -m app.release verify   dist/taxpilot-ai-1.1.0.tar.gz
    python -m app.release install  dist/taxpilot-ai-1.1.0.tar.gz --root /opt/taxpilot-ai
    python -m app.release rollback --root /opt/taxpilot-ai
    python -m app.release status   --root /opt/taxpilot-ai

`build` produces an archive and prints the string to sign. It cannot sign
anything: the private key lives offline, on a machine that holds nothing else,
and a build tool that could reach it would defeat the point of keeping it there.

Signing is a separate deliberate act on that machine:

    openssl dgst -sha256 -sign release-private.pem -out sig.bin manifest.txt
    base64 -w0 sig.bin

...or `php artisan updates:sign`, which the CMS releases already use. Either
produces the same bytes over the same string.
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import sys
from pathlib import Path

from app.api.compatibility import MINIMUM_CMS_VERSION
from app.release import package, signing
from app.release.installer import Installer, http_readiness, read_env_file
from app.release.layout import Layout
from app.release.manifest import ManifestError, Signature, parse_signing_string
from app.release.restart import NoRestart, from_env
from app.release.version import VERSION

logger = logging.getLogger("taxpilot.release")

EXIT_OK = 0
EXIT_FAILED = 1
EXIT_MISCONFIGURED = 78


def main(argv: list[str] | None = None) -> int:
    parser = _parser()
    arguments = parser.parse_args(argv)

    logging.basicConfig(
        level=logging.INFO,
        format="%(levelname)-8s %(message)s",
        stream=sys.stdout,
    )

    return arguments.handler(arguments)


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="python -m app.release", description=__doc__)
    commands = parser.add_subparsers(dest="command", required=True)

    build = commands.add_parser("build", help="package a release and print the string to sign")
    build.add_argument("--version", required=True)
    build.add_argument("--source", default=".", type=Path)
    build.add_argument("--out", default="dist", type=Path)
    build.add_argument(
        "--requires-cms",
        default=None,
        help="override the CMS floor this release declares (rarely right)",
    )
    build.add_argument("--notes", default="")
    build.set_defaults(handler=_build)

    verify = commands.add_parser("verify", help="check a package without installing it")
    verify.add_argument("archive", type=Path)
    verify.add_argument("--sig", type=Path, default=None)
    verify.add_argument("--key", type=Path, default=None)
    verify.set_defaults(handler=_verify)

    install = commands.add_parser("install", help="verify and install a package")
    install.add_argument("archive", type=Path)
    install.add_argument("--root", type=Path, default=None)
    install.add_argument("--sig", type=Path, default=None)
    install.add_argument("--key", type=Path, default=None)
    install.add_argument(
        "--allow-unsigned",
        action="store_true",
        help="install without verifying — for a locally built package only",
    )
    install.set_defaults(handler=_install)

    rollback = commands.add_parser("rollback", help="return to the previous release")
    rollback.add_argument("--root", type=Path, default=None)
    rollback.add_argument("--to", default=None, help="a specific installed version")
    rollback.set_defaults(handler=_rollback)

    status = commands.add_parser("status", help="what is installed and what happened")
    status.add_argument("--root", type=Path, default=None)
    status.add_argument("--json", action="store_true")
    status.set_defaults(handler=_status)

    return parser


# ── Commands ──────────────────────────────────────────────────────────────


def _build(arguments) -> int:
    # The code declares what it needs; the manifest records it. Taking this from
    # an operator by default would let a release claim a CMS floor its own
    # compatibility policy disagrees with — and the policy is what actually gets
    # enforced at boot, so the manifest would be the thing that was wrong.
    requires_cms = arguments.requires_cms or MINIMUM_CMS_VERSION

    try:
        artifact = package.build(
            source=arguments.source,
            version=arguments.version,
            destination=arguments.out,
            requires_cms=requires_cms,
            notes=arguments.notes,
        )
    except package.PackageError as exc:
        logger.error("%s", exc)

        return EXIT_FAILED

    manifest_file = artifact.path.with_suffix(artifact.path.suffix + ".manifest")
    manifest_file.write_text(artifact.signing_string, encoding="utf-8")

    print()
    print(f"  archive : {artifact.path}")
    print(f"  sha256  : {artifact.sha256}")
    print(f"  size    : {artifact.size}")
    print(f"  manifest: {manifest_file}")
    print()
    print("Sign this string with the offline key, then write the result beside the archive as")
    print(f"{artifact.path.name}.sig containing {{\"manifest\": ..., \"signature\": ...}}:")
    print()
    print(f"  {artifact.signing_string}")
    print()

    return EXIT_OK


def _verify(arguments) -> int:
    archive = arguments.archive

    if not archive.is_file():
        logger.error("No archive at %s.", archive)

        return EXIT_FAILED

    signature = _signature(archive, arguments.sig)

    if signature is None:
        logger.error("No signature found. Expected %s.sig beside the archive.", archive.name)

        return EXIT_FAILED

    key = _public_key(arguments.key)

    if key is None:
        logger.error("No public key configured. Set TAXPILOT_RELEASE_PUBLIC_KEY or pass --key.")

        return EXIT_MISCONFIGURED

    try:
        if not signing.verify(signature.manifest, signature.signature, key):
            logger.error("REFUSED — the signature does not match the manifest.")

            return EXIT_FAILED

        claim = parse_signing_string(signature.manifest)
    except (signing.SignatureError, ManifestError) as exc:
        logger.error("REFUSED — %s", exc)

        return EXIT_FAILED

    digest = package.sha256_of(archive)
    size = archive.stat().st_size

    if digest != claim.sha256:
        logger.error("REFUSED — digest mismatch: signed %s, found %s.", claim.sha256, digest)

        return EXIT_FAILED

    if size != claim.size:
        logger.error("REFUSED — size mismatch: signed %d bytes, found %d.", claim.size, size)

        return EXIT_FAILED

    print(f"OK — {archive.name} is release {claim.version}, signed by the release key.")

    return EXIT_OK


def _install(arguments) -> int:
    root = _root(arguments.root)

    if root is None:
        return EXIT_MISCONFIGURED

    layout = Layout(root)
    environment = dict(os.environ)
    environment.update(read_env_file(layout.shared / ".env"))
    restart = from_env(environment)

    installer = Installer(
        layout=layout,
        public_key=_public_key(arguments.key),
        restart=restart,
        probe=_probe(environment, restart),
        require_signature=not arguments.allow_unsigned,
    )

    result = installer.install(arguments.archive, _signature(arguments.archive, arguments.sig))

    if result.ok:
        print(f"Installed {result.summary()}")

        if result.detail:
            print(f"  {result.detail}")

        return EXIT_OK

    logger.error("%s", result.summary())

    return EXIT_FAILED


def _rollback(arguments) -> int:
    root = _root(arguments.root)

    if root is None:
        return EXIT_MISCONFIGURED

    layout = Layout(root)
    environment = dict(os.environ)
    environment.update(read_env_file(layout.shared / ".env"))
    restart = from_env(environment)

    installer = Installer(
        layout=layout,
        public_key=_public_key(None),
        restart=restart,
        probe=_probe(environment, restart),
    )

    result = installer.rollback(arguments.to)

    if result.ok:
        print(f"Rolled back to {result.version}. {result.detail}")

        return EXIT_OK

    logger.error("%s", result.summary())

    return EXIT_FAILED


def _status(arguments) -> int:
    root = _root(arguments.root)

    if root is None:
        return EXIT_MISCONFIGURED

    layout = Layout(root)
    state = layout.state()
    live = layout.current_version()

    if arguments.json:
        print(json.dumps({
            "running": VERSION,
            "current": live,
            "recorded": state.version,
            "previous": state.previous,
            "installed": layout.installed(),
            "history": [e.to_dict() for e in state.history],
        }, indent=2))

        return EXIT_OK

    print(f"  live      : {live or 'nothing'}")
    print(f"  previous  : {state.previous or '-'}")
    print(f"  installed : {', '.join(layout.installed()) or '-'}")

    if state.version and state.version != live:
        # The pointer is what runs; state.json is a record. Saying so beats
        # printing whichever is tidier and letting somebody act on the wrong one.
        print(f"  NOTE      : state.json says {state.version}, but the pointer says {live}.")

    if state.history:
        print()
        print("  recent:")

        for event in state.history[:10]:
            print(f"    {event.at}  {event.action:<8} {event.version:<10} {event.outcome:<12} {event.detail}")

    return EXIT_OK


# ── Shared ────────────────────────────────────────────────────────────────


def _root(explicit: Path | None) -> Path | None:
    root = explicit or os.environ.get("TAXPILOT_RELEASE_ROOT")

    if not root:
        logger.error("No deployment root. Pass --root or set TAXPILOT_RELEASE_ROOT.")

        return None

    return Path(root)


def _signature(archive: Path, explicit: Path | None) -> Signature | None:
    path = explicit or archive.with_suffix(archive.suffix + ".sig")

    if not path.is_file():
        return None

    try:
        return Signature.read(path)
    except ManifestError as exc:
        logger.error("%s", exc)

        return None


def _public_key(explicit: Path | None) -> str | None:
    """The pinned key, from a file or straight out of the environment.

    Inline PEM is supported because that is how a container gets a secret-free
    configuration value in without mounting a file for it.
    """
    if explicit is not None:
        try:
            return explicit.read_text(encoding="utf-8")
        except OSError as exc:
            logger.error("Could not read %s: %s", explicit, exc)

            return None

    configured = os.environ.get("TAXPILOT_RELEASE_PUBLIC_KEY", "").strip()

    if not configured:
        return None

    if "BEGIN" in configured:
        return configured

    path = Path(configured)

    return path.read_text(encoding="utf-8") if path.is_file() else None


def _probe(environment: dict[str, str], restart) -> object | None:
    """A readiness probe, but only when something will actually restart.

    Pairing a probe with NoRestart is not a slow path — it is a wrong one. If
    nothing restarts the service then either nothing is listening, and the
    install waits the full timeout before reporting a failure that did not
    happen; or the OLD process is still listening, answers 200, and the install
    is reported healthy while the code it replaced goes on running.

    The version check inside the probe closes the second case, but there is
    still no reason to spend ninety seconds asking a question whose premise is
    false. No restart, no probe — and the result says it was not verified.
    """
    if isinstance(restart, NoRestart):
        return None

    host = environment.get("TAXPILOT_HTTP_HOST", "127.0.0.1")
    port = environment.get("TAXPILOT_HTTP_PORT", "8080")

    # 0.0.0.0 is a bind address, not somewhere to connect to.
    host = "127.0.0.1" if host in {"0.0.0.0", "::", ""} else host

    return http_readiness(f"http://{host}:{port}/health/ready")


if __name__ == "__main__":
    raise SystemExit(main())
