"""
Look-ahead bias tests for signalab.

A look-ahead bias occurs when a signal generated at candle-close timestamp T
is visible to the strategy at any tick timestamped before T. These tests
verify that the BacktestEngine never places a signal on a row whose timestamp
precedes the candle close that produced it.

Test structure
──────────────
Each test:
  1. Runs the engine with one or more indicators over synthetic tick data.
  2. Iterates every candle-close timestamp where a non-zero signal was produced.
  3. Asserts that no row with timestamp < candle_close carries that signal value.

Helper
──────
assert_no_lookahead(indicators_df, candle_index, signal_col) is the shared
assertion used by all tests. It is intentionally kept separate from conftest
so the failure message is maximally informative.
"""

from __future__ import annotations

from typing import Any, cast

import numpy as np
import pandas as pd
from conftest import make_account, make_market_data, make_ticks
from pandas import DatetimeIndex

from signalab.analytics.report import Report
from signalab.account import Account
from signalab.backtest_engine import BacktestEngine
from signalab.data.market_data import MarketData
from signalab.indicator.adx import ADX
from signalab.indicator.base import Indicator
from signalab.indicator.bollinger_bands import BollingerBands
from signalab.indicator.moving_avg import MACD, MovingAverageCross
from signalab.indicator.rsi import RSI
from signalab.indicator.stochastic import Stochastic
from signalab.strategy.base import Strategy

# ─────────────────────────────────────────────
# Core assertion
# ─────────────────────────────────────────────


def assert_no_lookahead(
    indicators_df: pd.DataFrame,
    candle_index: pd.DatetimeIndex,
    signal_col: str,
) -> None:
    """
    For every candle close T that carries a non-zero signal, assert that no row
    in the half-open window (prev_candle_close, T) carries a non-zero value in
    that column.

    That window is the period during which candle T was still forming. A signal
    appearing there would mean the strategy can act on information from a candle
    that has not yet closed — genuine look-ahead bias.

    Earlier candle closes (at or before prev_candle_close) may legitimately carry
    their own signals and are excluded from the check.

    Parameters
    ----------
    indicators_df : pd.DataFrame
        The 'indicators' DataFrame returned by BacktestEngine.run().
    candle_index : pd.DatetimeIndex
        The index of the candle DataFrame for the relevant timeframe
        (e.g. MarketData.candles_5m.index).
    signal_col : str
        The fully-qualified signal column name in indicators_df
        (e.g. 'rsi_signal').
    """
    if signal_col not in indicators_df.columns:
        raise KeyError(f"Signal column '{signal_col}' not found in indicators_df.")

    sorted_candles: pd.DatetimeIndex = candle_index.sort_values()

    for i, candle_ts in enumerate(sorted_candles):
        if candle_ts not in indicators_df.index:
            continue

        signal_val: int = int(cast(int | str, indicators_df.at[candle_ts, signal_col]))
        if signal_val == 0:
            continue

        # The window to check: strictly after the previous candle close,
        # strictly before this candle close.
        # Any signal in this window was produced by a candle that had not yet closed.
        prev_candle_ts: pd.Timestamp = (
            sorted_candles[i - 1]
            if i > 0
            else indicators_df.index[0] - pd.Timedelta(seconds=1)
        )

        forming_window: pd.Series = indicators_df.loc[
            (indicators_df.index > prev_candle_ts) & (indicators_df.index < candle_ts),
            signal_col,
        ]
        leaking: pd.Series = forming_window[forming_window != 0]

        assert leaking.empty, (
            f"Look-ahead bias in '{signal_col}': "
            f"signal {signal_val} generated at candle close {candle_ts} "
            f"leaks into {len(leaking)} row(s) in the forming window "
            f"({prev_candle_ts}, {candle_ts}):\n{leaking}"
        )


# ─────────────────────────────────────────────
# Minimal no-op strategy
# ─────────────────────────────────────────────


class DoNothingStrategy(Strategy):
    def __init__(self) -> None:
        super().__init__(name="DoNothing", label="do_nothing")

    def perform(self, current_market: Any) -> None:
        pass


# ─────────────────────────────────────────────
# Shared engine runner
# ─────────────────────────────────────────────


def run_engine(
    indicators: list[Indicator],
    n: int = 600,
    freq: str = "1min",
) -> tuple[Report, MarketData]:
    """Run the engine and return (result, market_data)."""
    engine = BacktestEngine()
    md: MarketData = make_market_data(n=n, freq=freq)
    result: Report = engine.run(
        data=md,
        indicators=indicators,
        strategies=[DoNothingStrategy()],
        accounts=make_account(),
        individual_accounts=True,
    )
    return result, md


