# tofan.py - single-file Python client for the Tofan Trade Engine API.
#
# Vendored distribution: drop this file next to your code and `import tofan`.
# Only dependency is requests. Docs: https://tofantradeengine.com/docs
# Source of truth: sdk/python/tofan/client.py in the platform repo.

"""HTTP client for the Tofan Trade Engine public API.

Design notes:

* Only dependency is `requests`. A market-data client that drags in a
  framework is a client people vendor a copy of instead of installing.

* Errors are typed. The API distinguishes 401 (bad key), 403 (key lacks
  the scope), 429 (rate limited) and 404 — collapsing those into one
  exception forces every caller to re-parse the message to find out
  whether retrying could possibly help.

* 429 is retried automatically, honouring the server's `Retry-After`.
  The API's default budget is 60 requests/minute, which a naive loop over
  several pairs will hit; making every user discover that themselves is a
  bad first experience.

* `signals()` returns typed `Signal` objects rather than raw dicts, but
  every model keeps the untouched payload on `.raw` so a field added
  server-side is never lost just because this client predates it.
"""

from __future__ import annotations

import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional

import requests

DEFAULT_BASE_URL = "https://vhzdqlzyvuemieieenys.functions.supabase.co/public-api"

#: Public read-only key. Returns fixed sample data; no account required.
#: Publish calls with this key are validated and echoed but never written.
SANDBOX_KEY = "tofan_sandbox_demo"


# --------------------------------------------------------------------------
# Errors
# --------------------------------------------------------------------------

class TofanError(Exception):
    """Base class for every error this client raises."""


class AuthenticationError(TofanError):
    """401 — the key is missing, malformed, revoked or unknown."""


class PermissionError_(TofanError):
    """403 — the key is valid but lacks the scope this call needs.

    Named with a trailing underscore so it cannot shadow the builtin
    ``PermissionError`` for anyone doing ``from tofan import *``.
    """


class RateLimitError(TofanError):
    """429 — request budget exhausted and retries are also exhausted."""

    def __init__(self, message: str, retry_after: Optional[int] = None):
        super().__init__(message)
        self.retry_after = retry_after


class NotFoundError(TofanError):
    """404 — the addressed resource does not exist, or is not yours."""


class APIError(TofanError):
    """Any other non-2xx response."""

    def __init__(self, message: str, status_code: int, payload: Any = None):
        super().__init__(message)
        self.status_code = status_code
        self.payload = payload


# --------------------------------------------------------------------------
# Models
# --------------------------------------------------------------------------

@dataclass
class Signal:
    id: str
    pair: str
    direction: str
    status: str
    entry_price: Optional[float] = None
    stop_loss: Optional[float] = None
    take_profit: Optional[float] = None
    close_price: Optional[float] = None
    result: Optional[str] = None
    pips_result: Optional[float] = None
    title: Optional[str] = None
    provider_id: Optional[str] = None
    provider_name: Optional[str] = None
    created_at: Optional[str] = None
    closed_at: Optional[str] = None
    raw: Dict[str, Any] = field(default_factory=dict, repr=False)

    @property
    def is_open(self) -> bool:
        return self.status == "active"

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "Signal":
        return cls(
            id=d.get("id", ""),
            pair=d.get("pair", ""),
            direction=d.get("direction", ""),
            status=d.get("status", ""),
            entry_price=_num(d.get("entry_price")),
            stop_loss=_num(d.get("stop_loss")),
            take_profit=_num(d.get("take_profit")),
            close_price=_num(d.get("close_price")),
            result=d.get("result"),
            pips_result=_num(d.get("pips_result")),
            title=d.get("title"),
            provider_id=d.get("provider_id"),
            provider_name=d.get("provider_name"),
            created_at=d.get("created_at"),
            closed_at=d.get("closed_at"),
            raw=d,
        )


@dataclass
class Candle:
    """One OHLC bar plus its precomputed indicators.

    ``indicators`` keys depend on what the feature job wrote for that
    source — today: rsi_14, ema_20, ema_50, sma_200, atr_14, macd,
    macd_signal, macd_hist, bb_upper, bb_lower. Use ``.indicator(name)``
    rather than indexing, so a bar inside an indicator's warmup window
    (where the value is genuinely null) returns None instead of raising.
    """

    symbol: str
    timeframe: str
    source: str
    candle_time: str
    open: Optional[float] = None
    high: Optional[float] = None
    low: Optional[float] = None
    close: Optional[float] = None
    volume: Optional[float] = None
    indicators: Dict[str, Any] = field(default_factory=dict)
    raw: Dict[str, Any] = field(default_factory=dict, repr=False)

    def indicator(self, name: str) -> Optional[float]:
        return _num((self.indicators or {}).get(name))

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "Candle":
        return cls(
            symbol=d.get("symbol", ""),
            timeframe=d.get("timeframe", ""),
            source=d.get("source", ""),
            candle_time=d.get("candle_time", ""),
            open=_num(d.get("open")),
            high=_num(d.get("high")),
            low=_num(d.get("low")),
            close=_num(d.get("close")),
            volume=_num(d.get("volume")),
            indicators=d.get("indicators") or {},
            raw=d,
        )


