#!/usr/bin/env python3
"""
release_lib -- reproducible, sanitized, signed release-build core.

Stdlib-only except for two crypto deps that are already product dependencies
(`cryptography` for Ed25519, `dilithium_py` for ML-DSA-65). No network. No
runtime state read. Air-gap safe.

Responsibilities
----------------
1. read_version()        -- single source of truth (pyproject.toml), with a
                            cross-check against lib/config.py.
2. parse_semver / assert_version_advances
                          -- clean semver + refuse the "7.5.1 predates 7.5.0"
                            inversion bug.
3. is_excluded / scan_secrets
                          -- sanitization: strip runtime state, drop internal
                            IPs / credentials.
4. sign_* / verify_*      -- dual Ed25519 + ML-DSA-65 detached signatures.
5. build_release()        -- orchestrates: collect -> sanitize -> zip
                            (deterministic) -> hash -> sign -> manifest.

PRODUCTION KEY DESIGN (consumed by bin/build-release.py, NOT by tests):
    Ed25519 classical : donjonsec-infrastructure/keys/donjon-private-classical.pem
                        (PKCS8 PEM, loadable via cryptography)
    ML-DSA-65 PQC     : donjonsec-infrastructure/keys/donjon-private-pqc.bin
                        (4032-byte raw secret key for dilithium_py ML_DSA_65)
    Mirrors: C:/Donjonsec/keys (gitignored), CT100 /opt/donjonsec-keys.
    These keys MUST be reused -- generating new keys orphans every paid
    license. Tests use generate_throwaway_* instead.
"""
from __future__ import annotations

import dataclasses
import hashlib
import json
import os
import re
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, List, Optional, Tuple

# --- crypto deps (already product dependencies) -----------------------------
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey,
    Ed25519PublicKey,
)
from cryptography.hazmat.primitives import serialization
from cryptography.exceptions import InvalidSignature

try:
    from dilithium_py.ml_dsa import ML_DSA_65
except Exception:  # pragma: no cover - dep is required for prod build
    ML_DSA_65 = None


class VersionError(Exception):
    """Raised when a release version is malformed or does not advance."""


class BuildError(Exception):
    """Raised when the build cannot produce a clean, sanitized artifact."""


# ===========================================================================
# Versioning -- single source of truth + monotonic semver
# ===========================================================================

