"""
Integration tests for signalab.backtest_engine.BacktestEngine
"""

from __future__ import annotations

from typing import Any

import numpy as np
import pandas as pd
import pytest
from conftest import make_account, make_market_data

from signalab.analytics.report import Report
from signalab.account import Account, Action
from signalab.backtest_engine import BacktestEngine
from signalab.data.market_data import MarketData
from signalab.indicator.base import Indicator
from signalab.indicator.moving_avg import MACD
from signalab.indicator.rsi import RSI
from signalab.strategy.base import Signal, Strategy
from signalab.units import Units

# ─────────────────────────────────────────────
# Minimal test strategies
# ─────────────────────────────────────────────


class DoNothingStrategy(Strategy):
    """Never trades — used to verify engine mechanics without P&L noise."""

    def __init__(self) -> None:
        super().__init__(name="DoNothing", label="do_nothing")
        self.perform_count: int = 0

    def perform(self, current_market: Any) -> None:
        self.perform_count += 1


class BuyAndHoldStrategy(Strategy):
    """Opens one buy on the first tick, never closes."""

    def __init__(self, lot: float = 0.01) -> None:
        super().__init__(name="BuyAndHold", label="buy_and_hold")
        self.lot: float = lot
        self._opened: bool = False

    def perform(self, current_market: Any) -> None:
        if not self._opened:
            self.account.open(Action.BUY, self.lot)
            self._opened = True


class SignalFollowerStrategy(Strategy):
    """Opens a buy on BUY signal, sell on SELL signal. Max 1 position."""

    def __init__(self, lot: float = 0.01) -> None:
        super().__init__(name="SignalFollower", label="signal_follower")
        self.lot: float = lot
        self._pos_id: int | None = None

    def perform(self, current_market: Any) -> None:
        signals: list[Signal] = self.get_signals()
        _ = self.buy_signal_count()
        _ = self.sell_signal_count()
        if not signals:
            return
        signal: Signal = signals[0]

        if signal == Signal.BUY and self._pos_id is None:
            pid: int = self.account.open(Action.BUY, self.lot)
            if pid >= 0:
                self._pos_id = pid

        elif signal == Signal.SELL and self._pos_id is not None:
            self.account.close(self._pos_id)
            self._pos_id = None


# ─────────────────────────────────────────────
# Engine setup
# ─────────────────────────────────────────────


def run_simple(
    strategy: Strategy,
    indicators: list[Indicator] | None = None,
    n: int = 500,
    **kwargs: Any,
) -> Report:
    engine = BacktestEngine()
    md: MarketData = make_market_data(n=n, freq="1min")
    return engine.run(
        data=md,
        indicators=indicators or [],
        strategies=[strategy],
        accounts=make_account(balance=10_000.0),
        individual_accounts=True,  # ensures result is keyed by strategy label
        **kwargs,
    )


# ─────────────────────────────────────────────
# Basic engine mechanics
# ─────────────────────────────────────────────


class TestEngineBasics:
    def test_run_returns_report_with_indicators(self) -> None:
        result: Report = run_simple(DoNothingStrategy())
        assert isinstance(result.indicators_df, pd.DataFrame)

    def test_run_returns_account_df(self) -> None:
        strat = DoNothingStrategy()
        result: Report = run_simple(strat)
        assert strat.label in result.account_reports

    def test_perform_called_once_per_tick(self) -> None:
        strat = DoNothingStrategy()
        md: MarketData = make_market_data(n=100, freq="1min")
        engine = BacktestEngine()
        engine.run(
            data=md,
            indicators=[],
            strategies=[strat],
            accounts=make_account(),
            individual_accounts=True,
        )
        assert strat.perform_count == len(md.ticks)

    def test_account_df_length_matches_ticks(self) -> None:
        strat = DoNothingStrategy()
        md: MarketData = make_market_data(n=100, freq="1min")
        engine = BacktestEngine()
        result: Report = engine.run(
            data=md,
            indicators=[],
            strategies=[strat],
            accounts=make_account(),
            individual_accounts=True,
        )
        assert len(result.get_account_report(strat.label).dataframe) == len(md.ticks)

    def test_date_filtering_applied(self) -> None:
        md: MarketData = make_market_data(n=500, freq="1min")
        from_dt: pd.Timestamp = md.ticks.index[100]
        to_dt: pd.Timestamp = md.ticks.index[300]
        engine = BacktestEngine()
        result: Report = engine.run(
            data=md,
            indicators=[],
            strategies=[DoNothingStrategy()],
            accounts=make_account(),
            individual_accounts=True,
            from_date=from_dt,
            to_date=to_dt,
        )
        assert result.indicators_df.index.min() >= from_dt
        assert result.indicators_df.index.max() <= to_dt


