"""NEOedge — the module that runs at one company, on top of its own ERP.

What it does, in order: reads a document out of the ERP through that vendor's adapter,
normalises it into the shared grammar, appends it to the company's own hash-chained book,
and — when the company chooses to seal a period — emits a snapshot root. The documents never
leave. The company's ERP stays the authoritative record; NEOedge keeps a parallel, comparable
one and commits to it.

Both parties run this. A's edge records the delivery note it received; B's edge records the
delivery note it issued. Neither can quietly change what it recorded, because the other side
holds an independent record of the same event and both are sealed into chained snapshots.
That is the third entry: not a third copy of the documents, a neutral record of what each
party said, from each party.

The edge is trusted to do the WORK, not to be the only witness of it. It runs on hardware the
customer controls — it can be debugged, its clock moved, its files edited. Everything here is
built so that none of that helps: identity is a hash of the fields, position is a chain, and
the counterparty holds the other half of every event.
"""

from __future__ import annotations

from dataclasses import dataclass

from . import snapshot as snapshots
from .adapters.base import ErpAdapter
from .book import Book, Entry
from .grammar import (
    ORIGINATOR,
    GrammarError,
    REFUSED_MISSING_FIELD,
    RECORD_SCHEMA,
    event_key_of,
    record_hash,
    relation_ref,
)
from .matching import Report, review
from .members import MemberRegistry
from .outbox import NullSink, Sink
from .snapshot import Snapshot, verify_path


@dataclass(frozen=True)
class Relation:
    """One trading relationship, named by the contract both sides signed.

    `ref` is derived from the contract number and the two member ids, so the counterparty's
    edge computes the identical value without either side sending it anything.
    """

    ref: str
    contract_no: str
    counterparty_id: str
    role: str


@dataclass(frozen=True)
class Disclosure:
    """What is handed over in a dispute: one record, and proof it was in a sealed snapshot.

    A few hundred bytes. The book itself is never shown — the counterparty (or a Chamber)
    checks this packet against the snapshot the network already holds and learns exactly one
    document, which is the point of selective disclosure.
    """

    record: dict
    record_hash: str
    snapshot: dict
    path: list[dict]

    def verify(self) -> bool:
        """Standalone: re-derive the record hash from the fields, then the root from the path.
        No session, no gateway, no live process — this is the whole verification surface."""
        return record_hash(self.record) == self.record_hash and verify_path(
            self.record_hash, self.path, self.snapshot["root"]
        )


