"""
Agent Bridge — Calls 3rd party agents (local sandboxed or remote hosted).

Two modes:
  - Local agents: Downloaded from marketplace, run in sandbox on user's machine.
    Called via HTTP to localhost:{port}.
  - Remote agents: Hosted by the creator on their own servers. Called via HTTPS
    with marketplace auth token. Creator keeps code private, we take 30% commission.

Both modes return a standardised BridgeResponse.

All stdlib — no external packages.
"""

from __future__ import annotations

import json
import logging
import ssl
import time
import urllib.error
import urllib.request
from typing import Any, Optional

from .local_bridge import BridgeResponse, _estimate_tokens

log = logging.getLogger("brynq.runtime.agent_bridge")

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_LOCAL_TIMEOUT = 60  # seconds — local agents should be fast
_REMOTE_TIMEOUT = 120  # seconds — remote agents may need more time
_CONNECT_TIMEOUT = 10  # seconds — fail fast if agent is unreachable
_HEALTH_TIMEOUT = 3  # seconds — quick health check before calling agent
_MAX_RETRIES = 2  # default retry count for transient failures
_RETRY_BACKOFF = (1, 4)  # seconds — exponential backoff per retry


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _build_ssl_context() -> ssl.SSLContext:
    """Return a default SSL context for HTTPS requests."""
    return ssl.create_default_context()


def _http_post(
    url: str,
    body: dict,
    headers: Optional[dict[str, str]] = None,
    timeout: int = _LOCAL_TIMEOUT,
) -> dict:
    """Make an HTTP POST request and return parsed JSON.

    Raises RuntimeError on HTTP or network errors.
    """
    hdrs: dict[str, str] = {
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
    if headers:
        hdrs.update(headers)

    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(url, data=data, headers=hdrs, method="POST")
    context = _build_ssl_context() if url.startswith("https://") else None

    try:
        with urllib.request.urlopen(req, timeout=timeout, context=context) as resp:
            raw = resp.read().decode("utf-8")
            return json.loads(raw) if raw.strip() else {}
    except urllib.error.HTTPError as exc:
        detail = ""
        try:
            detail = exc.read().decode("utf-8", errors="replace")
        except Exception:
            pass
        raise RuntimeError(f"Agent returned HTTP {exc.code}: {detail}") from exc
    except urllib.error.URLError as exc:
        raise RuntimeError(f"Cannot reach agent at {url}: {exc.reason}") from exc


def _is_transient_error(exc: Exception) -> bool:
    """Return True if the error is transient and worth retrying."""
    if isinstance(exc, RuntimeError):
        msg = str(exc)
        # HTTP 5xx or 429 are transient
        for code in ("500", "502", "503", "504", "429"):
            if f"HTTP {code}" in msg:
                return True
        # Connection failures are transient
        if "Cannot reach agent" in msg:
            return True
    return False


def _http_post_with_retry(
    url: str,
    body: dict,
    headers: Optional[dict[str, str]] = None,
    timeout: int = _LOCAL_TIMEOUT,
    max_retries: int = _MAX_RETRIES,
) -> dict:
    """Wrap _http_post with retry on transient failures using exponential backoff."""
    last_exc: Optional[Exception] = None
    for attempt in range(max_retries + 1):
        try:
            return _http_post(url, body, headers=headers, timeout=timeout)
        except RuntimeError as exc:
            last_exc = exc
            if attempt < max_retries and _is_transient_error(exc):
                delay = _RETRY_BACKOFF[attempt] if attempt < len(_RETRY_BACKOFF) else _RETRY_BACKOFF[-1]
                log.warning(
                    "Transient error (attempt %d/%d), retrying in %ds: %s",
                    attempt + 1, max_retries + 1, delay, exc,
                )
                time.sleep(delay)
            else:
                raise
    raise last_exc  # type: ignore[misc]


def _probe_local_agent(endpoint: str) -> bool:
    """Check if a local agent is running at the given endpoint.

    Tries a GET to the endpoint's health path. Returns True if responsive.
    """
    # Normalise endpoint
    base = endpoint.rstrip("/")
    if not base.startswith("http"):
        base = f"http://{base}"

    # Try /health first, then root
    for path in ("/health", "/"):
        try:
            url = f"{base}{path}"
            req = urllib.request.Request(url, method="GET")
            with urllib.request.urlopen(req, timeout=_CONNECT_TIMEOUT):
                return True
        except Exception:
            continue

    return False


def _validate_endpoint_url(url: str) -> None:
    """Validate that an agent endpoint URL uses http:// or https://.

    Raises ValueError if the URL does not start with a valid scheme.
    """
    if not url.startswith(("http://", "https://")):
        raise ValueError(
            f"Invalid agent endpoint URL: {url!r}. "
            "URL must start with http:// or https://."
        )


def _health_check(base_url: str) -> bool:
    """Send GET /health with a 3-second timeout.

    Returns True if the agent responds (any 2xx), False otherwise.
    """
    url = f"{base_url.rstrip('/')}/health"
    context = _build_ssl_context() if url.startswith("https://") else None
    try:
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=_HEALTH_TIMEOUT, context=context):
            return True
    except Exception:
        return False


