#!/usr/bin/env python3
"""
Donjon Platform - Signed Intel Snapshot: verification + freshness SLA

PRODUCT-SIDE module (verification only — no private keys).

This is the intel-equivalent of ``licensing.py``: it embeds PUBLIC keys
only and verifies a *signed intel snapshot manifest* that the offline
pipeline (``bin/intel-pipeline.py``) produces.  It answers three questions
the product must enforce on every intel load:

    1. INTEGRITY  - does the manifest's signature verify against the embedded
                    public keys, and do the on-disk DB hashes match the
                    manifest?  (tamper rejection)
    2. AUTHENTICITY - was the manifest signed by DonjonSec's real key?
                    (Ed25519 + ML-DSA-65, the SAME dual scheme as licensing)
    3. FRESHNESS  - is the snapshot within the configured SLA?
                    (default 7 days; per-source KEV<=48h / NVD<=24h echo the
                    thresholds already in bin/update-intel.py --check)

SECURITY INVARIANT (fail-safe): on ANY failure — missing manifest, bad
signature, hash mismatch, or stale data — the snapshot is reported as
``trusted=False`` and visibly stale.  It is NEVER silently treated as
fresh-and-green.  This mirrors the licensing module's "fall back to
community on any failure" stance.

Canonical payload + signature primitives are intentionally IDENTICAL to
``lib/licensing.py`` so the same key material and the same admin-side
signing code can be reused:

    payload = json.dumps({k:v for k,v in manifest.items()
                          if k != "signatures"},
                         sort_keys=True, separators=(",", ":")).encode()

PRODUCTION KEYS: the real public keys are
``keys/donjon-public-classical.pem`` (Ed25519) and
``keys/donjon-public-pqc.bin`` (ML-DSA-65), whose private halves are
``keys/donjon-private-classical.pem`` / ``keys/donjon-private-pqc.bin``
(mirrored in donjonsec-infrastructure/keys and CT100
/opt/donjonsec-keys).  The embedded Ed25519 key below is byte-identical
to ``licensing._PUBLIC_KEY_CLASSICAL_B64`` (verified: both derive from
the same private PEM).  To override at runtime (e.g. tests with a
throwaway key) set DONJON_INTEL_PUBKEY_CLASSICAL_B64 /
DONJON_INTEL_PUBKEY_PQC_B64.
"""

from __future__ import annotations

import base64
import hashlib
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

try:
    from dilithium_py.ml_dsa import ML_DSA_65
except ImportError:  # pragma: no cover - graceful degradation
    ML_DSA_65 = None

try:
    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
except ImportError:  # pragma: no cover - graceful degradation
    Ed25519PublicKey = None

logger = logging.getLogger("donjon.intel_snapshot")

# Manifest filename the pipeline writes next to the DBs and inside bundles.
SNAPSHOT_MANIFEST_NAME = "intel-snapshot-manifest.json"

# ---------------------------------------------------------------------------
# Embedded PUBLIC verification keys (VERIFICATION ONLY).
#
# Ed25519 key is byte-identical to licensing._PUBLIC_KEY_CLASSICAL_B64 — both
# are the public half of keys/donjon-private-classical.pem.  Reusing the same
# key means a single signing identity for licenses AND intel snapshots.
# ---------------------------------------------------------------------------
_PUBLIC_KEY_CLASSICAL_B64: str = "A1ISZgiW2Xzgqmb+Ke8HU5ScQzh9VK/U4s4nW+b4Qbg="

# ML-DSA-65 (FIPS 204) public key — public half of keys/donjon-private-pqc.bin.
# Same key whose public half is keys/donjon-public-pqc.bin (1952 bytes).
_PUBLIC_KEY_PQC_B64: str = (
    "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"
)

# ---------------------------------------------------------------------------
# Freshness SLA (configurable via env, fail-safe defaults).
#   DONJON_INTEL_MAX_AGE_DAYS  overall snapshot SLA   (default 7)
# Per-source thresholds echo bin/update-intel.py check_freshness().
# ---------------------------------------------------------------------------
_DEFAULT_MAX_AGE_DAYS = 7
_PER_SOURCE_MAX_AGE_HOURS = {
    "cisa_kev": 48.0,
    "nvd_cves": 24.0,
}


