"""One interface, several brokers. Strategy code never knows which one it is talking to.

SimBroker   - local fake money on real prices (also used by the backtester)
CcxtBroker  - a crypto exchange DEMO/testnet account (mode='demo') or a real one (mode='live', heavily guarded)
Mt5Broker   - a MetaTrader 5 account: forex / gold / indices. Demo by default; live is guarded.
"""
import json
import math
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path


@dataclass
class Fill:
    price: float
    qty: float
    fee: float
    ticket: str = None


class NeedAccount(Exception):
    """No demo account keys are available for this strategy version."""


class OrderRejected(Exception):
    """The broker refused an order (market closed, bad volume, no money...). Not a crash."""


class Broker(ABC):
    name = "base"
    server_side_stops = False   # True when the broker itself holds our stop loss / take profit

    def set_price(self, price):  # only meaningful for SimBroker
        pass

    @abstractmethod
    def price(self, symbol): ...

    @abstractmethod
    def equity(self): ...

    @abstractmethod
    def market_order(self, symbol, side, qty, reduce_only=False) -> Fill: ...

    # ---- optional capabilities (safe defaults)
    def protect(self, symbol, ticket, sl, tp):
        pass

    def open_qty(self, symbol):
        return None          # None = "this broker can't tell me"

    def closed_info(self, symbol, trade):
        return None

    def loss_per_unit(self, symbol, side, entry, dist):
        return None

    def max_qty(self, symbol, side, price, equity, max_leverage):
        return None

    def normalize_qty(self, symbol, qty):
        return qty

    def pip_size(self, symbol):
        return None

    def spread_pips(self, symbol):
        return None


class SimBroker(Broker):
    name = "sim"

    def __init__(self, start_balance=10000.0, fee_rate=0.0006, slippage_bps=2.0):
        self.cash, self.pos, self.avg, self.px = float(start_balance), 0.0, 0.0, None
        self.fee_rate, self.slip = fee_rate, slippage_bps / 10000.0

    def set_price(self, price):
        self.px = float(price)

    def price(self, symbol):
        return self.px

    def equity(self):
        return self.cash + (self.pos * (self.px - self.avg) if self.pos and self.px else 0.0)

    def market_order(self, symbol, side, qty, reduce_only=False):
        sign = 1 if side == "buy" else -1
        if reduce_only:
            if self.pos == 0 or (self.pos > 0) == (sign > 0):
                raise ValueError("nothing to reduce")
            qty = min(qty, abs(self.pos))
        fill = self.px * (1 + sign * self.slip)
        fee = fill * qty * self.fee_rate
        signed = sign * qty
        if self.pos == 0 or self.pos * signed > 0:
            newpos = self.pos + signed
            self.avg = (abs(self.pos) * self.avg + qty * fill) / abs(newpos)
            self.pos = newpos
        else:
            closing = min(qty, abs(self.pos))
            self.cash += closing * (fill - self.avg) * (1 if self.pos > 0 else -1)
            newpos = self.pos + signed
            if abs(newpos) < 1e-12:
                self.pos, self.avg = 0.0, 0.0
            elif self.pos * newpos > 0:
                self.pos = newpos
            else:
                self.pos, self.avg = newpos, fill
        self.cash -= fee
        return Fill(fill, qty, fee)

    # persistence so a restart keeps the same fake account
    def save(self, path):
        Path(path).write_text(json.dumps({"cash": self.cash, "pos": self.pos, "avg": self.avg}))

    def load(self, path):
        p = Path(path)
        if p.exists():
            d = json.loads(p.read_text())
            self.cash, self.pos, self.avg = d["cash"], d["pos"], d["avg"]