# ─────────────────────────────────────────────
# Per-indicator look-ahead tests
# ─────────────────────────────────────────────


class TestRSILookahead:
    def test_no_lookahead_5m(self) -> None:
        result, md = run_engine([RSI(label="rsi", candles_type="5m")])
        assert_no_lookahead(
            result.indicators_df, cast(DatetimeIndex, md.candles_5m.index), "rsi_signal"
        )

    def test_no_lookahead_15m(self) -> None:
        result, md = run_engine([RSI(label="rsi", candles_type="15m")], n=1000)
        assert_no_lookahead(
            result.indicators_df,
            cast(DatetimeIndex, md.candles_15m.index),
            "rsi_signal",
        )

    def test_no_lookahead_1h(self) -> None:
        result, md = run_engine([RSI(label="rsi", candles_type="1h")], n=3000)
        assert_no_lookahead(
            result.indicators_df,
            cast(DatetimeIndex, md.candles_1h.index),
            "rsi_signal",
        )


class TestMACDLookahead:
    def test_no_lookahead_5m(self) -> None:
        result, md = run_engine([MACD(label="macd", candles_type="5m")])
        assert_no_lookahead(
            result.indicators_df,
            cast(DatetimeIndex, md.candles_5m.index),
            "macd_signal",
        )

    def test_no_lookahead_crossover_only_false(self) -> None:
        """Level-based MACD emits persistent signals — bias would be especially visible."""
        result, md = run_engine(
            [MACD(label="macd", crossover_only=False, candles_type="5m")]
        )
        assert_no_lookahead(
            result.indicators_df,
            cast(DatetimeIndex, md.candles_5m.index),
            "macd_signal",
        )


class TestMovingAverageLookahead:
    def test_no_lookahead_crossover_only(self) -> None:
        result, md = run_engine([MovingAverageCross(label="ma", candles_type="5m")])
        assert_no_lookahead(
            result.indicators_df,
            cast(DatetimeIndex, md.candles_5m.index),
            "ma_signal",
        )

    def test_no_lookahead_level_mode(self) -> None:
        result, md = run_engine(
            [MovingAverageCross(label="ma", crossover_only=False, candles_type="5m")]
        )
        assert_no_lookahead(
            result.indicators_df,
            cast(DatetimeIndex, md.candles_5m.index),
            "ma_signal",
        )


class TestBollingerBandsLookahead:
    def test_no_lookahead_15m(self) -> None:
        result, md = run_engine(
            [BollingerBands(label="bb", candles_type="15m")], n=1000
        )
        assert_no_lookahead(
            result.indicators_df,
            cast(DatetimeIndex, md.candles_15m.index),
            "bb_signal",
        )


class TestADXLookahead:
    def test_no_lookahead_5m(self) -> None:
        result, md = run_engine([ADX(label="adx", candles_type="5m")])
        assert_no_lookahead(
            result.indicators_df,
            cast(DatetimeIndex, md.candles_5m.index),
            "adx_signal",
        )


class TestStochasticLookahead:
    def test_no_lookahead_5m(self) -> None:
        result, md = run_engine([Stochastic(label="stoch", candles_type="5m")])
        assert_no_lookahead(
            result.indicators_df,
            cast(DatetimeIndex, md.candles_5m.index),
            "stoch_signal",
        )


# ─────────────────────────────────────────────
# Multi-indicator look-ahead tests
# ─────────────────────────────────────────────


