"""What a release says about itself, and the one string that gets signed.

TWO LAYERS, ON PURPOSE

The signature covers a short canonical string — version, digest, size — and
nothing else:

    taxpilot-ai-release-v1|{version}|{sha256}|{size}

Everything else a release needs to describe (which migrations it carries, what
Python it needs, which CMS versions it works with) lives in `manifest.json`
*inside* the archive. The digest covers the archive, the signature covers the
digest, so the manifest is authenticated by inclusion without the signed string
having to grow a field every time a release learns to describe something new.

That matters because the signed string is the one thing a signer and a verifier
must agree on exactly, forever. Keeping it to four values means the offline
signing step never has to change.

THE PREFIX IS THE PRODUCT BOUNDARY

`taxpilot-ai-release-v1`, where the CMS uses `taxpilot-update-v1`. The same
offline key signs both, so without distinct prefixes a CMS package signature
would verify perfectly well against an AI package of the same version and size —
and the CMS's own installer would happily extract a Python tarball over a
Laravel application. The prefix is what makes that impossible rather than
unlikely.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path

from app.release import version as versions

#: Bumping this invalidates every previous signature, by design.
MANIFEST_PREFIX = "taxpilot-ai-release-v1"

#: Where the manifest lives inside the archive.
MANIFEST_NAME = "manifest.json"


class ManifestError(ValueError):
    """The manifest is missing, malformed, or describes something unusable."""


def signing_string(version: str, sha256: str, size: int) -> str:
    """The exact bytes that get signed, built in one place.

    One function so a signer and a verifier cannot drift apart on whitespace or
    digest case — the failure that produces "the signature is invalid" on a
    package that is perfectly genuine, which is the worst kind of security
    error because the fix everyone reaches for is to switch the check off.
    """
    return "|".join([
        MANIFEST_PREFIX,
        version.strip(),
        sha256.strip().lower(),
        str(int(size)),
    ])


@dataclass(frozen=True, slots=True)
class SignedString:
    """A signing string that has been taken apart, after being verified."""

    version: str
    sha256: str
    size: int


def parse_signing_string(raw: str) -> SignedString:
    """Split a signing string back into its parts.

    Only ever called on a string whose signature has already been checked. That
    ordering is the whole point: the version, digest and size are attacker-
    controlled text until the signature verifies, and become facts afterwards.
    Parsing first and verifying later would make the parser part of the trust
    boundary for no reason.
    """
    pieces = raw.strip().split("|")

    if len(pieces) != 4:
        raise ManifestError("The signed string is malformed.")

    prefix, version, sha256, size = pieces

    if prefix != MANIFEST_PREFIX:
        # A CMS package signed with the same offline key would land here. The
        # digest and size would even be checkable — the prefix is the only thing
        # separating the two products, which is why it is checked explicitly and
        # loudly rather than being assumed from context.
        raise ManifestError(
            f"This is not a TaxPilot AI release: expected {MANIFEST_PREFIX!r}, found {prefix!r}."
        )

    if not versions.is_valid(version):
        raise ManifestError(f"The signed string has no usable version: {version!r}")

    if len(sha256) != 64 or not all(c in "0123456789abcdef" for c in sha256.lower()):
        raise ManifestError("The signed string has no usable digest.")

    try:
        parsed_size = int(size)
    except ValueError as exc:
        raise ManifestError("The signed string has no usable size.") from exc

    return SignedString(version=version, sha256=sha256.lower(), size=parsed_size)


@dataclass(frozen=True, slots=True)
class Signature:
    """A detached signature, as it sits beside an archive.

    The signed string travels WITH the signature rather than being rebuilt from
    the archive. That is what lets the installer verify before it opens
    anything: the version, digest and size all arrive as one authenticated
    claim, and the archive is then checked against that claim. Reading a
    manifest out of an unverified tarball to find out what to verify would put
    tar parsing inside the trust boundary.
    """

    manifest: str
    signature: str

    def to_json(self) -> str:
        return json.dumps({"manifest": self.manifest, "signature": self.signature}, indent=2) + "\n"

    @classmethod
    def from_json(cls, raw: str | bytes) -> Signature:
        try:
            data = json.loads(raw)
        except json.JSONDecodeError as exc:
            raise ManifestError(f"The signature file is not valid JSON: {exc}") from exc

        if not isinstance(data, dict):
            raise ManifestError("The signature file is not an object.")

        manifest = str(data.get("manifest") or "").strip()
        signature = str(data.get("signature") or "").strip()

        if not manifest or not signature:
            raise ManifestError("The signature file is missing the manifest or the signature.")

        return cls(manifest=manifest, signature=signature)

    @classmethod
    def read(cls, path: Path) -> Signature:
        try:
            return cls.from_json(path.read_text(encoding="utf-8"))
        except OSError as exc:
            raise ManifestError(f"Could not read {path}: {exc}") from exc


@dataclass(frozen=True, slots=True)
class Manifest:
    """What is in this release."""

    version: str

    built_at: str = ""
    """When it was built, ISO-8601 UTC. Informational — nothing gates on it.

    **Deliberately not serialised into the archive.** It describes the build
    event, not the thing built, and it used to travel inside the manifest: two
    builds of an identical tree that straddled a whole-second tick produced
    different bytes and so a different digest. Measured at 17 failures in 200
    builds — 8.5% — which made "I signed the code in this repository"
    unverifiable about one time in twelve, because rebuilding the same source
    could not be relied on to reproduce the artifact.

    It stays on the object so a caller can still record when a release was cut.
    It simply records that beside the artifact rather than inside it.
    """

    requires_python: str = "3.12.0"
    """Lowest Python this release runs on.

    Checked before installing rather than discovered on the first syntax error
    at import time, which happens after the files are already in place."""

    requires_cms: str | None = None
    """Lowest CMS version whose Agent API this release expects.

    Carried here and checked by Phase 7's negotiation. Recorded now so releases
    built before that exists still state what they need."""

    migrations: tuple[str, ...] = ()
    """Schema migrations included, in order.

    Recorded so `status` can show exactly what a release applied — which is the
    question an operator asks when deciding whether rolling the code back is
    safe. See docs/distribution.md on why schema changes must be additive."""

    notes: str = ""

    def to_dict(self) -> dict:
        return {
            "version": self.version,
            # `built_at` is absent on purpose — see the field. Reading it back
            # still works: from_dict defaults it, so a manifest written before
            # this change loads unchanged.
            "requires_python": self.requires_python,
            "requires_cms": self.requires_cms,
            "migrations": list(self.migrations),
            "notes": self.notes,
        }

    def to_json(self) -> str:
        # Sorted and newline-terminated so an unchanged manifest produces
        # identical bytes on every build — see package.py on reproducibility.
        return json.dumps(self.to_dict(), indent=2, sort_keys=True) + "\n"

    @classmethod
    def from_dict(cls, data: object) -> Manifest:
        if not isinstance(data, dict):
            raise ManifestError("The manifest is not an object.")

        version = str(data.get("version") or "").strip()

        if not versions.is_valid(version):
            raise ManifestError(f"The manifest has no usable version: {version!r}")

        requires_python = str(data.get("requires_python") or "3.12.0").strip()

        if not versions.is_valid(requires_python):
            raise ManifestError(f"requires_python is not a version: {requires_python!r}")

        requires_cms = data.get("requires_cms")

        if requires_cms is not None:
            requires_cms = str(requires_cms).strip()

            if not versions.is_valid(requires_cms):
                raise ManifestError(f"requires_cms is not a version: {requires_cms!r}")

        migrations = data.get("migrations") or []

        if not isinstance(migrations, list):
            raise ManifestError("migrations must be a list.")

        return cls(
            version=version,
            built_at=str(data.get("built_at") or ""),
            requires_python=requires_python,
            requires_cms=requires_cms,
            migrations=tuple(str(m) for m in migrations),
            notes=str(data.get("notes") or ""),
        )

    @classmethod
    def from_json(cls, raw: str | bytes) -> Manifest:
        try:
            return cls.from_dict(json.loads(raw))
        except json.JSONDecodeError as exc:
            raise ManifestError(f"The manifest is not valid JSON: {exc}") from exc

    @classmethod
    def read(cls, path: Path) -> Manifest:
        try:
            return cls.from_json(path.read_text(encoding="utf-8"))
        except OSError as exc:
            raise ManifestError(f"Could not read {path}: {exc}") from exc


def build_manifest(
    version: str,
    migrations: tuple[str, ...] = (),
    requires_python: str = "3.12.0",
    requires_cms: str | None = None,
    notes: str = "",
) -> Manifest:
    return Manifest(
        version=version,
        built_at=datetime.now(UTC).replace(microsecond=0).isoformat(),
        requires_python=requires_python,
        requires_cms=requires_cms,
        migrations=migrations,
        notes=notes,
    )


@dataclass(frozen=True, slots=True)
class Artifact:
    """A built package: the file, and what has to be signed for it."""

    path: Path
    version: str
    sha256: str
    size: int
    manifest: Manifest = field(default_factory=lambda: Manifest(version="0.0.0"))

    @property
    def signing_string(self) -> str:
        return signing_string(self.version, self.sha256, self.size)

    @property
    def signature_path(self) -> Path:
        """Where the detached signature is expected to sit.

        Beside the archive, same stem. A detached file rather than a header
        inside the archive, because the signature is produced on a different
        machine from the build — the one holding the offline key, which by
        design has nothing else on it."""
        return self.path.with_suffix(self.path.suffix + ".sig")
