"""NEOvaAnchorBackend — the fifth `AnchorBackend`: anchoring on NEOva through
its PUBLIC seam only (write surface `POST /v2/submit` + read surface
`GET /v2/era` / `GET /v2/bundle`), judged by the PINNED INDEPENDENT VERIFIER.

Serves: FINISH_BLUEPRINT Phase 1 / V-bridge (closes drift item D6 — today the
ledger anchors on old NEO; nothing feeds ledger evidence into NEOva).

Honest assurance mapping (declared, see PR):
  Ledger A2 ("anchored") ≙ the object is at a canonical NEOva position inside a
  COMMITTED segment whose verification bundle the independent verifier judged
  VALID against the pinned genesis. The deployed engine is single-node today,
  so the assurance string says exactly that — `neova_segment_committed_verified
  _single_node` — NOT "N1 confirmed" (multi-node), which would overclaim until
  the federated engine lands (Phases 2/3). `anchor()` never waits for an era
  seal (N2 is rare/off the hot path by design): Anchored != crypto-final.

Position discovery is the seam's OWN mechanism (admission rule 3): a
confirmation envelope carrying the same (object_root, scope_key, namespace_id)
under a fresh sequence returns ("CANONICAL_EXISTS", global_position) — the
engine names the canonical address; we never peek at internals.

Field semantics on `AnchorAttestation` (ABC field names are NEO-era; the
semantics here are declared, not faked):
  anchored_at_height        <- NEOva global position (canonical address)
  finality_certificate_hash <- committed SEGMENT HEADER hash (NOT a finality
                               certificate; kept in this field because the ABC
                               fixes the name — Anchored != crypto-final)
  block_hash                <- b"" (NEOva has no block concept; left empty
                               deliberately rather than faked)

Concurrency (task T1.6 finding): `/v2/submit` has NO per-caller transport
counter (that mechanism lives on the federation quorum transport, where it
serializes each caller — measured `stale_counter` in FF-1b). Envelope replay
protection is the (gateway_id, epoch, sequence) tuple INSIDE the signed
envelope, so concurrent `anchor()` calls are safe as long as sequences are
unique — enforced by the locked, persisted allocator below (persist-before-use:
a crash skips a sequence, never reuses one; sequence reuse with different
content would be recorded as GATEWAY_EQUIVOCATION, so the allocator is the
safety mechanism, not a nicety).
"""

from __future__ import annotations

import json
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional, Tuple

from cl.anchor.backend import AnchorAttestation, AnchorBackend, AnchorError
from cl.anchor.neova_wire import build_wire_envelope

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

# Same transport shape as neo.py: (method, path, body|None, headers) ->
# (status, body_text). Injectable: unit tests run against a fake; the live
# e2e binds it to real HTTP. Two surfaces -> two transports (write + read).
HttpTransport = Callable[[str, str, Optional[bytes], dict], Tuple[int, str]]

#: outcomes that mean "the engine holds (or already held) this root"
_OK_OUTCOMES = ("ACCEPTED", "IDEMPOTENT")


@dataclass(frozen=True)
class NeovaAnchorConfig:
    network_id: str
    namespace_id: str
    gateway_id: str
    gateway_seed: bytes          # 32B Ed25519 seed — from env/config, NEVER committed
    scope_key: bytes             # opaque 32B scope for this ledger's anchor stream
    era_id: int                  # current era == gateway_epoch (admission ERA1-2)
    genesis: dict                # pinned genesis the independent verifier judges against
    counter_path: str            # persisted gateway_sequence allocator
    poll_timeout_s: float = 30.0
    poll_interval_s: float = 0.05
    # Phase 4 (ERA1-2 rollover + backpressure law):
    era_probe_limit: int = 32        # how far past the last-known era the
    #   read-seam discovery scans on REJECT_STALE_EPOCH (the WHOLE window
    #   — retired eras 404, so holes precede the live era after downtime)
    backpressure_max_retries: int = 5  # bounded BACKPRESSURE retries; then
    #   fail-closed AnchorError — never a silent drop

    def __post_init__(self):
        if len(self.gateway_seed) != 32 or len(self.scope_key) != 32:
            raise ValueError("gateway_seed and scope_key must be 32 bytes")


