"""Build a release archive.

Runs on the vendor's machine, never on a deployment. The output is a `.tar.gz`
and the one string that has to be signed with the offline key.

REPRODUCIBLE ON PURPOSE

Every timestamp, owner and permission bit is normalised, members are sorted, and
gzip's own mtime field is zeroed. Building the same tree twice gives the same
bytes and therefore the same digest.

That is not tidiness. The signature attests to a digest, so anyone asking "is
the thing you signed the thing in the repository?" can only get an answer if the
build is deterministic. Without it, the honest answer is "trust me".
"""

from __future__ import annotations

import gzip
import hashlib
import io
import tarfile
from collections.abc import Iterable
from pathlib import Path

from app.release import version as versions
from app.release.manifest import MANIFEST_NAME, Artifact, Manifest, build_manifest

#: What goes into a release.
#:
#: An allow-list, not an ignore-list. An ignore-list ships whatever nobody
#: thought to exclude, and the thing nobody thinks to exclude is the file with
#: the credentials in it.
INCLUDE = ("app", "pyproject.toml", "README.md")

#: Never packaged, whatever the include list says.
#:
#: `.env` is here as a second line of defence rather than a first: configuration
#: lives in `shared/` and is not inside any included path. Belt and braces,
#: because the cost of being wrong once is a credential in a distributed archive.
EXCLUDE_NAMES = frozenset({
    "__pycache__",
    ".git",
    ".venv",
    "venv",
    ".pytest_cache",
    ".ruff_cache",
    ".env",
    ".DS_Store",
})

EXCLUDE_SUFFIXES = (".pyc", ".pyo", ".log", ".sqlite", ".sqlite3")

#: A fixed timestamp for every member. 2020-01-01, chosen only because it is
#: not zero — some tar readers treat epoch 0 as "unknown" and complain.
FIXED_MTIME = 1577836800


class PackageError(RuntimeError):
    """The release could not be built."""


def build(
    source: Path,
    version: str,
    destination: Path,
    requires_cms: str | None = None,
    notes: str = "",
) -> Artifact:
    """Package `source` as release `version` into `destination`.

    Returns the artifact, including the string that must be signed. Signing
    happens elsewhere, on the machine holding the private key — this function
    deliberately has no way to sign anything.
    """
    source = Path(source)
    destination = Path(destination)

    if not versions.is_valid(version):
        raise PackageError(f"Not a release version: {version!r}")

    if not source.is_dir():
        raise PackageError(f"No source tree at {source}.")

    members = sorted(_collect(source), key=lambda p: p[1])

    if not members:
        raise PackageError(f"Nothing to package in {source}.")

    manifest = build_manifest(
        version=version,
        migrations=_migrations(source),
        requires_python=_required_python(source),
        requires_cms=requires_cms,
        notes=notes,
    )

    destination.mkdir(parents=True, exist_ok=True)
    archive = destination / f"taxpilot-ai-{version}.tar.gz"

    _write_archive(archive, members, manifest)

    digest = sha256_of(archive)
    size = archive.stat().st_size

    return Artifact(
        path=archive,
        version=version,
        sha256=digest,
        size=size,
        manifest=manifest,
    )


def sha256_of(path: Path) -> str:
    """Digest a file without reading it into memory."""
    digest = hashlib.sha256()

    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)

    return digest.hexdigest()


# ── Internals ─────────────────────────────────────────────────────────────


def _collect(source: Path) -> Iterable[tuple[Path, str]]:
    """(absolute path, archive name) for everything that ships."""
    for entry in INCLUDE:
        path = source / entry

        if not path.exists():
            # A missing README should not fail a build; a missing app/ will
            # fail later on the empty-members check, which says more.
            continue

        if path.is_file():
            yield path, entry

            continue

        for child in path.rglob("*"):
            if not child.is_file() or _excluded(child, source):
                continue

            yield child, child.relative_to(source).as_posix()


def _excluded(path: Path, source: Path) -> bool:
    relative = path.relative_to(source)

    if any(part in EXCLUDE_NAMES for part in relative.parts):
        return True

    return path.suffix in EXCLUDE_SUFFIXES


def _write_archive(archive: Path, members: list[tuple[Path, str]], manifest: Manifest) -> None:
    manifest_bytes = manifest.to_json().encode("utf-8")

    # gzip's header carries an mtime of its own, which `tarfile.open(mode="w:gz")`
    # fills in with the clock. Wrapping it explicitly with mtime=0 is the only
    # way to keep two builds of the same tree byte-identical.
    with archive.open("wb") as raw, gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as compressed:
        with tarfile.open(fileobj=compressed, mode="w") as tar:  # type: ignore[arg-type]
            info = tarfile.TarInfo(MANIFEST_NAME)
            info.size = len(manifest_bytes)
            _normalise(info)
            tar.addfile(info, io.BytesIO(manifest_bytes))

            for path, name in members:
                info = tar.gettarinfo(str(path), arcname=name)
                _normalise(info)

                with path.open("rb") as handle:
                    tar.addfile(info, handle)


def _normalise(info: tarfile.TarInfo) -> None:
    """Strip everything about the machine that built this."""
    info.mtime = FIXED_MTIME
    info.uid = info.gid = 0
    info.uname = info.gname = ""
    info.mode = 0o755 if info.isdir() else 0o644


def _migrations(source: Path) -> tuple[str, ...]:
    """Which schema migrations this release carries.

    Read from the tree rather than declared by hand: a release that quietly
    omits a migration it needs is a service that starts and then fails on its
    first write, and nobody hand-maintains a list correctly forever.
    """
    directory = source / "app" / "database" / "migrations"

    if not directory.is_dir():
        return ()

    return tuple(sorted(p.name for p in directory.glob("*.sql")))


def _required_python(source: Path) -> str:
    """The floor from pyproject, normalised to a full version.

    Parsed rather than hardcoded so the package cannot disagree with the project
    it was built from — but only the simple `>=X.Y` form is understood, and
    anything else falls back rather than guessing at a constraint grammar.
    """
    pyproject = source / "pyproject.toml"

    if not pyproject.is_file():
        return "3.12.0"

    for line in pyproject.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()

        if not stripped.startswith("requires-python"):
            continue

        _, _, raw = stripped.partition("=")
        floor = raw.strip().strip('"').strip("'").lstrip(">=").strip()
        pieces = floor.split(".")

        if len(pieces) == 2 and all(p.isdigit() for p in pieces):
            return f"{floor}.0"

        if versions.is_valid(floor):
            return floor

    return "3.12.0"