# ---------------------------------------------------------------------------
# Agent Bridge
# ---------------------------------------------------------------------------


import os
from pathlib import Path

def get_installed_agent_endpoint(agent_id: str) -> Optional[dict]:
    """Read the installed agent's manifest and return endpoint or mcp_command info."""
    manifest_path = Path.home() / ".brynq" / "agents" / agent_id / "manifest.json"
    if not manifest_path.exists():
        return None
    try:
        with open(manifest_path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception as e:
        log.error(f"Failed to read manifest for {agent_id}: {e}")
        return None

class AgentBridge:
    """Bridge to 3rd party agents, both local and remote.

    Local agents run in a sandbox on the user's machine and are called
    via HTTP to localhost:{port}. Remote agents run on the creator's
    servers and are called via HTTPS with a marketplace auth token.
    """

    def call_installed_agent(self, agent_id: str, prompt: str, config: Optional[dict] = None) -> BridgeResponse:
        """Load manifest, determine install_type, and route to call_local or call_remote."""
        manifest = get_installed_agent_endpoint(agent_id)
        if not manifest:
            return BridgeResponse(
                model=agent_id,
                backend="agent",
                success=False,
                error=f"Agent '{agent_id}' is not installed locally."
            )
        
        # Check for paid agent
        price = manifest.get("price_per_call", 0)
        if float(price) > 0:
            # Check if user has a payment method (stubbed for now)
            has_payment_method = os.environ.get("BRYNQ_HAS_PAYMENT_METHOD", "false").lower() == "true"
            if not has_payment_method:
                return BridgeResponse(
                    model=agent_id,
                    backend="agent",
                    success=False,
                    error=f"Agent '{agent_id}' requires payment (${price} per call). Please add a payment method at brynq.ai/settings."
                )

        install_type = manifest.get("install_type")
        if install_type == "mcp_stdio":
            # Pass the mcp command through config
            cfg = config or {}
            cfg["mcp_command"] = manifest.get("mcp_command")
            return self.call_local(agent_id, "mcp_stdio", prompt, cfg)
        elif install_type == "local_agent":
            endpoint = manifest.get("endpoint", "http://localhost:9100")
            return self.call_local(agent_id, endpoint, prompt, config)
        elif install_type == "remote_agent":
            endpoint = manifest.get("endpoint")
            # For remote agents, we would need the auth token, maybe stored in credentials
            # Mocking auth token for this integration step
            auth_token = os.environ.get("BRYNQ_MARKETPLACE_TOKEN", "dummy-token")
            return self.call_remote(agent_id, endpoint, auth_token, prompt, config)
        else:
            return BridgeResponse(
                model=agent_id,
                backend="agent",
                success=False,
                error=f"Unknown install_type '{install_type}' for agent '{agent_id}'."
            )

    def call_local(
        self,
        agent_id: str,
        endpoint: str,
        prompt: str,
        config: Optional[dict] = None,
    ) -> BridgeResponse:
        """Call a local (sandboxed) agent.

        The agent is expected to expose a JSON endpoint that accepts:
            {"prompt": "...", "config": {...}}
        and returns:
            {"output": "...", "metadata": {...}}

        Args:
            agent_id: Marketplace agent identifier.
            endpoint: Local endpoint (e.g. "localhost:9100" or "mcp_stdio").
            prompt: The prompt to send to the agent.
            config: Optional config dict (forwarded to agent).

        Returns:
            BridgeResponse with the agent's output or error details.
        """
        config = config or {}
        response = BridgeResponse(
            model=agent_id,
            backend="local_agent",
        )
        t0 = time.monotonic()
        request_size = 0
        response_size = 0
        call_url = endpoint

        try:
            if endpoint == "mcp_stdio" or config.get("mcp_command"):
                # Handle mcp_stdio
                mcp_command = config.get("mcp_command")
                if not mcp_command:
                    raise RuntimeError("Missing mcp_command for mcp_stdio agent")
                
                # Lazy import
                import sys
                src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
                if src_path not in sys.path:
                    sys.path.insert(0, src_path)
                
                from runtime.mcp_adapter import MCPServerConnection, MCPAgentAdapter, MCPError, MCPTransportError, MCPTimeoutError
                
                try:
                    conn = MCPServerConnection(command=mcp_command, sandbox=True)
                    conn.connect()
                except Exception as e:
                    pkg = mcp_command[1] if len(mcp_command) > 1 else mcp_command[0]
                    if "-y" in mcp_command:
                        pkg = mcp_command[mcp_command.index("-y") + 1]
                    err_msg = f"MCP connection failed: {e}. Make sure {pkg} is installed. Try: npx -y {pkg} --help"
                    log.error(err_msg)
                    raise RuntimeError(err_msg)
                    
                adapter = MCPAgentAdapter(conn, agent_id=agent_id, name=agent_id)
                
                # Execute via adapter
                output_dict = adapter.execute(prompt, config=config)
                output = output_dict.get("output", "")
                
                # Cleanup
                conn.close()

                response.output = output
                response.success = True
                response.token_estimate = _estimate_tokens(output)
                response.elapsed_seconds = time.monotonic() - t0
                return response

            # Normalise endpoint URL
            base = endpoint.rstrip("/")
            if not base.startswith("http"):
                base = f"http://{base}"

            # Validate endpoint URL scheme
            _validate_endpoint_url(base)

            # Health check — skip agent if unresponsive
            if not _health_check(base):
                log.warning(
                    "Health check failed for local agent '%s' at %s — skipping",
                    agent_id, base,
                )
                response.success = False
                response.error = (
                    f"Local agent '{agent_id}' failed health check at {base}. "
                    "Skipping."
                )
                response.elapsed_seconds = time.monotonic() - t0
                return response

            # Build request body
            body: dict[str, Any] = {
                "prompt": prompt,
            }
            if config:
                body["config"] = config

            # Determine the call URL. If the endpoint already contains a path
            # beyond the host:port, use it directly. Otherwise, try standard paths.
            url = base
            if base.count("/") <= 2:
                # Just host:port, no specific path — use the standard agent path
                url = f"{base}/v1/chat"
            call_url = url

            request_size = len(json.dumps(body).encode("utf-8"))
            resp = _http_post_with_retry(url, body, timeout=_LOCAL_TIMEOUT)
            response_size = len(json.dumps(resp, default=str).encode("utf-8"))

            # Validate response schema — 'output' field is required
            schema_error = _validate_response_schema(resp, agent_id)
            if schema_error is not None:
                schema_error.backend = "local_agent"
                schema_error.elapsed_seconds = time.monotonic() - t0
                return schema_error

            # Extract output — agents should return {"output": "..."} but we
            # also handle {"response": "..."} and {"message": "..."} and
            # {"choices": [{"message": {"content": "..."}}]} for flexibility.
            output = _extract_output(resp)

            response.output = output
            response.success = True
            response.token_estimate = _estimate_tokens(output)
            response.metadata = resp.get("metadata", {})

        except RuntimeError as exc:
            response.success = False
            response.error = f"Agent error: {exc}"
            log.error("Agent bridge (local) runtime error for '%s': %s", agent_id, exc)

        except Exception as exc:
            response.success = False
            response.error = f"Unexpected error: {exc}"
            log.error(
                "Agent bridge (local) unexpected error for '%s': %s",
                agent_id, exc, exc_info=True,
            )

        finally:
            response.elapsed_seconds = time.monotonic() - t0
            log.info(
                "Agent call (local) agent=%s url=%s request_bytes=%d "
                "response_bytes=%d elapsed=%.3fs",
                agent_id, call_url, request_size, response_size,
                response.elapsed_seconds,
            )

        return response

    def call_remote(
        self,
        agent_id: str,
        endpoint: str,
        auth_token: str,
        prompt: str,
        config: Optional[dict] = None,
    ) -> BridgeResponse:
        """Call a remote (creator-hosted) agent.

        The agent is expected to expose an HTTPS endpoint that accepts:
            {"prompt": "...", "config": {...}}
        with Authorization header, and returns:
            {"output": "...", "metadata": {...}}

        Args:
            agent_id: Marketplace agent identifier.
            endpoint: Full HTTPS URL (e.g. "https://api.acme-agents.com/v1/review").
            auth_token: Marketplace auth token for this agent.
            prompt: The prompt to send to the agent.
            config: Optional config dict (forwarded to agent).

        Returns:
            BridgeResponse with the agent's output or error details.
        """
        config = config or {}
        response = BridgeResponse(
            model=agent_id,
            backend="remote_agent",
        )
        t0 = time.monotonic()
        request_size = 0
        response_size = 0

        try:
            if not auth_token:
                raise RuntimeError(
                    f"No auth token for remote agent '{agent_id}'. "
                    "Install the agent from the marketplace first."
                )

            # Validate endpoint URL scheme
            _validate_endpoint_url(endpoint)

            # Validate endpoint is HTTPS for remote agents
            if not endpoint.startswith("https://"):
                raise RuntimeError(
                    f"Remote agent endpoint must use HTTPS. Got: {endpoint}"
                )

            # Health check — skip agent if unresponsive
            # Derive base URL from endpoint for health check
            remote_base = endpoint.rstrip("/").rsplit("/", 1)[0] if endpoint.count("/") > 3 else endpoint.rstrip("/")
            if not _health_check(remote_base):
                log.warning(
                    "Health check failed for remote agent '%s' at %s — skipping",
                    agent_id, remote_base,
                )
                response.success = False
                response.error = (
                    f"Remote agent '{agent_id}' failed health check at {remote_base}. "
                    "Skipping."
                )
                response.elapsed_seconds = time.monotonic() - t0
                return response

            # Build request body
            body: dict[str, Any] = {
                "prompt": prompt,
            }
            if config:
                body["config"] = config

            # Make the call with auth header
            request_size = len(json.dumps(body).encode("utf-8"))
            resp = _http_post_with_retry(
                endpoint,
                body,
                headers={
                    "Authorization": f"Bearer {auth_token}",
                    "X-Brynq-Agent-Id": agent_id,
                },
                timeout=_REMOTE_TIMEOUT,
            )
            response_size = len(json.dumps(resp, default=str).encode("utf-8"))

            # Validate response schema — 'output' field is required
            schema_error = _validate_response_schema(resp, agent_id)
            if schema_error is not None:
                schema_error.backend = "remote_agent"
                schema_error.elapsed_seconds = time.monotonic() - t0
                return schema_error

            # Extract output
            output = _extract_output(resp)

            response.output = output
            response.success = True
            response.token_estimate = _estimate_tokens(output)
            response.metadata = resp.get("metadata", {})

        except RuntimeError as exc:
            response.success = False
            error_str = str(exc)
            if "401" in error_str or "403" in error_str:
                response.error = (
                    f"Auth failed for agent '{agent_id}'. "
                    "Your marketplace token may have expired."
                )
            elif "404" in error_str:
                response.error = (
                    f"Agent '{agent_id}' not found at {endpoint}. "
                    "The agent may have been removed by its creator."
                )
            elif "429" in error_str:
                response.error = (
                    f"Rate limit exceeded for agent '{agent_id}'. "
                    "Try again in a moment."
                )
            else:
                response.error = f"Remote agent error: {exc}"
            log.error("Agent bridge (remote) error for '%s': %s", agent_id, exc)

        except Exception as exc:
            response.success = False
            response.error = f"Unexpected error: {exc}"
            log.error(
                "Agent bridge (remote) unexpected error for '%s': %s",
                agent_id, exc, exc_info=True,
            )

        finally:
            response.elapsed_seconds = time.monotonic() - t0
            log.info(
                "Agent call (remote) agent=%s url=%s request_bytes=%d "
                "response_bytes=%d elapsed=%.3fs",
                agent_id, endpoint, request_size, response_size,
                response.elapsed_seconds,
            )

        return response


def _validate_response_schema(resp: dict, agent_id: str) -> Optional[BridgeResponse]:
    """Validate that an agent response contains the required 'output' field.

    Returns None if valid, or a synthetic error BridgeResponse if the
    'output' field is missing.
    """
    if "output" not in resp:
        keys = list(resp.keys()) if resp else []
        log.warning(
            "Agent '%s' response missing required 'output' field. "
            "Keys present: %s",
            agent_id, keys,
        )
        error_resp = BridgeResponse(
            model=agent_id,
            backend="agent",
        )
        error_resp.success = False
        error_resp.error = (
            f"Agent '{agent_id}' returned invalid response: "
            f"missing required 'output' field. Keys present: {keys}"
        )
        return error_resp
    return None


def _extract_output(resp: dict) -> str:
    """Extract text output from an agent response dict.

    Agents may return output in different formats. We try several
    common patterns in priority order.
    """
    # Standard: {"output": "..."}
    if "output" in resp:
        return str(resp["output"])

    # Alternative: {"response": "..."}
    if "response" in resp:
        return str(resp["response"])

    # Alternative: {"message": "..."}
    if "message" in resp and isinstance(resp["message"], str):
        return resp["message"]

    # OpenAI-compatible: {"choices": [{"message": {"content": "..."}}]}
    choices = resp.get("choices", [])
    if choices and isinstance(choices, list):
        content = choices[0].get("message", {}).get("content", "")
        if content:
            return str(content)

    # Alternative: {"result": "..."}
    if "result" in resp:
        return str(resp["result"])

    # Last resort: dump the whole response
    if resp:
        return json.dumps(resp, default=str)

    return ""
