import asyncio
import base64
import hashlib as _hashlib_mod
import json as json_mod
import logging
import math
import secrets
import time
from typing import Any

from cachetools import TTLCache
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse, StreamingResponse

from auth import (
    _private_plane_access_denied_payload,
    _private_plane_refusal_response,
    _private_infonet_policy_snapshot,
    require_admin,
    require_local_operator,
)
from limiter import limiter


# ---------------------------------------------------------------------------
# Transition delegates: forward to main.py so test monkeypatches still work.
# These will move to a shared module once main.py routes are removed.
# ---------------------------------------------------------------------------
def _main_delegate(name):
    def _wrapper(*a, **kw):
        import main as _m
        return getattr(_m, name)(*a, **kw)
    _wrapper.__name__ = name
    return _wrapper


_check_scoped_auth = _main_delegate("_check_scoped_auth")
_current_private_lane_tier = _main_delegate("_current_private_lane_tier")
_is_debug_test_request = _main_delegate("_is_debug_test_request")
_scoped_view_authenticated = _main_delegate("_scoped_view_authenticated")
_node_runtime_snapshot = _main_delegate("_node_runtime_snapshot")
_verify_gate_access_main = _main_delegate("_verify_gate_access")
from services.config import get_settings
from services.fetchers._store import get_latest_data_refs_snapshot as get_latest_data
from services.mesh.mesh_crypto import (
    derive_node_id,
    normalize_peer_url,
    parse_public_key_algo,
)
from services.mesh.mesh_protocol import (
    PROTOCOL_VERSION,
    normalize_payload,
)
from services.mesh.mesh_schema import validate_event_payload
from services.mesh.mesh_signed_events import (
    MeshWriteExemption,
    SignedWriteKind,
    get_prepared_signed_write,
    mesh_write_exempt,
    requires_signed_write,
    verify_key_rotation_claim_signature,
    verify_node_bound_signature,
)

logger = logging.getLogger(__name__)

router = APIRouter()
_INFONET_SYNC_RATE_LIMIT = "600/minute"


def _signed_body(request: Request) -> dict[str, Any]:
    prepared = get_prepared_signed_write(request)
    if prepared is None:
        return {}
    return dict(prepared.body)


# --- Public mesh log helpers ---

def _public_mesh_log_entry(entry: dict[str, Any]) -> dict[str, Any] | None:
    tier_str = str((entry or {}).get("trust_tier", "public_degraded") or "public_degraded").strip().lower()
    if tier_str.startswith("private_"):
        return None
    return {
        "sender": str((entry or {}).get("sender", "") or ""),
        "destination": str((entry or {}).get("destination", "") or ""),
        "routed_via": str((entry or {}).get("routed_via", "") or ""),
        "priority": str((entry or {}).get("priority", "") or ""),
        "route_reason": str((entry or {}).get("route_reason", "") or ""),
        "timestamp": float((entry or {}).get("timestamp", 0) or 0),
    }


def _public_mesh_log_size(entries: list[dict[str, Any]]) -> int:
    return sum(1 for item in entries if _public_mesh_log_entry(item) is not None)

# --- Constants ---

_PRIVATE_LANE_CONTROL_FIELDS = {"private_lane_tier", "private_lane_policy"}
_PUBLIC_RNS_STATUS_FIELDS = {"enabled", "ready", "configured_peers", "active_peers"}

# --- Gate timestamp redaction ---

def _redacted_gate_timestamp(event: dict[str, Any]) -> float:
    raw_ts = float((event or {}).get("timestamp", 0) or 0.0)
    if raw_ts <= 0:
        return 0.0
    try:
        jitter_window = max(0, int(get_settings().MESH_GATE_TIMESTAMP_JITTER_S or 0))
    except Exception:
        jitter_window = 0
    if jitter_window <= 0:
        return raw_ts
    event_id = str((event or {}).get("event_id", "") or "")
    seed = _hashlib_mod.sha256(f"{event_id}|{int(raw_ts)}".encode("utf-8")).digest()
    fraction = int.from_bytes(seed[:8], "big") / float(2**64 - 1)
    return max(0.0, raw_ts - (fraction * float(jitter_window)))

# --- Status/lane redaction helpers ---

def _redact_private_lane_control_fields(
    payload: dict[str, Any],
    authenticated: bool,
) -> dict[str, Any]:
    redacted = dict(payload)
    if authenticated:
        return redacted
    for field in _PRIVATE_LANE_CONTROL_FIELDS:
        redacted.pop(field, None)
    return redacted


def _redact_public_rns_status(
    payload: dict[str, Any],
    authenticated: bool,
) -> dict[str, Any]:
    redacted = _redact_private_lane_control_fields(payload, authenticated=authenticated)
    if authenticated:
        return redacted
    return {
        key: redacted.get(key)
        for key in _PUBLIC_RNS_STATUS_FIELDS
        if key in redacted
    }


def _redact_public_mesh_status(
    payload: dict[str, Any],
    authenticated: bool,
) -> dict[str, Any]:
    if authenticated:
        return dict(payload)
    return {
        "message_log_size": int(payload.get("message_log_size", 0) or 0),
    }

# --- Node history redaction ---

def _redact_public_node_history(
    events: list[dict[str, Any]],
    authenticated: bool,
) -> list[dict[str, Any]]:
    if authenticated:
        return [dict(event) for event in events]
    return [
        {
            "event_id": str(event.get("event_id", "") or ""),
            "event_type": str(event.get("event_type", "") or ""),
            "timestamp": float(event.get("timestamp", 0) or 0),
        }
        for event in events
    ]

# --- Composed gate message redaction ---

def _redact_composed_gate_message(payload: dict[str, Any]) -> dict[str, Any]:
    safe = {
        "ok": bool(payload.get("ok")),
        "gate_id": str(payload.get("gate_id", "") or ""),
        "identity_scope": str(payload.get("identity_scope", "") or ""),
        "ciphertext": str(payload.get("ciphertext", "") or ""),
        "nonce": str(payload.get("nonce", "") or ""),
        "sender_ref": str(payload.get("sender_ref", "") or ""),
        "format": str(payload.get("format", "mls1") or "mls1"),
        "timestamp": float(payload.get("timestamp", 0) or 0),
    }
    epoch = payload.get("epoch", 0)
    if epoch:
        safe["epoch"] = int(epoch or 0)
    if payload.get("detail"):
        safe["detail"] = str(payload.get("detail", "") or "")
    if payload.get("key_commitment"):
        safe["key_commitment"] = str(payload.get("key_commitment", "") or "")
    return safe

# --- Gate validation and access helpers ---

_validate_gate_vote_context = _main_delegate("_validate_gate_vote_context")


_GATE_REDACT_FIELDS = ("sender_ref", "epoch", "nonce")
_KEY_ROTATE_REDACT_FIELDS = {
    "old_node_id",
    "old_public_key",
    "old_public_key_algo",
    "old_signature",
}


def _redact_gate_metadata(event: dict) -> dict:
    """Strip MLS-internal fields from gate_message events in public sync responses."""
    if not isinstance(event, dict):
        return event
    event_type = str(event.get("event_type", "") or "")
    if event_type != "gate_message":
        return event
    redacted = dict(event)
    for field in ("node_id", "sequence"):
        redacted.pop(field, None)
    if isinstance(redacted.get("payload"), dict):
        payload = dict(redacted.get("payload") or {})
        for field in _GATE_REDACT_FIELDS:
            payload.pop(field, None)
        redacted["payload"] = payload
        return redacted
    for field in _GATE_REDACT_FIELDS:
        redacted.pop(field, None)
    return redacted


def _redact_key_rotate_payload(event: dict) -> dict:
    """Strip identity-linking fields from key_rotate events in public responses."""
    if not isinstance(event, dict):
        return event
    if str(event.get("event_type", "") or "") != "key_rotate":
        return event
    redacted = dict(event)
    payload = redacted.get("payload")
    if isinstance(payload, dict):
        payload = dict(payload)
        for field in _KEY_ROTATE_REDACT_FIELDS:
            payload.pop(field, None)
        redacted["payload"] = payload
    return redacted


def _redact_vote_gate(event: dict) -> dict:
    """Strip gate label from vote events in public responses."""
    if not isinstance(event, dict):
        return event
    if str(event.get("event_type", "") or "") != "vote":
        return event
    redacted = dict(event)
    payload = redacted.get("payload")
    if isinstance(payload, dict):
        payload = dict(payload)
        payload.pop("gate", None)
        redacted["payload"] = payload
    return redacted


def _redact_public_event(event: dict) -> dict:
    """Apply all public-response redactions for public chain endpoints."""
    return _redact_vote_gate(_redact_key_rotate_payload(_redact_gate_metadata(event)))


def _infonet_private_transport_required() -> bool:
    import main as _m

    return bool(_m._infonet_private_transport_required())


def _infonet_sync_response_events(events: list[dict], request=None) -> list[dict]:
    """Build the sync event surface for the current transport policy."""
    import main as _m

    return _m._infonet_sync_response_events(events, request=request)


def _trusted_gate_reply_to(event: dict) -> str:
    if not isinstance(event, dict):
        return ""
    payload = event.get("payload")
    if not isinstance(payload, dict):
        return ""
    reply_to = str(payload.get("reply_to", "") or "").strip()
    if not reply_to:
        return ""
    gate_id = str(payload.get("gate", "") or "").strip()
    node_id = str(event.get("node_id", "") or "").strip()
    public_key = str(event.get("public_key", "") or "").strip()
    public_key_algo = str(event.get("public_key_algo", "") or "").strip()
    if node_id and not public_key and gate_id:
        try:
            binding = _lookup_gate_member_binding(gate_id, node_id)
            if binding:
                public_key, public_key_algo = binding
        except Exception:
            return ""
    signature = str(event.get("signature", "") or "").strip()
    protocol_version = str(event.get("protocol_version", "") or "").strip()
    sequence = int(event.get("sequence", 0) or 0)
    if not (gate_id and node_id and public_key and public_key_algo and signature and protocol_version and sequence > 0):
        return ""
    verify_payload = {
        "gate": gate_id,
        "ciphertext": str(payload.get("ciphertext", "") or ""),
        "nonce": str(payload.get("nonce", "") or ""),
        "sender_ref": str(payload.get("sender_ref", "") or ""),
        "format": str(payload.get("format", "mls1") or "mls1"),
    }
    epoch = _safe_int(payload.get("epoch", 0) or 0)
    if epoch > 0:
        verify_payload["epoch"] = epoch
    envelope_hash = str(payload.get("envelope_hash", "") or "").strip()
    if envelope_hash:
        verify_payload["envelope_hash"] = envelope_hash
    return _recover_verified_gate_reply_to(
        node_id=node_id,
        sequence=sequence,
        public_key=public_key,
        public_key_algo=public_key_algo,
        signature=signature,
        payload=verify_payload,
        reply_to=reply_to,
        protocol_version=protocol_version,
    )


