"""Tests for the backtesting engine and analytics."""

import csv
import math
import os
import tempfile

import pytest

from horizon._horizon import Engine, Market, Quote, RiskConfig, Side
from horizon.analytics import Metrics, compute_metrics, _compute_trade_pnls, _compute_brier_score
from horizon.risk import Risk
from horizon.backtest import (
    Tick,
    _normalize_data,
    _validate_ticks,
    _resolve_feed_data,
    _build_timeline,
    backtest,
)
from horizon.context import Context, FeedData
from horizon.result import BacktestResult
from horizon.strategy import _run_pipeline


# ---------------------------------------------------------------------------
# Data normalization tests
# ---------------------------------------------------------------------------


class TestNormalizeData:
    def test_from_dicts(self):
        data = [
            {"timestamp": 1.0, "price": 0.5},
            {"timestamp": 2.0, "price": 0.6},
        ]
        ticks = _normalize_data(data)
        assert len(ticks) == 2
        assert ticks[0].timestamp == 1.0
        assert ticks[0].price == 0.5
        assert ticks[1].price == 0.6

    def test_from_dicts_bid_ask(self):
        data = [
            {"timestamp": 1.0, "bid": 0.48, "ask": 0.52},
        ]
        ticks = _normalize_data(data)
        assert ticks[0].bid == 0.48
        assert ticks[0].ask == 0.52
        assert abs(ticks[0].price - 0.50) < 1e-10  # Mid

    def test_from_dicts_price_derives_bid_ask(self):
        data = [{"timestamp": 1.0, "price": 0.5}]
        ticks = _normalize_data(data)
        assert ticks[0].bid == 0.5
        assert ticks[0].ask == 0.5

    def test_from_csv(self):
        with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
            writer = csv.writer(f)
            writer.writerow(["timestamp", "price", "bid", "ask"])
            writer.writerow([1.0, 0.5, 0.48, 0.52])
            writer.writerow([2.0, 0.6, 0.58, 0.62])
            csv_path = f.name

        try:
            ticks = _normalize_data(csv_path)
            assert len(ticks) == 2
            assert ticks[0].price == 0.5
            assert ticks[1].bid == 0.58
        finally:
            os.unlink(csv_path)

    def test_csv_not_found(self):
        with pytest.raises(FileNotFoundError):
            _normalize_data("/nonexistent/path.csv")

    def test_missing_timestamp(self):
        with pytest.raises(ValueError, match="missing required 'timestamp'"):
            _normalize_data([{"price": 0.5}])

    def test_missing_price_and_bid(self):
        with pytest.raises(ValueError, match="missing 'price' or 'bid'"):
            _normalize_data([{"timestamp": 1.0}])

    def test_none_returns_empty(self):
        assert _normalize_data(None) == []

    def test_unsupported_type(self):
        with pytest.raises(TypeError, match="Unsupported data type"):
            _normalize_data(42)


# ---------------------------------------------------------------------------
# Validation tests
# ---------------------------------------------------------------------------


class TestValidateTicks:
    def test_valid_ticks(self):
        ticks = [Tick(1.0, 0.5), Tick(2.0, 0.6)]
        _validate_ticks(ticks)  # Should not raise

    def test_empty_ticks(self):
        with pytest.raises(ValueError, match="has no data"):
            _validate_ticks([], "test_feed")

    def test_price_out_of_range_high(self):
        with pytest.raises(ValueError, match="outside valid range"):
            _validate_ticks([Tick(1.0, 1.5)])

    def test_price_negative(self):
        with pytest.raises(ValueError, match="outside valid range"):
            _validate_ticks([Tick(1.0, -0.1)])

    def test_non_monotonic_timestamps(self):
        ticks = [Tick(2.0, 0.5), Tick(1.0, 0.6)]
        with pytest.raises(ValueError, match="not monotonic"):
            _validate_ticks(ticks)

    def test_equal_timestamps_ok(self):
        ticks = [Tick(1.0, 0.5), Tick(1.0, 0.6)]
        _validate_ticks(ticks)  # Equal timestamps are fine

    def test_zero_price_rejected(self):
        """Price of 0.0 is invalid -- prediction market prices must be in (0, 1]."""
        ticks = [Tick(1.0, 0.0, bid=0.0, ask=0.0)]
        with pytest.raises(ValueError, match="outside valid range"):
            _validate_ticks(ticks)


