from __future__ import annotations

import abc
import asyncio
import io
import logging
from collections.abc import Callable
from pathlib import Path
from types import ModuleType
from typing import Literal, Protocol, runtime_checkable
from urllib.request import Request, urlopen

from ..errors import WorkspaceReadNotFoundError
from .base_sandbox_session import BaseSandboxSession
from .events import EventPayloadPolicy, SandboxSessionEvent
from .utils import event_to_json_line

logger = logging.getLogger(__name__)

DeliveryMode = Literal["sync", "async", "best_effort"]
OnErrorPolicy = Literal["raise", "log", "ignore"]


def _unwrap_session_wrapper(session: BaseSandboxSession) -> BaseSandboxSession:
    """
    Defensive unwrapping: if a sink is accidentally bound to a SandboxSession wrapper,
    unwrap to the underlying session to avoid recursive event loops.
    """

    # Avoid importing session.sandbox_session.SandboxSession here
    # (would create a dependency cycle).
    cls = type(session)
    if not (
        cls.__name__ == "SandboxSession"
        and cls.__module__ == "agents.sandbox.session.sandbox_session"
    ):
        return session
    inner = getattr(session, "_inner", None)
    return inner if isinstance(inner, BaseSandboxSession) else session


class EventSink(abc.ABC):
    """Consumes SandboxSessionEvent objects (e.g., callback, file outbox, proxy HTTP)."""

    name: str | None = None
    mode: DeliveryMode
    on_error: OnErrorPolicy
    payload_policy: EventPayloadPolicy | None

    @abc.abstractmethod
    async def handle(self, event: SandboxSessionEvent) -> None: ...


@runtime_checkable
class SandboxSessionBoundSink(Protocol):
    """Optional interface for sinks that need access to the underlying SandboxSession."""

    def bind(self, session: BaseSandboxSession) -> None: ...


class CallbackSink(EventSink):
    """Deliver events to a user-provided callable.

    Supports sync or async callables.
    """

    def __init__(
        self,
        callback: Callable[[SandboxSessionEvent, BaseSandboxSession], object],
        *,
        mode: DeliveryMode = "sync",
        on_error: OnErrorPolicy = "raise",
        payload_policy: EventPayloadPolicy | None = None,
        name: str | None = None,
    ) -> None:
        self._callback = callback
        self.mode = mode
        self.on_error = on_error
        self.payload_policy = payload_policy
        self._session: BaseSandboxSession | None = None
        self.name = name

    def bind(self, session: BaseSandboxSession) -> None:
        self._session = _unwrap_session_wrapper(session)

    async def handle(self, event: SandboxSessionEvent) -> None:
        if self._session is None:
            raise RuntimeError(
                "CallbackSink requires a bound session; use SandboxSession / "
                "a sandbox client with instrumentation (or call bind(session))."
            )
        out = self._callback(event, self._session)
        if asyncio.iscoroutine(out):
            await out


class JsonlOutboxSink(EventSink):
    """Append events to a JSONL file on the host filesystem."""

    def __init__(
        self,
        path: Path,
        *,
        mode: DeliveryMode = "best_effort",
        on_error: OnErrorPolicy = "log",
        payload_policy: EventPayloadPolicy | None = None,
    ) -> None:
        self.path = path
        self.mode = mode
        self.on_error = on_error
        self.payload_policy = payload_policy

    async def handle(self, event: SandboxSessionEvent) -> None:
        line = event_to_json_line(event)
        await asyncio.to_thread(self._append_line, line)

    def _append_line(self, line: str) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        fcntl_mod: ModuleType | None
        try:
            import fcntl as fcntl_mod
        except Exception:
            # Not available on all platforms (e.g. Windows)
            fcntl_mod = None

        with self.path.open("a", encoding="utf-8") as f:
            if fcntl_mod is not None:
                try:
                    fcntl_mod.flock(f.fileno(), fcntl_mod.LOCK_EX)
                except Exception:
                    pass
            f.write(line)
            f.flush()
            if fcntl_mod is not None:
                try:
                    # Nice to have release here; the OS releases the lock
                    # automatically when the file is closed.
                    fcntl_mod.flock(f.fileno(), fcntl_mod.LOCK_UN)
                except Exception:
                    pass


