# version: v6
"""
================================================================================
Quantum Terminal — MetaTrader 5 Provider

v6 — Auto-link broker symbol via fuzzy match. Brokers wrap canonical names
     in arbitrary suffixes / case variants — XAUUSD might be stored as
     `xauusd`, `XAUUSD.x`, `XAUUSD.cash`, `XAUUSDi`, `XAUUSD!`, `XAUUSD-cfd`
     etc.; common-name swaps like USTEC ↔ NAS100 / US100, US30 ↔ DOW are
     also widespread. Previously these required manual entry in Settings →
     Custom Tickers → broker_symbol. Now `resolve_symbol()` enumerates the
     broker's symbol catalog (`mt5.symbols_get()`), normalizes each name
     (lowercased, alphanumeric-only), and matches against the canonical
     plus a known-alias list. First hit wins, gets cached, logs an INFO
     line so the operator can see the auto-link. User overrides from
     Settings still take precedence.

v2 — Added broker-symbol override hook. set_broker_symbol_lookup(fn) lets
     the ConfigManager wire a runtime lookup for user-configured overrides
     (Settings panel → custom_tickers[X].broker_symbol). resolve_symbol()
     checks the override FIRST before the canonical / alias chain. New
     reset_symbol(canonical) method evicts a ticker's cached mapping so the
     next resolve picks up a changed override without requiring a restart.
================================================================================
================================================================================
Implements BaseProvider for MetaTrader 5.

This is the refactored MT5Adapter from data_server.py. Same proven logic:
    - Symbol alias resolution (XAUUSD → GOLD, GER40 → DE40, etc.)
    - Tick polling, bar fetching, new bar detection
    - Synchronous API wrapped by callers in asyncio.to_thread()

New in provider version:
    - Implements BaseProvider interface (swappable)
    - Account info reporting
    - Execution methods (place_order, get_positions, close_position)
    - Symbol info extraction from MT5 symbol_info()
    - Reads config from account dict, not ServerConfig

Usage:
    from providers.mt5_provider import MT5Provider

    provider = MT5Provider({
        "id": "mt5_primary",
        "label": "MT5 — CFI (Live)",
        "terminal_path": None,  # auto-detect
        "aliases": { "GER40": ["GER40", "DE40", "DAX40"] },
    })
    provider.connect()
    ticks = provider.get_latest_ticks(["XAUUSD", "EURUSD"])
================================================================================
"""

import logging
from datetime import datetime, timezone
from typing import Dict, List, Optional, Any

from models import (
    TickData, BarData, AccountInfo, SymbolInfo,
    OrderRequest, OrderResult, Position, PendingOrder,
)
from providers.base_provider import BaseProvider

log = logging.getLogger("provider.mt5")

# ── MT5 imported lazily — provider works in degraded mode if not installed ──
try:
    import MetaTrader5 as mt5
    MT5_AVAILABLE = True
except ImportError:
    mt5 = None
    MT5_AVAILABLE = False

# ── MT5 timeframe constants ──
MT5_TIMEFRAMES = {
    "M1":  1,
    "M5":  5,
    "M15": 15,
    "M30": 30,
    "H1":  16385,
    "H4":  16388,
    "D1":  16408,
    "W1":  32769,
}

# ── MT5 order type mapping ──
MT5_ORDER_TYPES = {
    "MARKET_BUY":  None,   # Populated after mt5 import check
    "MARKET_SELL": None,
    "LIMIT_BUY":   None,
    "LIMIT_SELL":  None,
    "STOP_BUY":    None,
    "STOP_SELL":   None,
}

if MT5_AVAILABLE:
    MT5_ORDER_TYPES.update({
        "MARKET_BUY":  mt5.ORDER_TYPE_BUY,
        "MARKET_SELL": mt5.ORDER_TYPE_SELL,
        "LIMIT_BUY":   mt5.ORDER_TYPE_BUY_LIMIT,
        "LIMIT_SELL":  mt5.ORDER_TYPE_SELL_LIMIT,
        "STOP_BUY":    mt5.ORDER_TYPE_BUY_STOP,
        "STOP_SELL":   mt5.ORDER_TYPE_SELL_STOP,
    })