class _SequenceAllocator:
    """Locked, persisted, persist-before-use monotonic sequence source."""

    def __init__(self, path: str):
        self._path = Path(path)
        self._lock = threading.Lock()
        self._next = 0
        if self._path.exists():
            self._next = int(self._path.read_text())

    def take(self) -> int:
        with self._lock:
            seq = self._next
            self._next = seq + 1
            tmp = self._path.with_suffix(".tmp")
            tmp.write_text(str(self._next))
            tmp.replace(self._path)          # durable BEFORE the envelope exists
            return seq


class NEOvaAnchorBackend(AnchorBackend):
    def __init__(self, config: NeovaAnchorConfig,
                 submit_http: HttpTransport, serve_http: HttpTransport,
                 bundle_judge: Optional[Callable[[dict, dict], dict]] = None):
        """`bundle_judge(bundle, genesis) -> {"verdict": "VALID"|...}`.
        Defaults to the pinned independent verifier (`neova_verifier`,
        stdlib-only, engine-import-free). FAIL-CLOSED: if no judge can be
        constructed, the backend refuses to exist — an attestation this
        backend cannot independently verify must never be issuable."""
        self._cfg = config
        # Phase 4: the era is LIVE state — CL_NEOVA_ERA is only the initial
        # hint; REJECT_STALE_EPOCH triggers read-seam discovery + re-sign
        # (ERA1-2: fresh sequence, admission replay-protection dedups).
        self._era = int(config.era_id)
        self._attempts = []          # ordered attempt log for durability
        self._last_details = None    # popped by the service after anchor()
        self._submit = submit_http
        self._serve = serve_http
        self._sk = Ed25519PrivateKey.from_private_bytes(config.gateway_seed)
        self._seq = _SequenceAllocator(config.counter_path)
        if bundle_judge is None:
            try:
                from neova_verifier.bundle_v2 import verify_bundle_v2
            except ImportError as error:
                raise AnchorError(
                    "neova_verifier (pinned independent verifier) is not "
                    "installed and no bundle_judge was injected — refusing "
                    "to construct an unverifiable anchoring backend"
                ) from error
            bundle_judge = verify_bundle_v2
        self._judge = bundle_judge

    # ------------------------------------------------------------ plumbing

    def _post_submit(self, wire_env: dict):
        body = json.dumps({"envelopes": [wire_env]}).encode()
        status, text = self._submit("POST", "/v2/submit", body,
                                    {"Content-Type": "application/json"})
        if status == 503:
            return "FENCED"
        if status != 200:
            raise AnchorError(f"NEOva submit transport status {status}: "
                              f"{text[:200]}")
        try:
            return json.loads(text)["outcomes"][0]
        except (ValueError, KeyError, IndexError) as error:
            raise AnchorError("malformed NEOva submit response") from error

    def _get_json(self, path: str, tolerant: bool = False):
        """`tolerant=True` is for DISCOVERY probes (wrong-segment position
        queries legitimately error until the covering segment commits): any
        non-200 reads as "not here (yet)". The overall deadline + fail-closed
        timeout in `anchor()` keeps tolerance from hiding a real outage."""
        status, text = self._serve("GET", path, None, {})
        if status == 404 or (tolerant and status != 200):
            return None
        if status != 200:
            raise AnchorError(f"NEOva read transport status {status} for "
                              f"{path}: {text[:200]}")
        try:
            return json.loads(text)
        except ValueError as error:
            raise AnchorError("malformed NEOva read response") from error

    def _discover_era(self) -> int:
        """ERA1-2 rollover discovery over the PUBLIC read seam: scan the
        ENTIRE probe window forward from the last-known era and take the
        NEWEST era the seam serves (`/v2/era` 200). Never stop at the
        first 404 — retired/pruned eras 404 too (their headers leave hot
        state at retirement, review MUST 4), so a ledger that was offline
        across several eras sees a hole before the live era. Returns the
        newest visible era, or era+1 as the blind step when the whole
        window is dark (a gap wider than `era_probe_limit` exhausts the
        re-sign budget with an explicit error — raise the limit, never
        loop silently)."""
        best = self._era
        for cand in range(self._era + 1,
                          self._era + 1 + self._cfg.era_probe_limit):
            if self._get_json(f"/v2/era?era={cand}",
                              tolerant=True) is not None:
                best = cand              # keep scanning: holes are normal
        return max(best, self._era + 1)

    def _submit_envelope(self, batch_root: bytes, deadline: float):
        """Submit under a fresh sequence, under the LIVE era. Retry laws:
        FENCED -> poll until deadline (successor recovers the journal, so
        retransmits land IDEMPOTENT) · BACKPRESSURE -> bounded retries of
        the SAME signed envelope (retryable refusal, engine law), then
        fail-closed · REJECT_STALE_EPOCH -> era discovery + RE-SIGN under
        the live era with a fresh sequence (ERA1-2; admission
        replay-protection dedups), bounded by era_probe_limit. Every
        attempt lands in the durable attempt log — never a silent drop."""
        backpressure_left = self._cfg.backpressure_max_retries
        resigns_left = self._cfg.era_probe_limit
        seq = self._seq.take()
        wire = build_wire_envelope(
            self._cfg.network_id, batch_root, self._cfg.scope_key,
            self._cfg.namespace_id, self._cfg.gateway_id, self._era,
            seq, self._sk)
        while True:
            outcome = self._post_submit(wire)
            self._attempts.append({
                "action": "submit", "era": self._era, "sequence": seq,
                "outcome": (list(outcome)
                            if isinstance(outcome, (list, tuple))
                            else str(outcome)),
            })
            if outcome == "FENCED":
                if time.monotonic() >= deadline:
                    raise AnchorError("NEOva sequencer stayed FENCED past "
                                      "the anchoring deadline")
                time.sleep(self._cfg.poll_interval_s)
                continue
            if outcome == "BACKPRESSURE":
                if backpressure_left <= 0 or time.monotonic() >= deadline:
                    raise AnchorError(
                        "NEOva backpressure persisted past the retry "
                        "budget — NOT anchored (fail-closed, attempt log "
                        "preserved)")
                backpressure_left -= 1
                time.sleep(self._cfg.poll_interval_s)
                continue
            if outcome == "REJECT_STALE_EPOCH":
                if resigns_left <= 0:
                    raise AnchorError(
                        "era rollover outran discovery (probe limit "
                        f"{self._cfg.era_probe_limit}) — NOT anchored")
                resigns_left -= 1
                self._era = self._discover_era()
                seq = self._seq.take()
                wire = build_wire_envelope(
                    self._cfg.network_id, batch_root, self._cfg.scope_key,
                    self._cfg.namespace_id, self._cfg.gateway_id,
                    self._era, seq, self._sk)
                self._attempts.append({
                    "action": "resign", "era": self._era, "sequence": seq,
                    "outcome": "ERA1-2 re-sign after rollover"})
                continue
            return outcome

    # ---------------------------------------------------------- the seam

    def pop_anchor_details(self) -> Optional[dict]:
        """Phase-4 durability seam: the service pops the submission details
        right after `anchor()` and persists them durably. On success:
        accepted era/sequence/outcome/position/bundle reference + the full
        attempt log. On FAILURE (review MUST 2): the attempt log alone, so
        the durable FAILED row still carries every submit/resign/refusal.
        One-shot; never partial."""
        details = self._last_details
        if details is None and self._attempts:
            details = {"submission_outcome": "FAILED",
                       "retry_history": list(self._attempts)}
        self._last_details = None
        self._attempts = []
        return details

    def anchor(self, batch_root: bytes) -> AnchorAttestation:
        if not isinstance(batch_root, bytes) or len(batch_root) != 32:
            raise AnchorError("batch_root must be 32 bytes")
        deadline = time.monotonic() + self._cfg.poll_timeout_s
        self._attempts = []
        self._last_details = None
        self._last_bundle_ref = None

        # 1. submit — fail-closed on any REJECT_* (rollover + backpressure
        #    laws live in _submit_envelope)
        outcome = self._submit_envelope(batch_root, deadline)
        accepted = dict(self._attempts[-1]) if self._attempts else {}
        position = None
        if isinstance(outcome, (list, tuple)) and len(outcome) == 2 \
                and outcome[0] == "CANONICAL_EXISTS":
            position = int(outcome[1])         # anchored earlier — rule 3
        elif outcome not in _OK_OUTCOMES:
            raise AnchorError(f"NEOva admission refused the root: {outcome}")

        # 2. canonical address via the seam's own rule 3: a confirmation
        #    envelope (same root/scope/namespace, fresh sequence) makes the
        #    engine NAME the canonical position.
        if position is None:
            confirm = self._submit_envelope(batch_root, deadline)
            if not (isinstance(confirm, (list, tuple)) and len(confirm) == 2
                    and confirm[0] == "CANONICAL_EXISTS"):
                raise AnchorError(
                    f"NEOva did not confirm a canonical position "
                    f"(confirmation outcome: {confirm})")
            position = int(confirm[1])

        # 3. wait until the covering segment is COMMITTED and served, then
        #    judge the position bundle with the independent verifier.
        while True:
            header_hash_hex = self._try_fetch_verified(batch_root, position)
            if header_hash_hex is not None:
                self._last_details = {
                    "gateway_epoch": accepted.get("era", self._era),
                    "gateway_sequence": accepted.get("sequence"),
                    "submission_outcome": (
                        "CANONICAL_EXISTS"
                        if isinstance(accepted.get("outcome"), list)
                        else str(accepted.get("outcome", "ACCEPTED"))),
                    "canonical_position": position,
                    "position_bundle_reference": self._last_bundle_ref,
                    "retry_history": list(self._attempts),
                }
                return AnchorAttestation(
                    opaque_root=batch_root,
                    anchored_at_height=position,
                    finality_certificate_hash=bytes.fromhex(header_hash_hex),
                    assurance_level="neova_segment_committed_verified_single_node",
                )
            if time.monotonic() >= deadline:
                raise AnchorError(
                    f"NEOva position {position} was not served inside a "
                    f"committed, independently-verified segment before the "
                    f"deadline — NOT anchored (fail-closed)")
            time.sleep(self._cfg.poll_interval_s)

    def _try_fetch_verified(self, batch_root: bytes,
                            position: int) -> Optional[str]:
        """One discovery pass over the PUBLIC read seam: probe the position
        bundle in each of the era's segments (newest first — the root was
        submitted just now). Returns the committed segment header hash (hex)
        once every check holds, else None (not committed/served yet)."""
        era = self._get_json(f"/v2/era?era={self._era}", tolerant=True)
        if era is None:
            return None
        for segment_id in reversed(era.get("segment_ids", [])):
            bundle = self._get_json(
                f"/v2/bundle?kind=position&era={self._era}"
                f"&segment={segment_id}&global_position={position}",
                tolerant=True)
            if bundle is None:
                continue                        # not this segment / not yet
            # re-derivation, not echo: the pinned verifier re-derives the
            # bundle against genesis; only VERIFIED fields are read after.
            verdict = self._judge(bundle, self._cfg.genesis)
            if verdict.get("verdict") != "VALID":
                raise AnchorError(
                    f"NEOva served a bundle the independent verifier refused "
                    f"({verdict.get('primary')}) — refusing the attestation")
            self._last_bundle_ref = {"era": self._era,
                                     "segment": segment_id}
            address = _proof_address(bundle)
            if address.get("object_root") != batch_root.hex() or \
                    address.get("global_position") != position:
                raise AnchorError(
                    "verified bundle does not bind the submitted root to the "
                    "canonical position — refusing the attestation")
            return address.get("segment_header_hash")
        return None