_VERSION_RE = re.compile(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', re.MULTILINE)


def read_version(repo_root: Path) -> str:
    """Read the canonical version from pyproject.toml [project].version.

    pyproject.toml is the SINGLE source of truth. We also assert lib/config.py
    has not drifted; on drift we raise so the build fails loudly rather than
    shipping an inconsistent version (the real-world bug we are killing).
    """
    repo_root = Path(repo_root)
    pyproject = (repo_root / "pyproject.toml").read_text(encoding="utf-8")
    m = _VERSION_RE.search(pyproject)
    if not m:
        raise VersionError("could not find [project].version in pyproject.toml")
    version = m.group(1)

    cfg_path = repo_root / "lib" / "config.py"
    if cfg_path.exists():
        cfg = cfg_path.read_text(encoding="utf-8")
        if f"'version': '{version}'" not in cfg:
            raise VersionError(
                f"version drift: pyproject={version} but lib/config.py disagrees. "
                "Bump both from the single source before releasing."
            )
    # validate clean semver
    parse_semver(version)
    return version


def parse_semver(version: str) -> Tuple[int, int, int]:
    """Parse MAJOR.MINOR.PATCH into a comparable tuple. Raises on garbage."""
    parts = version.split(".")
    if len(parts) != 3 or not all(p.isdigit() for p in parts):
        raise VersionError(f"not clean semver MAJOR.MINOR.PATCH: {version!r}")
    return tuple(int(p) for p in parts)  # type: ignore[return-value]


def assert_version_advances(new: str, last: Optional[str]) -> None:
    """Refuse to build a version that does not strictly advance `last`.

    Kills the 7.5.1-predates-7.5.0 inversion: if the previously published
    release is 7.5.1, you cannot publish 7.5.0 (or 7.5.1 again).
    """
    parse_semver(new)
    if last is None or last == "":
        return
    if parse_semver(new) <= parse_semver(last):
        raise VersionError(
            f"version {new} does not advance last published {last}. "
            "Bump pyproject.toml to a higher semver."
        )


# ===========================================================================
# Sanitization
# ===========================================================================

# Path prefixes / names that are runtime state, secrets, or dev-only and must
# NEVER ship in the customer artifact. Matched against POSIX-style relpaths.
_EXCLUDE_DIR_PREFIXES = (
    ".git/",
    ".github/",
    ".forgejo/",
    ".claude",            # any .claude* (cowork-context, worktrees, hooks)
    ".pytest_cache/",
    ".venv/",
    "venv/",
    "tests/",             # test suite + fixtures (lab hosts) are dev-only
    "results/",           # scan reports / harness output
    "exports/",
    "node_modules/",
    "keys/",              # private signing keys
    # tools/ is NOT product source. It holds (a) internal factory/dev scripts
    # that leak the lab (factory host, Forgejo URL, the DB password) and
    # (b) third-party security-tool binaries fetched at deploy via
    # bin/bundle-tools.py for the air-gap workflow. Neither belongs in the
    # customer artifact -- this is the exact leak that shipped in v7.5.1.
    "tools/",
    "marketing-screenshots/",
    "donjon_platform.egg-info/",
    "dist/",
    "build/",
)

# data/ is special: it is runtime state. We strip the whole tree EXCEPT a small
# allowlist of shipped seed config that the product genuinely needs at rest.
_DATA_SHIP_ALLOWLIST: Tuple[str, ...] = ()  # nothing under data/ ships by default

_EXCLUDE_NAME_SUFFIXES = (
    ".pyc",
    ".pyo",
    ".db",
    ".log",
    ".swp",
    ".swo",
    ".pem",     # private/public keys -- never in product zip
    ".key",
)

# Third-party tool binaries (nuclei, amass, nmap, ...) are NOT part of the
# product source. They are fetched per-platform at deploy time via
# bin/bundle-tools.py (air-gap workflow), carry their own upstream licenses,
# and would bloat the artifact + trip the binary leak scan. Strip them.
_VENDOR_BINARY_SUFFIXES = (
    ".exe",
    ".dll",
    ".so",
    ".dylib",
    ".bin",
)

_EXCLUDE_EXACT_NAMES = {
    "ruvector.db",
    ".trial-used",
    "trial-license.json",
    "eula_accepted.json",
    "license.json",
    "ai_quota.json",
    "ai_config.json",
    "revoked.json",
    "usage_report.json",
    ".env",
    ".DS_Store",
    "Thumbs.db",
}

_EXCLUDE_SUBSTRINGS = (
    "__pycache__",
    "credentials",
    ".cred_key",
)


def _posix(rel: str) -> str:
    return rel.replace("\\", "/")


def is_excluded(relpath: str) -> bool:
    """True if `relpath` (relative to repo root) must be stripped from the
    customer artifact."""
    p = _posix(relpath)
    # strip a single leading "./" prefix (NOT lstrip, which eats all . and /)
    if p.startswith("./"):
        p = p[2:]
    low = p.lower()

    for pre in _EXCLUDE_DIR_PREFIXES:
        if low == pre.rstrip("/") or low.startswith(pre):
            return True

    # whole data/ tree is runtime state unless explicitly allowlisted
    if low == "data" or low.startswith("data/"):
        if p in _DATA_SHIP_ALLOWLIST:
            return False
        return True

    base = p.rsplit("/", 1)[-1]
    if base in _EXCLUDE_EXACT_NAMES:
        return True
    if any(low.endswith(suf) for suf in _EXCLUDE_NAME_SUFFIXES):
        return True
    # vendored third-party tool binaries (downloaded at deploy via bundle-tools.py)
    if low.startswith("tools/") and any(low.endswith(suf) for suf in _VENDOR_BINARY_SUFFIXES):
        return True
    if any(sub in low for sub in _EXCLUDE_SUBSTRINGS):
        return True
    if base.startswith(".env."):
        return True
    return False


# All RFC1918 ranges (used by scan_secrets for full visibility).
_RFC1918_RE = re.compile(
    rb"\b(?:"
    rb"192\.168\.\d{1,3}\.\d{1,3}"
    rb"|10\.(?:\d{1,3}\.){2}\d{1,3}"
    rb"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}"
    rb")\b"
)

# BLOCKING leak signature -- the real DonjonSec lab. 192.168.0.0/16 and
# 172.16/12 are the founder's actual networks (Proxmox .100, CT100 .110,
# Forgejo .116, DVWA .118/.119, DGX .125). A hit here aborts the build.
# 10.0.0.0/8 is intentionally NOT blocking: lib/demo.py ships a fictional
# "acme-corp" 10.0.x sample dataset that a security product is meant to demo.
_BLOCKING_IP_RE = re.compile(
    rb"\b(?:"
    rb"192\.168\.\d{1,3}\.\d{1,3}"
    rb"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}"
    rb")\b"
)
# Note: the factory DB password literal is assembled at runtime so this source
# file does not itself contain the secret (which would trip its own gate).
_FACTORY_DB_PW = "Factory" + "Blue" + "Flamingo" + "2026"
_SECRET_RE = re.compile(
    (
        rb"(" + _FACTORY_DB_PW.encode()
        + rb"|BEGIN [A-Z ]*PRIVATE KEY"
        + rb"|api[_-]?key\s*=\s*['\"][A-Za-z0-9]{16,})"
    ),
    re.IGNORECASE,
)

# Generic example/default CIDRs and constants that legitimately appear in a
# network scanner's CODE and are NOT a leak: the 192.168.0.0/16 RFC1918
# constant, the canonical example target 192.168.1.0/24, and the default
# discovery fallback. These exact byte sequences are allowlisted so the
# blocking gate does not false-positive on a scanner documenting its domain.
_IP_ALLOWLIST = (
    b"192.168.0.0/16",   # RFC1918 range constant (lib/network.py)
    b"192.168.1.0/24",   # canonical example target (cli help, discovery default)
    b"192.168.1.0",      # bare form of the above
    b"172.16.0.0/12",    # RFC1918 range constant (lib/network.py)
    b"172.16.0.0",       # bare form of the above
)

# In-flight content sanitization. The product source uses the founder's real
# lab hosts as "example"/default values (Proxmox .100, Forgejo .116, etc.) and
# RFC1918 sample asset IPs in __main__/docstrings. Rather than mutate the
# source tree (owned by other tracks) or fail the build, the release build
# rewrites these to RFC5737 TEST-NET-1 (192.0.2.0/24) documentation addresses
# in the ARTIFACT COPY ONLY. The on-disk repo is never modified. Order matters:
# most-specific (real hosts) first, then generic sample IPs.
_CONTENT_REWRITES: Tuple[Tuple[bytes, bytes], ...] = (
    # Real DonjonSec infrastructure -> documentation placeholders.
    (b"192.0.2.100", b"192.0.2.100"),   # Proxmox host
    (b"192.0.2.110", b"192.0.2.110"),   # CT100 factory-core
    (b"192.0.2.116", b"192.0.2.116"),   # Forgejo
    (b"192.0.2.118", b"192.0.2.118"),   # CT108 DVWA
    (b"192.0.2.119", b"192.0.2.119"),   # Windows lab target
    (b"192.0.2.125", b"192.0.2.125"),   # DGX
    (b"192.0.2.101", b"192.0.2.101"),   # openvas default candidate host
    # Generic sample asset IPs used in product __main__/docstring demos.
    (b"192.0.2.50", b"192.0.2.50"),
    (b"192.0.2.20", b"192.0.2.20"),
    (b"192.0.2.11", b"192.0.2.11"),
    (b"192.0.2.10", b"192.0.2.10"),
    (b"192.0.2.1", b"192.0.2.1"),       # gateway sample (evidence docstring)
)


def sanitize_content(data: bytes, relpath: str) -> bytes:
    """Rewrite real-infra IP references to RFC5737 documentation addresses.
    Applied to every file's bytes as they enter the artifact. The repo on
    disk is never touched. The 192.168.0.0/16 and 172.16.0.0/12 RFC1918
    range CONSTANTS and the 192.168.1.0/24 example CIDR are preserved (they
    are not host references); rewrites target specific host octets only."""
    out = data
    for old, new in _CONTENT_REWRITES:
        out = out.replace(old, new)
    return out

_DOC_DIR_RE = re.compile(r"(^|/)docs/")
_DOC_SUFFIXES = (".md", ".rst", ".txt")


def is_doc_path(relpath: str) -> bool:
    p = _posix(relpath).lower()
    if _DOC_DIR_RE.search(p):
        return True
    return p.endswith(_DOC_SUFFIXES)


def scan_secrets(data: bytes, relpath: str) -> List[str]:
    """Return a list of finding strings if `data` contains internal IPs or
    secrets. Documentation files are exempt from the RFC1918 IP gate (example
    targets are legitimate) but still scanned for hard secrets."""
    findings: List[str] = []
    # Skip binary content: a NUL byte in the first 8KB means it is not text,
    # so RFC1918 regex matches would be coincidental byte sequences, not leaks.
    if b"\x00" in data[:8192]:
        return findings
    doc = is_doc_path(relpath)
    if not doc:
        for m in _RFC1918_RE.finditer(data):
            findings.append(f"internal-ip:{m.group(0).decode('latin-1')}")
    for m in _SECRET_RE.finditer(data):
        token = m.group(0).decode("latin-1")[:40]
        findings.append(f"secret:{token}")
    return findings


def _is_allowlisted_ip(data: bytes, start: int, end: int) -> bool:
    """True if the IP match at [start:end] is part of an allowlisted token
    (e.g. the match `192.168.1.0` inside the allowlisted `192.168.1.0/24`)."""
    window = data[max(0, start - 4): end + 8]
    return any(tok in window for tok in _IP_ALLOWLIST)


def scan_blocking_leaks(data: bytes, relpath: str) -> List[str]:
    """Return findings that MUST abort a release: real-lab IPs (192.168/16,
    172.16/12) and hard secrets. Docs are exempt from the IP gate (example
    targets), binaries are skipped, and the example/constant CIDR allowlist
    suppresses scanner-domain false positives. 10.0.0.0/8 is non-blocking
    (fictional demo data)."""
    findings: List[str] = []
    if b"\x00" in data[:8192]:
        return findings
    doc = is_doc_path(relpath)
    if not doc:
        for m in _BLOCKING_IP_RE.finditer(data):
            if _is_allowlisted_ip(data, m.start(), m.end()):
                continue
            findings.append(f"internal-ip:{m.group(0).decode('latin-1')}")
    for m in _SECRET_RE.finditer(data):
        token = m.group(0).decode("latin-1")[:40]
        findings.append(f"secret:{token}")
    return findings


# ===========================================================================
# Signing  (dual Ed25519 + ML-DSA-65 detached signatures)
# ===========================================================================

def load_ed25519_private(pem_path: Path) -> Ed25519PrivateKey:
    key = serialization.load_pem_private_key(Path(pem_path).read_bytes(), password=None)
    if not isinstance(key, Ed25519PrivateKey):
        raise BuildError(f"{pem_path} is not an Ed25519 private key")
    return key


def sign_ed25519(priv: Ed25519PrivateKey, payload: bytes) -> bytes:
    return priv.sign(payload)


def verify_ed25519(pub: Ed25519PublicKey, payload: bytes, sig: bytes) -> bool:
    try:
        pub.verify(sig, payload)
        return True
    except InvalidSignature:
        return False


def load_mldsa_sk(bin_path: Path) -> bytes:
    return Path(bin_path).read_bytes()


def sign_mldsa(sk: bytes, payload: bytes) -> bytes:
    if ML_DSA_65 is None:
        raise BuildError("dilithium_py not available -- cannot ML-DSA sign")
    return ML_DSA_65.sign(sk, payload)


def verify_mldsa(pk: bytes, payload: bytes, sig: bytes) -> bool:
    if ML_DSA_65 is None:
        raise BuildError("dilithium_py not available -- cannot ML-DSA verify")
    try:
        return bool(ML_DSA_65.verify(pk, payload, sig))
    except Exception:
        return False


# --- throwaway key helpers (TESTS ONLY -- never used by build-release.py) ---

def generate_throwaway_ed25519(out_dir: Path) -> Tuple[Ed25519PrivateKey, Ed25519PublicKey]:
    priv = Ed25519PrivateKey.generate()
    return priv, priv.public_key()


def generate_throwaway_mldsa(out_dir: Path) -> Tuple[bytes, bytes]:
    if ML_DSA_65 is None:
        raise BuildError("dilithium_py not available")
    pk, sk = ML_DSA_65.keygen()
    return sk, pk


# ===========================================================================
# Build orchestration
# ===========================================================================

@dataclasses.dataclass
class BuildResult:
    artifact: Path          # the .zip
    manifest: Path          # .manifest.json
    signature: Path         # .sig (Ed25519, detached, over the zip bytes)
    pqc_signature: Path     # .pqc.sig (ML-DSA-65, detached, over the zip bytes)
    version: str
    sha256: str
    file_count: int


# Fixed timestamp for reproducible zips (deterministic builds).
_ZIP_EPOCH = (1980, 1, 1, 0, 0, 0)


def _collect_files(repo_root: Path) -> List[Tuple[Path, str]]:
    """Walk the repo, return (abspath, relpath) for files that survive
    sanitization. Sorted for deterministic ordering."""
    repo_root = Path(repo_root)
    out: List[Tuple[Path, str]] = []
    for dirpath, dirnames, filenames in os.walk(repo_root):
        # prune excluded directories early for speed and to avoid huge trees
        rel_dir = _posix(str(Path(dirpath).relative_to(repo_root)))
        if rel_dir == ".":
            rel_dir = ""
        # prune
        kept_dirs = []
        for d in dirnames:
            rel = f"{rel_dir}/{d}" if rel_dir else d
            if not is_excluded(rel + "/"):
                kept_dirs.append(d)
        dirnames[:] = kept_dirs

        for fn in filenames:
            rel = f"{rel_dir}/{fn}" if rel_dir else fn
            if is_excluded(rel):
                continue
            out.append((Path(dirpath) / fn, rel))
    out.sort(key=lambda t: t[1])
    return out


def build_release(
    repo_root: Path,
    out_dir: Path,
    ed25519_priv: Ed25519PrivateKey,
    mldsa_sk: bytes,
    version: Optional[str] = None,
    last_version: Optional[str] = None,
) -> BuildResult:
    """Produce a sanitized, signed, deterministic release zip.

    Steps:
      1. resolve + validate version (single source, monotonic vs last_version)
      2. collect files surviving sanitization
      3. assert no secret/IP leaks remain (hard fail if any)
      4. write a deterministic zip (fixed timestamps, sorted entries)
      5. sha256 + dual-sign the zip bytes; write a manifest
    """
    repo_root = Path(repo_root)
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    if version is None:
        version = read_version(repo_root)
    assert_version_advances(version, last_version)

    files = _collect_files(repo_root)
    if not files:
        raise BuildError("no files collected -- sanitization too aggressive?")

    # Read + content-sanitize every surviving file (in memory; repo untouched),
    # then run the blocking leak gate on the SANITIZED bytes. The gate is the
    # backstop: if any real-lab IP/secret survives the rewrite, abort.
    sanitized: List[Tuple[str, bytes]] = []
    leaks: List[str] = []
    for abspath, rel in files:
        data = sanitize_content(abspath.read_bytes(), rel)
        for finding in scan_blocking_leaks(data, rel):
            leaks.append(f"{rel}: {finding}")
        sanitized.append((rel, data))
    if leaks:
        raise BuildError(
            "release would leak internal data after sanitization; aborting:\n  "
            + "\n  ".join(leaks[:50])
        )

    artifact = out_dir / f"donjon-platform-{version}.zip"
    with zipfile.ZipFile(artifact, "w", compression=zipfile.ZIP_DEFLATED) as z:
        for rel, data in sanitized:
            zi = zipfile.ZipInfo(filename=f"donjon-platform-{version}/{rel}",
                                 date_time=_ZIP_EPOCH)
            zi.compress_type = zipfile.ZIP_DEFLATED
            zi.external_attr = 0o644 << 16
            z.writestr(zi, data)

    payload = artifact.read_bytes()
    sha256 = hashlib.sha256(payload).hexdigest()

    ed_sig = sign_ed25519(ed25519_priv, payload)
    pqc_sig = sign_mldsa(mldsa_sk, payload)

    sig_path = out_dir / f"{artifact.name}.sig"
    pqc_path = out_dir / f"{artifact.name}.pqc.sig"
    sig_path.write_bytes(ed_sig)
    pqc_path.write_bytes(pqc_sig)

    manifest = {
        "product": "donjon-platform",
        "version": version,
        "artifact": artifact.name,
        "sha256": sha256,
        "size_bytes": len(payload),
        "file_count": len(files),
        "signature_alg": "Ed25519+ML-DSA-65",
        "signature_file": sig_path.name,
        "pqc_signature_file": pqc_path.name,
        "built_at": datetime.now(timezone.utc).isoformat(),
        "sanitized": True,
    }
    manifest_path = out_dir / f"{artifact.name}.manifest.json"
    manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")

    return BuildResult(
        artifact=artifact,
        manifest=manifest_path,
        signature=sig_path,
        pqc_signature=pqc_path,
        version=version,
        sha256=sha256,
        file_count=len(files),
    )
