"""Hash-chain primitives for the audit log — pure, stdlib-only, side-effect-free.

The chain links every audit record to its predecessor by digest, so an in-place
edit, a deletion, a reordering, or a truncation of the *beginning* severs the
sequence at a point a verifier can localise. This module builds the links and
verifies them; it never touches storage.

**What a keyless chain does NOT do — read before claiming anything about it.**
The digest is plain SHA-256 with no secret. Anyone who can read this file can
recompute every link, so an adversary holding write access to the database can
rewrite the log end-to-end and present a chain that verifies perfectly. The same
applies to deleting the *end* of the chain: the remaining records are a shorter
but perfectly valid chain. This chain is **tamper-evident, not tamper-proof**.
It detects corruption and naive editing; it does not detect a determined
attacker who recomputes. Closing that gap needs a keyed integrity mechanism or an
external anchor the writer cannot rewrite — deliberately out of scope here, and
recorded as an open item in the plan's F6 section.

These functions are intentionally free of any dependency on :class:`AuditLog`, so
that verification never inherits a bug from the code that wrote the chain.
"""

from __future__ import annotations

import hashlib
import hmac
import json
import os
from dataclasses import dataclass
from enum import Enum
from typing import Any, Final

#: The digest every chain starts from. Sixty-four zeros, so the first record is
#: anchored to a value nobody can forge and a truncated prefix fails from seq 1.
GENESIS: Final[str] = "0" * 64

_CHAIN_KEY_ENV = "CYBERBRAIN_CHAIN_KEY"


def _load_chain_key() -> bytes:
    """Načte HMAC klíč z env proměnné; selže, pokud chybí (fail-closed)."""
    key = os.environ.get(_CHAIN_KEY_ENV)
    if not key:
        raise RuntimeError(
            f"{_CHAIN_KEY_ENV} environment variable is not set. "
            "Refusing to start with a predictable audit-chain key."
        )
    return key.encode("utf-8")


CHAIN_KEY: Final[bytes] = _load_chain_key()

#: Field names excluded from the digest. These are the links themselves, not the
#: payload: including them would be self-referential. Everything else in a record
#: IS hashed — hashing only a subset of the schema leaves the rest unprotected,
#: which is a known and under-appreciated failure mode.
_CHAIN_FIELDS: Final[frozenset[str]] = frozenset({"seq", "prev_hash", "entry_hash"})


class TamperClass(str, Enum):
    """How a broken link most likely arose.

    Kept separate from "invalid" because the distinction is what makes a report
    actionable: a deletion and an edit are different incidents with different
    causes, and collapsing them into one boolean hides which one happened.
    """

    NONE = "none"
    IN_PLACE_EDIT = "in_place_edit"
    DELETION = "deletion"
    REORDERING = "reordering"
    PREFIX_TRUNCATION = "prefix_truncation"
    MISSING_LINK = "missing_link"


@dataclass(frozen=True)
class VerificationReport:
    """Result of walking a chain. Frozen: the verdict is itself evidence.

    ``linkage_ok`` and ``integrity_ok`` are reported separately and must stay so.
    A chain can link perfectly while a single field inside one record was edited
    (``linkage_ok=True``, ``integrity_ok=False``); collapsing them into one
    boolean hides exactly which attack occurred.
    """

    intact: bool
    linkage_ok: bool
    integrity_ok: bool
    records_checked: int
    head: str
    first_broken_seq: int | None = None
    tamper_class: TamperClass = TamperClass.NONE
    detail: str = ""
    #: True when the chain's first record has no digest at all — i.e. rows written
    #: before the chain existed. Such a log is NOT intact: its earlier history was
    #: never covered. Reported as its own state so it can never be mistaken for
    #: a clean verification.
    unverified_premise: bool = False