def _proof_address(bundle: dict) -> dict:
    proofs = bundle.get("position_proofs") or []
    return (proofs[0] or {}).get("address", {}) if proofs else {}


# ------------------------------------------------------- deploy-time wiring

def http_transport(base_url: str, timeout: float = 15.0) -> HttpTransport:
    """A real HTTP transport bound to one base URL (stdlib only)."""
    import urllib.error
    import urllib.request

    def transport(method, path, body, headers):
        req = urllib.request.Request(base_url.rstrip("/") + path,
                                     data=body, method=method,
                                     headers=headers or {})
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return r.status, r.read().decode("utf-8", "replace")
        except urllib.error.HTTPError as e:
            return e.code, e.read().decode("utf-8", "replace")
    return transport


def neova_backend_from_env(env=None) -> "NEOvaAnchorBackend":
    """`CL_ANCHOR_BACKEND=neova` factory. Owner-set values have NO defaults —
    a missing one refuses to start (same discipline as the NEO leg):
      CL_NEOVA_SUBMIT_URL / CL_NEOVA_SERVE_URL   the two seam surfaces
      CL_NEOVA_GATEWAY_SEED_HEX                  32B Ed25519 seed (owner-set)
      CL_NEOVA_GENESIS_PATH                      pinned genesis JSON file
    Optional (safe defaults):
      CL_NEOVA_NETWORK_ID / CL_NEOVA_NAMESPACE / CL_NEOVA_GATEWAY_ID
      CL_NEOVA_SCOPE_KEY_HEX (default: sha256 of the namespace id)
      CL_NEOVA_ERA (default 1) · CL_NEOVA_SEQ_PATH · CL_NEOVA_POLL_TIMEOUT_S
    """
    import hashlib
    import os as _os

    e = _os.environ if env is None else env

    def required(key: str) -> str:
        v = e.get(key)
        if not v:
            raise RuntimeError(f"CL_ANCHOR_BACKEND=neova requires {key} "
                               f"(owner-set; no default)")
        return v

    namespace = e.get("CL_NEOVA_NAMESPACE", "ns:cotrugli-ledger")
    scope_hex = e.get("CL_NEOVA_SCOPE_KEY_HEX")
    scope_key = (bytes.fromhex(scope_hex) if scope_hex
                 else hashlib.sha256(namespace.encode()).digest())
    config = NeovaAnchorConfig(
        network_id=e.get("CL_NEOVA_NETWORK_ID", "net-d"),
        namespace_id=namespace,
        gateway_id=e.get("CL_NEOVA_GATEWAY_ID", "gw-cl"),
        gateway_seed=bytes.fromhex(required("CL_NEOVA_GATEWAY_SEED_HEX")),
        scope_key=scope_key,
        era_id=int(e.get("CL_NEOVA_ERA", "1")),
        genesis=json.loads(Path(required("CL_NEOVA_GENESIS_PATH")).read_text()),
        counter_path=e.get("CL_NEOVA_SEQ_PATH", "./neova_gateway_sequence"),
        poll_timeout_s=float(e.get("CL_NEOVA_POLL_TIMEOUT_S", "30")),
    )
    return NEOvaAnchorBackend(
        config,
        submit_http=http_transport(required("CL_NEOVA_SUBMIT_URL")),
        serve_http=http_transport(required("CL_NEOVA_SERVE_URL")),
    )