def _derive_anon_handle_router(node_id: str, gate_id: str) -> str:
    """HMAC(node_id, gate_id)[:4] — stable session handle, rotates with session."""
    import hmac as _hmac, hashlib as _hashlib
    node_key = str(node_id or "").strip()
    gate_key = str(gate_id or "").strip().lower()
    if not node_key:
        return "anon_????"
    tag = _hmac.new(
        node_key.encode("utf-8"),
        f"{gate_key}|sender-handle-v1".encode("utf-8"),
        _hashlib.sha256,
    ).hexdigest()[:4]
    return f"anon_{tag}"


def _strip_gate_identity_member(event: dict, *, envelope_policy: str = "envelope_disabled") -> dict:
    """Narrowed member view: strips signer identity fields.

    Includes ``sender_handle`` (stable per-session anonymized display label)
    and the ``gate_envelope`` / ``envelope_hash`` fields members need to
    decrypt durable history via the AES-GCM envelope under gate_secret.
    """
    if not isinstance(event, dict):
        event = {}
    payload = event.get("payload")
    if not isinstance(payload, dict):
        payload = {}
    gate_id = str(payload.get("gate", "") or "")
    sender_handle = _derive_anon_handle_router(str(event.get("node_id", "") or ""), gate_id)
    result_payload: dict = {
        "gate": gate_id,
        "ciphertext": str(payload.get("ciphertext", "") or ""),
        "format": str(payload.get("format", "") or ""),
        "nonce": str(payload.get("nonce", "") or ""),
        "sender_ref": str(payload.get("sender_ref", "") or ""),
        "sender_handle": sender_handle,
        "transport_lock": str(payload.get("transport_lock", "") or ""),
        "gate_envelope": str(payload.get("gate_envelope", "") or ""),
        "envelope_hash": str(payload.get("envelope_hash", "") or ""),
        "reply_to": _trusted_gate_reply_to(event),
    }
    return {
        "event_id": str(event.get("event_id", "") or ""),
        "event_type": "gate_message",
        "timestamp": _redacted_gate_timestamp(event),
        "protocol_version": str(event.get("protocol_version", "") or ""),
        "sender_handle": sender_handle,
        "payload": result_payload,
    }


def _strip_gate_identity_privileged(event: dict) -> dict:
    """Privileged/audit view: preserves full signer identity surface."""
    if not isinstance(event, dict):
        event = {}
    payload = event.get("payload")
    if not isinstance(payload, dict):
        payload = {}
    node_id = str(event.get("node_id", "") or "")
    public_key = str(event.get("public_key", "") or "")
    public_key_algo = str(event.get("public_key_algo", "") or "")
    if node_id and not public_key:
        gate_id = str(payload.get("gate", "") or "")
        if gate_id:
            try:
                binding = _lookup_gate_member_binding(gate_id, node_id)
                if binding:
                    public_key, public_key_algo = binding
            except Exception:
                pass
    return {
        "event_id": str(event.get("event_id", "") or ""),
        "event_type": "gate_message",
        "timestamp": _redacted_gate_timestamp(event),
        "node_id": node_id,
        "sequence": int(event.get("sequence", 0) or 0),
        "signature": str(event.get("signature", "") or ""),
        "public_key": public_key,
        "public_key_algo": public_key_algo,
        "protocol_version": str(event.get("protocol_version", "") or ""),
        "payload": {
            "gate": str(payload.get("gate", "") or ""),
            "ciphertext": str(payload.get("ciphertext", "") or ""),
            "format": str(payload.get("format", "") or ""),
            "nonce": str(payload.get("nonce", "") or ""),
            "sender_ref": str(payload.get("sender_ref", "") or ""),
            "transport_lock": str(payload.get("transport_lock", "") or ""),
            "gate_envelope": str(payload.get("gate_envelope", "") or ""),
            "envelope_hash": str(payload.get("envelope_hash", "") or ""),
            "reply_to": _trusted_gate_reply_to(event),
        },
    }


def _strip_gate_identity(event: dict) -> dict:
    """Legacy alias — defaults to member (narrowed) view."""
    return _strip_gate_identity_member(event)


def _resolve_envelope_policy(gate_id: str) -> str:
    """Look up envelope_policy for a gate. Per-gate policy is the source of
    truth; the global recovery-envelope runtime gate is intentionally NOT
    checked here — it silently downgrades working configurations to
    envelope_disabled without surfacing any error."""
    try:
        from services.mesh.mesh_reputation import gate_manager
        return str(gate_manager.get_envelope_policy(gate_id) or "envelope_disabled")
    except Exception:
        return "envelope_disabled"


def _strip_gate_for_access(event: dict, access: str) -> dict:
    """Select member or privileged strip based on access level."""
    if access == "privileged":
        return _strip_gate_identity_privileged(event)
    payload = event.get("payload") if isinstance(event, dict) else None
    gate_id = str((payload or {}).get("gate", "") or "")
    envelope_policy = _resolve_envelope_policy(gate_id) if gate_id else "envelope_disabled"
    return _strip_gate_identity_member(event, envelope_policy=envelope_policy)


def _lookup_gate_member_binding(gate_id: str, node_id: str) -> tuple[str, str] | None:
    gate_key = str(gate_id or "").strip().lower()
    candidate = str(node_id or "").strip()
    if not gate_key or not candidate:
        return None
    try:
        from services.mesh.mesh_wormhole_persona import (
            bootstrap_wormhole_persona_state,
            read_wormhole_persona_state,
        )

        bootstrap_wormhole_persona_state()
        state = read_wormhole_persona_state()
    except Exception:
        return None
    for persona in list(state.get("gate_personas", {}).get(gate_key) or []):
        if str(persona.get("node_id", "") or "").strip() != candidate:
            continue
        public_key = str(persona.get("public_key", "") or "").strip()
        public_key_algo = str(persona.get("public_key_algo", "Ed25519") or "Ed25519").strip()
        if public_key and public_key_algo:
            return public_key, public_key_algo
    session = dict(state.get("gate_sessions", {}).get(gate_key) or {})
    if str(session.get("node_id", "") or "").strip() == candidate:
        public_key = str(session.get("public_key", "") or "").strip()
        public_key_algo = str(session.get("public_key_algo", "Ed25519") or "Ed25519").strip()
        if public_key and public_key_algo:
            return public_key, public_key_algo
    return None


_resolve_gate_proof_identity = _main_delegate("_resolve_gate_proof_identity")


def _sign_gate_access_proof(gate_id: str) -> dict[str, Any]:
    gate_key = str(gate_id or "").strip().lower()
    if not gate_key:
        return {"ok": False, "detail": "gate_id required"}
    identity = _resolve_gate_proof_identity(gate_key)
    if not identity:
        return {"ok": False, "detail": "gate_access_proof_unavailable"}
    private_key_b64 = str(identity.get("private_key", "") or "").strip()
    node_id = str(identity.get("node_id", "") or "").strip()
    public_key = str(identity.get("public_key", "") or "").strip()
    public_key_algo = str(identity.get("public_key_algo", "Ed25519") or "Ed25519").strip()
    if not (private_key_b64 and node_id and public_key and public_key_algo):
        return {"ok": False, "detail": "gate_access_proof_unavailable"}
    try:
        from cryptography.hazmat.primitives.asymmetric import ec, ed25519

        ts = int(time.time())
        challenge = f"{gate_key}:{ts}"
        key_bytes = base64.b64decode(private_key_b64)
        algo = parse_public_key_algo(public_key_algo)
        if algo == "Ed25519":
            signing_key = ed25519.Ed25519PrivateKey.from_private_bytes(key_bytes)
            signature = signing_key.sign(challenge.encode("utf-8"))
        elif algo == "ECDSA_P256":
            from cryptography.hazmat.primitives import hashes

            signing_key = ec.derive_private_key(int.from_bytes(key_bytes, "big"), ec.SECP256R1())
            signature = signing_key.sign(challenge.encode("utf-8"), ec.ECDSA(hashes.SHA256()))
        else:
            return {"ok": False, "detail": "gate_access_proof_unsupported_algo"}
    except Exception as exc:
        logger.warning("Gate access proof signing failed: %s", type(exc).__name__)
        return {"ok": False, "detail": "gate_access_proof_failed"}
    return {
        "ok": True,
        "gate_id": gate_key,
        "node_id": node_id,
        "ts": ts,
        "proof": base64.b64encode(signature).decode("ascii"),
    }


def _verify_gate_access(request: Request, gate_id: str) -> str:
    """Delegate gate access policy to main.py so the runtime seam stays singular."""
    return str(_verify_gate_access_main(request, gate_id) or "")

# --- Throttle state ---


# ─── Per-Identity Throttle State ──────────────────────────────────────────
# In-memory: {node_id: {"last_send": timestamp, "daily_count": int, "daily_reset": timestamp}}
# Bounded to 10000 entries with 24hr TTL to prevent unbounded memory growth
_node_throttle: TTLCache = TTLCache(maxsize=10000, ttl=86400)
_gate_post_cooldown: TTLCache = TTLCache(maxsize=20000, ttl=86400)

# Byte limits per payload type
_BYTE_LIMITS = {"text": 200, "pin": 300, "emergency": 200, "command": 200}

# --- Throttle and signed event helpers ---

_check_throttle = _main_delegate("_check_throttle")


_check_gate_post_cooldown = _main_delegate("_check_gate_post_cooldown")
_record_gate_post_cooldown = _main_delegate("_record_gate_post_cooldown")


