"""The local book — append-only, hash-chained, one per member, living on the customer's disk.

This is the company's own parallel record: what its ERP posted, expressed in the shared
grammar. It is NOT evidence on its own — it runs on hardware the customer controls and can
be edited by anyone with the file. What makes it hard to rewrite is the chain plus the
counterparty: each entry commits to the one before it, so a past entry cannot be changed or
inserted without changing every entry after it, and the counterparty independently holds
its own record of the same events.

Three behaviours are here because the Arena paid for them on 2026-07-26:

  * **Nothing is appended until everything that can fail has been checked.** A half-written
    append that then raises leaves a book claiming something happened twice.
  * **A retry returns the ORIGINAL entry, not a refusal.** An ERP that re-sends the same
    document after a timeout is behaving correctly; answering it with an error trains
    integrators to retry harder. Same record → same entry. Same event, DIFFERENT content →
    refused loudly, because that is a double invoice, not a retry.
  * **A torn final line is recoverable.** An edge on a laptop WILL be killed mid-write. The
    last line is discarded and the recovery is recorded; a break anywhere else still raises,
    because that is tampering, not a power cut.
"""

from __future__ import annotations

import hashlib
import json
import os
from dataclasses import dataclass
from pathlib import Path

from .canonical import canonical_bytes
from .grammar import event_key_of, record_hash, validate

DOMAIN_BOOK = b"NEOEDGES:BOOK:V1"

GENESIS = "0" * 64

REFUSED_CHAIN_BROKEN = "REFUSED_CHAIN_BROKEN"
REFUSED_EVENT_ALREADY_RECORDED = "REFUSED_EVENT_ALREADY_RECORDED"
REFUSED_FOREIGN_RECORD = "REFUSED_FOREIGN_RECORD"
BOOK_TAIL_RECOVERED = "BOOK_TAIL_RECOVERED"


class BookError(Exception):
    def __init__(self, code: str, detail: str):
        super().__init__(detail)
        self.code = code
        self.detail = detail


@dataclass(frozen=True)
class Entry:
    seq: int
    prev: str
    record_hash: str
    event_key: str
    entry_hash: str
    record: dict

    def to_json(self) -> dict:
        return {
            "seq": self.seq,
            "prev": self.prev,
            "record_hash": self.record_hash,
            "event_key": self.event_key,
            "entry_hash": self.entry_hash,
            "record": self.record,
        }


def entry_hash(seq: int, prev: str, rec_hash: str) -> str:
    """Chain link. Covers position and content — so neither can be changed alone."""
    body = {"seq": seq, "prev": prev, "record_hash": rec_hash}
    return hashlib.sha256(DOMAIN_BOOK + canonical_bytes(body)).hexdigest()