class TestMultiIndicatorLookahead:
    def test_no_lookahead_rsi_and_macd(self) -> None:
        """Two indicators on the same timeframe must not leak into each other's rows."""
        result, md = run_engine(
            [
                RSI(label="rsi", candles_type="5m"),
                MACD(label="macd", candles_type="5m"),
            ]
        )
        df: pd.DataFrame = result.indicators_df
        assert_no_lookahead(df, cast(DatetimeIndex, md.candles_5m.index), "rsi_signal")
        assert_no_lookahead(df, cast(DatetimeIndex, md.candles_5m.index), "macd_signal")

    def test_no_lookahead_mixed_timeframes(self) -> None:
        """
        Signals from different timeframes must each respect their own candle close.
        A 5m signal must not appear before its 5m candle close even when 15m rows
        are also being inserted into the same DataFrame.
        """
        result, md = run_engine(
            [
                RSI(label="rsi_5m", candles_type="5m"),
                RSI(label="rsi_15m", candles_type="15m"),
            ],
            n=1000,
        )
        df: pd.DataFrame = result.indicators_df
        assert_no_lookahead(
            df, cast(DatetimeIndex, md.candles_5m.index), "rsi_5m_signal"
        )
        assert_no_lookahead(
            df, cast(DatetimeIndex, md.candles_15m.index), "rsi_15m_signal"
        )

    def test_no_lookahead_three_indicators(self) -> None:
        result, md = run_engine(
            [
                RSI(label="rsi", candles_type="5m"),
                MACD(label="macd", candles_type="5m"),
                MovingAverageCross(label="ma", candles_type="5m"),
            ]
        )
        df: pd.DataFrame = result.indicators_df
        for col in ("rsi_signal", "macd_signal", "ma_signal"):
            assert_no_lookahead(df, cast(DatetimeIndex, md.candles_5m.index), col)


# ─────────────────────────────────────────────
# Boundary / edge-case look-ahead tests
# ─────────────────────────────────────────────


class TestBoundaryLookahead:
    def test_signal_at_exact_tick_match_does_not_leak(self) -> None:
        """
        When a candle close timestamp coincides exactly with a tick timestamp,
        the signal must be placed on that tick row (in-place update), not
        duplicated onto any earlier row.
        """
        # 1-min ticks + 1-min candles: every candle close IS a tick timestamp
        result, md = run_engine(
            [RSI(label="rsi", candles_type="1m")], n=300, freq="1min"
        )
        assert_no_lookahead(
            result.indicators_df,
            cast(DatetimeIndex, md.candles_1m.index),
            "rsi_signal",
        )

    def test_signal_not_present_before_first_candle_close(self) -> None:
        """
        Before the very first candle closes there are no previous candle closes,
        so the entire pre-history window must be zero — this is the one case where
        'any non-zero before T' is the correct check.
        """
        result, md = run_engine([RSI(label="rsi", candles_type="5m")])
        df: pd.DataFrame = result.indicators_df
        first_candle_close: pd.Timestamp = cast(DatetimeIndex, md.candles_5m.index)[0]
        pre_signal: pd.Series = df.loc[df.index < first_candle_close, "rsi_signal"]
        assert (pre_signal == 0).all(), (
            f"Non-zero signal found before first candle close ({first_candle_close}):\n"
            f"{pre_signal[pre_signal != 0]}"
        )

    def test_continuous_columns_do_not_carry_future_signal(self) -> None:
        """
        Continuous indicator values (e.g. rsi_rsi) are forward-filled from candle
        closes, which is correct. However, forward-filling must only go *forward*
        in time (past candle into future ticks), never backward.
        Verify: no continuous value appears at a timestamp before the first candle
        that produced it.
        """
        result, md = run_engine([RSI(label="rsi", candles_type="5m")])
        df: pd.DataFrame = result.indicators_df
        first_candle: pd.Timestamp = cast(DatetimeIndex, md.candles_5m.index)[0]

        # All rows before the first candle close must have NaN or 0 for rsi_rsi
        pre: pd.Series = df.loc[df.index < first_candle, "rsi_rsi"]
        assert pre.isna().all() or (pre == 0).all(), (
            f"Continuous RSI value appears before first candle close ({first_candle}):\n"
            f"{pre.dropna()}"
        )


# ─────────────────────────────────────────────
# Candle integrity tests
#
# For every candle at close timestamp T with frequency F, the window of ticks
# that produced it is [T - F, T) — closed on the left, open on the right.
# Every tick in that window must satisfy:
#   bid_low  ≤ tick.bid  ≤ bid_high
#   ask_low  ≤ tick.ask  ≤ ask_high
#
# Additionally:
#   candle.open  == first tick price in window
#   candle.close == last  tick price in window
#
# A violation means the OHLC computation is incorrect — either the wrong ticks
# are being included, or the aggregation is wrong.
# ─────────────────────────────────────────────