class CcxtBroker(Broker):
    def __init__(self, cfg, key, secret, password=None, mode="demo"):
        import ccxt  # imported lazily so backtests work without it
        self.mode, self.cfg = mode, cfg
        self.name = f"{cfg['exchange']}-{mode}"
        self.quote = cfg.get("quote_currency", "USDT")
        params = {"apiKey": key, "secret": secret, "enableRateLimit": True,
                  "options": {"defaultType": cfg.get("market_type", "swap")}}
        if password:
            params["password"] = password
        self.ex = getattr(ccxt, cfg["exchange"])(params)
        if mode == "demo":
            style = cfg.get("demo_style", "sandbox")
            if style == "okx_header":
                self.ex.headers = {**self.ex.headers, "x-simulated-trading": "1"}
            elif style == "demo_trading" and hasattr(self.ex, "enable_demo_trading"):
                self.ex.enable_demo_trading(True)
            else:
                self.ex.set_sandbox_mode(True)
        self.ex.load_markets()

    def price(self, symbol):
        return float(self.ex.fetch_ticker(symbol)["last"])

    def equity(self):
        return float(self.ex.fetch_balance()["total"].get(self.quote, 0.0))

    def normalize_qty(self, symbol, qty):
        try:
            return float(self.ex.amount_to_precision(symbol, qty))
        except Exception:
            return 0.0

    def market_order(self, symbol, side, qty, reduce_only=False):
        qty = float(self.ex.amount_to_precision(symbol, qty))
        params = {"reduceOnly": True} if reduce_only else {}
        try:
            o = self.ex.create_order(symbol, "market", side, qty, None, params)
        except Exception as e:
            raise OrderRejected(str(e)) from e
        px = o.get("average") or o.get("price") or self.price(symbol)
        fee = (o.get("fee") or {}).get("cost") or 0.0
        return Fill(float(px), float(o.get("filled") or qty), float(fee))