def _max_age_days() -> int:
    raw = os.environ.get("DONJON_INTEL_MAX_AGE_DAYS")
    if raw:
        try:
            v = int(raw)
            if v > 0:
                return v
        except ValueError:
            logger.warning("Invalid DONJON_INTEL_MAX_AGE_DAYS=%r — using default", raw)
    return _DEFAULT_MAX_AGE_DAYS


def _js_number_normalize(value: Any) -> Any:
    """Recursively coerce integer-valued floats to ``int`` for JS parity.

    The admin Worker signs the manifest after re-canonicalising it with
    ``JSON.stringify`` semantics, where an integer-valued float (e.g. ``2.0``)
    serialises as ``2`` — JavaScript has no separate float/int type.  Python's
    ``json.dumps`` would emit ``2.0``, so the canonical bytes would diverge and
    the Worker's signature would never verify here (observed for
    ``sources.*.age_hours`` values like ``2.0``).

    Coercing ``X.0`` floats to ``int`` makes Python's canonicalisation
    byte-identical to the Worker's for the all-ASCII intel manifest (hex
    hashes, ISO timestamps, integer counts, ascii names, and rounded
    ``age_hours``).  Non-integer floats such as ``1.5`` already round-trip
    identically.  The pipeline's own ``sign_manifest`` uses this same function,
    so the local-key path stays in lockstep too.
    """
    if isinstance(value, bool):
        return value  # bool is a subclass of int — keep True/False as-is
    if isinstance(value, float) and value.is_integer():
        return int(value)
    if isinstance(value, dict):
        return {k: _js_number_normalize(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_js_number_normalize(v) for v in value]
    return value


def canonical_payload(manifest: Dict[str, Any]) -> bytes:
    """Canonical signing payload: the manifest minus its ``signatures`` block.

    Canonicalisation matches the admin Worker's ``buildCanonicalPayload`` /
    ``sortedStringify`` (recursive key sort, compact separators) AND
    ``JSON.stringify`` number semantics (see ``_js_number_normalize``), so the
    Worker-signed manifest verifies here without the private key ever leaving
    the Worker.  Any divergence silently invalidates every signature, so this
    MUST stay in lockstep with the Worker signer and the pipeline signer.
    """
    payload_dict = {k: v for k, v in manifest.items() if k != "signatures"}
    payload_dict = _js_number_normalize(payload_dict)
    return json.dumps(payload_dict, sort_keys=True, separators=(",", ":")).encode("utf-8")


def sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def _pub_classical_b64() -> str:
    return os.environ.get("DONJON_INTEL_PUBKEY_CLASSICAL_B64") or _PUBLIC_KEY_CLASSICAL_B64


def _pub_pqc_b64() -> str:
    return os.environ.get("DONJON_INTEL_PUBKEY_PQC_B64") or _PUBLIC_KEY_PQC_B64


def _verify_ed25519(payload: bytes, signature: bytes) -> Optional[bool]:
    """True valid / False invalid / None backend-or-key unavailable."""
    if Ed25519PublicKey is None:
        logger.warning("Ed25519 verification skipped — 'cryptography' not installed")
        return None
    pub_b64 = _pub_classical_b64()
    if not pub_b64:
        return None
    try:
        pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub_b64, validate=True))
        pub.verify(signature, payload)
        return True
    except Exception:
        logger.debug("Ed25519 snapshot verification failed")
        return False


def _verify_ml_dsa(payload: bytes, signature: bytes) -> Optional[bool]:
    """True valid / False invalid / None backend-or-key unavailable."""
    if ML_DSA_65 is None:
        logger.warning("ML-DSA-65 verification skipped — 'dilithium-py' not installed")
        return None
    pub_b64 = _pub_pqc_b64()
    if not pub_b64:
        return None
    try:
        pub_bytes = base64.b64decode(pub_b64.strip())
        return bool(ML_DSA_65.verify(pub_bytes, payload, signature))
    except Exception as exc:
        logger.debug("ML-DSA-65 snapshot verification failed: %s", exc)
        return False