_recover_verified_gate_reply_to = _main_delegate("_recover_verified_gate_reply_to")
_verify_gate_message_signed_write = _main_delegate("_verify_gate_message_signed_write")
_verify_signed_write = _main_delegate("_verify_signed_write")



# --- Gate store hydration ---

def _hydrate_gate_store_from_chain(events: list[dict]) -> int:
    """Copy any gate_message chain events into the local gate_store for read/decrypt.

    Only events that are resident in the local infonet (accepted or already
    present) are hydrated.  The canonical infonet-resident event is used —
    never the raw batch event — so a forged batch entry carrying a valid
    event_id but attacker-chosen payload cannot pollute gate_store.
    """
    import copy

    from services.mesh.mesh_hashchain import gate_store, infonet

    count = 0
    for evt in events:
        if evt.get("event_type") != "gate_message":
            continue
        event_id = str(evt.get("event_id", "") or "").strip()
        if not event_id or event_id not in infonet.event_index:
            continue
        canonical = infonet.events[infonet.event_index[event_id]]
        payload = canonical.get("payload") or {}
        gate_id = str(payload.get("gate", "") or "").strip()
        if not gate_id:
            continue
        try:
            gate_store.append(gate_id, copy.deepcopy(canonical))
            count += 1
        except Exception:
            pass
    return count


def _hydrate_dm_relay_from_chain(events: list[dict]) -> int:
    import main as _m

    return int(_m._hydrate_dm_relay_from_chain(events))

# --- Safe type helpers ---

def _safe_int(val, default=0):
    try:
        return int(val)
    except (TypeError, ValueError):
        return default


def _safe_float(val, default=0.0):
    try:
        parsed = float(val)
        if not math.isfinite(parsed):
            return default
        return parsed
    except (TypeError, ValueError):
        return default

# --- Route handlers ---

@router.post("/api/mesh/send")
@limiter.limit("10/minute")
@requires_signed_write(kind=SignedWriteKind.MESH_SEND)
async def mesh_send(request: Request):
    """Unified mesh message endpoint — auto-routes via optimal transport.

    Body: { destination, message, priority?, channel?, node_id?, credentials? }
    The router picks APRS, Meshtastic, or Internet based on gate logic.
    Enforces byte limits and per-identity rate limiting.
    """
    body = _signed_body(request)
    destination = body.get("destination", "")
    message = body.get("message", "")
    if not destination or not message:
        return {"ok": False, "detail": "Missing required fields: destination, message"}

    # ─── Byte limit enforcement ───────────────────────────────────
    payload_bytes = len(message.encode("utf-8"))
    payload_type = body.get("payload_type", "text")
    max_bytes = _BYTE_LIMITS.get(payload_type, 200)
    if payload_bytes > max_bytes:
        return {
            "ok": False,
            "detail": f"Message too long ({payload_bytes} bytes). Maximum: {max_bytes} bytes for {payload_type} messages.",
        }

    # ─── Signature verification & node registration ──────────────
    node_id = body.get("node_id", body.get("sender_id", "anonymous"))
    public_key = body.get("public_key", "")
    public_key_algo = body.get("public_key_algo", "")
    signature = body.get("signature", "")
    sequence = _safe_int(body.get("sequence", 0) or 0)
    protocol_version = body.get("protocol_version", "")
    signed_payload = {
        "message": message,
        "destination": destination,
        "channel": body.get("channel", "LongFast"),
        "priority": body.get("priority", "normal").lower(),
        "ephemeral": bool(body.get("ephemeral", False)),
    }
    if body.get("transport_lock"):
        signed_payload["transport_lock"] = str(body.get("transport_lock"))
    # Register node in reputation ledger (auto-creates if new)
    if node_id != "anonymous":
        try:
            from services.mesh.mesh_reputation import reputation_ledger

            reputation_ledger.register_node(node_id, public_key, public_key_algo)
        except Exception:
            pass  # Non-critical — don't block sends if reputation module fails

    # ─── Per-identity throttle ────────────────────────────────────
    priority_str = signed_payload["priority"]
    transport_lock = str(body.get("transport_lock", "") or "").lower()
    throttle_ok, throttle_reason = _check_throttle(node_id, priority_str, transport_lock)
    if not throttle_ok:
        return {"ok": False, "detail": throttle_reason}

    from services.mesh.mesh_router import (
        MeshEnvelope,
        MeshtasticTransport,
        Priority,
        TransportResult,
        mesh_router,
    )

    priority_map = {
        "emergency": Priority.EMERGENCY,
        "high": Priority.HIGH,
        "normal": Priority.NORMAL,
        "low": Priority.LOW,
    }
    priority = priority_map.get(priority_str, Priority.NORMAL)

    # ─── C-1 fix: compute trust_tier from Wormhole state ───────
    from services.wormhole_supervisor import get_transport_tier

    computed_tier = get_transport_tier()

    envelope = MeshEnvelope(
        sender_id=node_id,
        destination=destination,
        channel=body.get("channel", "LongFast"),
        priority=priority,
        payload=message,
        ephemeral=body.get("ephemeral", False),
        trust_tier=computed_tier,
    )

    credentials = body.get("credentials", {})
    # ─── C-2 fix: enforce tier before transport_lock dispatch ──
    private_tier = str(envelope.trust_tier or "").startswith("private_")
    if transport_lock == "meshtastic":
        if private_tier:
            results = [TransportResult(
                False, "meshtastic",
                "Private-tier content cannot be sent over Meshtastic"
            )]
        elif not mesh_router.meshtastic.can_reach(envelope):
            results = [TransportResult(False, "meshtastic", "Message exceeds Meshtastic payload limit")]
        else:
            cb_ok, cb_reason = mesh_router.breakers["meshtastic"].check_and_record(envelope.priority)
            if not cb_ok:
                results = [TransportResult(False, "meshtastic", cb_reason)]
            else:
                envelope.route_reason = (
                    "Transport locked to Meshtastic public path"
                    if MeshtasticTransport._parse_node_id(destination) is None
                    else "Transport locked to Meshtastic public node-targeted path"
                )
                result = mesh_router.meshtastic.send(envelope, credentials)
                if result.ok:
                    envelope.routed_via = mesh_router.meshtastic.NAME
                results = [result]
    elif transport_lock == "aprs":
        if private_tier:
            results = [TransportResult(
                False, "aprs",
                "Private-tier content cannot be sent over APRS"
            )]
        else:
            results = mesh_router.route(envelope, credentials)
    else:
        results = mesh_router.route(envelope, credentials)
    any_ok = any(r.ok for r in results)

    # ─── Mirror to Meshtastic bridge feed ────────────────────────
    # The MQTT broker won't echo our own publishes back to our subscriber, so
    # inject successfully-sent channel broadcasts into the bridge directly.
    # Node-targeted packets must not appear in the public channel feed.
    is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
    if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination:
        try:
            from services.sigint_bridge import sigint_grid

            bridge = sigint_grid.mesh
            if bridge:
                from datetime import datetime

                bridge.messages.appendleft(
                    {
                        "from": MeshtasticTransport.mesh_address_for_sender(node_id),
                        "to": "broadcast",
                        "text": message,
                        "region": credentials.get("mesh_region", "US"),
                        "channel": body.get("channel", "LongFast"),
                        "timestamp": datetime.utcnow().isoformat() + "Z",
                    }
                )
        except Exception:
            pass  # Non-critical

    return {
        "ok": any_ok,
        "message_id": envelope.message_id,
        "event_id": "",
        "routed_via": envelope.routed_via,
        "route_reason": envelope.route_reason,
        "direct": is_direct_destination,
        "channel_echo": not is_direct_destination,
        "results": [r.to_dict() for r in results],
    }


@router.post("/api/mesh/meshtastic/send", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.LOCAL_OPERATOR_ONLY)
async def meshtastic_public_send(request: Request):
    """Local public-MQTT send path for standalone Meshtastic-style identities."""
    body = await request.json()
    destination = str(body.get("destination", "") or "").strip() or "broadcast"
    message = str(body.get("message", "") or "")
    sender_id = str(body.get("sender_id", "") or "").strip().lower()
    if not message:
        return {"ok": False, "detail": "Missing required field: message"}

    from services.mesh.mesh_router import (
        MeshEnvelope,
        MeshtasticTransport,
        Priority,
        TransportResult,
        mesh_router,
    )
    from services.meshtastic_mqtt_settings import mqtt_bridge_enabled

    if MeshtasticTransport._parse_node_id(sender_id) is None:
        return {"ok": False, "detail": "Missing or invalid public Meshtastic address"}
    if not mqtt_bridge_enabled():
        return {"ok": False, "detail": "Meshtastic MQTT bridge is disabled"}

    payload_bytes = len(message.encode("utf-8"))
    payload_type = str(body.get("payload_type", "text") or "text")
    max_bytes = _BYTE_LIMITS.get(payload_type, 200)
    if payload_bytes > max_bytes:
        return {
            "ok": False,
            "detail": f"Message too long ({payload_bytes} bytes). Maximum: {max_bytes} bytes for {payload_type} messages.",
        }

    priority_str = str(body.get("priority", "normal") or "normal").lower()
    throttle_ok, throttle_reason = _check_throttle(sender_id, priority_str, "meshtastic")
    if not throttle_ok:
        return {"ok": False, "detail": throttle_reason}

    priority_map = {
        "emergency": Priority.EMERGENCY,
        "high": Priority.HIGH,
        "normal": Priority.NORMAL,
        "low": Priority.LOW,
    }
    priority = priority_map.get(priority_str, Priority.NORMAL)
    envelope = MeshEnvelope(
        sender_id=sender_id,
        destination=destination,
        channel=str(body.get("channel", "LongFast") or "LongFast"),
        priority=priority,
        payload=message,
        ephemeral=bool(body.get("ephemeral", False)),
        trust_tier="public_degraded",
    )

    if not mesh_router.meshtastic.can_reach(envelope):
        results = [TransportResult(False, "meshtastic", "Message exceeds Meshtastic payload limit")]
    else:
        cb_ok, cb_reason = mesh_router.breakers["meshtastic"].check_and_record(envelope.priority)
        if not cb_ok:
            results = [TransportResult(False, "meshtastic", cb_reason)]
        else:
            is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
            envelope.route_reason = (
                "Local public Meshtastic MQTT path"
                if not is_direct_destination
                else "Local public Meshtastic direct node path"
            )
            credentials = {"mesh_region": str(body.get("mesh_region", "US") or "US")}
            result = mesh_router.meshtastic.send(envelope, credentials)
            if result.ok:
                envelope.routed_via = mesh_router.meshtastic.NAME
            results = [result]

    any_ok = any(r.ok for r in results)
    is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
    if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination:
        try:
            from datetime import datetime
            from services.sigint_bridge import sigint_grid

            bridge = sigint_grid.mesh
            if bridge:
                record = {
                    "from": MeshtasticTransport.mesh_address_for_sender(sender_id),
                    "to": "broadcast",
                    "text": message,
                    "region": str(body.get("mesh_region", "US") or "US"),
                    "root": str(body.get("mesh_region", "US") or "US"),
                    "channel": str(body.get("channel", "LongFast") or "LongFast"),
                    "timestamp": datetime.utcnow().isoformat() + "Z",
                }
                append_text = getattr(bridge, "append_text_message", None)
                if callable(append_text):
                    append_text(record)
                else:
                    bridge.messages.appendleft(record)
        except Exception:
            pass

    return {
        "ok": any_ok,
        "message_id": envelope.message_id,
        "event_id": "",
        "routed_via": envelope.routed_via,
        "route_reason": envelope.route_reason,
        "direct": is_direct_destination,
        "channel_echo": not is_direct_destination,
        "results": [r.to_dict() for r in results],
    }