# ─────────────────────────────────────────────
# Indicator integration
# ─────────────────────────────────────────────


class TestIndicatorIntegration:
    def test_indicator_columns_present_in_output(self) -> None:
        strat = DoNothingStrategy()
        result: Report = run_simple(
            strat, indicators=[RSI(label="rsi", candles_type="5m")]
        )
        df: pd.DataFrame = result.indicators_df
        assert "rsi_rsi" in df.columns
        assert "rsi_signal" in df.columns

    def test_signal_column_only_contains_valid_values(self) -> None:
        strat = DoNothingStrategy()
        result: Report = run_simple(
            strat, indicators=[RSI(label="rsi", candles_type="5m")]
        )
        sig: pd.Series = result.indicators_df["rsi_signal"]
        assert set(sig.unique()).issubset({-1, 0, 1})

    def test_multiple_indicators_no_column_collision(self) -> None:
        result: Report = run_simple(
            DoNothingStrategy(),
            indicators=[
                RSI(label="rsi_fast", period=5, candles_type="5m"),
                RSI(label="rsi_slow", period=14, candles_type="5m"),
            ],
        )
        df: pd.DataFrame = result.indicators_df
        assert "rsi_fast_signal" in df.columns
        assert "rsi_slow_signal" in df.columns

    def test_signal_rows_inserted_at_candle_close(self) -> None:
        """
        Signal rows must exist at the exact candle-close timestamps,
        not merely at the nearest tick.
        """
        md: MarketData = make_market_data(n=500, freq="1min")
        rsi = RSI(label="rsi", candles_type="5m")
        engine = BacktestEngine()
        result: Report = engine.run(
            data=md,
            indicators=[rsi],
            strategies=[DoNothingStrategy()],
            accounts=make_account(),
            individual_accounts=True,
        )
        candle_ts: set[pd.Timestamp] = set(md.candles_5m.index)
        result_ts: set[pd.Timestamp] = set(result.indicators_df.index)
        assert candle_ts.issubset(result_ts), (
            f"Missing candle timestamps: {candle_ts - result_ts}"
        )

    def test_continuous_columns_have_no_nans_after_warmup(self) -> None:
        strat = DoNothingStrategy()
        result: Report = run_simple(
            strat, indicators=[RSI(label="rsi", candles_type="5m")], n=500
        )
        df: pd.DataFrame = result.indicators_df
        warmup: int = 50
        assert df["rsi_rsi"].iloc[warmup:].isnull().sum() == 0

    @pytest.mark.xfail(
        reason="Duplicate label validation not yet implemented in BacktestEngine.",
        strict=True,
    )
    def test_duplicate_indicator_labels_raise(self) -> None:
        """Regression: duplicate labels must be caught early."""
        engine = BacktestEngine()
        md: MarketData = make_market_data(n=200)
        with pytest.raises(ValueError, match="Duplicate indicator labels"):
            engine.run(
                data=md,
                indicators=[
                    RSI(label="rsi", candles_type="5m"),
                    RSI(label="rsi", candles_type="5m"),
                ],
                strategies=[DoNothingStrategy()],
                accounts=make_account(),
                individual_accounts=True,
            )


# ─────────────────────────────────────────────
# Account integration
# ─────────────────────────────────────────────


class TestAccountIntegration:
    def test_buy_and_hold_balance_changes(self) -> None:
        strat = BuyAndHoldStrategy(lot=0.01)
        result: Report = run_simple(strat, n=200)
        acc_df: pd.DataFrame = result.get_account_report(strat.label).dataframe
        initial_balance: float = 10_000.0
        expected_commission: float = Units.open_commission(0.01)
        assert acc_df["balance"].iloc[1] == pytest.approx(
            initial_balance + expected_commission, rel=1e-9
        )

    def test_equity_never_below_zero_no_leverage_explosion(self) -> None:
        strat = BuyAndHoldStrategy(lot=0.01)
        result: Report = run_simple(strat, n=500)
        acc_df: pd.DataFrame = result.get_account_report(strat.label).dataframe
        assert (acc_df["equity"] > 0).all()

    def test_individual_accounts_are_independent(self) -> None:
        strat1 = BuyAndHoldStrategy(lot=0.01)
        strat1.name = "s1"
        strat1.label = "s1"
        strat2 = DoNothingStrategy()

        engine = BacktestEngine()
        md: MarketData = make_market_data(n=300, freq="1min")
        result: Report = engine.run(
            data=md,
            indicators=[],
            strategies=[strat1, strat2],
            individual_accounts=True,
            accounts=make_account(balance=10_000.0),
        )
        s1_final_balance: float = result.get_account_report("s1").balance.iloc[-1]
        s2_final_balance: float = result.get_account_report("do_nothing").balance.iloc[
            -1
        ]
        assert s1_final_balance != s2_final_balance

    def test_account_record_length_matches_result_index(self) -> None:
        strat = DoNothingStrategy()
        result: Report = run_simple(strat, n=100)
        assert len(result.get_account_report(strat.label).dataframe) == len(
            result.indicators_df
        )