class NEOedge:
    """One company's edge."""

    def __init__(
        self,
        member_id: str,
        registry: MemberRegistry,
        adapter: ErpAdapter,
        *,
        book_path=None,
        sink: Sink | None = None,
    ):
        self.member = registry.get(member_id)
        self.registry = registry
        self.adapter = adapter
        self.book = Book(member_id, book_path)
        self.sink = sink or NullSink()
        self._snapshots: list[tuple[Snapshot, list[bytes], list[Entry]]] = []

    @property
    def member_id(self) -> str:
        return self.member.member_id

    # ── relationships ────────────────────────────────────────────────────────────────

    def open_relation(self, contract_no: str, counterparty_id: str, role: str) -> Relation:
        """Name a trading relationship. Both sides must already be members — a trade with a
        non-member would produce one record and no witness, which is the shape this system
        treats as a fault rather than a transaction."""
        self.registry.require_pair(self.member_id, counterparty_id)
        return Relation(
            ref=relation_ref(contract_no, self.member_id, counterparty_id),
            contract_no=contract_no,
            counterparty_id=counterparty_id,
            role=role,
        )

    # ── recording ────────────────────────────────────────────────────────────────────

    def record(
        self,
        native: dict,
        relation: Relation,
        *,
        adapter: ErpAdapter | None = None,
        originator_doc_no: str | None = None,
        fulfills_event: str | None = None,
        settles_originator_doc_no: str | None = None,
        counterparty_doc_ref: dict | None = None,
        amount_basis: str | None = None,
    ) -> Entry:
        """Read one ERP document and append it to the book.

        `originator_doc_no` is required when this company did NOT write the document — it is
        the number printed on the paper that arrived, and it is the only thing that lets the
        two independent records of one event find each other. Asking for it explicitly is
        deliberate: it is the single field an integrator must map correctly, so it fails at
        configuration time rather than silently producing records that never pair.
        """
        # A company may receive a contract from a marketplace while its ordinary documents
        # come from SAP/Oracle/etc.  The book is one; adapters are translators.  Selecting
        # the marketplace translator for this call avoids inventing a second book/process.
        draft = (adapter or self.adapter).to_draft(native, amount_basis=amount_basis)
        kind = draft["doc_kind"]
        origin_role = ORIGINATOR[kind]

        if origin_role is not None and relation.role != origin_role:
            if originator_doc_no is None:
                raise GrammarError(
                    REFUSED_MISSING_FIELD,
                    f"{kind} is written by the {origin_role}; this company is the {relation.role}, so "
                    "the originator's document number must be supplied — it is what pairs the two records",
                )
            draft["originator_doc_no"] = originator_doc_no
        elif originator_doc_no is not None:
            draft["originator_doc_no"] = originator_doc_no

        record = {
            **draft,
            "record_schema": RECORD_SCHEMA,
            "member_id": self.member_id,
            "role": relation.role,
            "counterparty_id": relation.counterparty_id,
            "relation_ref": relation.ref,
        }
        if fulfills_event:
            record["fulfills_event"] = fulfills_event
        if settles_originator_doc_no:
            record["settles_originator_doc_no"] = settles_originator_doc_no
        if counterparty_doc_ref:
            record["counterparty_doc_ref"] = counterparty_doc_ref
        return self.book.append(record)

    # ── sealing ──────────────────────────────────────────────────────────────────────

    def seal(self, period: str) -> Snapshot | None:
        """Seal everything recorded since the last snapshot into one root, and send it.

        Returns None when nothing happened: silence is free, and an empty snapshot would be
        a heartbeat — a claim about a period rather than a commitment to records in it.
        """
        already = self._snapshots[-1][0].to_seq + 1 if self._snapshots else 0
        pending = [entry for entry in self.book.entries() if entry.seq >= already]
        if not pending:
            return None
        snapshot, leaves = snapshots.build(
            self.member_id,
            pending,
            seq=len(self._snapshots),
            prev_snapshot=self._snapshots[-1][0].snapshot_hash() if self._snapshots else snapshots.SNAPSHOT_GENESIS,
            period=period,
        )
        self._snapshots.append((snapshot, leaves, pending))
        self.sink.send(snapshot)
        return snapshot

    @property
    def sealed(self) -> list[Snapshot]:
        return [snapshot for snapshot, _, _ in self._snapshots]

    # ── disclosure and review ────────────────────────────────────────────────────────

    def disclose(self, event_key: str) -> Disclosure:
        """Produce the proof packet for one event: the record, and its path into the snapshot
        that sealed it. Everything else in the book stays private."""
        for snapshot, leaves, entries in self._snapshots:
            for index, entry in enumerate(entries):
                if entry.event_key == event_key:
                    return Disclosure(
                        record=entry.record,
                        record_hash=entry.record_hash,
                        snapshot=snapshot.body(),
                        path=snapshots.merkle_path(leaves, index),
                    )
        raise KeyError(
            f"event {event_key[:16]}… is not in any sealed snapshot — either it was never "
            "recorded here, or the period holding it has not been sealed yet"
        )

    def records(self, relation: Relation | None = None) -> list[dict]:
        """This edge's records, optionally for one relation. What a company hands over when a
        Chamber compels production."""
        out = [entry.record for entry in self.book.entries()]
        if relation is not None:
            out = [record for record in out if record["relation_ref"] == relation.ref]
        return out

    def review_against(self, counterparty_records: list[dict], relation: Relation) -> Report:
        """Compare this edge's view of a relation with the counterparty's. Pure and offline:
        the same two record sets produce the same report on any machine."""
        return review(self.records(relation), counterparty_records)

    def event_key_for(self, entry: Entry) -> str:
        return event_key_of(entry.record)
