"""
PandasStrategy — Nautilus Strategy base class with a DataFrame-oriented API.

Quants subclass PandasStrategy and implement evaluate(dfs) — the same function
they'd write in a research notebook. No Nautilus objects appear in quant code.

Features:
- Multiple data feeds: subscribe to N (ticker, period_seconds) pairs
- evaluate(dfs) receives a dict[feed_key, DataFrame] where feed_key is "TICKER:period_seconds"
- Every feed's DataFrame includes OHLC + volume, buy_vol, sell_vol, open_interest
- Timer hook (on_timer) reserved as extension point — TBD

Feed key format: "BTC/USDT.BINANCE:3600"
"""

from __future__ import annotations

import logging
from abc import abstractmethod
from typing import TYPE_CHECKING

import pandas as pd

from nautilus_trader.config import StrategyConfig
from nautilus_trader.model.data import Bar, BarType, BarSpecification
from nautilus_trader.model.enums import BarAggregation, PriceType, AggregationSource, OrderSide, TimeInForce
from nautilus_trader.model.identifiers import InstrumentId, Symbol, Venue
from nautilus_trader.model.objects import Quantity
from nautilus_trader.trading.strategy import Strategy

from dexorder.symbol_metadata_client import parse_ticker

log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Feed key helpers
# ---------------------------------------------------------------------------

_PERIOD_TO_AGGREGATION: list[tuple[int, BarAggregation, int]] = [
    # (threshold_exclusive, aggregation, divisor)
    # period_seconds < 60 → SECOND, step = period_seconds
    (60,    BarAggregation.SECOND, 1),
    # 60 <= period_seconds < 3600 → MINUTE
    (3600,  BarAggregation.MINUTE, 60),
    # 3600 <= period_seconds < 86400 → HOUR
    (86400, BarAggregation.HOUR, 3600),
]

_AGG_TO_SECONDS: dict[BarAggregation, int] = {
    BarAggregation.SECOND: 1,
    BarAggregation.MINUTE: 60,
    BarAggregation.HOUR:   3600,
    BarAggregation.DAY:    86400,
}


def make_feed_key(ticker: str, period_seconds: int) -> str:
    """Return canonical feed key, e.g. 'BTC/USDT.BINANCE:3600'."""
    return f"{ticker}:{period_seconds}"


def parse_feed_key(feed_key: str) -> tuple[str, int]:
    """Split 'BTC/USDT.BINANCE:3600' → ('BTC/USDT.BINANCE', 3600)."""
    ticker, period_str = feed_key.rsplit(":", 1)
    return ticker, int(period_str)


def bar_type_from_feed_key(feed_key: str) -> BarType:
    """Build a Nautilus BarType from a feed key string."""
    ticker, period_seconds = parse_feed_key(feed_key)
    exchange_id, market_id = parse_ticker(ticker)
    instrument_id = InstrumentId(Symbol(market_id), Venue(exchange_id))

    for threshold, agg, divisor in _PERIOD_TO_AGGREGATION:
        if period_seconds < threshold:
            step = period_seconds // divisor
            break
    else:
        agg = BarAggregation.DAY
        step = period_seconds // 86400

    spec = BarSpecification(step=step, aggregation=agg, price_type=PriceType.MID)
    return BarType(instrument_id=instrument_id, bar_spec=spec,
                   aggregation_source=AggregationSource.EXTERNAL)


def feed_key_from_bar_type(bar_type: BarType) -> str:
    """Reconstruct the feed key from a BarType."""
    iid = bar_type.instrument_id
    ticker = f"{iid.symbol}.{iid.venue}"
    multiplier = _AGG_TO_SECONDS.get(bar_type.spec.aggregation, 1)
    period_seconds = bar_type.spec.step * multiplier
    return f"{ticker}:{period_seconds}"


# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------

class PandasStrategyConfig(StrategyConfig, frozen=True):
    """
    Configuration for PandasStrategy.

    feed_keys: tuple of feed key strings, e.g. ("BTC/USDT.BINANCE:3600",)
               Set by the backtest/activate runner — not by the quant's code.
    initial_capital: informational; actual account balance is set in BacktestEngine.
    """
    feed_keys: tuple[str, ...] = ()
    initial_capital: float = 10_000.0


# ---------------------------------------------------------------------------
# Base class
# ---------------------------------------------------------------------------

