"""Audit gap coverage: Alpaca, BookSim, Backtesting, Database persistence.

Tests exchange backends (Alpaca config, BookSim fill models), backtesting
end-to-end, and database round-trip persistence.
"""

import os
import tempfile

import pytest

from horizon._horizon import (
    Engine,
    Fill,
    Market,
    OrderRequest,
    OrderSide,
    OrderType,
    RiskConfig,
    Side,
    TimeInForce,
)
from horizon.backtest import Tick, backtest, _normalize_data, _validate_ticks, _build_timeline
from horizon.context import Context, FeedData
from horizon.exchanges import Alpaca, Kalshi, Polymarket
from horizon.feeds import AlpacaFeed, MempoolFeed, TreasuryFeed
from horizon.result import BacktestResult
from horizon.strategy import _run_pipeline


RELAXED_RISK = RiskConfig(
    max_position_per_market=10000.0,
    max_portfolio_notional=100000.0,
    max_daily_drawdown_pct=100.0,
    max_order_size=10000.0,
    rate_limit_sustained=1_000_000,
    rate_limit_burst=1_000_000,
    dedup_window_ms=0,
)


# ===================================================================
# Alpaca Exchange Config
# ===================================================================

class TestAlpacaConfig:
    """Alpaca exchange configuration and feed dataclasses."""

    def test_alpaca_exchange_dataclass(self):
        a = Alpaca(api_key="test_key", api_secret="test_secret", paper=True)
        assert a.api_key == "test_key"
        assert a.api_secret == "test_secret"
        assert a.paper is True
        assert a.data_source == "iex"

    def test_alpaca_exchange_defaults(self):
        a = Alpaca()
        assert a.paper is True
        assert a.data_source == "iex"
        assert a.api_url is not None  # Defaults to paper URL

    def test_alpaca_feed_dataclass(self):
        f = AlpacaFeed(symbols=["AAPL", "GOOGL"])
        assert f._type == "alpaca"
        assert f.symbols == ["AAPL", "GOOGL"]
        assert f.data_source == "iex"

    def test_alpaca_feed_empty_symbols(self):
        f = AlpacaFeed()
        assert f.symbols == []

    def test_alpaca_sip_source(self):
        a = Alpaca(data_source="sip")
        assert a.data_source == "sip"

    def test_alpaca_env_var_resolution(self):
        """AlpacaFeed resolves api_key from env vars."""
        prev_key = os.environ.get("ALPACA_API_KEY")
        prev_secret = os.environ.get("ALPACA_API_SECRET")
        try:
            os.environ["ALPACA_API_KEY"] = "env_key"
            os.environ["ALPACA_API_SECRET"] = "env_secret"
            f = AlpacaFeed(symbols=["SPY"])
            assert f.api_key == "env_key"
            assert f.api_secret == "env_secret"
        finally:
            if prev_key is None:
                os.environ.pop("ALPACA_API_KEY", None)
            else:
                os.environ["ALPACA_API_KEY"] = prev_key
            if prev_secret is None:
                os.environ.pop("ALPACA_API_SECRET", None)
            else:
                os.environ["ALPACA_API_SECRET"] = prev_secret


# ===================================================================
# Kalshi / Polymarket Exchange Config
# ===================================================================

class TestExchangeConfigs:
    """Exchange configuration dataclasses."""

    def test_kalshi_config(self):
        k = Kalshi(email="test@test.com", password="pass123")
        assert k.email == "test@test.com"
        assert "kalshi" in k.api_url

    def test_kalshi_defaults(self):
        k = Kalshi()
        assert k.email is None
        assert k.api_key is None

    def test_polymarket_config(self):
        p = Polymarket(private_key="0xabc123")
        assert p.private_key == "0xabc123"
        assert "clob.polymarket.com" in p.clob_url

    def test_polymarket_defaults(self):
        p = Polymarket()
        assert p.private_key is None
        assert p.api_key is None


# ===================================================================
# Feed Dataclasses (Mempool, Treasury)
# ===================================================================

