"""
Cliente Alpaca de Leonex.

Wrapper minimalista del SDK oficial `alpaca-py` que:
- Lee credenciales desde variables de entorno (o desde el fichero `.env`).
- Detecta automaticamente si esta configurado el modo paper o live.
- Devuelve dataclasses limpias en lugar de los objetos del SDK, para que el
  dashboard pueda serializarlas a JSON sin trampa.

Si Alpaca no esta configurado o falla, las funciones devuelven None (no
lanzan), de manera que el resto del sistema sigue funcionando sin broker.

Documentacion oficial:
    https://docs.alpaca.markets/
    https://github.com/alpacahq/alpaca-py
"""

from __future__ import annotations

import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Optional


PROJECT_ROOT = Path(__file__).resolve().parents[1]


def _load_env_file(path: Path) -> None:
    """Mini loader para `.env` (no necesitamos python-dotenv)."""
    if not path.exists():
        return
    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" not in line:
            continue
        key, _, value = line.partition("=")
        key = key.strip()
        value = value.strip().strip('"').strip("'")
        if key and key not in os.environ:
            os.environ[key] = value


_load_env_file(PROJECT_ROOT / ".env")


@dataclass
class AlpacaAccount:
    account_number: str
    status: str
    currency: str
    cash: float
    equity: float
    last_equity: float
    buying_power: float
    portfolio_value: float
    daytrade_count: int
    pattern_day_trader: bool
    is_paper: bool
    pnl_today: float
    pnl_today_pct: float


@dataclass
class AlpacaPosition:
    symbol: str
    qty: float
    side: str               # "long" / "short"
    avg_entry_price: float
    current_price: float
    market_value: float
    cost_basis: float
    unrealized_pl: float
    unrealized_plpc: float
    change_today: float


@dataclass
class AlpacaOrder:
    id: str
    symbol: str
    side: str
    qty: float
    filled_qty: float
    filled_avg_price: Optional[float]
    status: str
    type: str
    created_at: str
    submitted_at: Optional[str]
    filled_at: Optional[str]


@dataclass
class AlpacaSnapshot:
    configured: bool
    error: Optional[str] = None
    account: Optional[AlpacaAccount] = None
    positions: list[AlpacaPosition] = field(default_factory=list)
    recent_orders: list[AlpacaOrder] = field(default_factory=list)
    # Flag crítico: True si get_all_positions() falló. Cuando esto pasa,
    # positions queda VACIA aunque en Alpaca haya posiciones abiertas. El
    # executor DEBE abortar todas las BUYs si positions_failed=True — si no,
    # abre posiciones encima de las ya existentes (piramide de riesgo).
    # Bug real detectado el 6-jul-2026: PGR llego a 246 uds sobre 30 porque
    # el snapshot fallo silenciosamente y el executor abrio ciego.
    positions_failed: bool = False


# Pares crypto soportados por Alpaca paper. Mapeo desde formato yfinance
# (BTC-USD) al formato que Alpaca usa internamente (BTC/USD).
CRYPTO_SYMBOL_MAP = {
    "BTC-USD": "BTC/USD",
    "ETH-USD": "ETH/USD",
    "SOL-USD": "SOL/USD",
    "DOGE-USD": "DOGE/USD",
    "AVAX-USD": "AVAX/USD",
    "LINK-USD": "LINK/USD",
    "MATIC-USD": "MATIC/USD",
    "UNI-USD": "UNI/USD",
    "BCH-USD": "BCH/USD",
    "LTC-USD": "LTC/USD",
    "AAVE-USD": "AAVE/USD",
    "DOT-USD": "DOT/USD",
}


def is_crypto_symbol(symbol: str) -> bool:
    """Devuelve True si el ticker es de crypto (formato YYY-USD o YYY/USD)."""
    if not symbol:
        return False
    s = symbol.upper()
    if s in CRYPTO_SYMBOL_MAP or s.replace("/", "-") in CRYPTO_SYMBOL_MAP:
        return True
    # Heuristica: si termina en -USD o /USD, asumimos crypto (no hay equity que use ese sufijo)
    return s.endswith("-USD") or s.endswith("/USD")


