"""Transport-neutral browser-control broker core: binds an identity-scoped *controller*
(the party driving a browser) to *callers* on any transport. Tickets are short-lived,
single-use, identity-bound and consumed exactly once; ``select`` matches every stable
identity field plus the current capability set; ``complete`` is single-shot; ``detach``
fails pending work closed while ``disconnect`` only marks the transport offline. State
changes happen under one RLock; the send callback runs *outside* it so a controller may
``complete`` from inside its own send."""

from __future__ import annotations

import logging
import secrets
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional

logger = logging.getLogger(__name__)
_OWNER_UNSET = object()

#: Default lifetime of a minted registration ticket, in clock seconds.
DEFAULT_TICKET_TTL = 30.0
#: Default wall time a dispatch waits for the controller to complete.
DEFAULT_COMMAND_TIMEOUT = 30.0
#: Maximum cancel frames retained while a same-identity controller is offline.
MAX_DEFERRED_CANCELS = 512
#: Current wire protocol version; registration requires this exact int (bools rejected).
BROWSER_CONTROL_PROTOCOL_VERSION = 1

#: Exact controller capability allowlist shared by every transport (raw CDP/eval/console stay out).
BROWSER_CONTROL_CAPABILITIES = frozenset({
    "controller.noop", "browser_back", "browser_click", "browser_navigate", "browser_press", "browser_screenshot",
    "browser_scroll", "browser_snapshot", "browser_tab_activate", "browser_tabs", "browser_type",
})
#: Privileged capabilities: fail-closed unless Developer Mode is on AND explicitly negotiated.
BROWSER_CONTROL_DEVELOPER_CAPABILITIES = frozenset({"browser_cdp", "browser_evaluate"})
#: Artifact transport capabilities; non-developer because only a store-validated ``artifact_id`` travels.
BROWSER_CONTROL_ARTIFACT_CAPABILITIES = frozenset({"browser_artifact_download", "browser_artifact_upload"})

#: Wire method names for controller frames; transports carry them verbatim.
FRAME_COMMAND = "browser.controller.command"
FRAME_CANCEL = "browser.controller.cancel"


def browser_control_protocol_supported(value: Any) -> bool:
    """Return whether ``value`` names the exact supported wire version."""
    return type(value) is int and value == BROWSER_CONTROL_PROTOCOL_VERSION


def _extension_control_flag(config: Optional[dict], key: str) -> bool:
    """Read ``browser.extension_control.<key>`` as a literal ``True`` (default off)."""
    if config is None:
        try:
            # Hot path (every browser tool call): the read-only loader skips load_config()'s deepcopy.
            from hermes_cli.config import load_config_readonly
            config = load_config_readonly()
        except Exception:
            return False
    browser = config.get("browser") if isinstance(config, dict) else None
    extension_control = browser.get("extension_control") if isinstance(browser, dict) else None
    return isinstance(extension_control, dict) and extension_control.get(key, False) is True


def browser_control_developer_mode(config: Optional[dict] = None) -> bool:
    """Explicit Developer Mode flag; gates ``browser_evaluate``/raw CDP only."""
    return _extension_control_flag(config, "developer_mode")


def browser_control_enabled(config: Optional[dict] = None) -> bool:
    """Return the explicit browser-control feature flag (disabled by default)."""
    return _extension_control_flag(config, "enabled")


def filter_browser_control_capabilities(value: Any, *, developer_mode: Optional[bool] = None) -> frozenset:
    """Permitted subset of a capability list (non-list -> empty); developer caps only in Developer Mode."""
    if not isinstance(value, list):
        return frozenset()
    allowed = BROWSER_CONTROL_CAPABILITIES | BROWSER_CONTROL_ARTIFACT_CAPABILITIES
    if (browser_control_developer_mode() if developer_mode is None else developer_mode) is True:
        allowed |= BROWSER_CONTROL_DEVELOPER_CAPABILITIES
    return frozenset(c for c in value if isinstance(c, str) and c in allowed)


class BrowserControlError(Exception):
    """Base class for broker contract failures."""


class ControllerTicketInvalid(BrowserControlError):
    """A registration ticket is unknown, already consumed, or expired."""


class ControllerUnavailable(BrowserControlError):
    """No attached controller exactly matches the requested scope/capability."""


class ControllerCancelled(BrowserControlError):
    """A pending command was cancelled (explicitly or by detach)."""


class ControllerTimeout(BrowserControlError):
    """The controller did not complete the command before the timeout."""