def _normalise(value: Any) -> Any:
    """Canonicalise a value so hashing survives a storage round-trip.

    SQLite is typed and does not preserve the distinction between an integer and
    an integral float: ``latency_ms = 5.0`` can read back as ``5``. Hashing the
    raw value would therefore produce a false mismatch on an untouched record.
    Collapsing integral floats to ``int`` on both the write and the verify side
    makes the two agree regardless of how the value was stored. ``bool`` is a
    subclass of ``int`` and must be excluded, or ``True`` would become ``1``.
    """
    if isinstance(value, bool):
        return value
    if isinstance(value, float) and value.is_integer():
        return int(value)
    if isinstance(value, dict):
        return {k: _normalise(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_normalise(v) for v in value]
    return value


def canonical_event_bytes(record: dict[str, Any]) -> bytes:
    """Deterministic serialisation of a record, for hashing.

    Sorted keys and fixed separators make key order and whitespace irrelevant, so
    a verifier can reproduce this from the persisted row alone. The hashed set is
    the whole record minus the chain columns — see :data:`_CHAIN_FIELDS`.

    Float formatting is a known fragility: a library upgrade that changes how a
    float renders would make every record fail re-verification even though
    nothing was tampered with. :func:`_normalise` removes the common case and
    ``tests/test_audit_chain.py`` pins the behaviour with a golden vector so a
    change cannot pass unnoticed.
    """
    payload = {k: _normalise(v) for k, v in record.items() if k not in _CHAIN_FIELDS}
    return json.dumps(
        payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str
    ).encode("utf-8")


def compute_link(canonical: bytes, prev_hash: str) -> str:
    """Return the hex digest binding ``canonical`` to ``prev_hash``.

    Deliberately ordered ``canonical || prev_hash``: the predecessor digest is the
    last thing folded in, which is what makes the link commit transitively to the
    entire prefix of the chain.
    """
    digest = hashlib.sha256(canonical + prev_hash.encode("ascii")).hexdigest()
    return digest


def link_for_record(record: dict[str, Any], prev_hash: str) -> str:
    """Convenience wrapper: canonicalise ``record`` then compute its link."""
    return compute_link(canonical_event_bytes(record), prev_hash)


def compute_event_hash(event: dict[str, Any], previous_hash: str) -> str:
    """Return the HMAC-SHA256 digest binding ``event`` to ``previous_hash``.

    Uses HMAC rather than plain SHA-256 so that an attacker who can read the
    code but not the key cannot forge a valid chain. The key is embedded in this
    module; a production deployment would replace it with a secret from a key
    management system.

    The digest is computed over the canonical event bytes followed by the
    previous hash, creating a keyed link that is part of the hash-chain but
    protected by the secret key.
    """
    canonical = canonical_event_bytes(event)
    digest = hmac.new(
        CHAIN_KEY,
        canonical + previous_hash.encode("ascii"),
        hashlib.sha256,
    ).hexdigest()
    return digest


def verify_chain(records: list[dict[str, Any]]) -> VerificationReport:
    """Walk ``records`` from genesis and report integrity, stopping at the first break.

    ``records`` must be ordered by ``seq`` ascending. Verification stops at the
    first break deliberately: once the chain diverges, every later record inherits
    an untrustworthy predecessor, so any mismatch after it is a consequence rather
    than an independent fact. Reporting only the first gives one defensible
    statement about where history diverged.

    Two invariants are checked separately at every step:

    * **integrity** — the record's stored digest equals the digest recomputed from
      its own stored fields. Catches an edit to any hashed field.
    * **linkage** — the record's stored ``prev_hash`` equals the running digest.
      Catches a deletion, a reorder, or an inserted record.

    A log whose first record carries no digest is reported as
    ``unverified_premise=True`` and ``intact=False``. Treating "never chained" as
    a pass is the failure mode where a green result proves nothing.
    """
    if not records:
        return VerificationReport(
            intact=True,
            linkage_ok=True,
            integrity_ok=True,
            records_checked=0,
            head=GENESIS,
            detail="empty log",
        )

    first_seq = records[0].get("seq")
    if not records[0].get("entry_hash"):
        return VerificationReport(
            intact=False,
            linkage_ok=False,
            integrity_ok=False,
            records_checked=0,
            head=GENESIS,
            unverified_premise=True,
            detail=(
                f"log is not hash-chained: the first record (seq={first_seq}) has no "
                "entry_hash. Rows written before the chain existed are not covered by "
                "it, so this is a declared boundary, not a clean verification"
            ),
        )

    running = GENESIS
    previous_seq: int | None = None

    for index, record in enumerate(records):
        seq = record.get("seq")
        if seq is None:
            return VerificationReport(
                intact=False,
                linkage_ok=False,
                integrity_ok=False,
                records_checked=index,
                head=running,
                tamper_class=TamperClass.MISSING_LINK,
                detail=f"record {index} has no seq, so its position is undefined",
            )

        # Sequence continuity is what distinguishes a deletion from an edit, and it
        # is checked before the digests so the classification has its evidence.
        if previous_seq is not None and seq != previous_seq + 1:
            return VerificationReport(
                intact=False,
                linkage_ok=False,
                integrity_ok=False,
                records_checked=index,
                head=running,
                first_broken_seq=seq,
                tamper_class=_classify_gap(records, index, running, previous_seq),
                detail=f"seq gap: expected {previous_seq + 1}, found {seq}",
            )
        previous_seq = seq

        stored_prev = record.get("prev_hash")
        stored_own = record.get("entry_hash")

        if stored_prev != running:
            return VerificationReport(
                intact=False,
                linkage_ok=False,
                integrity_ok=False,
                records_checked=index,
                head=running,
                first_broken_seq=seq,
                tamper_class=_classify_break(records, index, running),
                detail=(
                    f"link break at seq {seq}: prev_hash does not match the digest "
                    "produced by the preceding record"
                ),
            )

        recomputed = compute_event_hash(record, running)
        if stored_own != recomputed:
            return VerificationReport(
                intact=False,
                # Linkage held for this record; the break shows up at its successor.
                linkage_ok=True,
                integrity_ok=False,
                records_checked=index + 1,
                head=running,
                first_broken_seq=seq,
                tamper_class=TamperClass.IN_PLACE_EDIT,
                detail=(
                    f"integrity break at seq {seq}: stored entry_hash does not match the "
                    "digest recomputed from this record's own fields"
                ),
            )

        running = recomputed

    return VerificationReport(
        intact=True,
        linkage_ok=True,
        integrity_ok=True,
        records_checked=len(records),
        head=running,
    )


def _classify_gap(
    records: list[dict[str, Any]],
    index: int,
    running: str,
    previous_seq: int,
) -> TamperClass:
    """Decide whether a sequence gap means a deletion, a reorder, or a truncation."""
    later = {r.get("entry_hash") for r in records[index:]}
    if index == 0:
        return TamperClass.PREFIX_TRUNCATION
    # A survivor pointing at a digest nothing present produces => something that
    # used to sit between them is gone.
    if records[index].get("prev_hash") not in later and running not in later:
        return TamperClass.DELETION
    return TamperClass.REORDERING


def _classify_break(records: list[dict[str, Any]], index: int, running: str) -> TamperClass:
    """Classify a linkage break using the pointers around it."""
    if index == 0:
        # The first record's prev_hash should be genesis. Anything else means the
        # head of the chain was replaced.
        return TamperClass.PREFIX_TRUNCATION

    produced = {r.get("entry_hash") for r in records} | {GENESIS}
    pointer = records[index].get("prev_hash")
    if pointer not in produced:
        return TamperClass.DELETION
    if pointer != running:
        return TamperClass.REORDERING
    return TamperClass.IN_PLACE_EDIT


__all__ = [
    "CHAIN_KEY",
    "GENESIS",
    "TamperClass",
    "VerificationReport",
    "canonical_event_bytes",
    "compute_event_hash",
    "compute_link",
    "link_for_record",
    "verify_chain",
]