def verify_signature(manifest: Dict[str, Any]) -> bool:
    """Verify the dual signature over the manifest's canonical payload.

    Policy mirrors licensing._validate_license_v2: both signatures must pass
    when their backend is available; a ``None`` (unavailable backend) is
    tolerated ONLY if the other signature positively verifies.  If neither
    backend is available, FAIL CLOSED.
    """
    sigs = manifest.get("signatures")
    if not isinstance(sigs, dict):
        logger.error("Snapshot manifest missing 'signatures' object")
        return False

    classical_b64 = sigs.get("classical", "")
    pqc_b64 = sigs.get("pqc", "")
    if not classical_b64 and not pqc_b64:
        logger.error("Snapshot manifest has no signatures")
        return False

    payload = canonical_payload(manifest)

    classical_valid: Optional[bool] = None
    if classical_b64:
        try:
            classical_sig = base64.b64decode(classical_b64, validate=True)
        except Exception:
            logger.error("Snapshot classical signature is not valid base64")
            return False
        classical_valid = _verify_ed25519(payload, classical_sig)

    pqc_valid: Optional[bool] = None
    if pqc_b64:
        try:
            pqc_sig = base64.b64decode(pqc_b64, validate=True)
        except Exception:
            logger.error("Snapshot PQC signature is not valid base64")
            return False
        pqc_valid = _verify_ml_dsa(payload, pqc_sig)

    if Ed25519PublicKey is None and ML_DSA_65 is None:
        logger.error("No cryptographic backends available for snapshot verification")
        return False

    # Any present-and-invalid signature fails the whole manifest.
    if classical_valid is False or pqc_valid is False:
        return False
    # Need at least one positive verification.
    if classical_valid is True or pqc_valid is True:
        return True
    # Both were None (no usable backend/key for the sigs we had) — fail closed.
    return False


def _snapshot_age_days(manifest: Dict[str, Any]) -> Optional[float]:
    gen = manifest.get("generated_at")
    if not gen:
        return None
    try:
        gen_str = str(gen).replace("Z", "+00:00")
        dt = datetime.fromisoformat(gen_str)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0
    except (ValueError, TypeError):
        logger.warning("Snapshot manifest has malformed generated_at: %r", gen)
        return None


class SnapshotVerificationResult:
    """Outcome of verifying a signed intel snapshot — fail-safe by construction.

    ``trusted`` is True ONLY when the signature verified, every declared DB
    hash matched the on-disk file, and the snapshot is within the freshness
    SLA.  Otherwise ``trusted`` is False and ``reasons`` explains why.  The
    product surfaces ``stale``/``trusted`` directly; it must never render
    green when ``trusted`` is False.
    """

    def __init__(
        self,
        trusted: bool,
        signature_ok: bool,
        integrity_ok: bool,
        fresh: bool,
        age_days: Optional[float],
        max_age_days: int,
        reasons: List[str],
        manifest: Optional[Dict[str, Any]] = None,
    ) -> None:
        self.trusted = trusted
        self.signature_ok = signature_ok
        self.integrity_ok = integrity_ok
        self.fresh = fresh
        self.age_days = age_days
        self.max_age_days = max_age_days
        self.reasons = reasons
        self.manifest = manifest or {}

    @property
    def stale(self) -> bool:
        return not self.fresh

    def to_dict(self) -> Dict[str, Any]:
        return {
            "trusted": self.trusted,
            "signature_ok": self.signature_ok,
            "integrity_ok": self.integrity_ok,
            "fresh": self.fresh,
            "stale": self.stale,
            "age_days": round(self.age_days, 2) if self.age_days is not None else None,
            "max_age_days": self.max_age_days,
            "reasons": self.reasons,
            "generated_at": self.manifest.get("generated_at"),
            "snapshot_version": self.manifest.get("snapshot_version"),
        }

    def __repr__(self) -> str:
        return f"<SnapshotVerificationResult trusted={self.trusted} reasons={self.reasons}>"