class Mt5Broker(Broker):
    """MetaTrader 5. Stops and targets live ON THE BROKER SIDE, so they work even if this program dies."""
    server_side_stops = True

    def __init__(self, cfg, version, mode="demo"):
        from .mt5conn import get_mt5
        self.m = get_mt5()
        self.cfg, self.mode = cfg, mode
        self.magic = 20260000 + int(version)       # tags our positions, one number per strategy version
        self.name = f"mt5-{mode}"
        ai = self.m.account_info()
        if ai is None:
            raise RuntimeError(f"MT5 has no logged-in account: {self.m.last_error()}")
        is_demo = ai.trade_mode == self.m.ACCOUNT_TRADE_MODE_DEMO
        if mode == "demo" and not is_demo:
            raise PermissionError("Mode is 'demo' but the logged-in MT5 account is NOT a demo account. Refusing to trade.")
        if mode == "live" and is_demo:
            raise PermissionError("Mode is 'live' but the logged-in MT5 account is a demo account.")
        self.max_margin_pct = float(cfg.get("risk", {}).get("max_margin_pct", 20))
        self.server_side_stops = True

    # ---- helpers
    def _info(self, symbol):
        info = self.m.symbol_info(symbol)
        if info is None:
            raise OrderRejected(f"symbol {symbol} not found in MT5 (check the exact name in Market Watch)")
        if not info.visible:
            self.m.symbol_select(symbol, True)
        return info

    def _tick(self, symbol):
        self._info(symbol)
        t = self.m.symbol_info_tick(symbol)
        if t is None:
            raise OrderRejected(f"no price for {symbol}")
        return t

    def _filling(self, info):
        mode = info.filling_mode
        if mode & 2:
            return self.m.ORDER_FILLING_IOC
        if mode & 1:
            return self.m.ORDER_FILLING_FOK
        return self.m.ORDER_FILLING_RETURN

    def _send(self, req):
        r = self.m.order_send(req)
        if r is None:
            raise OrderRejected(f"order_send returned nothing: {self.m.last_error()}")
        if r.retcode not in (self.m.TRADE_RETCODE_DONE, getattr(self.m, "TRADE_RETCODE_PLACED", 10008)):
            raise OrderRejected(f"MT5 retcode {r.retcode}: {r.comment}")
        return r

    def _ours(self, symbol):
        pos = self.m.positions_get(symbol=symbol)
        if pos is None:
            return None
        return [p for p in pos if p.magic == self.magic]

    # ---- Broker interface
    def price(self, symbol):
        t = self._tick(symbol)
        return (t.bid + t.ask) / 2

    def equity(self):
        return float(self.m.account_info().equity)

    def normalize_qty(self, symbol, qty):
        info = self._info(symbol)
        lots = math.floor(qty / info.volume_step + 1e-9) * info.volume_step
        lots = min(lots, info.volume_max)
        if lots < info.volume_min - 1e-12:
            return 0.0
        return round(lots, 8)

    def loss_per_unit(self, symbol, side, entry, dist):
        """Money lost (in account currency) per 1.0 lot if price moves `dist` against us."""
        buy = side == "buy"
        exitp = entry - dist if buy else entry + dist
        v = self.m.order_calc_profit(self.m.ORDER_TYPE_BUY if buy else self.m.ORDER_TYPE_SELL, symbol, 1.0, entry, exitp)
        return abs(v) if v else None

    def max_qty(self, symbol, side, price, equity, max_leverage):
        buy = side == "buy"
        margin = self.m.order_calc_margin(self.m.ORDER_TYPE_BUY if buy else self.m.ORDER_TYPE_SELL, symbol, 1.0, price)
        if not margin or margin <= 0:
            return None
        return equity * self.max_margin_pct / 100.0 / margin

    def market_order(self, symbol, side, qty, reduce_only=False, ticket=None):
        info = self._info(symbol)
        tick = self._tick(symbol)
        base = {"action": self.m.TRADE_ACTION_DEAL, "symbol": symbol, "deviation": 20, "magic": self.magic,
                "comment": "sailar-x", "type_time": self.m.ORDER_TIME_GTC, "type_filling": self._filling(info)}
        if reduce_only:
            ours = self._ours(symbol)
            if not ours:
                raise OrderRejected("no open position to close")
            p = self._pick(ours, ticket)
            closing_buy = p.type == self.m.POSITION_TYPE_SELL
            req = {**base, "position": p.ticket, "volume": p.volume,
                   "type": self.m.ORDER_TYPE_BUY if closing_buy else self.m.ORDER_TYPE_SELL,
                   "price": tick.ask if closing_buy else tick.bid}
            r = self._send(req)
            return Fill(float(r.price), float(p.volume), 0.0, str(p.ticket))
        buy = side == "buy"
        req = {**base, "volume": float(qty), "type": self.m.ORDER_TYPE_BUY if buy else self.m.ORDER_TYPE_SELL,
               "price": tick.ask if buy else tick.bid}
        r = self._send(req)
        ticket = None
        deals = self.m.history_deals_get(ticket=r.deal) if getattr(r, "deal", 0) else None
        if deals:
            ticket = deals[0].position_id
        if not ticket:
            ticket = r.order
        return Fill(float(r.price), float(r.volume or qty), 0.0, str(ticket))

    def protect(self, symbol, ticket, sl, tp):
        digits = self._info(symbol).digits
        pos_ticket = self._resolve(symbol, ticket)
        self._send({"action": self.m.TRADE_ACTION_SLTP, "position": pos_ticket, "symbol": symbol,
                    "sl": round(sl, digits), "tp": round(tp, digits), "magic": self.magic})

    def _pick(self, ours, ticket):
        """The exact position to close. Never 'the first one in the list'."""
        if ticket:
            for q in ours:
                if str(q.ticket) == str(ticket):
                    return q
        if len(ours) == 1:
            return ours[0]
        raise OrderRejected(f"position {ticket} not found and {len(ours)} Sailar-x positions are open: refusing to guess which to close")

    def _resolve(self, symbol, ticket):
        t = int(ticket)
        if self.m.positions_get(ticket=t):
            return t
        ours = self._ours(symbol) or []
        if len(ours) == 1:
            return ours[0].ticket
        raise OrderRejected(f"position {t} not found and {len(ours)} Sailar-x positions are open: refusing to guess which to protect")

    def list_positions(self, symbol):
        pos = self.m.positions_get(symbol=symbol)
        if pos is None:
            return None
        return [{"ticket": q.ticket, "volume": q.volume, "magic": q.magic, "ours": q.magic == self.magic,
                 "side": "BUY" if q.type == self.m.POSITION_TYPE_BUY else "SELL", "price": q.price_open, "sl": q.sl, "tp": q.tp,
                 "profit": float(q.profit), "time": int(getattr(q, "time", 0))} for q in pos]

    def close_position(self, symbol, ticket):
        q = (self.m.positions_get(ticket=int(ticket)) or [None])[0]
        if q is None:
            raise OrderRejected(f"position {ticket} not found")
        info, tick = self._info(q.symbol), self._tick(q.symbol)
        buy = q.type == self.m.POSITION_TYPE_SELL
        r = self._send({"action": self.m.TRADE_ACTION_DEAL, "symbol": q.symbol, "deviation": 20, "magic": self.magic,
                        "comment": "sailar-x extra", "type_time": self.m.ORDER_TIME_GTC, "type_filling": self._filling(info),
                        "position": q.ticket, "volume": q.volume, "type": self.m.ORDER_TYPE_BUY if buy else self.m.ORDER_TYPE_SELL,
                        "price": tick.ask if buy else tick.bid})
        deals = self.m.history_deals_get(position=int(q.ticket)) or []
        pnl = sum(d.profit + d.commission + d.swap + getattr(d, "fee", 0.0) for d in deals)
        return {"price": float(r.price), "pnl": float(pnl), "volume": q.volume}

    def open_qty(self, symbol):
        ours = self._ours(symbol)
        return None if ours is None else sum(p.volume for p in ours)

    def pip_size(self, symbol):
        i = self._info(symbol)
        return i.point * 10 if i.digits in (3, 5) else i.point

    def spread_pips(self, symbol):
        t = self._tick(symbol)
        return (t.ask - t.bid) / self.pip_size(symbol)

    def closed_info(self, symbol, trade):
        """What really happened to a closed position, from the broker's own deal history."""
        deals = self.m.history_deals_get(position=int(trade["ticket"]))
        if not deals:
            return None
        outs = [d for d in deals if d.entry in (self.m.DEAL_ENTRY_OUT, getattr(self.m, "DEAL_ENTRY_OUT_BY", 3))]
        if not outs:
            return None
        last = outs[-1]
        pnl = sum(d.profit + d.commission + d.swap + getattr(d, "fee", 0.0) for d in deals)
        fees = -sum(d.commission + d.swap + getattr(d, "fee", 0.0) for d in deals)
        reason = {self.m.DEAL_REASON_SL: "SL", self.m.DEAL_REASON_TP: "TP"}.get(last.reason, "CLOSED")
        return {"price": float(last.price), "pnl": float(pnl), "fees": float(fees), "reason": reason}