class ControllerRejected(BrowserControlError):
    """The controller completed the command with ``ok=False``."""


@dataclass(frozen=True)
class ControllerScope:
    """Exact controller identity plus capability set; equality is over all fields."""
    principal_id: Optional[str] = None
    profile_id: Optional[str] = None
    session_id: Optional[str] = None
    controller_id: Optional[str] = None
    browser_profile_id: Optional[str] = None
    transport_family: Optional[str] = None
    capabilities: frozenset = frozenset()


#: Stable identity fields; negotiated ``capabilities`` are deliberately excluded.
_IDENTITY_FIELDS = ("principal_id", "profile_id", "session_id", "controller_id", "browser_profile_id", "transport_family")


def _same_scope_identity(first: ControllerScope, second: ControllerScope) -> bool:
    return all(getattr(first, name) == getattr(second, name) for name in _IDENTITY_FIELDS)


@dataclass(frozen=True)
class Ticket:
    """Opaque, single-use registration credential."""
    value: str
    expires_at: float


@dataclass
class _TicketRecord:
    scope: ControllerScope
    expires_at: float
    consumed: bool = False


@dataclass
class _Controller:
    scope: ControllerScope
    send: Callable[[dict], None]
    owner: Any = None
    connected: bool = True
    deferred_cancels: list[dict] = field(default_factory=list)
    # Serializes command/cancel writes with detach or replacement; never held with broker state.
    send_lock: threading.Lock = field(default_factory=threading.Lock)


@dataclass
class _PendingCommand:
    scope: ControllerScope
    command_id: str
    tool_call_id: Optional[str]
    event: threading.Event = field(default_factory=threading.Event)
    done: bool = False
    cancelled: bool = False
    ok: bool = False
    result: Any = None


def _cancel_frame(pending: _PendingCommand) -> dict:
    return {"method": FRAME_CANCEL, "params": {"command_id": pending.command_id, "tool_call_id": pending.tool_call_id}}