class TestFeedConfigs:
    """Feed dataclasses for mempool and treasury."""

    def test_mempool_feed(self):
        f = MempoolFeed()
        assert f._type == "mempool"
        assert f.interval == 2.0
        assert len(f.ctf_addresses) == 2  # CTF + Neg Risk CTF

    def test_mempool_feed_custom_rpc(self):
        f = MempoolFeed(rpc_url="https://eth.llamarpc.com")
        assert f.rpc_url == "https://eth.llamarpc.com"

    def test_treasury_feed(self):
        f = TreasuryFeed()
        assert f._type == "treasury"
        assert f.interval == 300.0
        assert f.primary_series == "DGS10"
        assert len(f.series_ids) == 9

    def test_treasury_feed_custom(self):
        f = TreasuryFeed(series_ids=["DGS2", "DGS10"], primary_series="DGS2")
        assert f.primary_series == "DGS2"
        assert len(f.series_ids) == 2


# ===================================================================
# BookSim Exchange
# ===================================================================

class TestBookSimExchange:
    """BookSim exchange with L2 matching, fill models, impact."""

    def test_create_book_sim_engine(self):
        e = Engine(risk_config=RELAXED_RISK, exchange_type="book_sim",
                   fill_model="deterministic")
        assert e.exchange_name() == "book_sim"

    def test_book_sim_order_id_format(self):
        """BookSim order IDs start with 'bs'."""
        e = Engine(risk_config=RELAXED_RISK, exchange_type="book_sim",
                   fill_model="deterministic")
        bids = [(0.50, 100.0)]
        asks = [(0.55, 100.0)]
        e.update_book("mkt_1", bids, asks, 1.0)

        oid = e.submit_order(OrderRequest(
            "mkt_1", Side.Yes, OrderSide.Buy, 10.0, 0.55,
        ))
        assert oid.startswith("bs")

    def test_book_sim_deterministic_fill(self):
        """Deterministic fill: buy order at ask price fills."""
        e = Engine(risk_config=RELAXED_RISK, exchange_type="book_sim",
                   fill_model="deterministic")
        bids = [(0.50, 100.0)]
        asks = [(0.55, 100.0)]
        e.update_book("mkt_1", bids, asks, 1.0)

        e.submit_order(OrderRequest(
            "mkt_1", Side.Yes, OrderSide.Buy, 10.0, 0.55,
        ))
        fills = e.tick("mkt_1", 0.0)
        assert fills > 0

    def test_book_sim_no_fill_below_ask(self):
        """Buy order below ask should not fill in deterministic model."""
        e = Engine(risk_config=RELAXED_RISK, exchange_type="book_sim",
                   fill_model="deterministic")
        bids = [(0.50, 100.0)]
        asks = [(0.55, 100.0)]
        e.update_book("mkt_1", bids, asks, 1.0)

        e.submit_order(OrderRequest(
            "mkt_1", Side.Yes, OrderSide.Buy, 10.0, 0.53,
        ))
        fills = e.tick("mkt_1", 0.0)
        assert fills == 0

    def test_book_sim_probabilistic_model(self):
        """Probabilistic fill model: order may or may not fill."""
        e = Engine(risk_config=RELAXED_RISK, exchange_type="book_sim",
                   fill_model="probabilistic")
        bids = [(0.50, 100.0)]
        asks = [(0.55, 100.0)]
        e.update_book("mkt_1", bids, asks, 1.0)

        e.submit_order(OrderRequest(
            "mkt_1", Side.Yes, OrderSide.Buy, 10.0, 0.55,
        ))
        # Probabilistic - may or may not fill, just verify no crash
        e.tick("mkt_1", 0.0)

    def test_book_sim_glft_model(self):
        """GLFT fill model: no crash with valid parameters."""
        e = Engine(risk_config=RELAXED_RISK, exchange_type="book_sim",
                   fill_model="glft")
        bids = [(0.50, 100.0)]
        asks = [(0.55, 100.0)]
        e.update_book("mkt_1", bids, asks, 1.0)

        e.submit_order(OrderRequest(
            "mkt_1", Side.Yes, OrderSide.Buy, 10.0, 0.55,
        ))
        # Just verify no crash
        e.tick("mkt_1", 0.0)

    def test_book_sim_sell_side(self):
        """Sell orders fill against bids."""
        e = Engine(risk_config=RELAXED_RISK, exchange_type="book_sim",
                   fill_model="deterministic")
        bids = [(0.50, 100.0)]
        asks = [(0.55, 100.0)]
        e.update_book("mkt_1", bids, asks, 1.0)

        e.submit_order(OrderRequest(
            "mkt_1", Side.Yes, OrderSide.Sell, 10.0, 0.50,
        ))
        fills = e.tick("mkt_1", 0.0)
        assert fills > 0