@router.get("/api/mesh/log")
@limiter.limit("30/minute")
async def mesh_log(request: Request):
    """Get recent mesh message routing log (audit trail)."""
    from services.mesh.mesh_router import mesh_router

    mesh_router.prune_message_log()
    entries = list(mesh_router.message_log)
    ok, _detail = _check_scoped_auth(request, "mesh.audit")
    if ok:
        return {"log": entries}
    public_entries = [entry for entry in (_public_mesh_log_entry(item) for item in entries) if entry]
    return {"log": public_entries}


@router.get("/api/mesh/status")
@limiter.limit("30/minute")
async def mesh_status(request: Request):
    """Get mesh system status including circuit breaker state."""
    from services.env_check import get_security_posture_warnings
    from services.mesh.mesh_router import mesh_router
    from services.sigint_bridge import sigint_grid

    mesh_router.prune_message_log()
    entries = list(mesh_router.message_log)
    sigs = sigint_grid.get_all_signals()
    aprs = sum(1 for s in sigs if s.get("source") == "aprs")
    mesh = sum(1 for s in sigs if s.get("source") == "meshtastic")
    js8 = sum(1 for s in sigs if s.get("source") == "js8call")
    ok, _detail = _check_scoped_auth(request, "mesh.audit")
    authenticated = _scoped_view_authenticated(request, "mesh.audit")
    response = {
        "circuit_breakers": {
            name: breaker.get_status() for name, breaker in mesh_router.breakers.items()
        },
        "message_log_size": len(entries) if ok else _public_mesh_log_size(entries),
        "signal_counts": {
            "aprs": aprs,
            "meshtastic": mesh,
            "js8call": js8,
            "total": aprs + mesh + js8,
        },
    }
    if ok:
        response["public_message_log_size"] = _public_mesh_log_size(entries)
        response["private_log_retention_seconds"] = int(
            getattr(get_settings(), "MESH_PRIVATE_LOG_TTL_S", 900) or 0
        )
        response["security_warnings"] = get_security_posture_warnings(get_settings())

    return _redact_public_mesh_status(response, authenticated=authenticated)


@router.get("/api/mesh/signals")
@limiter.limit("30/minute")
async def mesh_signals(
    request: Request,
    source: str = "",
    region: str = "",
    root: str = "",
    limit: int = 50,
):
    """Get SIGINT signals with optional source/region/root filters."""
    from services.fetchers.sigint import build_sigint_snapshot

    sigs, _channel_stats, totals = build_sigint_snapshot()
    if source:
        sigs = [s for s in sigs if s.get("source") == source.lower()]
    if region:
        region_filter = region.upper()
        sigs = [
            s
            for s in sigs
            if s.get("region", "").upper() == region_filter
            or s.get("root", "").upper() == region_filter
        ]
    if root:
        root_filter = root.upper()
        sigs = [s for s in sigs if s.get("root", "").upper() == root_filter]
    return {
        "signals": sigs[: min(limit, 500)],
        "total": len(sigs),
        "source_totals": totals,
    }


@router.get("/api/mesh/messages")
@limiter.limit("30/minute")
async def mesh_messages(
    request: Request,
    region: str = "",
    root: str = "",
    channel: str = "",
    limit: int = 30,
    include_direct: bool = False,
):
    """Get recent Meshtastic text messages from the MQTT bridge."""
    from services.sigint_bridge import sigint_grid

    bridge = sigint_grid.mesh
    if not bridge:
        return []
    msgs = list(bridge.messages)
    if region:
        region_filter = region.upper()
        msgs = [
            m
            for m in msgs
            if m.get("region", "").upper() == region_filter
            or m.get("root", "").upper() == region_filter
        ]
    if root:
        root_filter = root.upper()
        msgs = [m for m in msgs if m.get("root", "").upper() == root_filter]
    if channel:
        msgs = [m for m in msgs if m.get("channel", "").lower() == channel.lower()]
    if not include_direct:
        msgs = [
            m
            for m in msgs
            if str(m.get("to") or "broadcast").strip().lower() in {"", "broadcast", "^all"}
        ]
    return msgs[: min(limit, 100)]


@router.get("/api/mesh/channels")
@limiter.limit("30/minute")
async def mesh_channels(request: Request):
    """Get Meshtastic channel population stats — nodes per region/channel."""
    stats = get_latest_data().get("mesh_channel_stats", {})
    return stats


# ─── Reputation Endpoints ─────────────────────────────────────────────────

# Cached root node_id — avoids 5 encrypted disk reads per vote.
_root_node_id_cache: dict[str, object] = {"value": None, "ts": 0.0}
_ROOT_NODE_ID_TTL = 30.0  # seconds


def _cached_root_node_id() -> str:
    import time as _time

    now = _time.time()
    if _root_node_id_cache["value"] is not None and (now - float(_root_node_id_cache["ts"])) < _ROOT_NODE_ID_TTL:
        return str(_root_node_id_cache["value"])
    try:
        from services.mesh.mesh_wormhole_persona import read_wormhole_persona_state

        ps = read_wormhole_persona_state()
        nid = str(ps.get("root_identity", {}).get("node_id", "") or "").strip()
        _root_node_id_cache["value"] = nid
        _root_node_id_cache["ts"] = now
        return nid
    except Exception:
        return ""


@router.post("/api/mesh/vote")
@limiter.limit("30/minute")
@requires_signed_write(kind=SignedWriteKind.MESH_VOTE)
async def mesh_vote(request: Request):
    """Cast a reputation vote on a node.

    Body: {voter_id, voter_pubkey?, voter_sig?, target_id, vote: 1|-1, gate?: string}
    """
    from services.mesh.mesh_reputation import reputation_ledger

    body = _signed_body(request)
    voter_id = body.get("voter_id", "")
    target_id = body.get("target_id", "")
    vote = body.get("vote", 0)
    gate = body.get("gate", "")
    public_key = body.get("voter_pubkey", "")
    public_key_algo = body.get("public_key_algo", "")
    signature = body.get("voter_sig", "")
    sequence = _safe_int(body.get("sequence", 0) or 0)
    protocol_version = body.get("protocol_version", "")

    if not voter_id or not target_id:
        return {"ok": False, "detail": "Missing voter_id or target_id"}
    if vote not in (1, -1):
        return {"ok": False, "detail": "Vote must be 1 or -1"}

    gate_ok, gate_detail = _validate_gate_vote_context(voter_id, gate)
    if not gate_ok:
        return {"ok": False, "detail": gate_detail}
    gate = gate_detail or ""

    vote_payload = {"target_id": target_id, "vote": vote, "gate": gate}

    # Resolve stable local operator ID for duplicate-vote prevention.
    # Personas generate unique keypairs, so voter_id alone is insufficient —
    # use the root identity's node_id as a stable anchor so switching personas
    # doesn't let the same operator vote multiple times on the same post.
    stable_voter_id = voter_id
    try:
        root_nid = _cached_root_node_id()
        if root_nid:
            stable_voter_id = root_nid
    except Exception:
        pass

    # Register node if not known
    reputation_ledger.register_node(voter_id, public_key, public_key_algo)

    ok, reason, vote_weight = reputation_ledger.cast_vote(stable_voter_id, target_id, vote, gate)

    # Record on Infonet
    if ok:
        try:
            from services.mesh.mesh_hashchain import infonet

            normalized_payload = normalize_payload("vote", vote_payload)
            infonet.append(
                event_type="vote",
                node_id=voter_id,
                payload=normalized_payload,
                signature=signature,
                sequence=sequence,
                public_key=public_key,
                public_key_algo=public_key_algo,
                protocol_version=protocol_version,
            )
        except Exception:
            pass

    return {"ok": ok, "detail": reason, "weight": round(vote_weight, 2)}