# v6: known-alias table for the auto-link fuzzy match. When a canonical
#   ticker's exact name isn't found at the broker, resolve_symbol() will
#   look for any of these names too, in addition to fuzzy-normalizing both
#   sides (lowercased, alphanumeric-only) so suffix/case quirks
#   (XAUUSD.x, XAUUSDi, xauusd, XAUUSD-cfd, etc.) auto-resolve.
#   Keep entries CASE-INSENSITIVE — they're normalized on lookup. The
#   canonical itself doesn't need to be repeated here.
KNOWN_ALIASES: Dict[str, List[str]] = {
    "US500":   ["SPX500", "SP500", "USA500", "ES500", "WS500", "S&P500", "USA500IDX"],
    "USTEC":   ["NAS100", "US100", "USTECH100", "USTEC100", "NDX", "NQ100", "NAS100IDX"],
    "US30":    ["DOW",    "DJ30",  "WS30",     "INDU",     "DJ30IDX", "US30Cash"],
    "GER40":   ["DE40",   "GER30", "DAX",      "DAX40",    "DE30",    "GER30Cash"],
    "UK100":   ["FTSE100", "FTSE", "UK100Cash"],
    "JP225":   ["NIK225", "NIKKEI", "NIKKEI225", "JP225Cash"],
    "FRA40":   ["CAC40", "CAC"],
    "ESP35":   ["IBEX35", "IBEX"],
    "AUS200":  ["ASX200", "AU200"],
    "HK50":    ["HKG33", "HKG50", "HSI"],
    "XAUUSD":  ["GOLD",   "XAU",   "XAUUSDX"],
    "XAGUSD":  ["SILVER", "XAG",   "XAGUSDX"],
    "XPTUSD":  ["PLATINUM"],
    "XPDUSD":  ["PALLADIUM"],
    "XTIUSD":  ["WTI",    "USOIL", "OIL",      "CRUDE",    "WTIUSD",  "OILUSD"],
    "BRENT":   ["UKOIL",  "BCOUSD", "BRENTUSD", "UKOIL.cash"],
    "BTCUSD":  ["BITCOIN", "BTC"],
    "ETHUSD":  ["ETHEREUM", "ETH"],
    "NATGAS":  ["NGAS",  "NATURALGAS", "GAS"],
    "COPPER":  ["XCUUSD", "HG"],
}