class PandasStrategy(Strategy):
    """
    Base class for quant strategies.

    Quants implement evaluate(dfs) — the same function they'd write in a research
    notebook. All bar accumulation, OHLC+ field injection, and DataFrame management
    is handled internally.

    Example
    -------
    ::

        from dexorder.nautilus import PandasStrategy
        import pandas as pd
        import pandas_ta as ta

        class MyStrategy(PandasStrategy):
            def evaluate(self, dfs):
                df = dfs.get("BTC/USDT.BINANCE:3600")
                if df is None or len(df) < 14:
                    return
                rsi = ta.rsi(df["close"], length=14)
                if rsi.iloc[-1] < 30:
                    self.buy(0.01)
                elif rsi.iloc[-1] > 70:
                    self.sell(0.01)
    """

    def __init__(self, config: PandasStrategyConfig) -> None:
        super().__init__(config)
        # Per-feed row accumulator
        self._rows: dict[str, list[dict]] = {}
        # Per-feed DataFrame (updated after each bar)
        self._dfs: dict[str, pd.DataFrame] = {}
        # Per-feed extras lookup: {ts_event_ns: {buy_vol, sell_vol, open_interest}}
        self._extras: dict[str, dict[int, dict]] = {}
        # Resolved BarType objects (populated in on_start)
        self._bar_types: dict[str, BarType] = {}

    # ------------------------------------------------------------------
    # Nautilus lifecycle
    # ------------------------------------------------------------------

    def on_start(self) -> None:
        for feed_key in self.config.feed_keys:
            bar_type = bar_type_from_feed_key(feed_key)
            self._bar_types[feed_key] = bar_type
            self.subscribe_bars(bar_type)

    def on_bar(self, bar: Bar) -> None:
        feed_key = feed_key_from_bar_type(bar.bar_type)
        ts_ns = bar.ts_event

        # Merge OHLC+ extras (buy_vol, sell_vol, open_interest) by timestamp
        extras = self._extras.get(feed_key, {}).get(ts_ns, {})

        row = {
            "timestamp":     ts_ns,
            "open":          float(bar.open),
            "high":          float(bar.high),
            "low":           float(bar.low),
            "close":         float(bar.close),
            "volume":        float(bar.volume),
            "buy_vol":       extras.get("buy_vol"),
            "sell_vol":      extras.get("sell_vol"),
            "open_interest": extras.get("open_interest"),
        }

        if feed_key not in self._rows:
            self._rows[feed_key] = []
        self._rows[feed_key].append(row)
        self._dfs[feed_key] = pd.DataFrame(self._rows[feed_key])

        self.evaluate(self._dfs)

    def on_stop(self) -> None:
        pass

    # ------------------------------------------------------------------
    # Quant API — override in subclass
    # ------------------------------------------------------------------

    @abstractmethod
    def evaluate(self, dfs: dict[str, pd.DataFrame]) -> None:
        """
        Implement your strategy logic here.

        Called after every new bar on any subscribed feed.

        Args:
            dfs: Dict mapping feed_key → DataFrame.
                 Feed key format: "TICKER:period_seconds", e.g. "BTC/USDT.BINANCE:3600".
                 DataFrame columns: timestamp (ns), open, high, low, close, volume,
                                    buy_vol, sell_vol, open_interest.
                 All rows up to and including the latest bar are included.
                 A feed's DataFrame is absent (key missing) until its first bar arrives.

        Trading methods available:
            self.buy(quantity, feed_key=None)     — market buy
            self.sell(quantity, feed_key=None)    — market sell
            self.flatten(feed_key=None)           — close all positions for feed
        """

    def on_timer(self, timer_name: str) -> None:
        """
        Called on timer ticks (TBD — timer wiring not yet implemented).

        Override to handle time-based evaluation independent of bar arrival.
        Default implementation calls evaluate() with current DataFrames.
        """
        self.evaluate(self._dfs)

    # ------------------------------------------------------------------
    # Order helpers
    # ------------------------------------------------------------------

    def _resolve_feed_key(self, feed_key: str | None) -> str | None:
        """Return feed_key if given, else the first configured feed key."""
        if feed_key is not None:
            return feed_key
        keys = self.config.feed_keys
        return keys[0] if keys else None

    def _instrument_id_for_feed(self, feed_key: str) -> InstrumentId | None:
        ticker, _ = parse_feed_key(feed_key)
        try:
            exchange_id, market_id = parse_ticker(ticker)
            return InstrumentId(Symbol(market_id), Venue(exchange_id))
        except ValueError:
            log.error("Cannot parse ticker from feed key: %s", feed_key)
            return None

    def buy(self, quantity: float, feed_key: str | None = None) -> None:
        """Submit a market buy order. Defaults to the first configured feed."""
        fk = self._resolve_feed_key(feed_key)
        if not fk:
            log.error("buy(): no feed key available")
            return
        instrument_id = self._instrument_id_for_feed(fk)
        if instrument_id is None:
            return
        instrument = self.cache.instrument(instrument_id)
        if instrument is None:
            log.error("buy(): instrument not found for %s", instrument_id)
            return
        order = self.order_factory.market(
            instrument_id=instrument_id,
            order_side=OrderSide.BUY,
            quantity=Quantity(quantity, instrument.size_precision),
            time_in_force=TimeInForce.GTC,
        )
        self.submit_order(order)

    def sell(self, quantity: float, feed_key: str | None = None) -> None:
        """Submit a market sell order. Defaults to the first configured feed."""
        fk = self._resolve_feed_key(feed_key)
        if not fk:
            log.error("sell(): no feed key available")
            return
        instrument_id = self._instrument_id_for_feed(fk)
        if instrument_id is None:
            return
        instrument = self.cache.instrument(instrument_id)
        if instrument is None:
            log.error("sell(): instrument not found for %s", instrument_id)
            return
        order = self.order_factory.market(
            instrument_id=instrument_id,
            order_side=OrderSide.SELL,
            quantity=Quantity(quantity, instrument.size_precision),
            time_in_force=TimeInForce.GTC,
        )
        self.submit_order(order)

    def flatten(self, feed_key: str | None = None) -> None:
        """Close all open positions for the specified feed (defaults to first feed)."""
        fk = self._resolve_feed_key(feed_key)
        if not fk:
            return
        instrument_id = self._instrument_id_for_feed(fk)
        if instrument_id is None:
            return
        positions = self.cache.positions_open(instrument_id=instrument_id)
        for pos in positions:
            self.close_position(pos)

    # ------------------------------------------------------------------
    # Runner API — called by backtest_runner, not by quant code
    # ------------------------------------------------------------------

    def _inject_extras(self, feed_key: str, extras: dict[int, dict]) -> None:
        """
        Pre-load OHLC+ extras for a feed before the backtest runs.

        Args:
            feed_key: e.g. "BTC/USDT.BINANCE:3600"
            extras: {ts_event_ns: {"buy_vol": float|None, "sell_vol": float|None,
                                   "open_interest": float|None}}
        """
        self._extras[feed_key] = extras