# ---------------------------------------------------------------------------
# Timeline building tests
# ---------------------------------------------------------------------------


class TestBuildTimeline:
    def test_single_feed(self):
        feed_ticks = {
            "default": [Tick(1.0, 0.5), Tick(2.0, 0.6)],
        }
        timeline = _build_timeline(feed_ticks)
        assert len(timeline) == 2
        assert timeline[0][0] == 1.0
        assert timeline[0][1]["default"].price == 0.5
        assert timeline[1][1]["default"].price == 0.6

    def test_multi_feed_merge(self):
        feed_ticks = {
            "btc": [Tick(1.0, 0.5), Tick(3.0, 0.7)],
            "eth": [Tick(2.0, 0.4), Tick(4.0, 0.8)],
        }
        timeline = _build_timeline(feed_ticks)
        assert len(timeline) == 4
        # t=1: btc updated, eth still default
        assert timeline[0][1]["btc"].price == 0.5
        assert timeline[0][1]["eth"].price == 0.0  # Not yet updated
        # t=2: btc carried forward, eth updated
        assert timeline[1][1]["btc"].price == 0.5
        assert timeline[1][1]["eth"].price == 0.4
        # t=3: btc updated, eth carried forward
        assert timeline[2][1]["btc"].price == 0.7
        assert timeline[2][1]["eth"].price == 0.4

    def test_empty_feeds(self):
        timeline = _build_timeline({})
        assert timeline == []

    def test_overlapping_timestamps(self):
        feed_ticks = {
            "a": [Tick(1.0, 0.3)],
            "b": [Tick(1.0, 0.7)],
        }
        timeline = _build_timeline(feed_ticks)
        assert len(timeline) == 1  # Same timestamp merged
        assert timeline[0][1]["a"].price == 0.3
        assert timeline[0][1]["b"].price == 0.7


# ---------------------------------------------------------------------------
# Resolve feed data tests
# ---------------------------------------------------------------------------


class TestResolveFeedData:
    def test_single_data_default_feed(self):
        data = [{"timestamp": 1.0, "price": 0.5}]
        result = _resolve_feed_data(data, None, ["market"])
        assert "default" in result
        assert len(result["default"]) == 1

    def test_single_data_with_feed_mapping(self):
        data = [{"timestamp": 1.0, "price": 0.5}]
        result = _resolve_feed_data(data, {"btc": None}, ["market"])
        assert "btc" in result
        assert "default" not in result

    def test_dict_data_multi_feed(self):
        data = {
            "btc": [{"timestamp": 1.0, "price": 0.5}],
            "eth": [{"timestamp": 1.0, "price": 0.3}],
        }
        result = _resolve_feed_data(data, None, ["market"])
        assert "btc" in result
        assert "eth" in result


# ---------------------------------------------------------------------------
# Analytics tests
# ---------------------------------------------------------------------------


