"""Snapshots — what actually leaves the customer's machine.

A snapshot is a **root**, not a list. It commits to every record written since the previous
snapshot and its size does not depend on how many there were: a day with two documents and a
day with a hundred thousand both leave the node as the same small object. Network cost
therefore tracks days-with-activity, not volume — a growing customer costs nothing extra,
and a small one pays for what it does.

Two rules follow from that, and both matter more than the size:

  * **Silence is free.** Nothing happened → nothing is sent. There are no mandatory nil
    returns: a company with thirty invoices a year sends thirty snapshots, not 365
    heartbeats. Absence of a snapshot is not evidence of anything, and is never treated as
    such — absence is caught by the counterparty (see `matching.py`), never by a clock.
  * **Snapshots chain per member** (`prev_snapshot` + `seq`), so a past period cannot be
    back-filled. Once snapshot N+1 exists, no record can be slipped into the range that
    N+1's predecessor already sealed.

In a dispute you disclose a leaf and its path — a few hundred bytes — and the counterparty
verifies that one document was in the sealed set. The book itself never has to be shown.
The path is `ceil(log2(n))` hashes: 8 × 32 B for a 200-record period, 17 × 32 B for 100,000.
Quote the formula, never a single number.
"""

from __future__ import annotations

import hashlib
from dataclasses import dataclass

from .canonical import canonical_bytes

DOMAIN_SNAPSHOT = b"NEOEDGES:SNAPSHOT:V1"

# RFC 6962 domain separation: a leaf and an internal node can never be confused, which is
# what stops a second-preimage attack that presents an internal node as if it were a record.
LEAF_PREFIX = b"\x00"
NODE_PREFIX = b"\x01"

EMPTY_ROOT = hashlib.sha256(b"").hexdigest()

SNAPSHOT_GENESIS = "0" * 64


def leaf_hash(record_hash_hex: str) -> bytes:
    return hashlib.sha256(LEAF_PREFIX + bytes.fromhex(record_hash_hex)).digest()


def node_hash(left: bytes, right: bytes) -> bytes:
    return hashlib.sha256(NODE_PREFIX + left + right).digest()


def merkle_root(leaves: list[bytes]) -> str:
    """RFC 6962 root. An empty set has the empty-string root — a snapshot with no records is
    never emitted, but the value is defined so the function is total."""
    if not leaves:
        return EMPTY_ROOT
    level = list(leaves)
    while len(level) > 1:
        nxt = []
        for i in range(0, len(level) - 1, 2):
            nxt.append(node_hash(level[i], level[i + 1]))
        if len(level) % 2:
            nxt.append(level[-1])   # odd node is promoted, not duplicated
        level = nxt
    return level[0].hex()


def merkle_path(leaves: list[bytes], index: int) -> list[dict]:
    """The sibling path from `index` to the root: `[{"hash": hex, "side": "L"|"R"}, ...]`,
    where `side` is which side the SIBLING is on."""
    if not 0 <= index < len(leaves):
        raise IndexError(f"leaf index {index} out of range for {len(leaves)} leaves")
    path: list[dict] = []
    level = list(leaves)
    pos = index
    while len(level) > 1:
        count = len(level)
        nxt = [node_hash(level[i], level[i + 1]) for i in range(0, count - 1, 2)]
        if count % 2:
            nxt.append(level[-1])   # odd node is promoted, not duplicated

        if count % 2 and pos == count - 1:
            pos = count // 2        # promoted: no sibling at this level, so no path step
        else:
            sibling = pos ^ 1
            path.append({"hash": level[sibling].hex(), "side": "R" if sibling > pos else "L"})
            pos //= 2
        level = nxt
    return path


def verify_path(record_hash_hex: str, path: list[dict], root: str) -> bool:
    """Re-derive the root from one record and its path. This is the whole disclosure protocol:
    the holder shows one document and this returns True or False, with no access to the book."""
    current = leaf_hash(record_hash_hex)
    for step in path:
        sibling = bytes.fromhex(step["hash"])
        current = node_hash(sibling, current) if step["side"] == "L" else node_hash(current, sibling)
    return current.hex() == root


@dataclass(frozen=True)
class Snapshot:
    member_id: str
    seq: int
    prev_snapshot: str
    root: str
    record_count: int
    from_seq: int          # first book position covered, inclusive
    to_seq: int            # last book position covered, inclusive
    period: str            # the member's own label for the period, e.g. "2026-07-26"

    def body(self) -> dict:
        """Exactly what leaves the node. No documents, no amounts, no counterparty names —
        a root, its position, and the chain link."""
        return {
            "snapshot_schema": "NEOEDGES_SNAPSHOT_V1",
            "member_id": self.member_id,
            "seq": self.seq,
            "prev_snapshot": self.prev_snapshot,
            "root": self.root,
            "record_count": self.record_count,
            "from_seq": self.from_seq,
            "to_seq": self.to_seq,
            "period": self.period,
        }

    def snapshot_hash(self) -> str:
        return hashlib.sha256(DOMAIN_SNAPSHOT + canonical_bytes(self.body())).hexdigest()


def build(member_id: str, entries, *, seq: int, prev_snapshot: str, period: str) -> tuple[Snapshot, list[bytes]]:
    """Seal a run of book entries into one snapshot. Returns the snapshot and its leaves,
    so the caller can produce disclosure paths later without re-reading the book."""
    entries = list(entries)
    if not entries:
        raise ValueError("a snapshot is never emitted for an empty period — silence is free")
    leaves = [leaf_hash(entry.record_hash) for entry in entries]
    snapshot = Snapshot(
        member_id=member_id,
        seq=seq,
        prev_snapshot=prev_snapshot,
        root=merkle_root(leaves),
        record_count=len(entries),
        from_seq=entries[0].seq,
        to_seq=entries[-1].seq,
        period=period,
    )
    return snapshot, leaves