class WorkspaceJsonlSink(EventSink):
    """
    Append events to a JSONL file inside the session workspace (under manifest.root).

    This sink still runs in the client process, but writes into the session via
    `SandboxSession.write()`, so it works across sandboxes (Docker/Modal)
    without requiring host-mounted volumes.
    """

    def __init__(
        self,
        *,
        workspace_relpath: Path = Path("logs/events-{session_id}.jsonl"),
        ephemeral: bool = False,
        mode: DeliveryMode = "best_effort",
        on_error: OnErrorPolicy = "log",
        payload_policy: EventPayloadPolicy | None = None,
        flush_every: int = 1,
    ) -> None:
        """
        Args:
            workspace_relpath: Relative path under the session workspace root.
                This also supports lightweight templating which is expanded on `bind()`:
                - `"{session_id}"` (UUID string, e.g. "550e8400-e29b-41d4-a716-446655440000")
                - `"{session_id_hex}"` (UUID hex, e.g. "550e8400e29b41d4a716446655440000")

                Example:
                    Path("logs/events-{session_id}.jsonl")
        """
        self.workspace_relpath = workspace_relpath
        self.ephemeral = ephemeral
        self.mode = mode
        self.on_error = on_error
        self.payload_policy = payload_policy
        self._session: BaseSandboxSession | None = None
        self._resolved_workspace_relpath: Path | None = None
        self._buf = bytearray()
        self._seen = 0
        self._lock = asyncio.Lock()
        self._flush_every = max(1, int(flush_every))

    def _resolve_relpath(self) -> Path:
        rel = self.workspace_relpath
        if self._session is None:
            return rel
        template = str(rel)
        try:
            rendered = template.format(
                session_id=self._session.state.session_id,
                session_id_hex=self._session.state.session_id.hex,
            )
        except Exception:
            # If formatting fails for any reason, fall back to the literal path.
            rendered = template
        return Path(rendered)

    def bind(self, session: BaseSandboxSession) -> None:
        self._session = _unwrap_session_wrapper(session)
        self._resolved_workspace_relpath = self._resolve_relpath()
        if self.ephemeral:
            relpath = self._resolved_workspace_relpath or self.workspace_relpath
            self._session.register_persist_workspace_skip_path(relpath)

    def _buffer_event(self, event: SandboxSessionEvent) -> bool:
        self._buf.extend(event_to_json_line(event).encode("utf-8"))
        self._seen += 1

        if self._seen % self._flush_every == 0:
            return True
        if event.op == "persist_workspace" and event.phase == "start":
            return True
        if event.op == "stop":
            return True
        if event.op == "shutdown" and event.phase == "start":
            return True
        if event.op == "shutdown" and event.phase == "finish":
            return False

        return False

    async def _can_flush_to_workspace(self) -> bool:
        if self._session is None:
            return False

        # `SandboxSession.start()` emits the `start` event before the underlying sandbox
        # is fully running, so writes may still fail during early startup or late teardown.
        try:
            return await self._session.running()
        except Exception:
            return False

    async def _flush_buffer(self) -> None:
        if self._session is None or not self._buf:
            return

        relpath = self._resolved_workspace_relpath or self.workspace_relpath
        existing = await self._read_existing_outbox(relpath)
        pending = bytes(self._buf)
        await self._session.write(relpath, io.BytesIO(existing + pending))
        self._buf.clear()

    async def _read_existing_outbox(self, relpath: Path) -> bytes:
        if self._session is None:
            return b""

        try:
            existing = await self._session.read(relpath)
        except (FileNotFoundError, WorkspaceReadNotFoundError):
            return b""

        try:
            payload = existing.read()
        finally:
            existing.close()

        if isinstance(payload, str):
            return payload.encode("utf-8")
        return bytes(payload)

    async def handle(self, event: SandboxSessionEvent) -> None:
        # If unbound (e.g., audit event emission used without a SandboxSession wrapper),
        # no-op.
        if self._session is None:
            return

        async with self._lock:
            if not self._buffer_event(event):
                return

            if not await self._can_flush_to_workspace():
                return

            await self._flush_buffer()


class HttpProxySink(EventSink):
    """POST events as JSON to a proxy endpoint (local daemon or remote service)."""

    def __init__(
        self,
        endpoint: str,
        *,
        headers: dict[str, str] | None = None,
        timeout_s: float = 5.0,
        spool_path: Path | None = None,
        mode: DeliveryMode = "best_effort",
        on_error: OnErrorPolicy = "log",
        payload_policy: EventPayloadPolicy | None = None,
    ) -> None:
        self.endpoint = endpoint
        self.headers = headers or {}
        self.timeout_s = timeout_s
        self.spool_path = spool_path
        self.mode = mode
        self.on_error = on_error
        self.payload_policy = payload_policy

    async def handle(self, event: SandboxSessionEvent) -> None:
        payload = event.model_dump_json().encode("utf-8")
        spool_line = event_to_json_line(event) if self.spool_path is not None else None
        await asyncio.to_thread(self._post, payload, spool_line)

    def _post(self, body: bytes, spool_line: str | None) -> None:
        # TODO: thinking about using proxy instead of direct http call
        req = Request(
            self.endpoint,
            data=body,
            headers={"content-type": "application/json", **self.headers},
            method="POST",
        )
        try:
            with urlopen(req, timeout=self.timeout_s) as resp:
                _ = resp.read(1)  # ensure request completes
        except OSError as e:
            if spool_line is not None and self.spool_path is not None:
                try:
                    self.spool_path.parent.mkdir(parents=True, exist_ok=True)
                    with self.spool_path.open("a", encoding="utf-8") as f:
                        f.write(spool_line)
                        f.flush()
                except Exception:
                    pass
            raise RuntimeError(f"http proxy sink POST failed: {e}") from e


class ChainedSink(EventSink):
    """
    Groups multiple sinks that should run in order.

    Note: Instrumentation unwraps this group and applies per-op/per-sink
    payload policies to each inner sink individually (so grouping does not disable
    per-sink policy behavior).
    """

    def __init__(self, *sinks: EventSink) -> None:
        self.sinks = list(sinks)
        # These are not used directly when Instrumentation unwraps the
        # group, but keep the object conforming to EventSink.
        self.mode = "sync"
        self.on_error = "raise"
        self.payload_policy = None

    async def handle(self, event: SandboxSessionEvent) -> None:
        # Fallback behavior if used directly (without Instrumentation unwrapping).
        for sink in self.sinks:
            await sink.handle(event)