def assert_candle_integrity(
    ticks: pd.DataFrame,
    candles: pd.DataFrame,
    freq: pd.Timedelta,
    side: str = "bid",
    tol: float = 1e-9,
) -> None:
    """
    For every candle in `candles`, find all ticks in its forming window
    [candle_close - freq, candle_close) and assert:

    1. Every tick price is within [low, high].
    2. The first tick price equals the candle open.
    3. The last  tick price equals the candle close.

    Parameters
    ----------
    ticks : pd.DataFrame
        Raw tick DataFrame with at least a `bid` and `ask` column,
        indexed by timestamp.
    candles : pd.DataFrame
        Candle DataFrame as returned by MarketData (e.g. candles_5m).
        Must have columns {side}_open, {side}_high, {side}_low, {side}_close.
    freq : pd.Timedelta
        Duration of each candle (e.g. pd.Timedelta('5min')).
    side : str
        'bid' or 'ask'.
    tol : float
        Floating-point tolerance for price comparisons.
    """
    open_col = f"{side}_open"
    high_col = f"{side}_high"
    low_col = f"{side}_low"
    close_col = f"{side}_close"

    for candle_ts, candle in candles.iterrows():
        assert isinstance(candle_ts, pd.Timestamp)
        window_start = candle_ts - freq
        window_ticks: pd.DataFrame = ticks[
            (ticks.index >= window_start) & (ticks.index < candle_ts)
        ]

        if window_ticks.empty:
            # No ticks in window — candle should not exist; skip rather than fail
            continue

        prices: np.ndarray = cast(np.ndarray, window_ticks[side].values)
        c_high: float = float(candle[high_col])
        c_low: float = float(candle[low_col])
        c_open: float = float(candle[open_col])
        c_close: float = float(candle[close_col])

        # 1. All tick prices within [low, high]
        above_high: np.ndarray = prices[prices > c_high + tol]
        below_low: np.ndarray = prices[prices < c_low - tol]

        assert above_high.size == 0, (
            f"Candle [{side}] at {candle_ts}: "
            f"{above_high.size} tick(s) above high ({c_high:.6f}): {above_high}"
        )
        assert below_low.size == 0, (
            f"Candle [{side}] at {candle_ts}: "
            f"{below_low.size} tick(s) below low ({c_low:.6f}): {below_low}"
        )

        # 2. Open matches first tick in window
        first_price: float = float(prices[0])
        assert abs(first_price - c_open) <= tol, (
            f"Candle [{side}] at {candle_ts}: "
            f"open mismatch — candle={c_open:.6f}, first tick={first_price:.6f}"
        )

        # 3. Close matches last tick in window
        last_price: float = float(prices[-1])
        assert abs(last_price - c_close) <= tol, (
            f"Candle [{side}] at {candle_ts}: "
            f"close mismatch — candle={c_close:.6f}, last tick={last_price:.6f}"
        )