def to_alpaca_symbol(symbol: str) -> str:
    """Convierte el formato Leonex (BTC-USD) al formato Alpaca (BTC/USD).
    Para equities (AAPL, MSFT...) devuelve el ticker tal cual."""
    if not symbol:
        return symbol
    s = symbol.upper()
    if s in CRYPTO_SYMBOL_MAP:
        return CRYPTO_SYMBOL_MAP[s]
    if "/" in s:
        return s  # ya esta en formato Alpaca
    if s.endswith("-USD"):
        return s.replace("-USD", "/USD")
    return s  # equity sin transformar


def _env(*names: str) -> str:
    """Lee la primera env var no-vacia de la lista. Util para soportar las
    tres convenciones de naming que hay sueltas por Alpaca:
        - ALPACA_API_KEY  /  ALPACA_SECRET_KEY    (la que usa este repo)
        - ALPACA_API_KEY  /  ALPACA_API_SECRET    (community / EasyPanel)
        - APCA_API_KEY_ID /  APCA_API_SECRET_KEY  (la del SDK oficial)
    Asi el cliente deja de fallar silenciosamente por un nombre alternativo."""
    for n in names:
        v = (os.environ.get(n) or "").strip()
        if v:
            return v
    return ""


def _get_credentials() -> tuple[Optional[str], Optional[str], str, bool]:
    """Devuelve (api_key, secret, base_url, is_paper)."""
    api_key = _env("ALPACA_API_KEY", "APCA_API_KEY_ID")
    secret = _env("ALPACA_SECRET_KEY", "ALPACA_API_SECRET",
                  "APCA_API_SECRET_KEY")
    base_url = os.environ.get("ALPACA_BASE_URL", "https://paper-api.alpaca.markets").strip()
    is_paper = "paper" in base_url
    # Tratar como ausentes: vacios, placeholders del .env.example
    if not api_key or api_key.startswith("PKXXXX"):
        api_key = None
    if not secret or secret.startswith("YYYY"):
        secret = None
    return api_key, secret, base_url, is_paper


def _build_client():
    """Devuelve un TradingClient configurado, o None si falta config."""
    api_key, secret, base_url, is_paper = _get_credentials()
    if not api_key or not secret:
        return None, None, None, "missing_credentials"
    try:
        from alpaca.trading.client import TradingClient
    except ImportError:
        return None, None, None, "alpaca_sdk_not_installed"
    try:
        client = TradingClient(api_key, secret, paper=is_paper, url_override=base_url)
    except Exception as exc:  # pragma: no cover
        return None, None, None, f"client_init_failed: {exc!r}"
    return client, base_url, is_paper, None


def _safe_float(x, default: float = 0.0) -> float:
    try:
        return float(x) if x is not None else default
    except (ValueError, TypeError):
        return default