@router.post("/api/mesh/report")
@limiter.limit("10/minute")
@requires_signed_write(kind=SignedWriteKind.MESH_REPORT)
async def mesh_report(request: Request):
    """Report abusive or fraudulent behavior (signed, public, non-anonymous)."""
    body = _signed_body(request)
    reporter_id = body.get("reporter_id", "")
    target_id = body.get("target_id", "")
    reason = body.get("reason", "")
    gate = body.get("gate", "")
    evidence = body.get("evidence", "")
    public_key = body.get("public_key", "")
    public_key_algo = body.get("public_key_algo", "")
    signature = body.get("signature", "")
    sequence = _safe_int(body.get("sequence", 0) or 0)
    protocol_version = body.get("protocol_version", "")

    if not reporter_id or not target_id or not reason:
        return {"ok": False, "detail": "Missing reporter_id, target_id, or reason"}

    report_payload = {"target_id": target_id, "reason": reason, "gate": gate, "evidence": evidence}

    try:
        from services.mesh.mesh_reputation import reputation_ledger

        reputation_ledger.register_node(reporter_id, public_key, public_key_algo)
    except Exception:
        pass

    try:
        from services.mesh.mesh_hashchain import infonet

        normalized_payload = normalize_payload("abuse_report", report_payload)
        infonet.append(
            event_type="abuse_report",
            node_id=reporter_id,
            payload=normalized_payload,
            signature=signature,
            sequence=sequence,
            public_key=public_key,
            public_key_algo=public_key_algo,
            protocol_version=protocol_version,
        )
    except Exception:
        logger.exception("failed to record abuse report on infonet")
        return {"ok": False, "detail": "report_record_failed"}

    return {"ok": True, "detail": "Report recorded"}


@router.get("/api/mesh/reputation")
@limiter.limit("60/minute")
async def mesh_reputation(request: Request, node_id: str = ""):
    """Get reputation for a single node.

    Public callers receive a summary-only view; authenticated audit callers may
    access the richer breakdown.
    """
    from services.mesh.mesh_reputation import reputation_ledger

    if not node_id:
        return {"ok": False, "detail": "Provide ?node_id=xxx"}
    return reputation_ledger.get_reputation_log(
        node_id,
        detailed=_scoped_view_authenticated(request, "mesh.audit"),
    )


@router.get("/api/mesh/reputation/batch")
@limiter.limit("60/minute")
async def mesh_reputation_batch(request: Request, node_id: list[str] = Query(default=[])):
    """Get overall public reputation for multiple public node IDs."""
    from services.mesh.mesh_reputation import reputation_ledger

    normalized: list[str] = []
    seen: set[str] = set()
    for raw in list(node_id or []):
        candidate = str(raw or "").strip()
        if not candidate or candidate in seen:
            continue
        seen.add(candidate)
        normalized.append(candidate)
        if len(normalized) >= 100:
            break
    if not normalized:
        return {"ok": False, "detail": "Provide at least one node_id", "reputations": {}}
    return {
        "ok": True,
        "reputations": {
            candidate: reputation_ledger.get_reputation(candidate).get("overall", 0) or 0
            for candidate in normalized
        },
    }


@router.get("/api/mesh/reputation/all", dependencies=[Depends(require_admin)])
@limiter.limit("30/minute")
async def mesh_reputation_all(request: Request):
    """Get all known node reputations."""
    from services.mesh.mesh_reputation import reputation_ledger

    return {"reputations": reputation_ledger.get_all_reputations()}


@router.post("/api/mesh/identity/rotate")
@limiter.limit("5/minute")
@requires_signed_write(kind=SignedWriteKind.IDENTITY_ROTATE)
async def mesh_identity_rotate(request: Request):
    """Link a new node_id to an old one via dual-signature rotation."""
    body = _signed_body(request)
    old_node_id = body.get("old_node_id", "").strip()
    old_public_key = body.get("old_public_key", "").strip()
    old_public_key_algo = body.get("old_public_key_algo", "").strip()
    old_signature = body.get("old_signature", "").strip()
    new_node_id = body.get("new_node_id", "").strip()
    new_public_key = body.get("new_public_key", "").strip()
    new_public_key_algo = body.get("new_public_key_algo", "").strip()
    new_signature = body.get("new_signature", "").strip()
    timestamp = _safe_int(body.get("timestamp", 0) or 0)
    sequence = _safe_int(body.get("sequence", 0) or 0)
    protocol_version = body.get("protocol_version", "").strip()

    if not (
        old_node_id
        and old_public_key
        and old_public_key_algo
        and old_signature
        and new_node_id
        and new_public_key
        and new_public_key_algo
        and new_signature
        and timestamp
    ):
        return {"ok": False, "detail": "Missing rotation fields"}
    if old_node_id == new_node_id:
        return {"ok": False, "detail": "old_node_id must differ from new_node_id"}
    if abs(timestamp - int(time.time())) > 7 * 86400:
        return {"ok": False, "detail": "Rotation timestamp is too far from current time"}

    rotation_payload = {
        "old_node_id": old_node_id,
        "old_public_key": old_public_key,
        "old_public_key_algo": old_public_key_algo,
        "new_public_key": new_public_key,
        "new_public_key_algo": new_public_key_algo,
        "timestamp": timestamp,
        "old_signature": old_signature,
    }

    old_sig_ok, old_sig_reason = verify_key_rotation_claim_signature(
        old_node_id=old_node_id,
        old_public_key=old_public_key,
        old_public_key_algo=old_public_key_algo,
        old_signature=old_signature,
        new_public_key=new_public_key,
        new_public_key_algo=new_public_key_algo,
        timestamp=timestamp,
    )
    if not old_sig_ok:
        return {"ok": False, "detail": old_sig_reason}

    from services.mesh.mesh_reputation import reputation_ledger

    reputation_ledger.register_node(new_node_id, new_public_key, new_public_key_algo)
    ok, reason = reputation_ledger.link_identities(old_node_id, new_node_id)
    if not ok:
        return {"ok": False, "detail": reason}

    # Record on Infonet
    try:
        from services.mesh.mesh_hashchain import infonet

        normalized_payload = normalize_payload("key_rotate", rotation_payload)
        infonet.append(
            event_type="key_rotate",
            node_id=new_node_id,
            payload=normalized_payload,
            signature=new_signature,
            sequence=sequence,
            public_key=new_public_key,
            public_key_algo=new_public_key_algo,
            protocol_version=protocol_version,
        )
    except Exception:
        pass

    return {"ok": True, "detail": "Identity linked"}


@router.post("/api/mesh/identity/revoke")
@limiter.limit("5/minute")
@requires_signed_write(kind=SignedWriteKind.IDENTITY_REVOKE)
async def mesh_identity_revoke(request: Request):
    """Revoke a node's key with a grace window."""
    body = _signed_body(request)
    node_id = body.get("node_id", "").strip()
    public_key = body.get("public_key", "").strip()
    public_key_algo = body.get("public_key_algo", "").strip()
    signature = body.get("signature", "").strip()
    revoked_at = _safe_int(body.get("revoked_at", 0) or 0)
    grace_until = _safe_int(body.get("grace_until", 0) or 0)
    reason = body.get("reason", "").strip()
    sequence = _safe_int(body.get("sequence", 0) or 0)
    protocol_version = body.get("protocol_version", "").strip()

    if not (node_id and public_key and public_key_algo and signature and revoked_at and grace_until):
        return {"ok": False, "detail": "Missing revocation fields"}

    now = int(time.time())
    max_grace = 7 * 86400
    if grace_until < revoked_at:
        return {"ok": False, "detail": "grace_until must be >= revoked_at"}
    if grace_until - revoked_at > max_grace:
        return {"ok": False, "detail": "Grace window too large (max 7 days)"}
    if abs(revoked_at - now) > max_grace:
        return {"ok": False, "detail": "revoked_at is too far from current time"}

    payload = {
        "revoked_public_key": public_key,
        "revoked_public_key_algo": public_key_algo,
        "revoked_at": revoked_at,
        "grace_until": grace_until,
        "reason": reason,
    }

    if payload["revoked_public_key"] != public_key:
        return {"ok": False, "detail": "revoked_public_key must match public_key"}
    if payload["revoked_public_key_algo"] != public_key_algo:
        return {"ok": False, "detail": "revoked_public_key_algo must match public_key_algo"}

    try:
        from services.mesh.mesh_hashchain import infonet

        normalized_payload = normalize_payload("key_revoke", payload)
        infonet.append(
            event_type="key_revoke",
            node_id=node_id,
            payload=normalized_payload,
            signature=signature,
            sequence=sequence,
            public_key=public_key,
            public_key_algo=public_key_algo,
            protocol_version=protocol_version,
        )
    except Exception:
        logger.exception("failed to record key revocation on infonet")
        return {"ok": False, "detail": "revocation_record_failed"}

    return {"ok": True, "detail": "Identity revoked"}


# ─── Gate Endpoints ───────────────────────────────────────────────────────


@router.post("/api/mesh/gate/create")
@limiter.limit("5/hour")
@requires_signed_write(kind=SignedWriteKind.GATE_CREATE)
async def gate_create(request: Request):
    """Create a new reputation-gated community.

    Body: {creator_id, creator_pubkey?, creator_sig?, gate_id, display_name, rules?: {min_overall_rep, min_gate_rep}}
    """
    from services.mesh.mesh_reputation import (
        ALLOW_DYNAMIC_GATES,
        reputation_ledger,
        gate_manager,
    )

    if not ALLOW_DYNAMIC_GATES:
        return {"ok": False, "detail": "Gate creation is disabled for the fixed private launch catalog"}

    body = _signed_body(request)
    creator_id = body.get("creator_id", "")
    gate_id = body.get("gate_id", "")
    display_name = body.get("display_name", gate_id)
    rules = body.get("rules", {})
    public_key = body.get("creator_pubkey", "")
    public_key_algo = body.get("public_key_algo", "")
    signature = body.get("creator_sig", "")
    sequence = _safe_int(body.get("sequence", 0) or 0)
    protocol_version = body.get("protocol_version", "")

    if not creator_id or not gate_id:
        return {"ok": False, "detail": "Missing creator_id or gate_id"}

    gate_payload = {"gate_id": gate_id, "display_name": display_name, "rules": rules}

    reputation_ledger.register_node(creator_id, public_key, public_key_algo)

    ok, reason = gate_manager.create_gate(
        creator_id,
        gate_id,
        display_name,
        min_overall_rep=rules.get("min_overall_rep", 0),
        min_gate_rep=rules.get("min_gate_rep"),
    )

    # Record on Infonet
    if ok:
        try:
            from services.mesh.mesh_hashchain import infonet

            normalized_payload = normalize_payload("gate_create", gate_payload)
            infonet.append(
                event_type="gate_create",
                node_id=creator_id,
                payload=normalized_payload,
                signature=signature,
                sequence=sequence,
                public_key=public_key,
                public_key_algo=public_key_algo,
                protocol_version=protocol_version,
            )
        except Exception:
            pass

    return {"ok": ok, "detail": reason}