class TestMetrics:
    def test_empty_equity_curve(self):
        m = compute_metrics([], [], 1000.0)
        assert m.total_return == 0.0
        assert m.sharpe_ratio == 0.0

    def test_flat_equity(self):
        curve = [(float(i), 1000.0) for i in range(100)]
        m = compute_metrics(curve, [], 1000.0)
        assert m.total_return == 0.0
        assert m.total_return_pct == 0.0
        assert m.max_drawdown == 0.0

    def test_known_return(self):
        curve = [(0.0, 1000.0), (86400.0, 1100.0)]
        m = compute_metrics(curve, [], 1000.0)
        assert abs(m.total_return - 100.0) < 1e-10
        assert abs(m.total_return_pct - 10.0) < 1e-10

    def test_known_drawdown(self):
        curve = [
            (0.0, 1000.0),
            (1.0, 1200.0),
            (2.0, 1000.0),  # Drawdown of 200 from peak 1200
            (3.0, 1300.0),
        ]
        m = compute_metrics(curve, [], 1000.0)
        assert abs(m.max_drawdown - 200.0) < 1e-10
        assert abs(m.max_drawdown_pct - (200.0 / 1200.0) * 100.0) < 1e-6

    def test_sharpe_positive(self):
        # Steadily increasing equity → positive Sharpe
        curve = [(float(i), 1000.0 + i * 10.0) for i in range(100)]
        m = compute_metrics(curve, [], 1000.0)
        assert m.sharpe_ratio > 0

    def test_sortino_positive_for_noisy_uptrend(self):
        # Add small dips so there's downside to compute Sortino from
        import random
        random.seed(42)
        curve = []
        eq = 1000.0
        for i in range(200):
            eq += 5.0 + random.gauss(0, 3)  # Positive drift + noise
            curve.append((float(i), eq))
        m = compute_metrics(curve, [], 1000.0)
        assert m.sortino_ratio > 0

    def test_sortino_zero_for_pure_uptrend(self):
        # No downside returns → Sortino is 0 (undefined)
        curve = [(float(i), 1000.0 + i * 10.0) for i in range(100)]
        m = compute_metrics(curve, [], 1000.0)
        assert m.sortino_ratio == 0.0

    def test_trade_pnl_computation(self):
        """Test FIFO matching of buy/sell trades."""

        class MockFill:
            def __init__(self, market_id, order_side, price, size, fee=0.0):
                self.market_id = market_id
                self.order_side = order_side
                self.price = price
                self.size = size
                self.fee = fee

        fills = [
            MockFill("m1", "buy", 0.40, 10.0),
            MockFill("m1", "sell", 0.60, 10.0),
        ]
        pnls = _compute_trade_pnls(fills)
        assert len(pnls) == 1
        assert abs(pnls[0] - 2.0) < 1e-10  # (0.60 - 0.40) * 10

    def test_win_rate(self):
        class MockFill:
            def __init__(self, market_id, order_side, price, size, fee=0.0):
                self.market_id = market_id
                self.order_side = order_side
                self.price = price
                self.size = size
                self.fee = fee

        fills = [
            MockFill("m1", "buy", 0.40, 10.0),
            MockFill("m1", "sell", 0.60, 10.0),  # Win
            MockFill("m1", "buy", 0.70, 10.0),
            MockFill("m1", "sell", 0.50, 10.0),  # Loss
        ]
        curve = [(0.0, 1000.0), (1.0, 1002.0)]  # Dummy
        m = compute_metrics(curve, fills, 1000.0)
        assert m.total_trades == 2
        assert abs(m.win_rate - 50.0) < 1e-10

    def test_profit_factor(self):
        class MockFill:
            def __init__(self, market_id, order_side, price, size, fee=0.0):
                self.market_id = market_id
                self.order_side = order_side
                self.price = price
                self.size = size
                self.fee = fee

        fills = [
            MockFill("m1", "buy", 0.40, 10.0),
            MockFill("m1", "sell", 0.60, 10.0),   # +2.0
            MockFill("m1", "buy", 0.70, 10.0),
            MockFill("m1", "sell", 0.60, 10.0),   # -1.0
        ]
        curve = [(0.0, 1000.0), (1.0, 1001.0)]
        m = compute_metrics(curve, fills, 1000.0)
        assert abs(m.profit_factor - 2.0) < 1e-10  # 2.0 / 1.0


# ---------------------------------------------------------------------------
# Brier score tests
# ---------------------------------------------------------------------------