def fetch_snapshot(limit_orders: int = 20) -> AlpacaSnapshot:
    """Lee account + posiciones + ordenes recientes desde Alpaca."""
    client, base_url, is_paper, err = _build_client()
    if client is None:
        return AlpacaSnapshot(configured=False, error=err)

    snap = AlpacaSnapshot(configured=True)

    # ── Account ─────────────────────────────────────────────────────────
    try:
        a = client.get_account()
        equity = _safe_float(a.equity)
        last_equity = _safe_float(a.last_equity)
        pnl_today = equity - last_equity
        pnl_today_pct = (pnl_today / last_equity) if last_equity > 0 else 0.0
        snap.account = AlpacaAccount(
            account_number=str(getattr(a, "account_number", "")),
            status=str(getattr(a, "status", "")),
            currency=str(getattr(a, "currency", "USD")),
            cash=_safe_float(a.cash),
            equity=equity,
            last_equity=last_equity,
            buying_power=_safe_float(a.buying_power),
            portfolio_value=_safe_float(a.portfolio_value),
            daytrade_count=int(_safe_float(getattr(a, "daytrade_count", 0))),
            pattern_day_trader=bool(getattr(a, "pattern_day_trader", False)),
            is_paper=bool(is_paper),
            pnl_today=round(pnl_today, 2),
            pnl_today_pct=round(pnl_today_pct, 6),
        )
    except Exception as exc:
        snap.error = f"account_error: {exc!r}"
        return snap

    # ── Posiciones ───────────────────────────────────────────────────────
    try:
        positions = client.get_all_positions()
        for p in positions:
            snap.positions.append(AlpacaPosition(
                symbol=str(p.symbol),
                qty=_safe_float(p.qty),
                side=str(p.side).lower() if getattr(p, "side", None) else "long",
                avg_entry_price=_safe_float(p.avg_entry_price),
                current_price=_safe_float(getattr(p, "current_price", 0)),
                market_value=_safe_float(p.market_value),
                cost_basis=_safe_float(p.cost_basis),
                unrealized_pl=_safe_float(p.unrealized_pl),
                unrealized_plpc=_safe_float(p.unrealized_plpc),
                change_today=_safe_float(getattr(p, "change_today", 0)),
            ))
    except Exception as exc:
        # CRITICO: si esto falla, positions queda vacia -> el executor no
        # sabe que ya tienes posiciones abiertas y las apila. Marcamos flag
        # para que el executor ABORTE todas las BUYs por seguridad.
        snap.error = f"positions_error: {exc!r}"
        snap.positions_failed = True

    # ── Ordenes recientes ────────────────────────────────────────────────
    try:
        from alpaca.trading.requests import GetOrdersRequest
        from alpaca.trading.enums import QueryOrderStatus
        req = GetOrdersRequest(status=QueryOrderStatus.ALL, limit=limit_orders,
                               nested=False)
        orders = client.get_orders(filter=req)
        for o in orders:
            snap.recent_orders.append(AlpacaOrder(
                id=str(o.id),
                symbol=str(o.symbol),
                side=str(o.side).lower() if getattr(o, "side", None) else "buy",
                qty=_safe_float(o.qty),
                filled_qty=_safe_float(o.filled_qty),
                filled_avg_price=_safe_float(o.filled_avg_price) if o.filled_avg_price else None,
                status=str(o.status).lower() if getattr(o, "status", None) else "",
                type=str(o.type).lower() if getattr(o, "type", None) else "",
                created_at=str(o.created_at) if o.created_at else "",
                submitted_at=str(o.submitted_at) if getattr(o, "submitted_at", None) else None,
                filled_at=str(o.filled_at) if getattr(o, "filled_at", None) else None,
            ))
    except Exception as exc:
        # Sin ordenes aun no es fatal — pero registrar warning
        if snap.error is None:
            snap.error = f"orders_warning: {exc!r}"

    return snap


_TRADABLE_CACHE: dict = {"set": None, "ts": 0.0, "ttl_s": 3600.0}


def get_tradable_symbols(refresh: bool = False) -> set[str]:
    """Devuelve el SET de tickers operables en Alpaca consultando su API real
    (`trading.get_all_assets`). Cachea en RAM 1 hora para no llamar API por
    cada senal. Devuelve set vacio si Alpaca no esta configurado o falla.

    El caller (executor) debe combinarlo con su propio fallback: union con la
    lista hardcoded y con los tickers que tengamos en `prices` en BD."""
    import time as _t
    if not refresh and _TRADABLE_CACHE["set"] is not None:
        age = _t.time() - _TRADABLE_CACHE["ts"]
        if age < _TRADABLE_CACHE["ttl_s"]:
            return _TRADABLE_CACHE["set"]

    client, _, _, _ = _build_client()
    if client is None:
        return set()
    try:
        from alpaca.trading.requests import GetAssetsRequest
        from alpaca.trading.enums import AssetClass, AssetStatus
    except ImportError:
        return set()

    symbols: set[str] = set()
    try:
        # Equities US — solo los activos y tradeables. Excluimos los que tengan
        # tradable=False (assets retirados, suspendidos, etc.).
        eq_req = GetAssetsRequest(asset_class=AssetClass.US_EQUITY,
                                  status=AssetStatus.ACTIVE)
        for a in client.get_all_assets(eq_req):
            if getattr(a, "tradable", True):
                symbols.add(a.symbol)
    except Exception:
        pass
    try:
        # Crypto: el formato Alpaca es BTC/USD, pero Leonex usa BTC-USD.
        # Convertimos al formato Leonex para que el matching con prices/signals
        # encaje sin transformaciones extra en el executor.
        cr_req = GetAssetsRequest(asset_class=AssetClass.CRYPTO,
                                  status=AssetStatus.ACTIVE)
        for a in client.get_all_assets(cr_req):
            if getattr(a, "tradable", True):
                sym = a.symbol
                if "/" in sym:
                    sym = sym.replace("/", "-")
                symbols.add(sym)
    except Exception:
        pass

    _TRADABLE_CACHE["set"] = symbols
    _TRADABLE_CACHE["ts"] = _t.time()
    return symbols