@router.get("/api/mesh/gate/list")
@limiter.limit("30/minute")
async def gate_list(request: Request):
    """List all known gates (public catalog — secrets are never included)."""
    from services.mesh.mesh_reputation import gate_manager

    return {"gates": gate_manager.list_gates()}


@router.get("/api/mesh/gate/{gate_id}")
@limiter.limit("30/minute")
async def gate_detail(request: Request, gate_id: str):
    """Get gate details including ratification status."""
    from services.mesh.mesh_reputation import gate_manager

    gate = gate_manager.get_gate(gate_id)
    if not gate:
        return {"ok": False, "detail": f"Gate '{gate_id}' not found"}
    gate["ratification"] = gate_manager.get_ratification_status(gate_id)
    return gate


@router.post("/api/mesh/gate/{gate_id}/message")
@limiter.limit("10/minute")
@requires_signed_write(kind=SignedWriteKind.GATE_MESSAGE)
async def gate_message(request: Request, gate_id: str):
    """Post a message to a gate. Checks entry rules against sender's reputation.

    Body: {sender_id, ciphertext, nonce, sender_ref, signature?}
    """
    body = _signed_body(request)
    return _submit_gate_message_envelope(request, gate_id, body)


def _submit_gate_message_envelope(request: Request, gate_id: str, body: dict[str, Any]) -> dict[str, Any]:
    import main as _m

    return _m._submit_gate_message_envelope(request, gate_id, body)


# ─── Infonet Endpoints ───────────────────────────────────────────────────


@router.get("/api/mesh/infonet/status")
@limiter.limit("30/minute")
async def infonet_status(request: Request, verify_signatures: bool = False):
    """Get Infonet metadata — event counts, head hash, chain size.

    The ``verify_signatures`` query parameter is honored ONLY when the
    caller has authenticated via scoped auth or local-operator credentials.
    Verifying every signature in a long chain is O(n_events) work — letting
    anonymous callers trigger it is a DoS surface (issue #207). For
    anonymous callers we silently fall back to the cheap path; the response
    structure is identical so legitimate frontends see no behavior change.
    """
    from services.mesh.mesh_hashchain import infonet
    from services.wormhole_supervisor import get_wormhole_state

    # Silently downgrade for unauthenticated callers — no error surfaced.
    authenticated = _scoped_view_authenticated(request, "mesh.audit")
    effective_verify_signatures = bool(verify_signatures) and authenticated

    info = infonet.get_info()
    valid, reason = infonet.validate_chain(verify_signatures=effective_verify_signatures)
    try:
        wormhole = get_wormhole_state()
    except Exception:
        wormhole = {"configured": False, "ready": False, "rns_ready": False}
    info["valid"] = valid
    info["validation"] = reason
    info["verify_signatures"] = effective_verify_signatures
    info["private_lane_tier"] = _current_private_lane_tier(wormhole)
    info["private_lane_policy"] = _private_infonet_policy_snapshot()
    info.update(_node_runtime_snapshot())
    return _redact_private_lane_control_fields(
        info,
        authenticated=authenticated,
    )


@router.get("/api/mesh/infonet/merkle")
@limiter.limit("30/minute")
async def infonet_merkle(request: Request):
    """Merkle root for sync comparison."""
    from services.mesh.mesh_hashchain import infonet

    return {
        "merkle_root": infonet.get_merkle_root(),
        "head_hash": infonet.head_hash,
        "count": len(infonet.events),
        "network_id": infonet.get_info().get("network_id"),
    }


@router.get("/api/mesh/infonet/locator")
@limiter.limit("30/minute")
async def infonet_locator(request: Request, limit: int = Query(32, ge=4, le=128)):
    """Block locator for fork-aware sync."""
    from services.mesh.mesh_hashchain import infonet

    locator = infonet.get_locator(max_entries=limit)
    return {
        "locator": locator,
        "head_hash": infonet.head_hash,
        "count": len(infonet.events),
        "network_id": infonet.get_info().get("network_id"),
    }


@router.post("/api/mesh/infonet/sync")
@limiter.limit(_INFONET_SYNC_RATE_LIMIT)
@mesh_write_exempt(MeshWriteExemption.PEER_GOSSIP)
async def infonet_sync_post(
    request: Request,
    limit: int = Query(100, ge=1, le=500),
):
    """Fork-aware sync using a block locator."""
    from services.mesh.mesh_hashchain import infonet, GENESIS_HASH

    body = await request.json()
    req_proto = str(body.get("protocol_version", "") or "")
    if req_proto and req_proto != PROTOCOL_VERSION:
        return Response(
            content=json_mod.dumps(
                {
                    "ok": False,
                    "detail": "Unsupported protocol_version",
                    "protocol_version": PROTOCOL_VERSION,
                }
            ),
            status_code=426,
            media_type="application/json",
        )
    locator = body.get("locator", [])
    if not isinstance(locator, list):
        return {"ok": False, "detail": "locator must be a list"}
    expected_head = str(body.get("expected_head", "") or "")
    if expected_head and expected_head != infonet.head_hash:
        return Response(
            content=json_mod.dumps(
                {
                    "ok": False,
                    "detail": "head_hash mismatch",
                    "head_hash": infonet.head_hash,
                    "expected_head": expected_head,
                }
            ),
            status_code=409,
            media_type="application/json",
        )
    if "limit" in body:
        try:
            limit = max(1, min(500, _safe_int(body["limit"], 0)))
        except Exception:
            pass

    matched_hash, start_index, events = infonet.get_events_after_locator(locator, limit=limit)
    forked = False
    if not matched_hash:
        forked = True
    elif matched_hash == GENESIS_HASH and len(locator) > 1:
        forked = True

    events = _infonet_sync_response_events(events, request=request)

    response = {
        "events": events,
        "matched_hash": matched_hash,
        "forked": forked,
        "head_hash": infonet.head_hash,
        "count": len(events),
        "protocol_version": PROTOCOL_VERSION,
    }
    if body.get("include_proofs"):
        proofs = infonet.get_merkle_proofs(start_index, len(events)) if start_index >= 0 else {}
        response.update(
            {
                "merkle_root": proofs.get("root", infonet.get_merkle_root()),
                "merkle_total": proofs.get("total", len(infonet.events)),
                "merkle_start": proofs.get("start", 0),
                "merkle_proofs": proofs.get("proofs", []),
            }
        )
    return response


@router.get("/api/mesh/metrics")
@limiter.limit("30/minute")
async def mesh_metrics(request: Request):
    """Mesh protocol health counters."""
    from services.mesh.mesh_metrics import snapshot

    ok, detail = _check_scoped_auth(request, "mesh.audit")
    if not ok:
        if detail == "insufficient scope":
            raise HTTPException(status_code=403, detail="Forbidden — insufficient scope")
        raise HTTPException(status_code=403, detail=detail)
    return snapshot()


@router.get("/api/mesh/rns/status")
@limiter.limit("30/minute")
async def mesh_rns_status(request: Request):
    from services.wormhole_supervisor import get_wormhole_state

    try:
        from services.mesh.mesh_rns import rns_bridge

        status = await asyncio.to_thread(rns_bridge.status)
    except Exception:
        status = {"enabled": False, "ready": False, "configured_peers": 0, "active_peers": 0}
    try:
        wormhole = get_wormhole_state()
    except Exception:
        wormhole = {"configured": False, "ready": False, "rns_ready": False}
    status["private_lane_tier"] = _current_private_lane_tier(wormhole)
    status["private_lane_policy"] = _private_infonet_policy_snapshot()
    return _redact_public_rns_status(
        status,
        authenticated=_scoped_view_authenticated(request, "mesh.audit"),
    )


@router.get("/api/mesh/infonet/sync")
@limiter.limit(_INFONET_SYNC_RATE_LIMIT)
async def infonet_sync(
    request: Request,
    after_hash: str = "",
    limit: int = Query(100, ge=1, le=500),
    expected_head: str = "",
    protocol_version: str = "",
):
    """Return events after a given hash (delta sync)."""
    from services.mesh.mesh_hashchain import infonet, GENESIS_HASH

    if protocol_version and protocol_version != PROTOCOL_VERSION:
        return Response(
            content=json_mod.dumps(
                {
                    "ok": False,
                    "detail": "Unsupported protocol_version",
                    "protocol_version": PROTOCOL_VERSION,
                }
            ),
            status_code=426,
            media_type="application/json",
        )
    if expected_head and expected_head != infonet.head_hash:
        return Response(
            content=json_mod.dumps(
                {
                    "ok": False,
                    "detail": "head_hash mismatch",
                    "head_hash": infonet.head_hash,
                    "expected_head": expected_head,
                }
            ),
            status_code=409,
            media_type="application/json",
        )
    base = after_hash or GENESIS_HASH
    events = infonet.get_events_after(base, limit=limit)
    events = _infonet_sync_response_events(events, request=request)
    return {
        "events": events,
        "after_hash": base,
        "count": len(events),
        "protocol_version": PROTOCOL_VERSION,
    }


