"""
APEX V16 — Market data provider.

The indicators pipeline (analysis/tech_snapshot.py) needs OHLCV bars
to compute RSI, ATR, swing levels, candle patterns, etc.

Rather than coupling indicators to TopstepX (or any specific broker),
we depend on the MarketDataProvider Protocol — an async source of
pandas DataFrames. Concrete implementations:

  * BrokerMarketDataProvider: forwards to broker.fetch_bars(...).
    NB: BrokerBase does NOT currently expose fetch_bars; the wrapper
    is duck-typed and will work as soon as the broker layer adds the
    method (TODO tracked in BACKLOG.md). Until then, use the Fake.

  * FakeMarketDataProvider: returns canned DataFrames keyed by
    (symbol, timeframe). Used by all unit tests so we never hit a
    network in CI.

DataFrame contract (consumer-side):
  - columns: open, high, low, close, volume
  - 'time' column or DatetimeIndex (used for candle_time stamping)
  - sorted ascending by time, last row = most recent bar
  - V15 used 'tick_volume'; the broker adapter aliases it to 'volume',
    so V16 standardizes on 'volume'.
"""

from __future__ import annotations

from typing import Protocol, runtime_checkable

import pandas as pd


@runtime_checkable
class MarketDataProvider(Protocol):
    """Async provider of OHLCV bars. Stateless from the caller's POV."""

    async def get_bars(
        self,
        symbol: str,
        timeframe: str,
        n: int,
    ) -> pd.DataFrame:
        """
        Return the last `n` bars for `symbol` at `timeframe`.

        Args:
            symbol: instrument root (e.g. "MES", "6E").
            timeframe: V16 standard strings: "5min" | "1hour" | "4hour".
            n: number of most-recent bars requested.

        Returns:
            DataFrame with columns [open, high, low, close, volume]
            and either a 'time' column (UTC) or a DatetimeIndex.
            len(df) <= n; may be smaller near session boundaries.
            Empty DataFrame returned when no bars are available
            (callers should treat empty == "skip computation").
        """
        ...


# ============================================================
# Broker-backed provider (duck-typed)
# ============================================================

class BrokerMarketDataProvider:
    """
    Wrap a broker that exposes `async fetch_bars(symbol, timeframe, n)`.

    BrokerBase does not yet declare fetch_bars; this class therefore
    relies on duck-typing. When BACKLOG item "broker_base.fetch_bars"
    is implemented, this becomes a typed forwarder.

    Until then, instantiating it against a BrokerBase that lacks
    fetch_bars will raise AttributeError on the first get_bars call
    (intentional fail-loud, per V16 architecture).
    """

    def __init__(self, broker) -> None:
        self.broker = broker

    async def get_bars(
        self,
        symbol: str,
        timeframe: str,
        n: int,
    ) -> pd.DataFrame:
        # Duck-typed call — see class docstring.
        return await self.broker.fetch_bars(symbol, timeframe, n)


# ============================================================
# Fake provider for tests
# ============================================================

class FakeMarketDataProvider:
    """
    Canned-bars provider for unit tests.

    Usage:
        bars = {("MES", "5min"): df_m5,
                ("MES", "1hour"): df_h1,
                ("MES", "4hour"): df_h4}
        provider = FakeMarketDataProvider(bars)
        await provider.get_bars("MES", "5min", 200)

    If a (symbol, timeframe) key is missing, get_bars returns an
    empty DataFrame — same contract as a broker mid-session gap.
    Tracks calls in `self.calls` for assertions.
    """

    def __init__(self, bars: dict[tuple[str, str], pd.DataFrame]) -> None:
        self.bars = bars
        self.calls: list[tuple[str, str, int]] = []

    async def get_bars(
        self,
        symbol: str,
        timeframe: str,
        n: int,
    ) -> pd.DataFrame:
        self.calls.append((symbol, timeframe, n))
        df = self.bars.get((symbol, timeframe))
        if df is None or df.empty:
            return pd.DataFrame(columns=["open", "high", "low", "close", "volume"])
        # Honor `n` by returning at most the last n rows
        return df.tail(n).reset_index(drop=True)