def snapshot_to_dict(snap: AlpacaSnapshot) -> dict:
    """Serializacion clean para el JSON del dashboard."""
    return {
        "configured": snap.configured,
        "error": snap.error,
        "account": asdict(snap.account) if snap.account else None,
        "positions": [asdict(p) for p in snap.positions],
        "recent_orders": [asdict(o) for o in snap.recent_orders],
    }


def has_open_position(symbol: str) -> tuple[bool, Optional[str]]:
    """Consulta EN TIEMPO REAL si hay una posicion abierta en Alpaca para
    este symbol. Devuelve (existe, error). Diseñada como ANTI-PIRAMIDE
    guard: llamar justo antes de enviar una BUY para asegurar que no
    apilamos sobre una posicion existente aunque el snapshot cached
    hubiese fallado.

    Retornos:
        (True, None)  -> hay posicion. NO enviar la BUY.
        (False, None) -> no hay posicion. SEGURO enviar.
        (False, err)  -> error consultando. Fail-safe: NO enviar (retorna
                         False pero con error, y el llamador debe decidir).

    Nota: get_open_position(symbol) devuelve 404 cuando no hay posicion,
    lo capturamos como "no existe" (False, None). Cualquier otro error se
    reporta para que el llamador aborte por seguridad."""
    client, _, _, err = _build_client()
    if client is None:
        return False, f"alpaca_client_no_configurado: {err}"
    try:
        alpaca_symbol = to_alpaca_symbol(symbol)
        pos = client.get_open_position(alpaca_symbol)
        qty = _safe_float(getattr(pos, "qty", 0))
        return abs(qty) > 0, None
    except Exception as exc:
        msg = repr(exc)
        # 404 / not found = no hay posicion (comportamiento Alpaca)
        low = msg.lower()
        if "404" in msg or "not found" in low or "position does not exist" in low:
            return False, None
        # Cualquier otro error (network, timeout, 500...) es CRITICO:
        # devolvemos "no hay" (False) pero con error para que el llamador
        # decida abortar por seguridad. Anti-piramide en el executor lo
        # trata como "no puedo confirmar -> no envio".
        return False, f"has_open_position_error: {msg}"