@router.post("/api/mesh/infonet/ingest", dependencies=[Depends(require_admin)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL)
async def infonet_ingest(request: Request):
    """Ingest externally sourced Infonet events (strict verification)."""
    from services.mesh.mesh_hashchain import infonet

    body = await request.json()
    events = body.get("events", [])
    expected_head = str(body.get("expected_head", "") or "")
    if expected_head and expected_head != infonet.head_hash:
        return Response(
            content=json_mod.dumps(
                {
                    "ok": False,
                    "detail": "head_hash mismatch",
                    "head_hash": infonet.head_hash,
                    "expected_head": expected_head,
                }
            ),
            status_code=409,
            media_type="application/json",
        )
    if not isinstance(events, list):
        return {"ok": False, "detail": "events must be a list"}
    if len(events) > 200:
        return {"ok": False, "detail": "Too many events in one ingest batch"}

    result = infonet.ingest_events(events)
    _hydrate_gate_store_from_chain(events)
    _hydrate_dm_relay_from_chain(events)
    return {"ok": True, **result}



# ---------------------------------------------------------------------------
# Peer Management API — operator endpoints for adding / removing / listing
# peers without editing peer_store.json by hand.
# ---------------------------------------------------------------------------


@router.get("/api/mesh/peers", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def list_peers(request: Request, bucket: str = Query(None)):
    """List all peers (or filter by bucket: sync, push, bootstrap)."""
    from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerStore

    store = PeerStore(DEFAULT_PEER_STORE_PATH)
    try:
        store.load()
    except Exception as exc:
        return {"ok": False, "detail": f"Failed to load peer store: {exc}"}

    if bucket:
        records = store.records_for_bucket(bucket)
    else:
        records = store.records()

    return {
        "ok": True,
        "count": len(records),
        "peers": [r.to_dict() for r in records],
    }


@router.post("/api/mesh/peers", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.LOCAL_OPERATOR_ONLY)
async def add_peer(request: Request):
    """Add a peer to the store. Body: {peer_url, transport?, label?, role?, buckets?[]}."""
    from services.mesh.mesh_crypto import normalize_peer_url
    from services.mesh.mesh_peer_store import (
        DEFAULT_PEER_STORE_PATH,
        PeerStore,
        PeerStoreError,
        make_push_peer_record,
        make_sync_peer_record,
    )
    from services.mesh.mesh_router import peer_transport_kind

    body = await request.json()
    peer_url_raw = str(body.get("peer_url", "") or "").strip()
    if not peer_url_raw:
        return {"ok": False, "detail": "peer_url is required"}

    peer_url = normalize_peer_url(peer_url_raw)
    if not peer_url:
        return {"ok": False, "detail": "Invalid peer_url"}

    transport = str(body.get("transport", "") or "").strip().lower()
    if not transport:
        transport = peer_transport_kind(peer_url)
    if not transport:
        return {"ok": False, "detail": "Cannot determine transport for peer_url — provide transport explicitly"}

    label = str(body.get("label", "") or "").strip()
    role = str(body.get("role", "") or "").strip().lower() or "relay"
    buckets = body.get("buckets", ["sync", "push"])
    if isinstance(buckets, str):
        buckets = [buckets]
    if not isinstance(buckets, list):
        buckets = ["sync", "push"]

    store = PeerStore(DEFAULT_PEER_STORE_PATH)
    try:
        store.load()
    except Exception:
        store = PeerStore(DEFAULT_PEER_STORE_PATH)

    added: list[str] = []
    try:
        for b in buckets:
            b = str(b).strip().lower()
            if b == "sync":
                store.upsert(make_sync_peer_record(peer_url=peer_url, transport=transport, role=role, label=label))
                added.append("sync")
            elif b == "push":
                store.upsert(make_push_peer_record(peer_url=peer_url, transport=transport, role=role, label=label))
                added.append("push")
        store.save()
    except PeerStoreError as exc:
        return {"ok": False, "detail": str(exc)}

    return {"ok": True, "peer_url": peer_url, "buckets": added}


@router.delete("/api/mesh/peers", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.LOCAL_OPERATOR_ONLY)
async def remove_peer(request: Request):
    """Remove a peer. Body: {peer_url, bucket?}. If bucket omitted, removes from all buckets."""
    from services.mesh.mesh_crypto import normalize_peer_url
    from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerStore

    body = await request.json()
    peer_url_raw = str(body.get("peer_url", "") or "").strip()
    if not peer_url_raw:
        return {"ok": False, "detail": "peer_url is required"}

    peer_url = normalize_peer_url(peer_url_raw)
    if not peer_url:
        return {"ok": False, "detail": "Invalid peer_url"}

    bucket_filter = str(body.get("bucket", "") or "").strip().lower()

    store = PeerStore(DEFAULT_PEER_STORE_PATH)
    try:
        store.load()
    except Exception:
        return {"ok": False, "detail": "Failed to load peer store"}

    removed: list[str] = []
    for b in ["bootstrap", "sync", "push"]:
        if bucket_filter and b != bucket_filter:
            continue
        key = f"{b}:{peer_url}"
        if key in store._records:
            del store._records[key]
            removed.append(b)

    if not removed:
        return {"ok": False, "detail": "Peer not found in any bucket"}

    store.save()
    return {"ok": True, "peer_url": peer_url, "removed_from": removed}


@router.patch("/api/mesh/peers", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.LOCAL_OPERATOR_ONLY)
async def toggle_peer(request: Request):
    """Enable or disable a peer. Body: {peer_url, bucket, enabled: bool}."""
    from services.mesh.mesh_crypto import normalize_peer_url
    from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerRecord, PeerStore

    body = await request.json()
    peer_url_raw = str(body.get("peer_url", "") or "").strip()
    bucket = str(body.get("bucket", "") or "").strip().lower()
    enabled = body.get("enabled")

    if not peer_url_raw:
        return {"ok": False, "detail": "peer_url is required"}
    if not bucket:
        return {"ok": False, "detail": "bucket is required"}
    if enabled is None:
        return {"ok": False, "detail": "enabled (true/false) is required"}

    peer_url = normalize_peer_url(peer_url_raw)
    if not peer_url:
        return {"ok": False, "detail": "Invalid peer_url"}

    store = PeerStore(DEFAULT_PEER_STORE_PATH)
    try:
        store.load()
    except Exception:
        return {"ok": False, "detail": "Failed to load peer store"}

    key = f"{bucket}:{peer_url}"
    record = store._records.get(key)
    if not record:
        return {"ok": False, "detail": f"Peer not found in {bucket} bucket"}

    updated = PeerRecord(**{**record.to_dict(), "enabled": bool(enabled), "updated_at": int(time.time())})
    store._records[key] = updated
    store.save()

    return {"ok": True, "peer_url": peer_url, "bucket": bucket, "enabled": bool(enabled)}


@router.put("/api/mesh/gate/{gate_id}/envelope_policy")
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL)
async def set_gate_envelope_policy(request: Request, gate_id: str):
    """Set the envelope_policy for a gate. Requires gate admin scope."""
    ok, detail = _check_scoped_auth(request, "gate")
    if not ok:
        return Response(
            content='{"ok":false,"detail":"Gate admin scope required"}',
            status_code=403,
            media_type="application/json",
        )
    try:
        body = await request.json()
    except Exception:
        return {"ok": False, "detail": "Invalid JSON body"}
    policy = str(body.get("envelope_policy", "") or "").strip()
    acknowledge_recovery_risk = bool(body.get("acknowledge_recovery_risk", False))
    from services.mesh.mesh_reputation import gate_manager, VALID_ENVELOPE_POLICIES
    if policy not in VALID_ENVELOPE_POLICIES:
        return {"ok": False, "detail": f"Invalid policy: must be one of {VALID_ENVELOPE_POLICIES}"}
    success, msg = gate_manager.set_envelope_policy(
        gate_id,
        policy,
        acknowledge_recovery_risk=acknowledge_recovery_risk,
    )
    return {"ok": success, "detail": msg}


@router.put("/api/mesh/gate/{gate_id}/legacy_envelope_fallback")
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.ADMIN_CONTROL)
async def set_gate_legacy_envelope_fallback(request: Request, gate_id: str):
    """Set legacy_envelope_fallback for a gate. Requires gate admin scope."""
    ok, detail = _check_scoped_auth(request, "gate")
    if not ok:
        return Response(
            content='{"ok":false,"detail":"Gate admin scope required"}',
            status_code=403,
            media_type="application/json",
        )
    try:
        body = await request.json()
    except Exception:
        return {"ok": False, "detail": "Invalid JSON body"}
    raw = body.get("legacy_envelope_fallback")
    acknowledge_legacy_risk = body.get("acknowledge_legacy_risk", False)
    if raw is None or not isinstance(raw, bool):
        return {"ok": False, "detail": "legacy_envelope_fallback must be a boolean"}
    if acknowledge_legacy_risk is not None and not isinstance(acknowledge_legacy_risk, bool):
        return {"ok": False, "detail": "acknowledge_legacy_risk must be a boolean"}
    from services.mesh.mesh_reputation import gate_manager
    success, msg = gate_manager.set_legacy_envelope_fallback(
        gate_id,
        raw,
        acknowledge_legacy_risk=bool(acknowledge_legacy_risk),
    )
    return {"ok": success, "detail": msg}


@router.get("/api/mesh/gate/{gate_id}/messages")
@limiter.limit("60/minute")
async def gate_messages(
    request: Request,
    gate_id: str,
    limit: int = Query(20, ge=1, le=100),
    offset: int = Query(0, ge=0),
):
    """Get encrypted gate messages from private store (newest first). Requires gate membership."""
    access = _verify_gate_access(request, gate_id)
    if not access:
        return await _private_plane_refusal_response(
            request,
            status_code=403,
            payload=_private_plane_access_denied_payload(),
        )
    return _build_gate_message_response(gate_id, access, limit=limit, offset=offset)


def _build_gate_message_response(
    gate_id: str,
    access: str,
    *,
    limit: int = 20,
    offset: int = 0,
) -> dict[str, Any]:
    from services.mesh.mesh_hashchain import gate_store
    from services.mesh.mesh_reputation import gate_manager

    raw_messages, cursor = gate_store.get_messages_with_cursor(gate_id, limit=limit, offset=offset)
    safe_messages = [_strip_gate_for_access(m, access) for m in raw_messages]
    if gate_id and not safe_messages:
        gate_meta = gate_manager.get_gate(gate_id)
        if gate_meta:
            welcome_text = str(gate_meta.get("welcome") or gate_meta.get("description") or "").strip()
            if welcome_text:
                safe_messages = [
                    {
                        "event_id": f"seed_{gate_id}_welcome",
                        "event_type": "gate_notice",
                        "node_id": "!sb_gate",
                        "message": welcome_text,
                        "gate": gate_id,
                        "timestamp": int(gate_meta.get("created_at") or time.time()),
                        "sequence": 0,
                        "ephemeral": False,
                        "system_seed": True,
                        "fixed_gate": bool(gate_meta.get("fixed", False)),
                    }
                ]
    return {"messages": safe_messages, "count": len(safe_messages), "gate": gate_id, "cursor": cursor}


def _gate_session_stream_enabled() -> bool:
    try:
        return bool(get_settings().MESH_GATE_SESSION_STREAM_ENABLED)
    except Exception:
        return False