def verify_snapshot(
    manifest_path: Path,
    data_dir: Optional[Path] = None,
    max_age_days: Optional[int] = None,
    check_hashes: bool = True,
) -> SnapshotVerificationResult:
    """Verify a signed intel snapshot manifest. FAIL-SAFE.

    Args:
        manifest_path: path to the signed snapshot manifest JSON.
        data_dir:      base dir the manifest's relative ``files`` paths resolve
                       against (defaults to manifest_path.parent).  This is
                       where the live DBs sit, so the hashes are checked
                       against what the product will actually load.
        max_age_days:  override the SLA (defaults to env / 7).
        check_hashes:  when False, skip on-disk hash recomputation (signature
                       + freshness only — useful when DBs are huge and the
                       signature already covers the recorded hashes).

    Returns a SnapshotVerificationResult.  Any error path yields
    ``trusted=False`` and a populated ``reasons`` list — never an exception.
    """
    sla = max_age_days if max_age_days is not None else _max_age_days()
    reasons: List[str] = []

    if not manifest_path.exists():
        return SnapshotVerificationResult(
            trusted=False, signature_ok=False, integrity_ok=False, fresh=False,
            age_days=None, max_age_days=sla,
            reasons=[f"snapshot manifest not found: {manifest_path}"],
        )

    try:
        manifest = json.loads(manifest_path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError) as exc:
        return SnapshotVerificationResult(
            trusted=False, signature_ok=False, integrity_ok=False, fresh=False,
            age_days=None, max_age_days=sla,
            reasons=[f"could not parse manifest: {exc}"],
        )
    if not isinstance(manifest, dict):
        return SnapshotVerificationResult(
            trusted=False, signature_ok=False, integrity_ok=False, fresh=False,
            age_days=None, max_age_days=sla,
            reasons=["manifest root is not a JSON object"],
        )

    base = data_dir if data_dir is not None else manifest_path.parent

    # 1) Signature / authenticity.
    signature_ok = verify_signature(manifest)
    if not signature_ok:
        reasons.append("signature verification failed (untrusted or tampered manifest)")

    # 2) Integrity: recompute on-disk DB hashes vs the (signed) manifest.
    integrity_ok = True
    files = manifest.get("files", {})
    if check_hashes and isinstance(files, dict):
        for rel_path, info in files.items():
            fp = base / rel_path
            expected = (info or {}).get("sha256")
            if not fp.exists():
                integrity_ok = False
                reasons.append(f"missing data file: {rel_path}")
                continue
            if expected:
                actual = sha256_file(fp)
                if actual != expected:
                    integrity_ok = False
                    reasons.append(f"hash mismatch (tampered data): {rel_path}")
    elif not isinstance(files, dict):
        integrity_ok = False
        reasons.append("manifest 'files' block missing or malformed")

    # 3) Freshness SLA.
    age_days = _snapshot_age_days(manifest)
    fresh = True
    if age_days is None:
        fresh = False
        reasons.append("snapshot has no/invalid generated_at timestamp")
    else:
        if age_days > sla:
            fresh = False
            reasons.append(
                f"snapshot is {age_days:.1f} days old (SLA {sla} days)"
            )
        if age_days < 0:
            fresh = False
            reasons.append(f"snapshot generated_at is in the future ({age_days:.1f}d)")

    # Per-source freshness (KEV<=48h, NVD<=24h) when source ages are present.
    sources = manifest.get("sources", {})
    if isinstance(sources, dict):
        for src, limit_h in _PER_SOURCE_MAX_AGE_HOURS.items():
            info = sources.get(src)
            if isinstance(info, dict):
                src_age_h = info.get("age_hours")
                if isinstance(src_age_h, (int, float)) and src_age_h > limit_h:
                    fresh = False
                    reasons.append(
                        f"source '{src}' is {src_age_h:.0f}h old (threshold {limit_h:.0f}h)"
                    )

    trusted = signature_ok and integrity_ok and fresh
    return SnapshotVerificationResult(
        trusted=trusted,
        signature_ok=signature_ok,
        integrity_ok=integrity_ok,
        fresh=fresh,
        age_days=age_days,
        max_age_days=sla,
        reasons=reasons,
        manifest=manifest,
    )


def load_snapshot_status(
    data_dir: Path, max_age_days: Optional[int] = None, check_hashes: bool = False
) -> Dict[str, Any]:
    """Convenience for the product load path / web dashboard (G9).

    Looks for ``<data_dir>/intel-snapshot-manifest.json`` and returns the
    verification result as a dict.  ``check_hashes`` defaults to False here
    because the live DBs can be ~1GB and the dashboard polls frequently — the
    signed manifest already binds the hashes; full recompute is reserved for
    the explicit verify command / import.  Missing manifest => stale, not
    green.
    """
    manifest_path = data_dir / SNAPSHOT_MANIFEST_NAME
    result = verify_snapshot(
        manifest_path, data_dir=data_dir, max_age_days=max_age_days, check_hashes=check_hashes
    )
    return result.to_dict()