class Book:
    """One member's append-only book, persisted as JSONL (one entry per line, never rewritten)."""

    def __init__(self, member_id: str, path: str | os.PathLike | None = None):
        self.member_id = member_id
        self.path = Path(path) if path is not None else None
        self._entries: list[Entry] = []
        self._by_record: dict[str, Entry] = {}
        self._by_event: dict[str, Entry] = {}
        self.recovered_tail = False
        if self.path is not None and self.path.exists():
            self._load()

    # ── reading ──────────────────────────────────────────────────────────────────────

    @property
    def head(self) -> str:
        return self._entries[-1].entry_hash if self._entries else GENESIS

    @property
    def height(self) -> int:
        return len(self._entries)

    def entries(self) -> list[Entry]:
        return list(self._entries)

    def by_event(self, key: str) -> Entry | None:
        return self._by_event.get(key)

    def events(self) -> dict[str, Entry]:
        return dict(self._by_event)

    # ── writing ──────────────────────────────────────────────────────────────────────

    def append(self, record: dict) -> Entry:
        """Validate, check for a retry or a conflict, then write. In that order, always."""
        clean = validate(record)
        if clean["member_id"] != self.member_id:
            raise BookError(
                REFUSED_FOREIGN_RECORD,
                f"this book belongs to {self.member_id}; the record asserts {clean['member_id']} — "
                "a member never writes another member's record, that is what the counterparty's book is for",
            )
        rec_hash = record_hash(clean)
        existing = self._by_record.get(rec_hash)
        if existing is not None:
            return existing  # a retry, byte-identical: return the original outcome

        key = event_key_of(clean)
        conflict = self._by_event.get(key)
        if conflict is not None:
            raise BookError(
                REFUSED_EVENT_ALREADY_RECORDED,
                f"event {key[:16]}… ({clean['doc_kind']} {clean['originator_doc_no']}) is already in this "
                f"book at seq {conflict.seq} with different content — a second, differing record of one "
                "event is a duplicate document, not a retry",
            )

        entry = Entry(
            seq=len(self._entries),
            prev=self.head,
            record_hash=rec_hash,
            event_key=key,
            entry_hash=entry_hash(len(self._entries), self.head, rec_hash),
            record=clean,
        )
        self._persist(entry)          # durable first; an in-memory book that outran its file lies
        self._entries.append(entry)
        self._by_record[rec_hash] = entry
        self._by_event[key] = entry
        return entry

    def _persist(self, entry: Entry) -> None:
        if self.path is None:
            return
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with self.path.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(entry.to_json(), sort_keys=True, ensure_ascii=False) + "\n")
            handle.flush()
            os.fsync(handle.fileno())

    # ── integrity ────────────────────────────────────────────────────────────────────

    def verify(self) -> None:
        """Re-derive the entire chain from the records themselves. Raises on the first break.

        This is what a verifier does with a book handed over in a dispute: it trusts none of
        the stored hashes and recomputes all of them.
        """
        prev = GENESIS
        for i, entry in enumerate(self._entries):
            if entry.seq != i:
                raise BookError(REFUSED_CHAIN_BROKEN, f"entry {i}: seq is {entry.seq}")
            if entry.prev != prev:
                raise BookError(REFUSED_CHAIN_BROKEN, f"entry {i}: prev does not match the previous entry")
            expect_record = record_hash(entry.record)
            if entry.record_hash != expect_record:
                raise BookError(REFUSED_CHAIN_BROKEN, f"entry {i}: record_hash does not re-derive from the record")
            if entry.entry_hash != entry_hash(entry.seq, entry.prev, entry.record_hash):
                raise BookError(REFUSED_CHAIN_BROKEN, f"entry {i}: entry_hash does not re-derive")
            prev = entry.entry_hash

    def _load(self) -> None:
        raw = self.path.read_text(encoding="utf-8")
        lines = raw.split("\n")
        if lines and lines[-1] == "":
            lines.pop()
        for i, line in enumerate(lines):
            try:
                data = json.loads(line)
            except json.JSONDecodeError:
                if i == len(lines) - 1:
                    # A torn FINAL line is a power cut mid-append: discard it, record that we did.
                    self.recovered_tail = True
                    self._rewrite(lines[:i])
                    break
                raise BookError(
                    REFUSED_CHAIN_BROKEN,
                    f"{self.path}: line {i} is corrupt and is not the last line — this is damage, not a torn write",
                )
            entry = Entry(
                seq=data["seq"],
                prev=data["prev"],
                record_hash=data["record_hash"],
                event_key=data["event_key"],
                entry_hash=data["entry_hash"],
                record=data["record"],
            )
            self._entries.append(entry)
            self._by_record[entry.record_hash] = entry
            self._by_event[entry.event_key] = entry
        self.verify()

    def _rewrite(self, keep: list[str]) -> None:
        tmp = self.path.with_suffix(self.path.suffix + ".recover")
        tmp.write_text("".join(line + "\n" for line in keep), encoding="utf-8")
        tmp.replace(self.path)
