"""Horizon Cloud - deploy, monitor, and manage strategies on the Horizon platform.

Usage:
    from horizon import HorizonCloud

    cloud = HorizonCloud(api_key="hz_sdk_...")

    # Full lifecycle: create → validate → deploy → monitor → stop
    strat = cloud.create_strategy(name="MyMM", code=open("strategy.py").read())
    cred = cloud.save_credentials(private_key="0x...")
    cloud.validate(strat["id"])
    cloud.deploy(strat["id"], credential_id=cred["id"])
    cloud.wait_for_running(strat["id"])
    cloud.metrics(strat["id"])
    cloud.stop(strat["id"])
"""

from __future__ import annotations

import os
import re
import time
from typing import Any

try:
    import requests as _requests
except ImportError:
    _requests = None  # type: ignore[assignment]

_DEFAULT_BASE_URL = "https://api.mathematicalcompany.com"


class HorizonCloudError(Exception):
    """Raised when a Horizon Cloud API call fails."""

    def __init__(self, message: str, status_code: int | None = None, body: dict | None = None):
        self.status_code = status_code
        self.body = body or {}
        super().__init__(message)


class HorizonCloud:
    """Client for the Horizon Cloud platform API (v1).

    Args:
        api_key: An ``hz_sdk_...`` key. Falls back to ``HORIZON_API_KEY`` env var.
        base_url: Platform URL. Falls back to ``HORIZON_PLATFORM_URL`` env var or the
            default ``https://api.mathematicalcompany.com``.
        timeout: Request timeout in seconds (default 30).
    """

    def __init__(
        self,
        api_key: str | None = None,
        base_url: str | None = None,
        timeout: int = 30,
    ):
        if _requests is None:
            raise ImportError(
                "The 'requests' package is required for HorizonCloud. "
                "Install it with: pip install requests"
            )

        self._api_key = api_key or os.environ.get("HORIZON_API_KEY")
        if not self._api_key:
            raise HorizonCloudError(
                "No API key provided. Pass api_key= or set HORIZON_API_KEY env var."
            )
        if not (self._api_key.startswith("hz_live_") or self._api_key.startswith("hz_sdk_")):
            raise HorizonCloudError("API key must start with 'hz_live_' or 'hz_sdk_'")

        raw_url = base_url or os.environ.get("HORIZON_PLATFORM_URL", _DEFAULT_BASE_URL)
        self._base_url = raw_url.rstrip("/")
        self._timeout = timeout
        self._session = _requests.Session()
        self._session.headers.update({
            "Authorization": f"Bearer {self._api_key}",
            "Content-Type": "application/json",
            "User-Agent": "horizon-sdk/cloud",
        })

    # ── helpers ──────────────────────────────────────────────────────

    def _url(self, path: str) -> str:
        return f"{self._base_url}/api/v1{path}"

    def _request(self, method: str, path: str, **kwargs: Any) -> dict:
        kwargs.setdefault("timeout", self._timeout)
        resp = self._session.request(method, self._url(path), **kwargs)
        try:
            body = resp.json()
        except Exception:
            body = {}
        if not resp.ok:
            msg = body.get("error", f"HTTP {resp.status_code}")
            raise HorizonCloudError(msg, status_code=resp.status_code, body=body)
        return body

    def _get(self, path: str, **params: Any) -> dict:
        return self._request("GET", path, params=params)

    def _post(self, path: str, data: dict | None = None) -> dict:
        return self._request("POST", path, json=data or {})

    # ── account ──────────────────────────────────────────────────────

    def account(self) -> dict:
        """Get account info (plan, limits, usage, scopes)."""
        return self._get("/account").get("data", {})

    # ── strategies ───────────────────────────────────────────────────

    def list_strategies(self) -> list[dict]:
        """List all strategies for the authenticated user."""
        return self._get("/strategies").get("data", [])

    def get_strategy(self, strategy_id: str) -> dict:
        """Get detailed info for a single strategy."""
        return self._get(f"/strategies/{strategy_id}").get("data", {})

    def create_strategy(
        self,
        name: str,
        code: str,
        *,
        description: str = "",
        params: dict | None = None,
        risk_config: dict | None = None,
        auto_fix: bool = True,
    ) -> dict:
        """Create a new strategy from Python code.

        The code is sanitized, validated (forbidden imports, required SDK usage),
        and stored server-side. On success the strategy is immediately ``validated``
        and ready for deployment.

        Args:
            name: Human-readable strategy name (max 200 chars).
            code: Python source code using the Horizon SDK (``hz.run``, ``hz.quotes``).
            description: Optional description (max 2000 chars).
            params: Optional strategy parameters dict.
            risk_config: Optional risk configuration dict.
            auto_fix: Auto-fix common AI generation mistakes (default True).

        Returns:
            Strategy record with ``id``, ``name``, ``class_name``, ``status``.

        Raises:
            HorizonCloudError: 422 if code fails validation (includes ``validation_errors``).
        """
        if not name or not name.strip():
            raise HorizonCloudError("name is required")
        if not code or not code.strip():
            raise HorizonCloudError("code is required")

        payload: dict[str, Any] = {
            "name": name,
            "code": code,
            "auto_fix": auto_fix,
        }
        if description:
            payload["description"] = description
        if params:
            payload["params"] = params
        if risk_config:
            payload["risk_config"] = risk_config
        return self._post("/strategies/create", payload).get("data", {})

    def validate(self, strategy_id: str) -> dict:
        """Validate a strategy's code (static analysis + worker sandbox).

        Runs two validation phases:
        1. **Static**: forbidden imports, required SDK patterns, control-flow checks.
        2. **Sandbox**: AST parsing and import verification on the worker.

        On success, the strategy status is updated to ``validated``.

        Args:
            strategy_id: UUID of the strategy to validate.

        Returns:
            ``{"valid": bool, "phase": str, "errors": [...]}``
        """
        return self._post(f"/strategies/{strategy_id}/validate").get("data", {})

    # ── credentials ──────────────────────────────────────────────────

    def list_credentials(self) -> list[dict]:
        """List saved exchange credentials (metadata only - keys are never returned)."""
        return self._get("/credentials").get("data", [])

    def save_credentials(
        self,
        private_key: str,
        *,
        label: str = "Default",
        exchange: str = "polymarket",
        wallet_address: str | None = None,
    ) -> dict:
        """Save exchange credentials (encrypted at rest with AES-256-GCM).

        The private key is transmitted over HTTPS, encrypted server-side, and
        **never stored in plaintext or returned in any response**.

        Args:
            private_key: Hex private key (64 hex chars, optional ``0x`` prefix).
            label: Human-readable label (max 100 chars).
            exchange: Exchange name: ``"polymarket"`` or ``"kalshi"``.
            wallet_address: Optional wallet address (``0x`` + 40 hex chars).

        Returns:
            ``{"id": "...", "label": "...", "exchange": "..."}``

        Raises:
            HorizonCloudError: 400 if key format is invalid or credential limit reached.
        """
        # Client-side validation to avoid sending bad keys over the wire
        key_str = private_key.strip()
        hex_body = key_str[2:] if key_str.startswith("0x") else key_str
        if not re.fullmatch(r"[0-9a-fA-F]{64}", hex_body):
            raise HorizonCloudError(
                "Invalid private key format. Expected 64 hex characters (with optional 0x prefix)."
            )
        if exchange not in ("polymarket", "kalshi"):
            raise HorizonCloudError("exchange must be 'polymarket' or 'kalshi'")

        payload: dict[str, Any] = {
            "private_key": key_str,
            "label": label,
            "exchange": exchange,
        }
        if wallet_address:
            payload["wallet_address"] = wallet_address
        return self._post("/credentials", payload).get("data", {})

    # ── deploy / stop ────────────────────────────────────────────────

    def deploy(
        self,
        strategy_id: str,
        *,
        credential_id: str,
        mode: str = "paper",
        markets: list[str] | None = None,
    ) -> dict:
        """Deploy a strategy to the cloud.

        Args:
            strategy_id: UUID of the strategy to deploy.
            credential_id: UUID of the exchange credential to use.
            mode: ``"paper"`` (default) or ``"live"``.
            markets: Optional list of market slugs to trade.

        Returns:
            Deployment record dict with ``id``, ``status``, etc.
        """
        payload: dict[str, Any] = {
            "credential_id": credential_id,
            "mode": mode,
        }
        if markets:
            payload["markets"] = markets
        return self._post(f"/strategies/{strategy_id}/deploy", payload).get("data", {})

    def stop(self, strategy_id: str) -> dict:
        """Stop all active deployments for a strategy.

        Returns:
            ``{"success": True, "worker_reachable": bool}``
        """
        return self._post(f"/strategies/{strategy_id}/stop").get("data", {})

    # ── monitoring ───────────────────────────────────────────────────

    def deployments(self, strategy_id: str) -> list[dict]:
        """List recent deployments for a strategy (most recent first)."""
        return self._get(f"/strategies/{strategy_id}/deployments").get("data", [])

    def status(self, strategy_id: str) -> dict:
        """Get the current deployment status for a strategy.

        Returns the latest active deployment, or the most recent one if none active.
        """
        deps = self.deployments(strategy_id)
        if not deps:
            return {"status": "no_deployments"}
        active = [d for d in deps if d.get("status") in (
            "pending", "starting", "running", "queued", "restarting", "scanner_idle"
        )]
        return active[0] if active else deps[0]

    def metrics(self, strategy_id: str, limit: int = 50) -> dict:
        """Get performance metrics for the active deployment.

        Returns:
            ``{"latest": {...}, "history": [...]}``
        """
        return self._get(f"/strategies/{strategy_id}/metrics", limit=limit).get("data", {})

    def logs(
        self,
        strategy_id: str,
        *,
        limit: int = 100,
        level: str | None = None,
        deployment_id: str | None = None,
    ) -> list[dict]:
        """Get deployment logs for a strategy.

        Args:
            strategy_id: UUID of the strategy.
            limit: Max number of log entries (default 100, max 500).
            level: Filter by log level: ``"info"``, ``"error"``, ``"warning"``.
            deployment_id: Specific deployment UUID (defaults to most recent).

        Returns:
            List of log entries (oldest first).
        """
        params: dict[str, Any] = {"limit": limit}
        if level:
            params["level"] = level
        if deployment_id:
            params["deployment_id"] = deployment_id
        return self._get(f"/strategies/{strategy_id}/logs", **params).get("data", [])

    # ── convenience ──────────────────────────────────────────────────

    def wait_for_running(
        self,
        strategy_id: str,
        timeout: int = 120,
        poll_interval: int = 3,
    ) -> dict:
        """Block until a deployment reaches 'running' status or times out.

        Args:
            strategy_id: UUID of the strategy.
            timeout: Max seconds to wait (default 120).
            poll_interval: Seconds between polls (default 3).

        Returns:
            The deployment dict once running.

        Raises:
            HorizonCloudError: If timeout or terminal status reached.
        """
        deadline = time.monotonic() + timeout
        terminal = {"stopped", "error", "killed"}
        while time.monotonic() < deadline:
            dep = self.status(strategy_id)
            st = dep.get("status", "")
            if st == "running":
                return dep
            if st in terminal:
                raise HorizonCloudError(
                    f"Deployment reached terminal status: {st}",
                    body=dep,
                )
            time.sleep(poll_interval)
        raise HorizonCloudError(f"Timed out waiting for deployment after {timeout}s")

    def __repr__(self) -> str:
        masked = self._api_key[:12] + "..." if self._api_key else "None"
        return f"HorizonCloud(base_url={self._base_url!r}, api_key={masked!r})"