def live_allowed(cfg, version):
    """Real money needs ALL of: config flag, env confirmation, and a passed gate file for this version."""
    from .config import STATE
    if not cfg.get("live_enabled"):
        return False, "live_enabled is false in config.json"
    if os.environ.get("SAILAR_LIVE_CONFIRM") != "I_UNDERSTAND_THE_RISK":
        return False, "SAILAR_LIVE_CONFIRM is not set"
    gate = STATE / f"gate_passed_v{version}.json"
    if not gate.exists():
        return False, f"no gate file for v{version}; run `python run.py gate` first"
    return True, ""


def make_broker(cfg, version_row, journal=None):
    mode = cfg["mode"]
    ver = version_row["version"]
    if mode == "sim":
        from .config import STATE
        b = SimBroker(cfg["sim_start_balance"], cfg["fee_rate"], cfg["slippage_bps"])
        b.state_path = STATE / f"sim_account_v{ver}.json"
        b.load(b.state_path)
        return b
    if cfg.get("platform") == "mt5":
        if mode == "live":
            ok, why = live_allowed(cfg, ver)
            if not ok:
                raise PermissionError(f"Live trading is locked: {why}")
        return Mt5Broker(cfg, ver, mode)
    if mode == "demo":
        n = version_row.get("account") or 1
        key = os.environ.get(f"DEMO_KEY_{n}")
        if not key and cfg.get("reuse_demo_account"):
            n, key = 1, os.environ.get("DEMO_KEY_1")
        if not key:
            raise NeedAccount(f"Add DEMO_KEY_{n} / DEMO_SECRET_{n} to .env (a fresh demo account for v{ver})")
        return CcxtBroker(cfg, key, os.environ.get(f"DEMO_SECRET_{n}"), os.environ.get(f"DEMO_PASSWORD_{n}"), "demo")
    if mode == "live":
        ok, why = live_allowed(cfg, ver)
        if not ok:
            raise PermissionError(f"Live trading is locked: {why}")
        return CcxtBroker(cfg, os.environ["LIVE_KEY"], os.environ["LIVE_SECRET"], os.environ.get("LIVE_PASSWORD"), "live")
    raise ValueError(f"unknown mode {mode}")
