"""Rippletide SDK (Python) — thin client for the local Rippletide platform.

Design contract, in order of importance:

1. Never break the host agent. Every method returns a usable value even when
   the platform is down, slow, or errors — it falls back to the values the
   code already defines.
2. Seed on first use. Code-defined prompts/tools/connections are pushed to
   the platform the first time the agent runs, so the platform UI is
   populated from reality without manual copying.
3. Standard library only, single file. Install by copying this file (or
   adding this directory to sys.path / pip installing it as a path package).

Synchronous by design: calls are sub-millisecond against a local platform
and time out fast (1.5s default) when it is unreachable.
"""

from __future__ import annotations

import json
import os
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List, Optional, Union

__all__ = ["Rippletide", "ManagedPrompt", "ManagedConnection"]

_DEFAULT_BASE_URL = "http://127.0.0.1:4747"
_DEFAULT_TIMEOUT_S = 1.5
_DEFAULT_CACHE_TTL_S = 5.0

_VARIABLE_PATTERN = re.compile(r"\{\{\s*([\w.-]+)\s*\}\}")

Prompt = Union[str, List[Dict[str, str]]]


def _compile_template(text: str, variables: Optional[Dict[str, Any]]) -> str:
    if not variables:
        return text
    return _VARIABLE_PATTERN.sub(
        lambda match: str(variables[match.group(1)]) if match.group(1) in variables else match.group(0),
        text,
    )


class ManagedPrompt:
    """A prompt resolved from the platform (or built from the code fallback)."""

    def __init__(
        self,
        name: str,
        type: str,
        prompt: Prompt,
        config: Optional[Dict[str, Any]] = None,
        version: Optional[int] = None,
        labels: Optional[List[str]] = None,
        is_fallback: bool = False,
    ) -> None:
        self.name = name
        self.type = type
        self.config: Dict[str, Any] = config or {}
        self.version = version
        self.labels = labels or []
        self.is_fallback = is_fallback
        if type == "chat":
            self.messages: List[Dict[str, str]] = prompt  # type: ignore[assignment]
            self.text: Optional[str] = None
        else:
            self.text = prompt  # type: ignore[assignment]
            self.messages = []

    def compile(self, **variables: Any) -> Prompt:
        """Substitutes {{variable}} placeholders. Unknown placeholders are kept."""
        if self.type == "chat":
            return [
                {**message, "content": _compile_template(message["content"], variables)}
                for message in self.messages
            ]
        return _compile_template(self.text or "", variables)

    def __str__(self) -> str:
        return json.dumps(self.messages) if self.type == "chat" else (self.text or "")


class ManagedConnection:
    """A third-party connection resolved from the platform (or from env vars)."""

    def __init__(
        self,
        provider: str,
        enabled: bool,
        config: Optional[Dict[str, str]] = None,
        secrets: Optional[Dict[str, str]] = None,
        is_fallback: bool = False,
    ) -> None:
        self.provider = provider
        self.enabled = enabled
        self.config = config or {}
        self.secrets = secrets or {}
        self.is_fallback = is_fallback

    def secret(self, key: str = "token") -> Optional[str]:
        """Returns a secret value, or None when absent (or an unfilled
        placeholder) or the connection is off."""
        if not self.enabled:
            return None
        return self.secrets.get(key) or None