def submit_market_order(
    symbol: str,
    qty: float,
    side: str = "buy",
    time_in_force: Optional[str] = None,
) -> dict:
    """
    Envia una orden market a Alpaca paper.

    Maneja automaticamente:
        - Equities: time_in_force='day' por defecto, qty entero recomendado
        - Crypto:   time_in_force='gtc' obligatorio (no acepta 'day'),
                    formato simbolo BTC/USD (convertimos desde BTC-USD)

    Devuelve dict con:
        ok, order_id, status, filled_qty, error, alpaca_symbol (el que se envio)
    """
    client, _, _, err = _build_client()
    if client is None:
        return {"ok": False, "order_id": None, "status": None,
                "filled_qty": None, "error": err, "alpaca_symbol": symbol}

    try:
        from alpaca.trading.requests import MarketOrderRequest
        from alpaca.trading.enums import OrderSide, TimeInForce
    except ImportError:
        return {"ok": False, "order_id": None, "status": None,
                "filled_qty": None, "error": "alpaca_sdk_not_installed",
                "alpaca_symbol": symbol}

    side_enum = OrderSide.BUY if side.lower() == "buy" else OrderSide.SELL
    is_crypto = is_crypto_symbol(symbol)
    alpaca_symbol = to_alpaca_symbol(symbol)

    # tif default: crypto necesita GTC; equities usan DAY si no se especifica
    if time_in_force is None:
        time_in_force = "gtc" if is_crypto else "day"

    tif_map = {
        "day": TimeInForce.DAY,
        "gtc": TimeInForce.GTC,
        "opg": TimeInForce.OPG,
        "ioc": TimeInForce.IOC,
        "fok": TimeInForce.FOK,
    }
    tif_enum = tif_map.get(time_in_force.lower(), TimeInForce.GTC if is_crypto else TimeInForce.DAY)

    # Crypto en Alpaca solo soporta GTC o IOC — si nos pasan DAY, lo forzamos
    if is_crypto and tif_enum == TimeInForce.DAY:
        tif_enum = TimeInForce.GTC

    # ── DRY_RUN_MODE GLOBAL (nivel mas bajo, imposible saltarselo) ──────
    # Si LEONEX_DRY_RUN_MODE=1, rechazamos TODAS las ordenes market a nivel
    # cliente. Sirve como red de seguridad definitiva mientras se observa
    # el comportamiento del sistema en produccion sin arriesgar dinero (ni
    # de paper). Se activa desde EasyPanel Environment -> LEONEX_DRY_RUN_MODE=1.
    # Cualquier caller que quiera enviar una orden real recibira un reject
    # con motivo claro, y en el journal aparecera como skipped_dry_run_mode.
    if os.environ.get("LEONEX_DRY_RUN_MODE", "0").strip().lower() in ("1", "true", "yes"):
        return {
            "ok": False, "order_id": None, "status": "rejected",
            "filled_qty": None,
            "error": (f"dry_run_mode: LEONEX_DRY_RUN_MODE=1 esta activo. "
                      f"Orden {side.upper()} {qty} {alpaca_symbol} NO enviada. "
                      f"Desactivar quitando la env var o poniendola a 0."),
            "alpaca_symbol": alpaca_symbol,
            "dry_run_mode": True,
        }

    # ── LONG-ONLY GUARD ─────────────────────────────────────────────────
    # Si la orden es SELL y NO existe posicion LONG previa, seria un SHORT
    # en descubierto. Leonex es LONG-only por diseno. Rechazamos por defecto.
    # Bug del 7-jul-2026: perdimos ~$39k (-41%) porque close_monitor mandaba
    # SELLs para posiciones que Alpaca ya no tenia (cerradas overnight) y
    # esas SELLs abrieron shorts fantasma. Este guard hace imposible repetir
    # ese escenario. Se puede desactivar con LEONEX_ALLOW_SHORTS=1 si algun
    # dia queremos operar shorts intencionadamente.
    allow_shorts = os.environ.get(
        "LEONEX_ALLOW_SHORTS", "0").strip().lower() in ("1", "true", "yes")
    if side.lower() == "sell" and not allow_shorts:
        try:
            existing_pos = client.get_open_position(alpaca_symbol)
            existing_qty = _safe_float(getattr(existing_pos, "qty", 0))
            existing_side = str(getattr(existing_pos, "side", "long")).lower()
            if existing_qty <= 0 or "long" not in existing_side:
                return {
                    "ok": False, "order_id": None, "status": "rejected",
                    "filled_qty": None,
                    "error": (f"long_only_guard: no hay posicion LONG en "
                              f"{alpaca_symbol} (qty={existing_qty}, "
                              f"side={existing_side}). SELL bloqueado para "
                              f"evitar SHORT fantasma. Set LEONEX_ALLOW_SHORTS=1 "
                              f"si quieres shorts."),
                    "alpaca_symbol": alpaca_symbol,
                }
            if qty > existing_qty:
                return {
                    "ok": False, "order_id": None, "status": "rejected",
                    "filled_qty": None,
                    "error": (f"long_only_guard: SELL qty={qty} > posicion "
                              f"LONG actual {existing_qty}. El exceso creaba "
                              f"un SHORT. Set LEONEX_ALLOW_SHORTS=1 si quieres "
                              f"shorts."),
                    "alpaca_symbol": alpaca_symbol,
                }
        except Exception as exc:
            msg = repr(exc)
            low = msg.lower()
            if "404" in msg or "not found" in low or "position does not exist" in low:
                # No hay posicion en absoluto -> SELL crearia SHORT. Bloquear.
                return {
                    "ok": False, "order_id": None, "status": "rejected",
                    "filled_qty": None,
                    "error": (f"long_only_guard: no hay posicion en "
                              f"{alpaca_symbol}. SELL bloqueado para evitar "
                              f"SHORT fantasma."),
                    "alpaca_symbol": alpaca_symbol,
                }
            # Otro error (network / rate limit) -> fail-safe: no enviamos.
            return {
                "ok": False, "order_id": None, "status": "rejected",
                "filled_qty": None,
                "error": (f"long_only_guard: no puedo verificar posicion "
                          f"({msg}) -> fail-safe, no envio."),
                "alpaca_symbol": alpaca_symbol,
            }

    try:
        req = MarketOrderRequest(
            symbol=alpaca_symbol,
            qty=qty,
            side=side_enum,
            time_in_force=tif_enum,
        )
        order = client.submit_order(order_data=req)
        return {
            "ok": True,
            "order_id": str(order.id),
            "status": str(order.status).lower() if getattr(order, "status", None) else "submitted",
            "filled_qty": _safe_float(getattr(order, "filled_qty", 0)),
            "error": None,
            "alpaca_symbol": alpaca_symbol,
        }
    except Exception as exc:
        return {
            "ok": False,
            "order_id": None,
            "status": "rejected",
            "filled_qty": None,
            "error": repr(exc),
            "alpaca_symbol": alpaca_symbol,
        }