class TestCandleIntegrity:
    def test_bid_ohlc_within_range_1m(self) -> None:
        """Every bid tick must be within its 1m candle's bid high/low."""
        ticks: pd.DataFrame = make_ticks(n=300, freq="1min")
        md = MarketData(ticks)
        assert_candle_integrity(ticks, md.candles_1m, pd.Timedelta("1min"), side="bid")

    def test_ask_ohlc_within_range_1m(self) -> None:
        """Every ask tick must be within its 1m candle's ask high/low."""
        ticks: pd.DataFrame = make_ticks(n=300, freq="1min")
        md = MarketData(ticks)
        assert_candle_integrity(ticks, md.candles_1m, pd.Timedelta("1min"), side="ask")

    def test_bid_ohlc_within_range_5m(self) -> None:
        """Every bid tick must be within its 5m candle's bid high/low."""
        ticks: pd.DataFrame = make_ticks(n=600, freq="1min")
        md = MarketData(ticks)
        assert_candle_integrity(ticks, md.candles_5m, pd.Timedelta("5min"), side="bid")

    def test_ask_ohlc_within_range_5m(self) -> None:
        ticks: pd.DataFrame = make_ticks(n=600, freq="1min")
        md = MarketData(ticks)
        assert_candle_integrity(ticks, md.candles_5m, pd.Timedelta("5min"), side="ask")

    def test_bid_ohlc_within_range_15m(self) -> None:
        ticks: pd.DataFrame = make_ticks(n=1000, freq="1min")
        md = MarketData(ticks)
        assert_candle_integrity(
            ticks, md.candles_15m, pd.Timedelta("15min"), side="bid"
        )

    def test_bid_ohlc_within_range_1h(self) -> None:
        ticks: pd.DataFrame = make_ticks(n=3000, freq="1min")
        md = MarketData(ticks)
        assert_candle_integrity(ticks, md.candles_1h, pd.Timedelta("1h"), side="bid")

    def test_open_matches_first_tick(self) -> None:
        """
        The candle open must exactly equal the price of the first tick
        that falls in the candle's time window.
        """
        ticks: pd.DataFrame = make_ticks(n=600, freq="1min")
        md = MarketData(ticks)
        assert_candle_integrity(ticks, md.candles_5m, pd.Timedelta("5min"), side="bid")

    def test_close_matches_last_tick(self) -> None:
        """
        The candle close must exactly equal the price of the last tick
        that falls in the candle's time window.
        """
        ticks: pd.DataFrame = make_ticks(n=600, freq="1min")
        md = MarketData(ticks)
        assert_candle_integrity(ticks, md.candles_5m, pd.Timedelta("5min"), side="ask")

    def test_high_is_maximum_tick_in_window(self) -> None:
        """
        Explicitly verify that bid_high equals the maximum bid tick in the window —
        not just that all ticks are below it, but that the maximum tick IS the high.
        """
        ticks: pd.DataFrame = make_ticks(n=600, freq="1min")
        md = MarketData(ticks)
        freq = pd.Timedelta("5min")
        candles: pd.DataFrame = md.candles_5m

        for candle_ts, candle in candles.iterrows():
            assert isinstance(candle_ts, pd.Timestamp)
            window_ticks = ticks[
                (ticks.index >= candle_ts - freq) & (ticks.index < candle_ts)
            ]
            if window_ticks.empty:
                continue
            actual_max: float = float(window_ticks["bid"].max())
            candle_high: float = float(candle["bid_high"])
            assert abs(actual_max - candle_high) <= 1e-9, (
                f"bid_high at {candle_ts}: candle={candle_high:.6f}, "
                f"actual max tick={actual_max:.6f}"
            )

    def test_low_is_minimum_tick_in_window(self) -> None:
        """
        Explicitly verify that bid_low equals the minimum bid tick in the window.
        """
        ticks: pd.DataFrame = make_ticks(n=600, freq="1min")
        md = MarketData(ticks)
        freq = pd.Timedelta("5min")
        candles: pd.DataFrame = md.candles_5m

        for candle_ts, candle in candles.iterrows():
            assert isinstance(candle_ts, pd.Timestamp)
            window_ticks = ticks[
                (ticks.index >= candle_ts - freq) & (ticks.index < candle_ts)
            ]
            if window_ticks.empty:
                continue
            actual_min: float = float(window_ticks["bid"].min())
            candle_low: float = float(candle["bid_low"])
            assert abs(actual_min - candle_low) <= 1e-9, (
                f"bid_low at {candle_ts}: candle={candle_low:.6f}, "
                f"actual min tick={actual_min:.6f}"
            )

    def test_ask_always_above_bid_in_every_candle(self) -> None:
        """
        At every timeframe, ask OHLC must be strictly above bid OHLC —
        ask_low > bid_low, ask_high > bid_high, etc.
        """
        ticks: pd.DataFrame = make_ticks(n=600, freq="1min")
        md = MarketData(ticks)
        for attr, freq_str in [
            ("candles_1m", "1min"),
            ("candles_5m", "5min"),
            ("candles_15m", "15min"),
        ]:
            candles: pd.DataFrame = getattr(md, attr)
            assert (candles["ask_low"] > candles["bid_low"]).all(), (
                f"{freq_str}: ask_low not > bid_low"
            )
            assert (candles["ask_high"] > candles["bid_high"]).all(), (
                f"{freq_str}: ask_high not > bid_high"
            )
            assert (candles["ask_open"] > candles["bid_open"]).all(), (
                f"{freq_str}: ask_open not > bid_open"
            )
            assert (candles["ask_close"] > candles["bid_close"]).all(), (
                f"{freq_str}: ask_close not > bid_close"
            )

    def test_sub_tick_frequency_ticks(self) -> None:
        """
        When ticks arrive more frequently than the candle frequency
        (e.g. multiple ticks per second aggregated into 1m candles),
        the OHLC must still correctly reflect all ticks in the window.
        """
        ticks: pd.DataFrame = make_ticks(n=600, freq="10s")
        md = MarketData(ticks)
        assert_candle_integrity(ticks, md.candles_1m, pd.Timedelta("1min"), side="bid")
        assert_candle_integrity(ticks, md.candles_5m, pd.Timedelta("5min"), side="bid")