class BrowserControlBroker:
    """Thread-safe broker core; ``clock`` is injectable (default ``time.monotonic``)."""
    def __init__(self, *, ticket_ttl: float = DEFAULT_TICKET_TTL, command_timeout: float = DEFAULT_COMMAND_TIMEOUT,
                 clock: Optional[Callable[[], float]] = None, developer_mode: Optional[bool] = None) -> None:
        self._ticket_ttl = ticket_ttl
        self._command_timeout = command_timeout
        self._clock = clock if clock is not None else time.monotonic
        self._lock = threading.RLock()
        self._tickets: Dict[str, _TicketRecord] = {}
        self._controllers: Dict[ControllerScope, _Controller] = {}
        self._pending: Dict[str, _PendingCommand] = {}
        # None defers to live config on every selection (so flipping developer_mode off REVOKES
        # raw CDP/eval from attached controllers without restart); a bool pins the gate.
        self._developer_mode_pinned: Optional[bool] = None if developer_mode is None else developer_mode is True
        # Artifact stores keyed by profile id; ``None`` is the default slot.
        self._artifact_stores: Dict[Optional[str], Any] = {}

    @property
    def developer_mode(self) -> bool:
        """Whether privileged capabilities may be selected/dispatched (live config unless pinned)."""
        if self._developer_mode_pinned is not None:
            return self._developer_mode_pinned
        try:
            return browser_control_developer_mode()
        except Exception:
            return False

    def attach_artifact_store(self, store: Any, *, profile_id: Optional[str] = None) -> None:
        """Attach a store exposing ``validate(artifact_id, *, scope) -> receipt`` for one profile
        (``None`` = default slot); ``store=None`` clears the slot. Artifact actions fail closed without one."""
        if store is None:
            self._artifact_stores.pop(profile_id, None)
        else:
            self._artifact_stores[profile_id] = store

    def _artifact_store_for_scope(self, scope: "ControllerScope") -> Any:
        store = self._artifact_stores.get(getattr(scope, "profile_id", None) or None)
        return store if store is not None else self._artifact_stores.get(None)

    def mint_ticket(self, scope: ControllerScope) -> Ticket:
        """Mint a short-lived, single-use ticket bound to ``scope``."""
        now = self._clock()
        with self._lock:
            self._tickets = {v: rec for v, rec in self._tickets.items() if rec.expires_at > now}
            value = secrets.token_urlsafe(32)
            self._tickets[value] = record = _TicketRecord(scope=scope, expires_at=now + self._ticket_ttl)
        return Ticket(value=value, expires_at=record.expires_at)

    def consume_ticket(self, value: str) -> ControllerScope:
        """Exchange a ticket for its scope exactly once; unknown/consumed/expired -> ControllerTicketInvalid."""
        now = self._clock()
        with self._lock:
            record = self._tickets.get(value)
            if record is None:
                raise ControllerTicketInvalid("unknown ticket")
            if record.consumed:
                raise ControllerTicketInvalid("ticket already consumed")
            if now > record.expires_at:
                raise ControllerTicketInvalid("ticket expired")
            record.consumed = True
            return record.scope

    def _controller_for_identity_locked(self, scope: ControllerScope) -> Optional[_Controller]:
        """Attached controller sharing ``scope``'s stable identity (any capabilities)."""
        return next((c for c in self._controllers.values() if _same_scope_identity(c.scope, scope)), None)

    def _live_controller(self, scope: ControllerScope) -> Optional[_Controller]:
        with self._lock:
            controller = self._controller_for_identity_locked(scope)
        return controller if controller is not None and controller.connected else None

    def attach(self, scope: ControllerScope, send: Callable[[dict], None], *, owner: Any = None) -> None:
        """Attach or refresh the controller for one stable identity: a same-identity reconnect refreshes send
        and capabilities without cancelling pending work; a different controller/browser profile in the same
        authenticated session lane hard-replaces it."""
        while True:
            with self._lock:
                existing = self._controller_for_identity_locked(scope)
                lane_scopes = [
                    c for c in self._controllers if not _same_scope_identity(c, scope)
                    and (c.principal_id, c.profile_id, c.session_id, c.transport_family)
                    == (scope.principal_id, scope.profile_id, scope.session_id, scope.transport_family)
                ]
                if existing is None and not lane_scopes:
                    self._controllers[scope] = _Controller(scope=scope, send=send, owner=owner)
                    return

            # Hard replacement, not a recoverable reconnect: terminalize the
            # lane siblings before inserting so session lookup stays unique.
            if lane_scopes:
                for lane_scope in lane_scopes:
                    self.detach(lane_scope, notify_controller=False)
                continue

            with existing.send_lock:
                with self._lock:
                    if self._controllers.get(existing.scope) is not existing:
                        continue
                    self._controllers.pop(existing.scope, None)
                    existing.scope, existing.send, existing.owner, existing.connected = scope, send, owner, False
                    for pending in self._pending_for_scope_locked(scope):
                        pending.scope = scope
                    deferred, existing.deferred_cancels = existing.deferred_cancels, []
                    self._controllers[scope] = existing

                for index, frame in enumerate(deferred):
                    try:
                        send(frame)
                        continue
                    except Exception:
                        logger.exception("failed to flush deferred browser-controller cancel")
                    with self._lock:
                        if self._controllers.get(scope) is existing:
                            existing.deferred_cancels = deferred[index:][-MAX_DEFERRED_CANCELS:]
                    raise ConnectionError("browser controller reconnect could not flush deferred cancels")
                with self._lock:
                    if self._controllers.get(scope) is existing:
                        existing.connected = True
                return

    def select(self, scope: ControllerScope, capability: str) -> Optional[_Controller]:
        """Connected controller matching identity whose *current* negotiated set holds ``capability`` (the
        caller's set is not authoritative); developer capabilities are also gated on LIVE Developer Mode."""
        if capability in BROWSER_CONTROL_DEVELOPER_CAPABILITIES and not self.developer_mode:
            return None
        controller = self._live_controller(scope)
        return controller if controller is not None and capability in controller.scope.capabilities else None

    def is_owner(self, scope: ControllerScope, owner: Any) -> bool:
        """Whether ``owner`` is the exact live transport for ``scope`` (capability-independent)."""
        controller = self._live_controller(scope)
        return controller is not None and controller.owner is owner

    def disconnect(self, scope: ControllerScope, *, owner: Any = _OWNER_UNSET) -> bool:
        """Mark one exact controller transport offline without cancelling work."""
        with self._lock:
            controller = self._controller_for_identity_locked(scope)
        if controller is None:
            return False
        with controller.send_lock:
            with self._lock:
                owned = owner is _OWNER_UNSET or controller.owner is owner
                if self._controllers.get(controller.scope) is not controller or not owned:
                    return False
                controller.connected, controller.owner = False, None
        return True

    def detach(self, scope: ControllerScope, *, owner: Any = _OWNER_UNSET, notify_controller: bool = True) -> None:
        """Remove the controller for ``scope`` and fail its pending work closed (ControllerCancelled)."""
        with self._lock:
            controller = self._controllers.get(scope)
        if controller is None or (owner is not _OWNER_UNSET and controller.owner != owner):
            return
        with controller.send_lock:
            with self._lock:
                if self._controllers.get(scope) is not controller or (owner is not _OWNER_UNSET and controller.owner != owner):
                    return
                self._controllers.pop(scope, None)
                pendings = self._pending_for_scope_locked(scope)
                for pending in pendings:
                    self._resolve_pending(pending, cancelled=True)
            # Hold the old generation's send lock through cancellation so a
            # command frame can never overtake its terminal cancel frame.
            if notify_controller:
                self._emit_cancel_frames(controller, pendings)

    def dispatch(
        self, scope: ControllerScope, *, action: str, arguments: Optional[dict] = None, tool_call_id: Optional[str] = None,
    ) -> Any:
        """Send one controller command and block for completion; raises ControllerUnavailable/Cancelled/Timeout/
        Rejected. Artifact actions also need an attached store and an approved ``artifact_id`` (only the id travels)."""
        controller = self.select(scope, action)
        if controller is None:
            raise ControllerUnavailable(f"no controller for scope {scope!r} with capability {action!r}")
        arguments = dict(arguments or {})
        if action in BROWSER_CONTROL_ARTIFACT_CAPABILITIES:
            self._validate_artifact_reference(scope, action, arguments)
        command_id = secrets.token_hex(16)
        frame = {"method": FRAME_COMMAND, "params": {
            "command_id": command_id, "action": action, "arguments": arguments, "controller_id": scope.controller_id,
            "browser_profile_id": scope.browser_profile_id, "tool_call_id": tool_call_id,
        }}
        pending = _PendingCommand(scope=controller.scope, command_id=command_id, tool_call_id=tool_call_id)
        with controller.send_lock:
            with self._lock:
                # select() ran outside the send lock; revalidate the live
                # controller so disconnect/replacement can't strand a command.
                if self._controller_for_identity_locked(scope) is not controller or not controller.connected:
                    raise ControllerUnavailable(f"controller for scope {scope!r} detached before dispatch")
                pending.scope = controller.scope
                self._pending[command_id] = pending
            try:
                controller.send(frame)
            except Exception:
                # Never left the building: unreserve the id, surface the error.
                with self._lock:
                    self._pending.pop(command_id, None)
                raise

        if not pending.event.wait(timeout=self._command_timeout):
            with self._lock:
                # Event.wait() may return False at the exact boundary where a
                # completion already won and removed the pending command.
                timed_out = not pending.done and self._pending.get(command_id) is pending
                if timed_out:
                    pending.done = True
                    del self._pending[command_id]
            if timed_out:
                with controller.send_lock:
                    with self._lock:
                        active = self._controller_for_identity_locked(scope) or controller
                        if not active.connected:
                            self._defer_cancel_locked(active, pending)
                            active = None
                    if active is not None:
                        self._emit_cancel_frames(active, [pending])
                raise ControllerTimeout(f"controller did not complete command {command_id!r} within {self._command_timeout}s")
        if pending.cancelled:
            raise ControllerCancelled(f"command {command_id!r} was cancelled")
        if not pending.ok:
            raise ControllerRejected(f"controller rejected command {command_id!r}: {pending.result!r}")
        return pending.result

    def complete(self, command_id: str, *, scope: Optional[ControllerScope] = None, ok: bool, result: Any = None) -> bool:
        """Resolve a pending command by id; ``False`` when none is pending. Safe from inside the send callback."""
        with self._lock:
            pending = self._pending.get(command_id)
            if pending is None or pending.done or (scope is not None and pending.scope != scope):
                return False
            pending.done, pending.ok, pending.result = True, ok is True, result
            del self._pending[command_id]
            pending.event.set()
        return True

    def cancel(self, scope: ControllerScope, *, tool_call_id: Optional[str]) -> bool:
        """Cancel the pending command matching ``scope`` + tool_call_id (one cancel frame); ``False`` if none."""
        controller = self._live_controller(scope)
        if controller is None:
            return False
        with controller.send_lock:
            with self._lock:
                if self._controller_for_identity_locked(scope) is not controller or not controller.connected:
                    return False
                target = next((p for p in self._pending_for_scope_locked(scope)
                               if p.tool_call_id == tool_call_id and not p.done), None)
                if target is None:
                    return False
                self._resolve_pending(target, cancelled=True)
            self._emit_cancel_frames(controller, [target])
            return True

    def _resolve_pending(self, pending: _PendingCommand, *, cancelled: bool) -> None:
        pending.cancelled, pending.done = cancelled, True
        del self._pending[pending.command_id]
        pending.event.set()

    def _validate_artifact_reference(self, scope: ControllerScope, action: str, arguments: dict) -> None:
        """Fail closed unless ``arguments`` carries a store-approved artifact id (failures -> ControllerRejected)."""
        store = self._artifact_store_for_scope(scope)
        if store is None:
            raise ControllerRejected(f"{action} requires an attached artifact store")
        artifact_id = arguments.get("artifact_id")
        if not isinstance(artifact_id, str) or not artifact_id.strip():
            raise ControllerRejected(f"{action} requires a non-empty artifact_id")
        try:
            store.validate(artifact_id.strip(), scope=scope)
        except ControllerRejected:
            raise
        except Exception as exc:
            raise ControllerRejected(f"{action} rejected artifact reference {artifact_id!r}: {exc}") from exc

    def _defer_cancel_locked(self, controller: _Controller, pending: _PendingCommand) -> None:
        controller.deferred_cancels.append(_cancel_frame(pending))
        if len(controller.deferred_cancels) > MAX_DEFERRED_CANCELS:
            del controller.deferred_cancels[:-MAX_DEFERRED_CANCELS]

    def _pending_for_scope_locked(self, scope: ControllerScope) -> list[_PendingCommand]:
        return [p for p in list(self._pending.values()) if _same_scope_identity(p.scope, scope)]

    def _emit_cancel_frames(self, controller: _Controller, pendings: list[_PendingCommand]) -> None:
        """Send cancel frames (caller holds ``send_lock``, never the broker lock)."""
        for pending in pendings:
            try:
                controller.send(_cancel_frame(pending))
            except Exception:
                logger.exception("failed to emit cancel frame for command %r", pending.command_id)

    def _lane_scopes(self, session_id, task_id, principal_id, transport_family) -> list[ControllerScope]:
        """Attached scopes bound to one session lane (session + principal + transport)."""
        key = tuple(str(v or "").strip() for v in (session_id or task_id, principal_id, transport_family))
        if not all(key):
            return []
        with self._lock:
            return [s for s in self._controllers if (s.session_id, s.principal_id, s.transport_family) == key]

    def scope_for_session(self, *, session_id: Optional[str] = None, task_id: Optional[str] = None,
                          principal_id: Optional[str] = None, transport_family: Optional[str] = None) -> Optional[ControllerScope]:
        """One unambiguous attached scope for a server-owned session (session id is only a hint; the caller
        supplies its server-derived principal + transport family). Missing/ambiguous identity fails closed."""
        matches = self._lane_scopes(session_id, task_id, principal_id, transport_family)
        return matches[0] if len(matches) == 1 else None

    def lane_registered(self, *, session_id: Optional[str] = None, task_id: Optional[str] = None,
                        principal_id: Optional[str] = None, transport_family: Optional[str] = None) -> bool:
        """Whether ANY controller (even offline) registered for this lane: "bound but unavailable" fails closed
        vs "never registered" (caller keeps the legacy backend). Ambiguous lanes report True."""
        return bool(self._lane_scopes(session_id, task_id, principal_id, transport_family))

    def disconnect_owner(self, owner: Any) -> int:
        """Mark every controller owned by one lost transport offline."""
        with self._lock:
            scopes = [s for s, c in self._controllers.items() if c.owner is owner]
        return sum(int(self.disconnect(scope, owner=owner)) for scope in scopes)

    def reset(self) -> None:
        """Fail all live work closed and clear tickets (tests/shutdown)."""
        with self._lock:
            scopes = list(self._controllers)
        for scope in scopes:
            self.detach(scope)
        with self._lock:
            self._tickets.clear()
            # Pending entries whose controller a concurrent teardown removed.
            for pending in list(self._pending.values()):
                self._resolve_pending(pending, cancelled=True)

    @property
    def ticket_ttl_seconds(self) -> float:
        """Configured lifetime for newly minted one-shot tickets."""
        return self._ticket_ttl

    @property
    def pending_count(self) -> int:
        """Number of commands awaiting completion (diagnostics/tests)."""
        with self._lock:
            return len(self._pending)


_GLOBAL_BROKER = BrowserControlBroker()


def get_browser_control_broker() -> BrowserControlBroker:
    """Process-local broker shared by API and dashboard Gateway transports."""
    return _GLOBAL_BROKER