def get_latest_quote(symbol: str) -> Optional[dict]:
    """
    Devuelve {bid, ask, mid, ts, asset_class} del ultimo quote conocido.
    Soporta equities (StockHistoricalDataClient) y crypto (CryptoHistoricalDataClient).
    None si Alpaca no responde o no esta configurado.

    Uso interno del executor para calcular qty con el precio actual.
    """
    api_key = _env("ALPACA_API_KEY", "APCA_API_KEY_ID")
    secret = _env("ALPACA_SECRET_KEY", "ALPACA_API_SECRET",
                  "APCA_API_SECRET_KEY")
    if not api_key or not secret or api_key.startswith("PKXXXX"):
        return None

    is_crypto = is_crypto_symbol(symbol)
    alpaca_symbol = to_alpaca_symbol(symbol)

    if is_crypto:
        # Crypto: cliente y request distintos, ademas la API de crypto no
        # requiere autenticacion para los datos publicos (key opcional).
        try:
            from alpaca.data.historical import CryptoHistoricalDataClient
            from alpaca.data.requests import CryptoLatestQuoteRequest
        except ImportError:
            return None
        try:
            data_client = CryptoHistoricalDataClient(api_key, secret)
            req = CryptoLatestQuoteRequest(symbol_or_symbols=alpaca_symbol)
            quotes = data_client.get_crypto_latest_quote(req)
            q = quotes.get(alpaca_symbol)
            if q is None:
                return None
            bid = _safe_float(getattr(q, "bid_price", 0))
            ask = _safe_float(getattr(q, "ask_price", 0))
            mid = (bid + ask) / 2 if (bid > 0 and ask > 0) else max(bid, ask)
            return {"bid": bid, "ask": ask, "mid": mid,
                    "ts": str(getattr(q, "timestamp", "")),
                    "asset_class": "crypto",
                    "alpaca_symbol": alpaca_symbol}
        except Exception:
            return None

    # Equity
    try:
        from alpaca.data.historical import StockHistoricalDataClient
        from alpaca.data.requests import StockLatestQuoteRequest
    except ImportError:
        return None
    try:
        data_client = StockHistoricalDataClient(api_key, secret)
        req = StockLatestQuoteRequest(symbol_or_symbols=symbol)
        quotes = data_client.get_stock_latest_quote(req)
        q = quotes.get(symbol)
        if q is None:
            return None
        bid = _safe_float(getattr(q, "bid_price", 0))
        ask = _safe_float(getattr(q, "ask_price", 0))
        mid = (bid + ask) / 2 if (bid > 0 and ask > 0) else max(bid, ask)
        return {"bid": bid, "ask": ask, "mid": mid,
                "ts": str(getattr(q, "timestamp", "")),
                "asset_class": "equity",
                "alpaca_symbol": symbol}
    except Exception:
        return None


if __name__ == "__main__":
    import json
    snap = fetch_snapshot()
    print(json.dumps(snapshot_to_dict(snap), indent=2, default=str))