class TestBrierScore:
    def test_perfect_forecast(self):
        """Brier score = 0 for perfect forecasts."""

        class MockFill:
            def __init__(self, market_id, order_side, price, size=1.0, fee=0.0):
                self.market_id = market_id
                self.order_side = order_side
                self.price = price
                self.size = size
                self.fee = fee

        fills = [
            MockFill("m1", "buy", 1.0),  # Bought at 1.0, outcome = 1.0
            MockFill("m2", "buy", 0.0),  # Bought at 0.0, outcome = 0.0
        ]
        outcomes = {"m1": 1.0, "m2": 0.0}
        score = _compute_brier_score(fills, outcomes)
        assert abs(score) < 1e-10

    def test_worst_forecast(self):
        """Brier score = 1.0 for worst forecasts."""

        class MockFill:
            def __init__(self, market_id, order_side, price, size=1.0, fee=0.0):
                self.market_id = market_id
                self.order_side = order_side
                self.price = price
                self.size = size
                self.fee = fee

        fills = [
            MockFill("m1", "buy", 1.0),  # Bought at 1.0, outcome = 0.0
            MockFill("m2", "buy", 0.0),  # Bought at 0.0, outcome = 1.0
        ]
        outcomes = {"m1": 0.0, "m2": 1.0}
        score = _compute_brier_score(fills, outcomes)
        assert abs(score - 1.0) < 1e-10

    def test_random_forecast(self):
        """Brier score ~ 0.25 for buying at 0.5 on 50/50 outcomes."""

        class MockFill:
            def __init__(self, market_id, order_side, price, size=1.0, fee=0.0):
                self.market_id = market_id
                self.order_side = order_side
                self.price = price
                self.size = size
                self.fee = fee

        fills = [
            MockFill("m1", "buy", 0.5),
            MockFill("m2", "buy", 0.5),
        ]
        outcomes = {"m1": 1.0, "m2": 0.0}
        score = _compute_brier_score(fills, outcomes)
        assert abs(score - 0.25) < 1e-10


# ---------------------------------------------------------------------------
# BacktestResult tests
# ---------------------------------------------------------------------------


class TestBacktestResult:
    def test_summary_format(self):
        result = BacktestResult(
            equity_curve=[(0.0, 1000.0), (86400.0, 1100.0)],
            initial_capital=1000.0,
        )
        s = result.summary()
        assert "BACKTEST RESULTS" in s
        assert "$1,100.00" in s
        assert "10.00%" in s

    def test_lazy_metrics(self):
        result = BacktestResult(
            equity_curve=[(0.0, 1000.0), (1.0, 1100.0)],
            initial_capital=1000.0,
        )
        assert result._metrics is None
        _ = result.metrics
        assert result._metrics is not None

    def test_to_csv_equity(self):
        result = BacktestResult(
            equity_curve=[(0.0, 1000.0), (1.0, 1050.0), (2.0, 1100.0)],
            initial_capital=1000.0,
        )
        with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
            csv_path = f.name

        try:
            result.to_csv(csv_path, what="equity")
            with open(csv_path) as f:
                reader = csv.reader(f)
                rows = list(reader)
            assert rows[0] == ["timestamp", "equity"]
            assert len(rows) == 4  # header + 3 data rows
            assert rows[1] == ["0.0", "1000.0"]
        finally:
            os.unlink(csv_path)

    def test_to_csv_trades(self):
        class MockFill:
            def __init__(self):
                self.timestamp = 1.0
                self.market_id = "m1"
                self.order_side = "buy"
                self.price = 0.5
                self.size = 10.0
                self.fee = 0.01

        result = BacktestResult(
            equity_curve=[(0.0, 1000.0)],
            trades=[MockFill()],
            initial_capital=1000.0,
        )
        with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as f:
            csv_path = f.name

        try:
            result.to_csv(csv_path, what="trades")
            with open(csv_path) as f:
                reader = csv.reader(f)
                rows = list(reader)
            assert rows[0][0] == "timestamp"
            assert len(rows) == 2  # header + 1 trade
        finally:
            os.unlink(csv_path)

    def test_pnl_by_market(self):
        class MockFill:
            def __init__(self, market_id, order_side, price, size):
                self.market_id = market_id
                self.order_side = order_side
                self.price = price
                self.size = size

        result = BacktestResult(
            equity_curve=[(0.0, 1000.0)],
            trades=[
                MockFill("m1", "buy", 0.40, 10.0),
                MockFill("m1", "sell", 0.60, 10.0),
                MockFill("m2", "buy", 0.70, 5.0),
                MockFill("m2", "sell", 0.50, 5.0),
            ],
            initial_capital=1000.0,
        )
        pnl = result.pnl_by_market()
        assert abs(pnl["m1"] - 2.0) < 1e-10
        assert abs(pnl["m2"] - (-1.0)) < 1e-10