@dataclass
class Provider:
    provider_id: Optional[str]
    provider_name: str
    subscriber_count: Optional[int] = None
    total_signals: Optional[int] = None
    win_rate: Optional[float] = None
    raw: Dict[str, Any] = field(default_factory=dict, repr=False)

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "Provider":
        return cls(
            provider_id=d.get("provider_id"),
            provider_name=d.get("provider_name", ""),
            subscriber_count=d.get("subscriber_count"),
            total_signals=d.get("total_signals"),
            win_rate=_num(d.get("win_rate")),
            raw=d,
        )


@dataclass
class Performance:
    closed_signals: int
    decided_signals: int
    wins: int
    losses: int
    win_rate_pct: Optional[float]
    total_pips: float
    sandbox: bool = False
    raw: Dict[str, Any] = field(default_factory=dict, repr=False)

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "Performance":
        return cls(
            closed_signals=int(d.get("closed_signals") or 0),
            decided_signals=int(d.get("decided_signals") or 0),
            wins=int(d.get("wins") or 0),
            losses=int(d.get("losses") or 0),
            win_rate_pct=_num(d.get("win_rate_pct")),
            total_pips=_num(d.get("total_pips")) or 0.0,
            sandbox=bool(d.get("sandbox")),
            raw=d,
        )


def _num(value: Any) -> Optional[float]:
    """Coerce to float, tolerating the strings PostgREST returns for numerics."""
    if value is None:
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


# --------------------------------------------------------------------------
# Client
# --------------------------------------------------------------------------

