"""The outbox — where snapshots go once they leave the node.

Deliberately a seam, not an integration. NEOedge is finished as a module before anything
decides where its snapshots land, and the destination is a one-line swap: a file today, a
NEOos ingress later, a queue in between if a customer needs one. The edge never learns what
is on the other side, which is what lets the network's design change without touching a
single installed edge.

Two rules, both from the Arena's hot path:

  * **Sending never blocks the business.** A sink that is slow, down or angry must not stop
    a company recording what its ERP did. Failures are counted and left for the next attempt.
  * **Only the snapshot crosses.** A root, its position and the chain link. No records, no
    documents, no counterparty names, no amounts.
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol

from .snapshot import Snapshot


class Sink(Protocol):
    def send(self, snapshot: Snapshot) -> bool:
        """Return True when the snapshot is accepted. Never raise into the caller."""


@dataclass
class NullSink:
    """Records nothing anywhere. The default: an edge with no destination configured still
    keeps a correct local book, and that is the state most of a pilot runs in."""

    sent: list[Snapshot] = field(default_factory=list)

    def send(self, snapshot: Snapshot) -> bool:
        self.sent.append(snapshot)
        return True


@dataclass
class CLSink:
    """Send each seal to a live Cotrugli Ledger over its `/v1/agent-events` door.

    Ported verbatim in behaviour from `neoedges` — this is TODAY's proven wire (the full
    circle of 2026-07-28 ran through it); `framesink.FrameSink` is tomorrow's. What
    crosses is a COMMITMENT, not content: the snapshot's own hash rides as
    `tool_output_hash`, the previous snapshot's hash as `tool_input_hash` — the CL learns
    that this member sealed a period and what the seal is, nothing else.

    The two outbox rules hold: sending never blocks the business (failures are counted,
    never raised — the book is already durable), and only the seal crosses.
    `request_id` is the snapshot hash, so a retry is idempotent at the door.

    Receipts are appended to `receipts_path` (JSONL) — beside the book, never inside it:
    a book entry has no field for a receipt, and adding one would change the entry's own
    hash. That lesson is already paid for (Arena, 2026-07-25).
    """

    base_url: str
    token: str
    connector_id: str
    receipts_path: str | os.PathLike | None = None
    timeout_s: float = 5.0
    event_type: str = "neoedges.snapshot.sealed"
    failures: int = 0
    # A 200 is not the same as a contribution. The door deduplicates by `request_id`,
    # which IS the snapshot hash — re-sending an identical book returns success and
    # changes nothing. Counted apart from failures: "already known" is not an error and
    # must not be reported as one; it also must not be reported as new.
    duplicates: int = 0

    def send(self, snapshot: Snapshot) -> bool:
        import urllib.error
        import urllib.request

        body = snapshot.body()
        snap_hash = snapshot.snapshot_hash()
        payload = {
            "connector_id": self.connector_id,
            "request_id": snap_hash,
            "event": {
                "event_type": self.event_type,
                "tool_name": "neoedges.seal",
                "status": "success",
                "tool_output_hash": snap_hash,
                "tool_input_hash": body.get("prev_snapshot"),
            },
        }
        request = urllib.request.Request(
            f"{self.base_url.rstrip('/')}/v1/agent-events",
            data=json.dumps(payload).encode("utf-8"),
            headers={"Content-Type": "application/json",
                     "Authorization": f"Bearer {self.token}"},
            method="POST",
        )
        try:
            with urllib.request.urlopen(request, timeout=self.timeout_s) as response:
                receipt = json.loads(response.read().decode("utf-8"))
        except (urllib.error.URLError, OSError, ValueError):
            self.failures += 1     # counted, never propagated — the book is already durable
            return False
        if receipt.get("was_duplicate"):
            self.duplicates += 1
        self._store_receipt(snap_hash, receipt)
        return True

    def _store_receipt(self, snap_hash: str, receipt: dict) -> None:
        if self.receipts_path is None:
            return
        try:
            target = Path(self.receipts_path)
            target.parent.mkdir(parents=True, exist_ok=True)
            with target.open("a", encoding="utf-8") as handle:
                handle.write(json.dumps({"snapshot_hash": snap_hash, "receipt": receipt},
                                        sort_keys=True, ensure_ascii=False) + "\n")
                handle.flush()
                os.fsync(handle.fileno())
        except OSError:
            self.failures += 1     # the send succeeded; a receipt that failed to persist is a retry matter


@dataclass
class FileSink:
    """Append snapshot bodies to a JSONL file — an inspectable stand-in for the network,
    and the artifact a verifier is handed when there is no network at all."""

    path: str | os.PathLike
    failures: int = 0

    def send(self, snapshot: Snapshot) -> bool:
        try:
            target = Path(self.path)
            target.parent.mkdir(parents=True, exist_ok=True)
            body = dict(snapshot.body(), snapshot_hash=snapshot.snapshot_hash())
            with target.open("a", encoding="utf-8") as handle:
                handle.write(json.dumps(body, sort_keys=True, ensure_ascii=False) + "\n")
                handle.flush()
                os.fsync(handle.fileno())
            return True
        except OSError:
            self.failures += 1     # counted, never propagated — the book is already durable
            return False