class Rippletide:
    """Client for one app namespace on the local Rippletide platform."""

    def __init__(
        self,
        app: Optional[str] = None,
        base_url: Optional[str] = None,
        timeout_s: Optional[float] = None,
        cache_ttl_s: float = _DEFAULT_CACHE_TTL_S,
        disabled: Optional[bool] = None,
        quiet: Optional[bool] = None,
        launch: Optional[Dict[str, Any]] = None,
        description: Optional[str] = None,
    ) -> None:
        """`launch` tells the platform how to run this agent with a prompt,
        e.g. {"command": 'python run_agent.py {{prompt}}'}. `{{prompt}}` is
        replaced with the (shell-quoted) prompt; the prompt is also exported
        as $RIPPLETIDE_PROMPT. `cwd` defaults to the current directory."""
        self.app = app or os.environ.get("RIPPLETIDE_APP")
        if not self.app:
            raise ValueError("Rippletide: an `app` name is required.")
        self.base_url = (base_url or os.environ.get("RIPPLETIDE_BASE_URL") or _DEFAULT_BASE_URL).rstrip("/")
        env_timeout = os.environ.get("RIPPLETIDE_TIMEOUT_MS")
        self.timeout_s = timeout_s if timeout_s is not None else (
            float(env_timeout) / 1000 if env_timeout else _DEFAULT_TIMEOUT_S
        )
        self.cache_ttl_s = cache_ttl_s
        self.disabled = disabled if disabled is not None else os.environ.get("RIPPLETIDE_DISABLED") == "1"
        self.quiet = quiet if quiet is not None else os.environ.get("RIPPLETIDE_QUIET") == "1"
        self._agent_meta: Optional[Dict[str, Any]] = None
        if launch or description:
            self._agent_meta = {"description": description}
            if launch:
                self._agent_meta["launch"] = {"cwd": os.getcwd(), **launch}
        self._registered = False
        self._cache: Dict[str, Any] = {}
        self._warned: set = set()

    def _ensure_registered(self) -> None:
        """Registers agent metadata (description + launch command) once per
        process, so the app shows up launchable on the platform. Never raises."""
        if self._registered or not self._agent_meta or self.disabled:
            return
        self._registered = True
        try:
            self._request("POST", "/agent", {"seed": True, **self._agent_meta})
        except Exception:  # noqa: BLE001 — registration must never break the host
            pass

    # -- prompts ------------------------------------------------------------

    def prompt(
        self,
        name: str,
        fallback: Optional[Prompt] = None,
        config: Optional[Dict[str, Any]] = None,
        label: Optional[str] = None,
        version: Optional[int] = None,
        type: Optional[str] = None,
    ) -> ManagedPrompt:
        """Fetches a managed prompt, seeding the platform with the code-defined
        fallback on first use. Resolution: version > label > "production" label
        > latest version. On any failure returns the fallback."""
        prompt_type = type or ("chat" if isinstance(fallback, list) else "text")

        def build_fallback() -> ManagedPrompt:
            if fallback is None:
                raise RuntimeError(
                    f'Rippletide: prompt "{name}" is unavailable and no fallback was provided.'
                )
            return ManagedPrompt(name, prompt_type, fallback, config, is_fallback=True)

        if self.disabled:
            return build_fallback()
        self._ensure_registered()
        cache_key = f"prompt:{name}:{label}:{version}"
        try:
            if fallback is not None:
                resolved = self._cached(
                    cache_key,
                    lambda: self._request(
                        "POST",
                        f"/prompts/{urllib.parse.quote(name, safe='')}",
                        {
                            "seed": True,
                            "type": prompt_type,
                            "prompt": fallback,
                            "config": config,
                            "label": label,
                            "version": version,
                            "source": "sdk",
                        },
                    )["resolved"],
                )
            else:
                query: Dict[str, str] = {}
                if label:
                    query["label"] = label
                if version is not None:
                    query["version"] = str(version)
                resolved = self._cached(
                    cache_key,
                    lambda: self._request(
                        "GET",
                        f"/prompts/{urllib.parse.quote(name, safe='')}/resolve"
                        f"?{urllib.parse.urlencode(query)}",
                    ),
                )
            return ManagedPrompt(
                name,
                resolved["type"],
                resolved["prompt"],
                resolved.get("config"),
                resolved.get("version"),
                resolved.get("labels"),
            )
        except Exception as error:  # noqa: BLE001 — fallback must never raise
            self._warn(f"prompt:{name}", f'prompt "{name}"', error)
            return build_fallback()

    # -- tools ---------------------------------------------------------------

    def tools(self, set_name: str, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """Syncs a tool list with the platform and returns it with the user's
        overlay applied: disabled tools removed, descriptions and parameter
        schemas reflecting platform edits. Accepts OpenAI function tools,
        Anthropic tools, or neutral {name, description, parameters} dicts;
        preserves shape and order. Returns the input unchanged on failure."""
        if self.disabled or not tools:
            return tools
        self._ensure_registered()
        try:
            entries = [_describe_tool(tool) for tool in tools]
            payload = {"tools": [entry["iface"] for entry in entries if entry["iface"]]}
            if not payload["tools"]:
                return tools
            cache_key = f"tools:{set_name}:{json.dumps(payload, sort_keys=True)}"
            resolved = self._cached(
                cache_key,
                lambda: self._request(
                    "POST", f"/toolsets/{urllib.parse.quote(set_name, safe='')}/sync", payload
                )["tools"],
            )
            by_name = {tool["name"]: tool for tool in resolved}
            output: List[Dict[str, Any]] = []
            for entry in entries:
                overlay = by_name.get(entry["iface"]["name"]) if entry["iface"] else None
                if overlay is None:
                    output.append(entry["tool"])  # unrecognized shape: pass through
                    continue
                if not overlay["enabled"]:
                    continue
                output.append(_apply_overlay(entry, overlay))
            return output
        except Exception as error:  # noqa: BLE001 — fallback must never raise
            self._warn(f"tools:{set_name}", f'toolset "{set_name}"', error)
            return tools

    # -- connections ----------------------------------------------------------

    def connection(
        self,
        provider: str,
        env: Optional[Dict[str, str]] = None,
        config: Optional[Dict[str, str]] = None,
    ) -> ManagedConnection:
        """Fetches a third-party connection (github, notion, linear, ...),
        seeding it on first use. Secrets known to the platform win; keys the
        platform does not have fall back to the mapped environment variables.

        `env` maps secret keys to env var names, e.g. {"token": "GITHUB_TOKEN"}.
        """
        # Declared keys without an env value seed as empty placeholders so the
        # platform UI shows the expected key name, ready to be filled in.
        env_values = {
            key: os.environ.get(env_name, "")
            for key, env_name in (env or {}).items()
        }
        if self.disabled:
            return ManagedConnection(provider, True, config, env_values, is_fallback=True)
        self._ensure_registered()
        try:
            record = self._cached(
                f"connection:{provider}",
                lambda: self._request(
                    "POST",
                    f"/connections/{urllib.parse.quote(provider, safe='')}",
                    {"seed": True, "config": config, "secrets": env_values},
                ),
            )
            return ManagedConnection(
                provider,
                record["enabled"],
                {**(config or {}), **record.get("config", {})},
                {**env_values, **record.get("secrets", {})},
            )
        except Exception as error:  # noqa: BLE001 — fallback must never raise
            self._warn(f"connection:{provider}", f'connection "{provider}"', error)
            return ManagedConnection(provider, True, config, env_values, is_fallback=True)

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

    def _request(self, method: str, api_path: str, body: Optional[Dict[str, Any]] = None) -> Any:
        url = f"{self.base_url}/api/apps/{urllib.parse.quote(self.app, safe='')}{api_path}"
        data = json.dumps(body).encode("utf-8") if body is not None else None
        request = urllib.request.Request(
            url,
            data=data,
            method=method,
            headers={"Content-Type": "application/json"} if data else {},
        )
        try:
            with urllib.request.urlopen(request, timeout=self.timeout_s) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as error:
            try:
                message = json.loads(error.read().decode("utf-8")).get("error", "")
            except Exception:  # noqa: BLE001
                message = ""
            raise RuntimeError(message or f"HTTP {error.code}") from error

    def _cached(self, key: str, load):
        hit = self._cache.get(key)
        now = time.monotonic()
        if hit and now - hit[0] < self.cache_ttl_s:
            return hit[1]
        try:
            value = load()
        except Exception:
            if hit:
                return hit[1]  # stale-on-error beats falling back entirely
            raise
        self._cache[key] = (now, value)
        return value

    def _warn(self, key: str, what: str, error: Exception) -> None:
        if self.quiet or key in self._warned:
            return
        self._warned.add(key)
        print(
            f"[rippletide] falling back to code-defined {what} "
            f"(platform at {self.base_url} said: {error})",
            flush=True,
        )


def _is_plain_json(value: Any) -> bool:
    if value is None or isinstance(value, (str, int, float, bool)):
        return True
    if isinstance(value, list):
        return all(_is_plain_json(item) for item in value)
    if isinstance(value, dict):
        return all(isinstance(key, str) and _is_plain_json(item) for key, item in value.items())
    return False


def _describe_tool(tool: Any) -> Dict[str, Any]:
    """Extracts the model-facing interface of one tool, remembering its shape."""
    if isinstance(tool, dict) and tool.get("type") == "function" and isinstance(tool.get("function"), dict):
        function = tool["function"]
        params = function.get("parameters")
        serializable = _is_plain_json(params)
        return {
            "tool": tool,
            "shape": "openai",
            "params_serializable": serializable,
            "iface": {
                "name": function.get("name", ""),
                "description": function.get("description", ""),
                "parameters": params if serializable else None,
            },
        }
    if isinstance(tool, dict) and isinstance(tool.get("name"), str) and "input_schema" in tool:
        params = tool.get("input_schema")
        serializable = _is_plain_json(params)
        return {
            "tool": tool,
            "shape": "anthropic",
            "params_serializable": serializable,
            "iface": {
                "name": tool["name"],
                "description": tool.get("description", ""),
                "parameters": params if serializable else None,
            },
        }
    if isinstance(tool, dict) and isinstance(tool.get("name"), str):
        params = tool.get("parameters", tool.get("inputSchema"))
        serializable = _is_plain_json(params)
        return {
            "tool": tool,
            "shape": "neutral",
            "params_serializable": serializable,
            "iface": {
                "name": tool["name"],
                "description": tool.get("description", ""),
                "parameters": params if serializable else None,
            },
        }
    return {"tool": tool, "shape": "unknown", "params_serializable": False, "iface": None}


def _apply_overlay(entry: Dict[str, Any], overlay: Dict[str, Any]) -> Dict[str, Any]:
    """Clones the tool with the platform overlay applied, preserving its shape."""
    tool = entry["tool"]
    with_params = overlay.get("parameters") is not None and entry["params_serializable"]
    if entry["shape"] == "openai":
        function = {**tool["function"], "description": overlay["description"]}
        if with_params:
            function["parameters"] = overlay["parameters"]
        return {**tool, "function": function}
    updated = {**tool, "description": overlay["description"]}
    if with_params:
        params_key = "input_schema" if entry["shape"] == "anthropic" else (
            "parameters" if "parameters" in tool or "inputSchema" not in tool else "inputSchema"
        )
        updated[params_key] = overlay["parameters"]
    return updated