class TofanClient:
    """Client for the Tofan Trade Engine public API.

    Args:
        api_key: Your key from Dashboard -> Security -> Developer. Defaults
            to the public sandbox key, so the client is usable immediately
            without an account.
        base_url: Override the API root (useful for a self-hosted deploy).
        timeout: Per-request timeout in seconds.
        max_retries: How many times to retry a 429 before raising.
        session: Supply your own ``requests.Session`` for connection reuse
            or custom transport adapters.
    """

    def __init__(
        self,
        api_key: str = SANDBOX_KEY,
        base_url: str = DEFAULT_BASE_URL,
        timeout: float = 15.0,
        max_retries: int = 3,
        session: Optional[requests.Session] = None,
    ):
        if not api_key:
            raise ValueError("api_key must not be empty. Use SANDBOX_KEY to try the API.")
        self.api_key = api_key
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.max_retries = max_retries
        self._session = session or requests.Session()
        self._session.headers.update(
            {
                "Authorization": f"Bearer {self.api_key}",
                "Content-Type": "application/json",
                "User-Agent": "tofan-python/1.0.0",
            }
        )

    @property
    def is_sandbox(self) -> bool:
        return self.api_key == SANDBOX_KEY

    # -- transport ---------------------------------------------------------

    def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
        url = f"{self.base_url}{path}"
        attempt = 0

        while True:
            response = self._session.request(
                method, url, timeout=self.timeout, **kwargs
            )

            if response.status_code == 429 and attempt < self.max_retries:
                # Prefer the server's own guidance; fall back to a short
                # linear backoff. The API returns both a Retry-After header
                # and retry_after_seconds in the body.
                wait = response.headers.get("Retry-After")
                try:
                    delay = int(wait) if wait else 0
                except ValueError:
                    delay = 0
                if not delay:
                    delay = _payload(response).get("retry_after_seconds") or (attempt + 1) * 2
                time.sleep(float(delay))
                attempt += 1
                continue

            return self._handle(response)

    def _handle(self, response: requests.Response) -> Dict[str, Any]:
        payload = _payload(response)

        if response.ok:
            return payload

        message = payload.get("error") or f"HTTP {response.status_code}"
        hint = payload.get("hint")
        if hint:
            message = f"{message} ({hint})"

        if response.status_code == 401:
            raise AuthenticationError(message)
        if response.status_code == 403:
            raise PermissionError_(message)
        if response.status_code == 404:
            raise NotFoundError(message)
        if response.status_code == 429:
            raise RateLimitError(message, payload.get("retry_after_seconds"))
        raise APIError(message, response.status_code, payload)

    # -- read --------------------------------------------------------------

    def discovery(self) -> Dict[str, Any]:
        """Service metadata: version, endpoints, rate limit, sandbox key."""
        return self._request("GET", "/")

    def signals(
        self,
        status: Optional[str] = None,
        pair: Optional[str] = None,
        provider: Optional[str] = None,
        limit: int = 50,
    ) -> List[Signal]:
        """Fetch the signal feed.

        Args:
            status: ``"active"`` or ``"closed"``. Omit for both.
            pair: Instrument symbol, e.g. ``"EURUSD"``. Case-insensitive.
            provider: Restrict to one provider's UUID.
            limit: Max rows, server-capped at 200.
        """
        if status is not None and status not in ("active", "closed"):
            raise ValueError("status must be 'active', 'closed' or None")

        params: Dict[str, Any] = {"limit": min(int(limit), 200)}
        if status:
            params["status"] = status
        if pair:
            params["pair"] = pair.upper()
        if provider:
            params["provider"] = provider

        data = self._request("GET", "/signals", params=params)
        return [Signal.from_dict(row) for row in data.get("signals", [])]

    def symbols(self) -> List[Dict[str, Any]]:
        """Instruments in the feature store, with row counts and history depth."""
        return self._request("GET", "/symbols").get("symbols", [])

    def candles(
        self,
        symbol: str,
        timeframe: Optional[str] = None,
        start: Optional[str] = None,
        end: Optional[str] = None,
        limit: int = 500,
        order: str = "asc",
    ) -> List[Candle]:
        """Fetch OHLC bars with precomputed indicators.

        Args:
            symbol: e.g. ``"EURUSD"``. Case-insensitive.
            timeframe: e.g. ``"1d"``, ``"1h"``, ``"5m"``. Omit for all.
            start / end: ISO dates or timestamps, inclusive.
            limit: Max rows, server-capped at 5000.
            order: ``"asc"`` (oldest first, the default) or ``"desc"``.

        Note the server caps ``limit``; check ``len(result) == limit`` and
        page with ``start`` if you need a longer range.
        """
        if order not in ("asc", "desc"):
            raise ValueError("order must be 'asc' or 'desc'")

        params: Dict[str, Any] = {"symbol": symbol.upper(), "limit": int(limit), "order": order}
        if timeframe:
            params["timeframe"] = timeframe
        if start:
            params["from"] = start
        if end:
            params["to"] = end

        data = self._request("GET", "/candles", params=params)
        return [Candle.from_dict(row) for row in data.get("candles", [])]

    def candles_csv(
        self,
        symbol: str,
        timeframe: Optional[str] = None,
        start: Optional[str] = None,
        end: Optional[str] = None,
        limit: int = 5000,
    ) -> str:
        """Same query as :meth:`candles`, returned as CSV text.

        Indicators arrive as flat columns, so this drops straight into
        pandas::

            import io, pandas as pd
            df = pd.read_csv(io.StringIO(client.candles_csv("EURUSD", "1d")))
        """
        params: Dict[str, Any] = {
            "symbol": symbol.upper(),
            "limit": int(limit),
            "format": "csv",
        }
        if timeframe:
            params["timeframe"] = timeframe
        if start:
            params["from"] = start
        if end:
            params["to"] = end

        url = f"{self.base_url}/candles"
        response = self._session.get(url, params=params, timeout=self.timeout)
        if not response.ok:
            # Reuse the JSON error mapping; a failed CSV call still returns
            # a JSON error body.
            self._handle(response)
        return response.text

    def providers(self) -> List[Provider]:
        """Approved signal providers with subscriber counts and win rates."""
        data = self._request("GET", "/providers")
        return [Provider.from_dict(row) for row in data.get("providers", [])]

    def performance(self) -> Performance:
        """Aggregate statistics across all closed signals."""
        return Performance.from_dict(self._request("GET", "/performance"))

    # -- publish (requires the 'publish' scope) ----------------------------

    def publish_signal(
        self,
        pair: str,
        direction: str,
        entry_price: float,
        stop_loss: Optional[float] = None,
        take_profit: Optional[float] = None,
        title: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Publish an attributed signal. Requires a publish-scoped key.

        Returns the created signal plus ``mirrored_to_accounts``, the real
        number of copier accounts it fanned out to.
        """
        direction = direction.upper()
        if direction not in ("BUY", "SELL"):
            raise ValueError("direction must be 'BUY' or 'SELL'")

        body: Dict[str, Any] = {
            "pair": pair.upper(),
            "direction": direction,
            "entry_price": float(entry_price),
        }
        if stop_loss is not None:
            body["stop_loss"] = float(stop_loss)
        if take_profit is not None:
            body["take_profit"] = float(take_profit)
        if title:
            body["title"] = title

        return self._request("POST", "/signals", json=body)

    def close_signal(
        self,
        signal_id: str,
        close_price: Optional[float] = None,
        result: Optional[str] = None,
        pips_result: Optional[float] = None,
    ) -> Dict[str, Any]:
        """Close one of your own active signals. Requires a publish-scoped key.

        Args:
            result: ``"win"``, ``"loss"`` or ``"breakeven"``.
        """
        if result is not None and result.lower() not in ("win", "loss", "breakeven"):
            raise ValueError("result must be 'win', 'loss' or 'breakeven'")

        body: Dict[str, Any] = {"signal_id": signal_id}
        if close_price is not None:
            body["close_price"] = float(close_price)
        if result is not None:
            body["result"] = result.lower()
        if pips_result is not None:
            body["pips_result"] = float(pips_result)

        return self._request("POST", "/signals/close", json=body)

    def close(self) -> None:
        """Release the underlying HTTP session."""
        self._session.close()

    def __enter__(self) -> "TofanClient":
        return self

    def __exit__(self, *exc: Any) -> None:
        self.close()


def _payload(response: requests.Response) -> Dict[str, Any]:
    """Parse a JSON body, tolerating an empty or non-JSON error response."""
    try:
        parsed = response.json()
        return parsed if isinstance(parsed, dict) else {"data": parsed}
    except ValueError:
        return {}