class MT5Provider(BaseProvider):
    """
    MetaTrader 5 data and execution provider.
    
    Config dict keys:
        id:             str  — unique provider instance ID (e.g., "mt5_primary")
        label:          str  — display name (e.g., "MT5 — CFI (Live)")
        terminal_path:  str or None — path to terminal64.exe (None = auto-detect)
        aliases:        dict — canonical → [broker_names] override map
    """

    def __init__(self, account_config: Dict[str, Any]):
        self._id = account_config.get("id", "mt5_default")
        self._label = account_config.get("label", "MetaTrader 5")
        self._terminal_path = account_config.get("terminal_path", None)
        self._aliases = account_config.get("aliases", {})
        self._connected = False

        # Symbol resolution caches
        self._symbol_map: Dict[str, str] = {}       # canonical → broker name
        self._reverse_map: Dict[str, str] = {}       # broker name → canonical
        self._resolved_symbols: List[str] = []       # canonical names that resolved
        # v6: lazy normalized index of the broker's full symbol catalog so
        #   resolve_symbol() can do an alphanumeric-only case-insensitive
        #   match. Built on first use after connect; cleared on disconnect.
        self._broker_symbols_norm: Dict[str, str] = {}   # normalized → real broker name

        # Bar tracking for check_new_bars
        self._last_bar_times: Dict[str, int] = {}    # "TICKER_TF" → epoch

        # v2: runtime broker-symbol override lookup (wired by ConfigManager).
        # Callable(canonical: str) -> Optional[str].
        self._broker_symbol_lookup = None

    # ── v6: fuzzy auto-link helpers ──

    @staticmethod
    def _normalize_symbol_name(name: str) -> str:
        """Lower-case, alphanumeric-only. So `XAUUSD.x`, `xauusd!`,
        `XAUUSD-cfd`, `xau usd` all collapse to `xauusd`."""
        if not name:
            return ""
        return "".join(ch.lower() for ch in str(name) if ch.isalnum())

    def _ensure_broker_index(self) -> None:
        """Lazy-build the normalized → real-name lookup over every symbol
        the broker exposes. Cheap (< 50ms for 1000+ symbols) and runs only
        once per connection. Cleared on disconnect / reset."""
        if self._broker_symbols_norm or not self._connected:
            return
        try:
            all_syms = mt5.symbols_get()
        except Exception as e:
            log.warning(f"[symbol auto-link] symbols_get() failed: {e}")
            return
        if not all_syms:
            return
        idx = {}
        for s in all_syms:
            try:
                key = self._normalize_symbol_name(s.name)
                if key and key not in idx:
                    idx[key] = s.name
            except Exception:
                continue
        self._broker_symbols_norm = idx
        log.info(f"[symbol auto-link] indexed {len(idx)} broker symbols")

    def _fuzzy_resolve(self, canonical: str) -> Optional[str]:
        """Match `canonical` (and KNOWN_ALIASES[canonical]) against the
        broker's full catalog with normalize-and-compare. First hit wins."""
        self._ensure_broker_index()
        if not self._broker_symbols_norm:
            return None
        candidates: List[str] = [canonical] + KNOWN_ALIASES.get(canonical.upper(), [])
        seen_keys = set()
        for c in candidates:
            key = self._normalize_symbol_name(c)
            if not key or key in seen_keys:
                continue
            seen_keys.add(key)
            real = self._broker_symbols_norm.get(key)
            if real:
                return real
        return None

    # ── Identity ──

    @property
    def provider_type(self) -> str:
        return "mt5"

    @property
    def provider_id(self) -> str:
        return self._id

    @property
    def label(self) -> str:
        return self._label

    # ── Capabilities ──

    @property
    def can_execute(self) -> bool:
        return True  # MT5 supports order execution

    @property
    def supported_timeframes(self) -> List[str]:
        return list(MT5_TIMEFRAMES.keys())

    # ── Connection ──

    @property
    def connected(self) -> bool:
        return self._connected

    def connect(self) -> bool:
        """Initialize MT5 connection. Returns True on success."""
        if not MT5_AVAILABLE:
            log.warning("MetaTrader5 package not installed — provider unavailable")
            return False

        if self._connected:
            return True  # Idempotent

        init_kwargs = {}
        if self._terminal_path:
            init_kwargs["path"] = self._terminal_path
            log.info(f"MT5 terminal path: {self._terminal_path}")
        else:
            log.info("MT5 terminal path: auto-detect")

        if not mt5.initialize(**init_kwargs):
            log.error(f"MT5 initialize() failed: {mt5.last_error()}")
            return False

        info = mt5.terminal_info()
        if info is None:
            log.error("MT5 terminal_info() returned None")
            mt5.shutdown()
            return False

        self._connected = True
        log.info(
            f"MT5 connected: {info.name} | "
            f"Company: {info.company} | "
            f"Build: {info.build}"
        )
        return True

    def disconnect(self) -> None:
        """Shutdown MT5 connection."""
        if self._connected and MT5_AVAILABLE:
            mt5.shutdown()
            self._connected = False
            self._symbol_map.clear()
            self._reverse_map.clear()
            self._resolved_symbols.clear()
            self._broker_symbols_norm.clear()  # v6: drop broker-specific index on disconnect
            log.info("MT5 disconnected")

    def heartbeat(self) -> bool:
        """
        Lightweight MT5 health check.
        Calls mt5.terminal_info() — fast and read-only.
        Returns False and marks disconnected if MT5 is unresponsive.
        """
        if not self._connected or not MT5_AVAILABLE:
            return False
        try:
            info = mt5.terminal_info()
            if info is None:
                self._connected = False
                log.warning("MT5 heartbeat failed — terminal_info() returned None")
                return False
            return True
        except Exception as e:
            self._connected = False
            log.warning(f"MT5 heartbeat exception: {e}")
            return False

    # ── Symbol Resolution ──

    def set_broker_symbol_lookup(self, fn) -> None:
        """v2: Register a callable(canonical) -> Optional[str] that returns
        the user-configured broker-symbol override for a ticker, if any.
        Called on every cache miss in resolve_symbol so runtime config
        changes take effect without provider restart."""
        self._broker_symbol_lookup = fn

    def reset_symbol(self, canonical: str) -> Optional[str]:
        """v2: Evict a ticker's cached mapping and re-resolve. Call this
        after the user changes broker_symbol in Settings so the next
        tick/bar request uses the new mapping."""
        canonical = canonical.upper()
        old = self._symbol_map.pop(canonical, None)
        if old is not None:
            self._reverse_map.pop(old, None)
            # Also drop cached bar time so the next poll re-seeds.
            for k in list(self._last_bar_times.keys()):
                if k.startswith(canonical + "_"):
                    self._last_bar_times.pop(k, None)
        return self.resolve_symbol(canonical)

    def resolve_symbol(self, canonical: str) -> Optional[str]:
        """
        Map canonical name to broker symbol.
        Order:
          0. user override (Settings panel) via broker_symbol_lookup callable — v2
          1. cache
          2. canonical name itself
          3. provider-level aliases (from account_config["aliases"])
        Caches result in _symbol_map for fast lookups.
        """
        if not self._connected:
            return None

        # v2: (0) user override takes precedence over cache — so changing
        # broker_symbol in Settings and calling reset_symbol() picks it up.
        if self._broker_symbol_lookup is not None:
            try:
                override = self._broker_symbol_lookup(canonical)
            except Exception as e:
                log.warning(f"broker_symbol_lookup failed for {canonical}: {e}")
                override = None
            if override:
                sym = mt5.symbol_info(override)
                if sym is not None:
                    if not sym.visible:
                        mt5.symbol_select(override, True)
                    self._symbol_map[canonical] = override
                    self._reverse_map[override] = canonical
                    log.info(f"Symbol resolved via user override: {canonical} → {override}")
                    return override
                else:
                    log.warning(f"User override {canonical} → {override} not found in MT5; falling back")

        # (1) cache
        if canonical in self._symbol_map:
            return self._symbol_map[canonical]

        # (2) canonical name directly
        sym = mt5.symbol_info(canonical)
        if sym is not None:
            if not sym.visible:
                mt5.symbol_select(canonical, True)
            self._symbol_map[canonical] = canonical
            self._reverse_map[canonical] = canonical
            return canonical

        # v6: (2.5) fuzzy auto-link — handle suffix/case variants and known
        #   common aliases (USTEC ↔ NAS100/US100, US30 ↔ DOW, etc.) without
        #   the user needing to set Settings → broker_symbol manually.
        auto = self._fuzzy_resolve(canonical)
        if auto is not None:
            sym = mt5.symbol_info(auto)
            if sym is not None:
                if not sym.visible:
                    mt5.symbol_select(auto, True)
                self._symbol_map[canonical] = auto
                self._reverse_map[auto] = canonical
                log.info(f"[symbol auto-link] {canonical} → {auto}")
                return auto

        # (3) provider aliases
        aliases = self._aliases.get(canonical, [])
        for alias in aliases:
            if alias == canonical:
                continue
            sym = mt5.symbol_info(alias)
            if sym is not None:
                if not sym.visible:
                    mt5.symbol_select(alias, True)
                self._symbol_map[canonical] = alias
                self._reverse_map[alias] = canonical
                log.info(f"Symbol resolved: {canonical} → {alias}")
                return alias

        log.warning(f"Symbol {canonical} not found in MT5 (tried {len(aliases)} aliases)")
        return None

    def resolve_universe(self, universe: List[str]) -> List[str]:
        """
        Resolve a full universe of canonical tickers.
        Returns list of canonical names that successfully resolved.
        Populates internal symbol maps.
        """
        self._symbol_map.clear()
        self._reverse_map.clear()
        self._resolved_symbols.clear()

        for canonical in universe:
            broker_name = self.resolve_symbol(canonical)
            if broker_name is not None:
                self._resolved_symbols.append(canonical)
                if broker_name != canonical:
                    log.info(f"  ✓ {canonical} → {broker_name}")
                else:
                    log.info(f"  ✓ {canonical}")
            else:
                log.warning(f"  ✗ {canonical} — not found")

        log.info(f"Resolved {len(self._resolved_symbols)}/{len(universe)} symbols")
        return list(self._resolved_symbols)

    def get_available_symbols(self) -> List[str]:
        """Return canonical names that have been successfully resolved."""
        return list(self._resolved_symbols)

    def _broker_symbol(self, canonical: str) -> str:
        """Quick lookup: canonical → broker symbol (assumes already resolved)."""
        return self._symbol_map.get(canonical, canonical)

    # ── Market Data ──

    def get_latest_ticks(self, symbols: List[str]) -> Dict[str, TickData]:
        """Fetch latest tick for each symbol."""
        if not self._connected:
            return {}

        ticks = {}
        for canonical in symbols:
            broker_sym = self._broker_symbol(canonical)
            tick = mt5.symbol_info_tick(broker_sym)
            if tick is None:
                continue

            sym_info = mt5.symbol_info(broker_sym)
            dec = sym_info.digits if sym_info else 5

            ticks[canonical] = TickData(
                ticker=canonical,
                bid=round(tick.bid, dec),
                ask=round(tick.ask, dec),
                last=round(tick.last or tick.bid, dec),
                time=datetime.fromtimestamp(
                    tick.time, tz=timezone.utc
                ).strftime("%Y-%m-%dT%H:%M:%S"),
                spread=round(tick.ask - tick.bid, dec),
            )
        return ticks

    def get_bars(
        self, ticker: str, timeframe: str = "M15", count: int = 200
    ) -> List[BarData]:
        """Fetch recent OHLCV bars for a canonical ticker."""
        if not self._connected:
            return []

        tf_value = MT5_TIMEFRAMES.get(timeframe)
        if tf_value is None:
            log.error(f"Unknown timeframe: {timeframe}")
            return []

        broker_sym = self._broker_symbol(ticker)
        rates = mt5.copy_rates_from_pos(broker_sym, tf_value, 0, count)
        if rates is None or len(rates) == 0:
            return []

        sym_info = mt5.symbol_info(broker_sym)
        dec = sym_info.digits if sym_info else 5

        bars = []
        for r in rates:
            bars.append(BarData(
                time=datetime.fromtimestamp(
                    r[0], tz=timezone.utc
                ).strftime("%Y-%m-%dT%H:%M:%S"),
                open=round(float(r[1]), dec),
                high=round(float(r[2]), dec),
                low=round(float(r[3]), dec),
                close=round(float(r[4]), dec),
                volume=int(r[5]),
            ))
        return bars

    def get_bars_range(
        self, ticker: str, timeframe: str, from_dt: datetime, to_dt: datetime,
    ) -> List[BarData]:
        """v5: Fetch OHLCV bars between two UTC datetimes via copy_rates_range.
        Used by the Stress Lab last-signal panel to replay historical trades."""
        if not self._connected:
            return []
        tf_value = MT5_TIMEFRAMES.get(timeframe)
        if tf_value is None:
            log.error(f"Unknown timeframe: {timeframe}")
            return []
        broker_sym = self._broker_symbol(ticker)
        # Ensure tz-aware UTC for the MT5 call
        if from_dt.tzinfo is None: from_dt = from_dt.replace(tzinfo=timezone.utc)
        if to_dt.tzinfo   is None: to_dt   = to_dt.replace(tzinfo=timezone.utc)
        rates = mt5.copy_rates_range(broker_sym, tf_value, from_dt, to_dt)
        if rates is None or len(rates) == 0:
            return []
        sym_info = mt5.symbol_info(broker_sym)
        dec = sym_info.digits if sym_info else 5
        bars = []
        for r in rates:
            bars.append(BarData(
                time=datetime.fromtimestamp(r[0], tz=timezone.utc)
                     .strftime("%Y-%m-%dT%H:%M:%S"),
                open=round(float(r[1]), dec),
                high=round(float(r[2]), dec),
                low=round(float(r[3]), dec),
                close=round(float(r[4]), dec),
                volume=int(r[5]),
            ))
        return bars

    def check_new_bars(
        self, symbols: List[str], timeframe: str = "M15"
    ) -> List[dict]:
        """
        Detect newly closed bars since last check.
        Returns list of bar event dicts for broadcasting.
        """
        if not self._connected:
            return []

        tf_value = MT5_TIMEFRAMES.get(timeframe)
        if tf_value is None:
            return []

        new_bars = []
        for canonical in symbols:
            broker_sym = self._broker_symbol(canonical)
            rates = mt5.copy_rates_from_pos(broker_sym, tf_value, 0, 2)
            if rates is None or len(rates) < 2:
                continue

            # Second-to-last bar = most recently CLOSED bar
            closed_bar = rates[-2]
            bar_epoch = int(closed_bar[0])

            cache_key = f"{canonical}_{timeframe}"
            if cache_key in self._last_bar_times:
                if bar_epoch > self._last_bar_times[cache_key]:
                    sym_info = mt5.symbol_info(broker_sym)
                    dec = sym_info.digits if sym_info else 5
                    bar = BarData(
                        time=datetime.fromtimestamp(
                            bar_epoch, tz=timezone.utc
                        ).strftime("%Y-%m-%dT%H:%M:%S"),
                        open=round(float(closed_bar[1]), dec),
                        high=round(float(closed_bar[2]), dec),
                        low=round(float(closed_bar[3]), dec),
                        close=round(float(closed_bar[4]), dec),
                        volume=int(closed_bar[5]),
                    )
                    new_bars.append({
                        "type": "bar",
                        "ticker": canonical,
                        "timeframe": timeframe,
                        "bar": bar.to_dict(),
                    })

            self._last_bar_times[cache_key] = bar_epoch

        return new_bars

    # ── Symbol Info ──

    def get_symbol_info(self, ticker: str) -> Optional[SymbolInfo]:
        """Extract full symbol metadata from MT5."""
        if not self._connected:
            return None

        broker_sym = self._broker_symbol(ticker)
        sym = mt5.symbol_info(broker_sym)
        if sym is None:
            return None

        # Determine asset class from symbol path or properties
        asset_class = self._classify_symbol(sym)

        return SymbolInfo(
            ticker=ticker,
            broker_symbol=broker_sym,
            asset_class=asset_class,
            decimals=sym.digits,
            description=sym.description or ticker,
            trade_allowed=sym.trade_mode != 0,
            min_lot=sym.volume_min,
            max_lot=sym.volume_max,
            lot_step=sym.volume_step,
            contract_size=sym.trade_contract_size,
            currency_profit=sym.currency_profit,
            currency_margin=sym.currency_margin,
            tick_size=sym.trade_tick_size,
            tick_value=sym.trade_tick_value,
        )

    def _classify_symbol(self, sym) -> str:
        """Guess asset class from MT5 symbol properties."""
        path = (sym.path or "").lower()
        desc = (sym.description or "").lower()

        if "forex" in path or "currencies" in path:
            return "FX"
        if "index" in path or "indices" in path:
            return "INDEX"
        if "commodit" in path or "metals" in path or "energy" in path:
            return "COMMODITY"
        if "crypto" in path:
            return "CRYPTO"
        if "stock" in path or "equit" in path:
            return "STOCK"
        # Heuristic fallbacks
        if "gold" in desc or "silver" in desc or "oil" in desc:
            return "COMMODITY"
        if sym.currency_profit == sym.currency_margin and sym.digits >= 4:
            return "FX"
        return "OTHER"

    # ── Account Info ──

    def get_account_info(self) -> Optional[AccountInfo]:
        """Get MT5 account snapshot."""
        if not self._connected:
            return None

        info = mt5.account_info()
        if info is None:
            return None

        term = mt5.terminal_info()
        return AccountInfo(
            provider_type="mt5",
            account_id=str(info.login),
            label=self._label,
            broker=info.company,
            server=info.server,
            currency=info.currency,
            balance=info.balance,
            equity=info.equity,
            margin_free=info.margin_free,
            leverage=info.leverage,
            connected=True,
        )

    # ── Execution ──

    def place_order(self, order: OrderRequest) -> OrderResult:
        """Place a trade order through MT5."""
        if not self._connected:
            return OrderResult(success=False, error="MT5 not connected")

        broker_sym = self._broker_symbol(order.ticker)
        sym_info = mt5.symbol_info(broker_sym)
        if sym_info is None:
            return OrderResult(
                success=False,
                error=f"Symbol {order.ticker} not found",
            )

        # Determine MT5 order type
        type_key = f"{order.order_type}_{order.direction}"
        mt5_type = MT5_ORDER_TYPES.get(type_key)
        if mt5_type is None:
            return OrderResult(
                success=False,
                error=f"Unsupported order type: {type_key}",
            )

        # Build request
        request = {
            "action": mt5.TRADE_ACTION_DEAL if order.order_type == "MARKET" else mt5.TRADE_ACTION_PENDING,
            "symbol": broker_sym,
            "volume": order.lots,
            "type": mt5_type,
            "magic": order.magic,
            "comment": order.comment or "Quantum Terminal",
            "type_time": mt5.ORDER_TIME_GTC,
            "type_filling": mt5.ORDER_FILLING_IOC,
        }

        if order.price is not None:
            request["price"] = order.price
        elif order.order_type == "MARKET":
            # Use current ask/bid for market orders
            tick = mt5.symbol_info_tick(broker_sym)
            if tick:
                request["price"] = tick.ask if order.direction == "BUY" else tick.bid

        if order.stop_loss is not None:
            request["sl"] = order.stop_loss
        if order.take_profit is not None:
            request["tp"] = order.take_profit

        # Send order
        result = mt5.order_send(request)
        if result is None:
            return OrderResult(
                success=False,
                error=f"MT5 order_send returned None: {mt5.last_error()}",
            )

        if result.retcode != mt5.TRADE_RETCODE_DONE:
            return OrderResult(
                success=False,
                error=f"MT5 order rejected: {result.retcode} — {result.comment}",
                raw=result._asdict() if hasattr(result, '_asdict') else None,
            )

        return OrderResult(
            success=True,
            order_id=str(result.deal),
            ticker=order.ticker,
            direction=order.direction,
            lots=order.lots,
            price=result.price,
        )

    def get_positions(self) -> List[Position]:
        """Get all open positions."""
        if not self._connected:
            return []

        positions = mt5.positions_get()
        if positions is None:
            return []

        result = []
        for pos in positions:
            # Map broker symbol back to canonical
            canonical = self._reverse_map.get(pos.symbol, pos.symbol)
            result.append(Position(
                ticket=str(pos.ticket),
                ticker=canonical,
                direction="BUY" if pos.type == 0 else "SELL",
                lots=pos.volume,
                open_price=pos.price_open,
                current_price=pos.price_current,
                stop_loss=pos.sl if pos.sl != 0 else None,
                take_profit=pos.tp if pos.tp != 0 else None,
                profit=pos.profit,
                swap=pos.swap,
                open_time=datetime.fromtimestamp(
                    pos.time, tz=timezone.utc
                ).strftime("%Y-%m-%dT%H:%M:%S"),
            ))
        return result

    def close_position(
        self, ticket: str, lots: Optional[float] = None
    ) -> OrderResult:
        """Close a position by ticket (full or partial)."""
        if not self._connected:
            return OrderResult(success=False, error="MT5 not connected")

        # Find the position
        positions = mt5.positions_get(ticket=int(ticket))
        if not positions or len(positions) == 0:
            return OrderResult(
                success=False,
                error=f"Position {ticket} not found",
            )

        pos = positions[0]
        close_lots = lots if lots is not None else pos.volume

        # Determine close direction (opposite of position)
        close_type = mt5.ORDER_TYPE_SELL if pos.type == 0 else mt5.ORDER_TYPE_BUY
        tick = mt5.symbol_info_tick(pos.symbol)
        price = tick.bid if pos.type == 0 else tick.ask

        request = {
            "action": mt5.TRADE_ACTION_DEAL,
            "symbol": pos.symbol,
            "volume": close_lots,
            "type": close_type,
            "position": pos.ticket,
            "price": price,
            "comment": "Quantum Terminal close",
            "type_time": mt5.ORDER_TIME_GTC,
            "type_filling": mt5.ORDER_FILLING_IOC,
        }

        result = mt5.order_send(request)
        if result is None:
            return OrderResult(
                success=False,
                error=f"Close failed: {mt5.last_error()}",
            )

        if result.retcode != mt5.TRADE_RETCODE_DONE:
            return OrderResult(
                success=False,
                error=f"Close rejected: {result.retcode} — {result.comment}",
            )

        canonical = self._reverse_map.get(pos.symbol, pos.symbol)
        return OrderResult(
            success=True,
            order_id=str(result.deal),
            ticker=canonical,
            direction="SELL" if pos.type == 0 else "BUY",
            lots=close_lots,
            price=result.price,
        )

    def modify_position(
        self, ticket: str, stop_loss=None, take_profit=None
    ) -> dict:
        """Modify SL/TP on an open position."""
        if not self._connected:
            return {"success": False, "error": "MT5 not connected"}

        positions = mt5.positions_get(ticket=int(ticket))
        if not positions or len(positions) == 0:
            return {"success": False, "error": f"Position {ticket} not found"}

        pos = positions[0]
        tick = mt5.symbol_info_tick(pos.symbol)
        if not tick:
            return {"success": False, "error": f"No tick data for {pos.symbol}"}

        # v3: Preserve the other side when only one is being modified.
        #     MT5 treats sl=0.0 / tp=0.0 as "clear the level", so passing None
        #     through as 0 would silently remove whichever side the caller
        #     omitted. Read the live pos.sl / pos.tp and pass them when the
        #     corresponding parameter is None.
        request = {
            "action": mt5.TRADE_ACTION_SLTP,
            "symbol": pos.symbol,
            "position": pos.ticket,
            "sl": float(stop_loss)   if stop_loss   is not None else float(getattr(pos, "sl", 0.0) or 0.0),
            "tp": float(take_profit) if take_profit is not None else float(getattr(pos, "tp", 0.0) or 0.0),
        }

        result = mt5.order_send(request)
        if result is None:
            return {"success": False, "error": f"Modify failed: {mt5.last_error()}"}

        if result.retcode != mt5.TRADE_RETCODE_DONE:
            return {"success": False, "error": f"Modify rejected: {result.retcode} — {result.comment}"}

        return {
            "success": True,
            "ticket": ticket,
            "stop_loss": stop_loss,
            "take_profit": take_profit,
        }

    # ── Pending orders (LIMIT / STOP) — v4 ────────────────────────────

    def get_pending_orders(self) -> List[PendingOrder]:
        """List all resting pending orders (LIMIT / STOP) on this account."""
        if not self._connected:
            return []
        orders = mt5.orders_get()
        if orders is None:
            return []
        out = []
        for o in orders:
            t = o.type
            if t == mt5.ORDER_TYPE_BUY_LIMIT:   direction, kind = "BUY",  "LIMIT"
            elif t == mt5.ORDER_TYPE_SELL_LIMIT: direction, kind = "SELL", "LIMIT"
            elif t == mt5.ORDER_TYPE_BUY_STOP:   direction, kind = "BUY",  "STOP"
            elif t == mt5.ORDER_TYPE_SELL_STOP:  direction, kind = "SELL", "STOP"
            else:
                continue  # skip stop-limit / market / closed types
            canonical = self._reverse_map.get(o.symbol, o.symbol)
            out.append(PendingOrder(
                ticket=str(o.ticket),
                symbol=o.symbol,
                ticker=canonical,
                direction=direction,
                order_type=kind,
                lots=o.volume_current,
                price=o.price_open,
                stop_loss=o.sl if o.sl != 0 else None,
                take_profit=o.tp if o.tp != 0 else None,
                comment=o.comment or "",
                time_setup=datetime.fromtimestamp(
                    o.time_setup, tz=timezone.utc
                ).strftime("%Y-%m-%dT%H:%M:%S"),
            ))
        return out

    def cancel_order(self, ticket: str) -> OrderResult:
        """Remove (cancel) a resting pending order."""
        if not self._connected:
            return OrderResult(success=False, error="MT5 not connected")
        orders = mt5.orders_get(ticket=int(ticket))
        if not orders:
            return OrderResult(success=False, error=f"Order {ticket} not found")
        req = {"action": mt5.TRADE_ACTION_REMOVE, "order": int(ticket)}
        result = mt5.order_send(req)
        if result is None:
            return OrderResult(success=False, error=f"Cancel failed: {mt5.last_error()}")
        if result.retcode != mt5.TRADE_RETCODE_DONE:
            return OrderResult(
                success=False,
                error=f"Cancel rejected: {result.retcode} — {result.comment}",
            )
        return OrderResult(success=True, order_id=int(ticket))

    def modify_order(
        self, ticket: str,
        price: Optional[float] = None,
        stop_loss: Optional[float] = None,
        take_profit: Optional[float] = None,
    ) -> OrderResult:
        """
        Modify a resting pending order's trigger price and/or SL/TP.
        Parameters left as None are preserved from the existing order so
        the caller can patch a single field without clobbering the rest.
        """
        if not self._connected:
            return OrderResult(success=False, error="MT5 not connected")
        orders = mt5.orders_get(ticket=int(ticket))
        if not orders:
            return OrderResult(success=False, error=f"Order {ticket} not found")
        o = orders[0]
        req = {
            "action":  mt5.TRADE_ACTION_MODIFY,
            "order":   int(ticket),
            "symbol":  o.symbol,
            "price":   float(price)       if price       is not None else float(o.price_open),
            "sl":      float(stop_loss)   if stop_loss   is not None else float(o.sl or 0.0),
            "tp":      float(take_profit) if take_profit is not None else float(o.tp or 0.0),
            "type_time":    o.type_time,
            "type_filling": o.type_filling,
        }
        result = mt5.order_send(req)
        if result is None:
            return OrderResult(success=False, error=f"Modify failed: {mt5.last_error()}")
        if result.retcode != mt5.TRADE_RETCODE_DONE:
            return OrderResult(
                success=False,
                error=f"Modify rejected: {result.retcode} — {result.comment}",
            )
        return OrderResult(success=True, order_id=int(ticket))

    def get_trade_history(self, days: int = 7) -> list:
        """
        Get closed trade history from MT5 using history_deals_get().
        Groups DEAL_ENTRY_IN + DEAL_ENTRY_OUT by position_id into ClosedTrade records.
        """
        from models import ClosedTrade
        from datetime import timedelta

        if not self._connected:
            return []

        utc_to = datetime.now(timezone.utc)
        utc_from = utc_to - timedelta(days=days)

        deals = mt5.history_deals_get(utc_from, utc_to)
        if deals is None or len(deals) == 0:
            return []

        # Group deals by position_id
        positions = {}  # position_id -> {"in": deal, "out": deal}
        for deal in deals:
            pid = deal.position_id
            if pid == 0:
                continue  # Balance/correction operations
            if pid not in positions:
                positions[pid] = {"in": None, "out": None}
            # entry=0 is DEAL_ENTRY_IN, entry=1 is DEAL_ENTRY_OUT
            if deal.entry == 0:
                positions[pid]["in"] = deal
            elif deal.entry == 1:
                positions[pid]["out"] = deal

        # Build ClosedTrade records (only complete round-trips)
        trades = []
        for pid, pair in positions.items():
            entry = pair["in"]
            exit_deal = pair["out"]
            if entry is None or exit_deal is None:
                continue  # Partial — still open or missing leg

            # Resolve canonical ticker from broker symbol
            canonical = entry.symbol
            for canon, aliases in self._aliases.items():
                if entry.symbol in aliases:
                    canonical = canon
                    break

            direction = "BUY" if entry.type == 0 else "SELL"  # DEAL_TYPE_BUY=0
            trades.append(ClosedTrade(
                ticket=str(pid),
                ticker=canonical,
                direction=direction,
                lots=entry.volume,
                open_price=entry.price,
                close_price=exit_deal.price,
                profit=exit_deal.profit,
                commission=round((entry.commission or 0) + (exit_deal.commission or 0), 2),
                swap=round((entry.swap or 0) + (exit_deal.swap or 0), 2),
                open_time=datetime.fromtimestamp(entry.time, tz=timezone.utc).isoformat(),
                close_time=datetime.fromtimestamp(exit_deal.time, tz=timezone.utc).isoformat(),
                comment=entry.comment or "",
                magic=entry.magic,
            ))

        # Sort newest first
        trades.sort(key=lambda t: t.close_time, reverse=True)
        return trades