# ─────────────────────────────────────────────
# Day change regression
# ─────────────────────────────────────────────


class TestDayChangeRegression:
    def test_overnight_swap_charged_correct_days(self) -> None:
        """
        Regression for report bug 3.2: day_change used timedelta.days + 1.
        A Friday→Monday gap is 2 calendar days; swap must be charged for 2 days, not 3.
        """
        friday_ticks: pd.DatetimeIndex = pd.date_range(
            "2024-01-05 22:00", periods=10, freq="1min"
        )
        monday_ticks: pd.DatetimeIndex = pd.date_range(
            "2024-01-08 00:00", periods=10, freq="1min"
        )
        index = friday_ticks.append(monday_ticks)
        price: np.ndarray = np.full(len(index), 1.10000)
        ticks_df = pd.DataFrame(
            {"bid": price, "ask": price + 0.0001},
            index=index,
        )
        ticks_df.index.name = "timestamp"
        ticks_df["spread"] = 0.0001
        md = MarketData(ticks_df)

        strat = BuyAndHoldStrategy(lot=0.01)
        engine = BacktestEngine()
        acc: Account = make_account(balance=10_000.0)
        result: Report = engine.run(
            data=md,
            indicators=[],
            strategies=[strat],
            accounts=acc,
            individual_accounts=True,
        )
        acc_df: pd.DataFrame = result.get_account_report(strat.label).dataframe

        expected_cost: float = Units.maintain_buy_commission(0.01) * 2  # 2 days
        actual_cost: float = acc_df["maintain_buy_net_impact"].iloc[-1]
        assert actual_cost == pytest.approx(expected_cost, rel=1e-6), (
            f"Expected {expected_cost:.4f} (2 days), got {actual_cost:.4f}. "
            "Possible day_change +1 overcount regression."
        )


# ─────────────────────────────────────────────
# End-to-end smoke test
# ─────────────────────────────────────────────


class TestEndToEnd:
    def test_full_run_rsi_signal_follower(self) -> None:
        """Full pipeline: RSI indicator + signal-following strategy."""
        engine = BacktestEngine()
        md: MarketData = make_market_data(n=1000, freq="1min")
        strat = SignalFollowerStrategy(lot=0.01)
        result: Report = engine.run(
            data=md,
            indicators=[RSI(label="rsi", period=9, candles_type="5m")],
            strategies=[strat],
            accounts=make_account(balance=10_000.0),
            individual_accounts=True,
        )
        assert isinstance(result.indicators_df, pd.DataFrame)
        assert strat.label in result.account_reports
        acc_df: pd.DataFrame = result.get_account_report(strat.label).dataframe
        assert len(acc_df) > 0
        assert np.isfinite(acc_df["balance"].iloc[-1])

    def test_full_run_macd(self) -> None:
        engine = BacktestEngine()
        md: MarketData = make_market_data(n=1000, freq="1min")
        result: Report = engine.run(
            data=md,
            indicators=[MACD(label="macd", candles_type="5m")],
            strategies=[DoNothingStrategy()],
            accounts=make_account(),
            individual_accounts=True,
        )
        df: pd.DataFrame = result.indicators_df
        assert "macd_signal" in df.columns

    def test_empty_date_range_raises_or_returns_empty(self) -> None:
        engine = BacktestEngine()
        md: MarketData = make_market_data(n=200, freq="1min")
        future = pd.Timestamp("2099-01-01")
        try:
            result: Report = engine.run(
                data=md,
                indicators=[],
                strategies=[DoNothingStrategy()],
                accounts=make_account(),
                individual_accounts=True,
                from_date=future,
            )
            assert len(result.indicators_df) == 0
        except (ValueError, KeyError):
            pass  # Acceptable — empty range is an error condition