def _gate_session_stream_heartbeat_s() -> int:
    try:
        return max(1, int(get_settings().MESH_GATE_SESSION_STREAM_HEARTBEAT_S or 20))
    except Exception:
        return 20


def _gate_session_stream_batch_ms() -> int:
    try:
        return max(250, int(get_settings().MESH_GATE_SESSION_STREAM_BATCH_MS or 1500))
    except Exception:
        return 1500


def _gate_session_stream_max_gates() -> int:
    try:
        return max(1, int(get_settings().MESH_GATE_SESSION_STREAM_MAX_GATES or 16))
    except Exception:
        return 16


def _normalize_gate_session_stream_gates(raw: str, limit: int) -> list[str]:
    seen: set[str] = set()
    normalized: list[str] = []
    for gate_id in str(raw or "").split(","):
        candidate = str(gate_id or "").strip().lower()
        if not candidate or candidate in seen:
            continue
        seen.add(candidate)
        normalized.append(candidate)
        if len(normalized) >= limit:
            break
    return normalized


def _format_gate_session_stream_event(event: str, data: dict[str, Any]) -> str:
    payload = json_mod.dumps(data, default=str)
    return f"event: {event}\ndata: {payload}\n\n"


def _build_gate_session_stream_gate_access(gate_id: str) -> dict[str, Any] | None:
    proof = _sign_gate_access_proof(gate_id)
    if not proof.get("ok"):
        return None
    node_id = str(proof.get("node_id") or "").strip()
    gate_proof = str(proof.get("proof") or "").strip()
    gate_ts = str(proof.get("ts") or "").strip()
    if not node_id or not gate_proof or not gate_ts:
        return None
    return {
        "node_id": node_id,
        "proof": gate_proof,
        "ts": gate_ts,
    }


def _build_gate_session_stream_gate_key_status(gate_id: str) -> dict[str, Any]:
    from services.mesh.mesh_gate_mls import get_local_gate_key_status

    status = get_local_gate_key_status(gate_id)
    if not isinstance(status, dict):
        return {"ok": False, "gate_id": gate_id, "detail": "gate_key_status_unavailable"}
    return dict(status)


@router.get("/api/mesh/infonet/messages")
@limiter.limit("60/minute")
async def infonet_messages(
    request: Request,
    gate: str = "",
    limit: int = Query(20, ge=1, le=100),
    offset: int = Query(0, ge=0),
):
    """Browse messages on the Infonet (newest first). Optional gate filter."""
    from services.mesh.mesh_hashchain import infonet

    if gate:
        access = _verify_gate_access(request, gate)
        if not access:
            return await _private_plane_refusal_response(
                request,
                status_code=403,
                payload=_private_plane_access_denied_payload(),
            )
        return _build_gate_message_response(gate, access, limit=limit, offset=offset)
    else:
        messages = infonet.get_messages(gate_id="", limit=limit, offset=offset)
        messages = [m for m in messages if m.get("event_type") != "gate_message"]
        messages = [_redact_public_event(m) for m in messages]
    return {"messages": messages, "count": len(messages), "gate": gate or "all", "cursor": 0}


@router.get("/api/mesh/infonet/messages/wait")
@limiter.limit("60/minute")
async def infonet_messages_wait(
    request: Request,
    gate: str = "",
    after: int = Query(0, ge=0),
    limit: int = Query(20, ge=1, le=100),
    timeout_ms: int = Query(25_000, ge=1_000, le=90_000),
):
    """Wait for gate message changes, then return the latest gate view."""
    gate_id = str(gate or "").strip().lower()
    if not gate_id:
        return Response(
            content='{"ok":false,"detail":"gate required"}',
            status_code=400,
            media_type="application/json",
        )
    access = _verify_gate_access(request, gate_id)
    if not access:
        return await _private_plane_refusal_response(
            request,
            status_code=403,
            payload=_private_plane_access_denied_payload(),
        )
    from services.mesh.mesh_hashchain import gate_store

    changed, _cursor = await asyncio.to_thread(
        gate_store.wait_for_gate_change,
        gate_id,
        after,
        timeout_ms / 1000.0,
    )
    payload = _build_gate_message_response(gate_id, access, limit=limit, offset=0)
    payload["changed"] = bool(changed)
    return payload


@router.get("/api/mesh/infonet/session-stream", dependencies=[Depends(require_admin)])
@limiter.limit("30/minute")
async def infonet_session_stream(
    request: Request,
    gates: str = Query(""),
):
    """Feature-flagged session-level gate stream for multiplexed room updates.

    Current behavior:
    - admin-gated control-plane access
    - immediate hello event with normalized subscriptions and gate bootstrap context
    - gate_update events for subscribed rooms
    - coarse heartbeats and reconnect-friendly session state
    """
    if not _gate_session_stream_enabled():
        return JSONResponse(
            status_code=404,
            content={"ok": False, "detail": "gate_session_stream_disabled"},
        )

    heartbeat_s = _gate_session_stream_heartbeat_s()
    batch_ms = _gate_session_stream_batch_ms()
    max_gates = _gate_session_stream_max_gates()
    subscriptions = _normalize_gate_session_stream_gates(gates, max_gates)
    session_id = secrets.token_hex(8)
    from services.mesh.mesh_hashchain import gate_store

    cursors = {
        gate_id: gate_store.gate_cursor(gate_id)
        for gate_id in subscriptions
    }
    gate_access = {
        gate_id: access
        for gate_id in subscriptions
        for access in [_build_gate_session_stream_gate_access(gate_id)]
        if access
    }
    gate_key_status = {
        gate_id: _build_gate_session_stream_gate_key_status(gate_id)
        for gate_id in subscriptions
    }

    async def event_stream():
        try:
            yield _format_gate_session_stream_event(
                "hello",
                {
                    "ok": True,
                    "mode": "skeleton",
                    "transport": "sse",
                    "session_id": session_id,
                    "subscriptions": subscriptions,
                    "cursors": cursors,
                    "gate_access": gate_access,
                    "gate_key_status": gate_key_status,
                    "heartbeat_s": heartbeat_s,
                    "batch_ms": batch_ms,
                },
            )
            last_heartbeat = time.monotonic()
            while True:
                if await request.is_disconnected():
                    break
                updates = await asyncio.to_thread(
                    gate_store.wait_for_any_gate_change,
                    cursors,
                    batch_ms / 1000.0,
                )
                if await request.is_disconnected():
                    break
                if updates:
                    update_list = [
                        {"gate_id": gate_id, "cursor": cursor}
                        for gate_id, cursor in sorted(updates.items())
                    ]
                    cursors.update({gate_id: cursor for gate_id, cursor in updates.items()})
                    yield _format_gate_session_stream_event(
                        "gate_update",
                        {
                            "session_id": session_id,
                            "updates": update_list,
                            "ts": int(time.time()),
                        },
                    )
                now = time.monotonic()
                if now - last_heartbeat >= heartbeat_s:
                    yield _format_gate_session_stream_event(
                        "heartbeat",
                        {
                            "session_id": session_id,
                            "ts": int(time.time()),
                        },
                    )
                    last_heartbeat = now
        except asyncio.CancelledError:
            pass

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        },
    )


@router.get("/api/mesh/infonet/event/{event_id}")
@limiter.limit("60/minute")
async def infonet_event(request: Request, event_id: str):
    """Look up a single Infonet event by ID."""
    from services.mesh.mesh_hashchain import gate_store, infonet

    evt = infonet.get_event(event_id)
    if not evt:
        evt = gate_store.get_event(event_id)
        if evt:
            gate_id = str(evt.get("payload", {}).get("gate", "") or evt.get("gate", "") or "").strip()
            access = _verify_gate_access(request, gate_id) if gate_id else ""
            if not gate_id or not access:
                return await _private_plane_refusal_response(
                    request,
                    status_code=403,
                    payload=_private_plane_access_denied_payload(),
                )
            return _strip_gate_for_access(evt, access)
        return {"ok": False, "detail": "Event not found"}
    if evt.get("event_type") == "dm_message":
        return await _private_plane_refusal_response(
            request,
            status_code=403,
            payload=_private_plane_access_denied_payload(),
        )
    if evt.get("event_type") == "gate_message":
        gate_id = str(evt.get("payload", {}).get("gate", "") or evt.get("gate", "") or "").strip()
        access = _verify_gate_access(request, gate_id) if gate_id else ""
        if not gate_id or not access:
            return await _private_plane_refusal_response(
                request,
                status_code=403,
                payload=_private_plane_access_denied_payload(),
            )
        return _strip_gate_for_access(evt, access)
    return _redact_public_event(infonet.decorate_event(evt))


@router.get("/api/mesh/infonet/node/{node_id}")
@limiter.limit("30/minute")
async def infonet_node_events(
    request: Request,
    node_id: str,
    limit: int = Query(20, ge=1, le=100),
):
    """Get recent Infonet events by a specific node."""
    from services.mesh.mesh_hashchain import infonet

    events = infonet.get_events_by_node(node_id, limit=limit)
    events = [e for e in events if e.get("event_type") not in {"gate_message", "dm_message"}]
    events = [_redact_public_event(e) for e in infonet.decorate_events(events)]
    events = _redact_public_node_history(
        events,
        authenticated=_scoped_view_authenticated(request, "mesh.audit"),
    )
    return {"events": events, "count": len(events), "node_id": node_id}


@router.get("/api/mesh/infonet/events")
@limiter.limit("30/minute")
async def infonet_events_by_type(
    request: Request,
    event_type: str = "",
    limit: int = Query(20, ge=1, le=100),
    offset: int = Query(0, ge=0),
):
    """Get recent Infonet events, optionally filtered by type."""
    from services.mesh.mesh_hashchain import infonet

    if event_type:
        events = infonet.get_events_by_type(event_type, limit=limit, offset=offset)
    else:
        events = list(reversed(infonet.events))
        events = events[offset : offset + limit]
    events = [e for e in events if e.get("event_type") not in {"gate_message", "dm_message"}]
    events = [_redact_public_event(e) for e in infonet.decorate_events(events)]
    return {
        "events": events,
        "count": len(events),
        "event_type": event_type or "all",
    }