# ---------------------------------------------------------------------------
# End-to-end backtest tests
# ---------------------------------------------------------------------------


class TestBacktestExecution:
    def test_simple_backtest(self):
        """A simple strategy that always quotes - should produce fills and equity movement."""

        def fair_value(ctx: Context) -> float:
            feed = ctx.feeds.get("default")
            return feed.price if feed else 0.5

        def quoter(ctx: Context, fair: float) -> list[Quote]:
            return [Quote(bid=fair - 0.02, ask=fair + 0.02, size=5.0)]

        # Price moves from 0.5 to 0.6 over 10 ticks
        data = [
            {"timestamp": float(i), "price": 0.50 + i * 0.01}
            for i in range(10)
        ]

        result = backtest(
            name="test",
            markets=["test-market"],
            data=data,
            pipeline=[fair_value, quoter],
            risk=RiskConfig(),
            initial_capital=1000.0,
        )

        assert isinstance(result, BacktestResult)
        assert len(result.equity_curve) == 10
        # Engine should have at least tried to place orders
        assert result.equity_curve[0][0] == 0.0

    def test_backtest_with_fills(self):
        """Verify that the paper exchange actually fills orders."""

        def always_buy(ctx: Context) -> list[Quote]:
            # Wide quotes that should get filled by the paper exchange tick
            return [Quote(bid=0.99, ask=1.00, size=1.0)]

        data = [
            {"timestamp": float(i), "price": 0.50}
            for i in range(5)
        ]

        result = backtest(
            name="test_fills",
            markets=["test-market"],
            data=data,
            pipeline=[always_buy],
            initial_capital=1000.0,
        )

        assert isinstance(result, BacktestResult)
        assert len(result.equity_curve) == 5
        # With bid=0.99 and market at 0.50, the buy order should fill
        assert len(result.trades) > 0

    def test_backtest_respects_risk_limits(self):
        """Risk limits should be enforced during backtest."""

        def aggressive_quoter(ctx: Context) -> list[Quote]:
            return [Quote(bid=0.99, ask=1.00, size=1000.0)]  # Huge size

        data = [
            {"timestamp": float(i), "price": 0.50}
            for i in range(10)
        ]

        result = backtest(
            name="test_risk",
            markets=["test-market"],
            data=data,
            pipeline=[aggressive_quoter],
            risk=Risk(max_order_size=5.0, max_position=10.0),
            initial_capital=1000.0,
        )

        # Position should be capped by risk limits
        for pos in result.positions:
            assert pos.size <= 10.0 + 1e-6

    def test_empty_pipeline_raises(self):
        with pytest.raises(ValueError, match="pipeline must contain"):
            backtest(data=[{"timestamp": 1.0, "price": 0.5}], pipeline=[])

    def test_no_pipeline_raises(self):
        with pytest.raises(ValueError, match="pipeline must contain"):
            backtest(data=[{"timestamp": 1.0, "price": 0.5}])

    def test_empty_data(self):
        def noop(ctx: Context) -> list[Quote]:
            return []

        result = backtest(
            data=[],
            pipeline=[noop],
        )
        # Should return a minimal result without error
        assert result.initial_capital == 1000.0

    def test_multi_feed_backtest(self):
        """Test backtest with multiple feed sources."""

        def multi_feed_strategy(ctx: Context) -> list[Quote]:
            btc = ctx.feeds.get("btc")
            eth = ctx.feeds.get("eth")
            if btc and eth and btc.price > 0 and eth.price > 0:
                fair = (btc.price + eth.price) / 2
                return [Quote(bid=fair - 0.02, ask=fair + 0.02, size=1.0)]
            return []

        data = {
            "btc": [
                {"timestamp": 1.0, "price": 0.50},
                {"timestamp": 2.0, "price": 0.55},
            ],
            "eth": [
                {"timestamp": 1.0, "price": 0.40},
                {"timestamp": 3.0, "price": 0.45},
            ],
        }

        result = backtest(
            name="multi_feed",
            data=data,
            pipeline=[multi_feed_strategy],
        )

        assert len(result.equity_curve) == 3  # t=1, 2, 3

    def test_csv_backtest(self):
        """Test backtest from CSV file."""

        def simple(ctx: Context) -> list[Quote]:
            feed = ctx.feeds.get("default")
            if feed and feed.price > 0:
                return [Quote(bid=feed.price - 0.02, ask=feed.price + 0.02, size=1.0)]
            return []

        with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
            writer = csv.writer(f)
            writer.writerow(["timestamp", "price"])
            for i in range(5):
                writer.writerow([float(i), 0.50 + i * 0.01])
            csv_path = f.name

        try:
            result = backtest(
                data=csv_path,
                pipeline=[simple],
            )
            assert len(result.equity_curve) == 5
        finally:
            os.unlink(csv_path)

    def test_same_pipeline_works_for_run_and_backtest(self):
        """Verify the same pipeline functions work in both modes."""

        def fair_value(ctx: Context) -> float:
            feed = ctx.feeds.get("default")
            return feed.price if feed and feed.price > 0 else 0.5

        def quoter(ctx: Context, fair: float) -> list[Quote]:
            return [Quote(bid=fair - 0.02, ask=fair + 0.02, size=1.0)]

        pipeline = [fair_value, quoter]

        # Test in backtest mode
        data = [{"timestamp": float(i), "price": 0.50} for i in range(5)]
        result = backtest(data=data, pipeline=pipeline)
        assert len(result.equity_curve) == 5

        # Test pipeline directly (simulating what hz.run would do)
        ctx = Context(feeds={"default": FeedData(price=0.5, timestamp=1.0, bid=0.48, ask=0.52)})
        output = _run_pipeline(pipeline, ctx)
        assert isinstance(output, list)
        assert isinstance(output[0], Quote)

    def test_backtest_with_outcomes(self):
        """Test backtest with prediction market outcomes for Brier score."""

        def buyer(ctx: Context) -> list[Quote]:
            return [Quote(bid=0.99, ask=1.00, size=1.0)]

        data = [{"timestamp": float(i), "price": 0.50} for i in range(3)]

        result = backtest(
            data=data,
            markets=["m1"],
            pipeline=[buyer],
            outcomes={"m1": 1.0},
        )

        m = result.metrics
        assert m.brier_score is not None

    def test_backtest_with_params(self):
        """Pipeline functions can access params from Context."""

        def parametric(ctx: Context) -> list[Quote]:
            spread = ctx.params.get("spread", 0.04)
            return [Quote(bid=0.50 - spread / 2, ask=0.50 + spread / 2, size=1.0)]

        data = [{"timestamp": 1.0, "price": 0.50}]
        result = backtest(
            data=data,
            pipeline=[parametric],
            params={"spread": 0.10},
        )
        assert len(result.equity_curve) == 1

    def test_default_market_name(self):
        """When no markets specified, should default to ['market']."""

        def noop(ctx: Context) -> list[Quote]:
            assert ctx.market.id == "market"
            return []

        data = [{"timestamp": 1.0, "price": 0.50}]
        result = backtest(data=data, pipeline=[noop])
        assert len(result.equity_curve) == 1