# ===================================================================
# Backtesting
# ===================================================================

class TestBacktestE2E:
    """End-to-end backtesting tests."""

    def test_simple_backtest(self):
        """Simple backtest with constant bid strategy."""
        def fair_value(ctx: Context) -> float:
            return ctx.feed.price

        from horizon._horizon import Quote
        def quoter(ctx: Context, fair: float) -> list[Quote]:
            if fair > 0:
                return [Quote(bid=fair - 0.03, ask=fair + 0.03, size=5.0)]
            return []

        data = [
            {"timestamp": float(i), "price": 0.50 + (i % 10) * 0.01}
            for i in range(1, 51)
        ]

        result = backtest(
            name="audit_test",
            markets=["mkt_1"],
            data=data,
            pipeline=[fair_value, quoter],
            initial_capital=100.0,
        )

        assert isinstance(result, BacktestResult)
        assert len(result.equity_curve) > 0
        assert result.initial_capital == 100.0

    def test_backtest_with_outcomes(self):
        """Backtest with market resolution outcomes for Brier score."""
        def always_buy(ctx: Context) -> list:
            from horizon._horizon import Quote
            if ctx.feed.price > 0:
                return [Quote(bid=ctx.feed.price - 0.02, ask=ctx.feed.price + 0.02, size=2.0)]
            return []

        data = [
            {"timestamp": float(i), "price": 0.50 + i * 0.005}
            for i in range(20)
        ]

        result = backtest(
            name="outcome_test",
            markets=["mkt_1"],
            data=data,
            pipeline=[always_buy],
            initial_capital=100.0,
            outcomes={"mkt_1": 1.0},
        )

        assert result.outcomes == {"mkt_1": 1.0}

    def test_backtest_multi_feed(self):
        """Backtest with multiple feeds."""
        def fair_value(ctx: Context) -> float:
            return ctx.feeds.get("feed_a", ctx.feed).price

        from horizon._horizon import Quote
        def quoter(ctx: Context, fair: float) -> list[Quote]:
            if fair > 0:
                return [Quote(bid=fair - 0.02, ask=fair + 0.02, size=3.0)]
            return []

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

        result = backtest(
            name="multi_feed_test",
            markets=["mkt_1"],
            data=data,
            pipeline=[fair_value, quoter],
            initial_capital=100.0,
        )

        assert len(result.equity_curve) > 0

    def test_backtest_result_metrics(self):
        """BacktestResult.metrics should compute without error."""
        def quoter(ctx: Context) -> list:
            from horizon._horizon import Quote
            p = ctx.feed.price
            if p > 0:
                return [Quote(bid=p - 0.03, ask=p + 0.03, size=5.0)]
            return []

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

        result = backtest(
            name="metrics_test",
            markets=["mkt_1"],
            data=data,
            pipeline=[quoter],
            initial_capital=100.0,
        )

        m = result.metrics
        assert hasattr(m, "total_return")
        assert hasattr(m, "sharpe_ratio")
        assert hasattr(m, "max_drawdown")
        assert hasattr(m, "win_rate")

    def test_backtest_summary(self):
        """BacktestResult.summary() should return a string."""
        data = [{"timestamp": float(i), "price": 0.50} for i in range(10)]
        result = backtest(
            name="summary_test",
            markets=["mkt_1"],
            data=data,
            pipeline=[lambda ctx: []],
            initial_capital=100.0,
        )
        s = result.summary()
        assert isinstance(s, str)
        assert len(s) > 0

    def test_backtest_book_sim_mode(self):
        """Backtest with BookSim fill model."""
        from horizon._horizon import Quote
        def quoter(ctx: Context) -> list[Quote]:
            p = ctx.feed.price
            if p > 0:
                return [Quote(bid=p - 0.02, ask=p + 0.02, size=5.0)]
            return []

        book_data = {
            "mkt_1": [
                {"timestamp": float(i),
                 "bids": [(0.48, 100.0), (0.47, 200.0)],
                 "asks": [(0.52, 100.0), (0.53, 200.0)]}
                for i in range(20)
            ],
        }

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

        result = backtest(
            name="booksim_test",
            markets=["mkt_1"],
            data=data,
            pipeline=[quoter],
            initial_capital=100.0,
            fill_model="deterministic",
            book_data=book_data,
        )

        assert len(result.equity_curve) > 0


# ===================================================================
# Backtest Data Handling
# ===================================================================

class TestBacktestData:
    """Tick normalization, validation, and timeline building."""

    def test_normalize_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].price == 0.5

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

    def test_validate_rejects_price_above_1(self):
        """Prediction market prices must be in (0, 1]."""
        ticks = [Tick(timestamp=1.0, price=1.5)]
        with pytest.raises(ValueError):
            _validate_ticks(ticks)

    def test_validate_rejects_price_zero(self):
        ticks = [Tick(timestamp=1.0, price=0.0)]
        with pytest.raises(ValueError):
            _validate_ticks(ticks)

    def test_validate_rejects_negative_price(self):
        ticks = [Tick(timestamp=1.0, price=-0.5)]
        with pytest.raises(ValueError):
            _validate_ticks(ticks)

    def test_validate_accepts_price_1(self):
        """Price exactly 1.0 should be valid (binary resolution)."""
        ticks = [Tick(timestamp=1.0, price=1.0)]
        _validate_ticks(ticks)  # Should not raise

    def test_build_timeline_carry_forward(self):
        """Multi-feed timeline should carry forward last known price."""
        feeds = {
            "a": [Tick(1.0, 0.5), Tick(3.0, 0.6)],
            "b": [Tick(2.0, 0.4), Tick(4.0, 0.7)],
        }
        timeline = _build_timeline(feeds)
        assert len(timeline) == 4  # timestamps 1, 2, 3, 4
        # At t=2, feed "a" should carry forward 0.5
        t2 = timeline[1]
        assert t2[0] == 2.0
        assert t2[1]["a"].price == 0.5  # carried forward


# ===================================================================
# Database Persistence
# ===================================================================

class TestDatabasePersistence:
    """SQLite persistence: save/load fills, positions, ticks."""

    def test_engine_with_db(self):
        """Engine with database should persist fills."""
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db_path = f.name

        try:
            e = Engine(risk_config=RELAXED_RISK, db_path=db_path)
            e.process_fill(Fill(
                fill_id="f1", order_id="o1", market_id="mkt_1",
                side=Side.Yes, order_side=OrderSide.Buy,
                price=0.50, size=10.0,
            ))

            # Fill should be in DB
            count = len(e.recent_fills())
            assert count >= 1
        finally:
            os.unlink(db_path)

    def test_engine_memory_db(self):
        """Engine with :memory: DB should work."""
        e = Engine(risk_config=RELAXED_RISK, db_path=":memory:")
        e.process_fill(Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.50, size=10.0,
        ))
        assert len(e.recent_fills()) >= 1

    def test_position_snapshot_recovery(self):
        """Positions can be snapshotted and recovered."""
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db_path = f.name

        try:
            # Create engine, build position
            e1 = Engine(risk_config=RELAXED_RISK, db_path=db_path)
            e1.process_fill(Fill(
                fill_id="f1", order_id="o1", market_id="mkt_1",
                side=Side.Yes, order_side=OrderSide.Buy,
                price=0.55, size=10.0,
            ))

            positions = e1.positions()
            assert len(positions) == 1

            # Snapshot positions
            e1.snapshot_positions()

            # Create new engine from same DB, recover
            e2 = Engine(risk_config=RELAXED_RISK, db_path=db_path)
            e2.recover_state()

            positions2 = e2.positions()
            assert len(positions2) >= 1
            assert positions2[0].market_id == "mkt_1"
        finally:
            os.unlink(db_path)

    def test_tick_storage(self):
        """Feed ticks can be stored and queried."""
        e = Engine(risk_config=RELAXED_RISK, db_path=":memory:")
        e.start_feed("test_feed", "rest", url="https://httpbin.org/json", interval=60.0)

        # Tick count should be accessible
        count = e.tick_count("test_feed")
        assert count >= 0  # May be 0 initially

    def test_no_db_engine_works(self):
        """Engine without DB should still work."""
        e = Engine(risk_config=RELAXED_RISK)
        e.process_fill(Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.50, size=10.0,
        ))
        positions = e.positions()
        assert len(positions) == 1
