"""Paper trading simulation scenarios.

End-to-end scenarios that exercise the full recommendation-to-execution
pipeline through the paper trading adapter, risk engine, and position
tracking. Each scenario simulates a realistic trading session using
real logic from all service modules — no mocked business logic.

Covers:
- Single-symbol buy-and-sell round trips with P&L verification
- Multi-symbol portfolio construction and diversification
- Risk engine rejection scenarios (position limits, daily loss, lockouts)
- Idempotent order submission under replay conditions
- Insufficient funds and insufficient shares edge cases
- Recommendation-driven order flow (bullish → buy, bearish → sell)
- Portfolio drawdown halting via daily loss limits
- News-shock lockout preventing trades during high-impact events

Requirements: 7.1-7.4, 8.1-8.5
"""
from __future__ import annotations

from datetime import datetime, timedelta, timezone

import pytest

from services.adapters.broker_adapter import (
    OrderRequest,
    OrderSide,
    OrderStatus,
    OrderType,
    TradingMode,
)
from services.adapters.paper_trading import PaperTradingAdapter
from services.aggregation.worker import (
    ImpactRow,
    assemble_trend_with_evidence,
    build_weighted_signals,
)
from services.recommendation.eligibility import evaluate_eligibility
from services.recommendation.worker import build_recommendation
from services.risk.engine import (
    AccountRiskState,
    DailyLossLimits,
    NewsShockLockout,
    PortfolioRiskConfig,
    PositionLimits,
    ProposedOrder,
    RiskCheckResult,
    SectorExposureLimits,
    SymbolCooldown,
    evaluate_order,
)
from services.shared.schemas import (
    ActionType,
    RecommendationMode,
)

NOW = datetime(2026, 4, 11, 14, 0, 0, tzinfo=timezone.utc)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _bullish_impacts(ticker: str, count: int = 3) -> list[ImpactRow]:
    """Generate bullish impact rows for aggregation."""
    return [
        ImpactRow(
            document_id=f"doc-bull-{ticker}-{i}",
            confidence=0.80 + i * 0.02,
            novelty_score=0.6,
            source_credibility=0.8,
            sentiment="positive",
            impact_score=0.70 + i * 0.03,
            catalyst_type="earnings",
            key_facts=[f"Strong Q{i+1} results for {ticker}"],
            risks=[],
            published_at=NOW - timedelta(hours=i + 1),
        )
        for i in range(count)
    ]


def _bearish_impacts(ticker: str, count: int = 3) -> list[ImpactRow]:
    """Generate bearish impact rows for aggregation."""
    return [
        ImpactRow(
            document_id=f"doc-bear-{ticker}-{i}",
            confidence=0.78 + i * 0.02,
            novelty_score=0.55,
            source_credibility=0.75,
            sentiment="negative",
            impact_score=0.65 + i * 0.03,
            catalyst_type="legal",
            key_facts=[f"Regulatory action against {ticker}"],
            risks=[f"Potential fine for {ticker}"],
            published_at=NOW - timedelta(hours=i + 1),
        )
        for i in range(count)
    ]


def _build_trend_and_recommendation(impacts, ticker, window="7d"):
    """Run aggregation + eligibility + recommendation for a set of impacts."""
    signals = build_weighted_signals(impacts, NOW, window)
    assembled = assemble_trend_with_evidence(
        ticker, window, signals, impacts, reference_time=NOW,
    )
    summary = assembled.summary
    eligibility = evaluate_eligibility(summary)
    rec = build_recommendation(summary, eligibility, reference_time=NOW)
    return summary, eligibility, rec


def _risk_state_from_adapter(adapter: PaperTradingAdapter) -> AccountRiskState:
    """Build an AccountRiskState snapshot from the paper adapter's in-memory state."""
    acct = adapter.account
    positions_by_symbol = {
        t: p.quantity * p.avg_entry_price
        for t, p in acct.positions.items()
        if p.is_open
    }
    return AccountRiskState(
        account_id=acct.account_id,
        portfolio_value=acct.portfolio_value,
        cash=acct.cash,
        buying_power=acct.buying_power,
        positions_by_symbol=positions_by_symbol,
        open_position_count=sum(1 for p in acct.positions.values() if p.is_open),
    )


# ---------------------------------------------------------------------------
# Scenario 1: Single-symbol buy-sell round trip
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestSingleSymbolRoundTrip:
    """Buy shares, sell at a profit, verify P&L and cash reconciliation."""

    async def test_buy_hold_sell_profit(self):
        adapter = PaperTradingAdapter(initial_cash=100_000.0)

        # Generate bullish recommendation
        impacts = _bullish_impacts("AAPL")
        summary, eligibility, rec = _build_trend_and_recommendation(impacts, "AAPL")
        assert rec.action == ActionType.BUY

        # Execute buy
        buy = OrderRequest(
            ticker="AAPL", side=OrderSide.BUY, quantity=50,
            order_type=OrderType.LIMIT, limit_price=180.0,
        )
        buy_resp = await adapter.submit_order(buy)
        assert buy_resp.status == OrderStatus.FILLED
        assert adapter.account.cash == pytest.approx(100_000.0 - 50 * 180.0)

        # Verify position
        positions = await adapter.get_positions()
        assert len(positions) == 1
        assert positions[0].ticker == "AAPL"
        assert positions[0].quantity == 50

        # Sell at higher price
        sell = OrderRequest(
            ticker="AAPL", side=OrderSide.SELL, quantity=50,
            order_type=OrderType.LIMIT, limit_price=195.0,
        )
        sell_resp = await adapter.submit_order(sell)
        assert sell_resp.status == OrderStatus.FILLED
        assert sell_resp.raw_response["realized_pnl"] == pytest.approx(50 * 15.0)

        # Cash should be back to initial + profit
        expected_cash = 100_000.0 + 50 * 15.0
        assert adapter.account.cash == pytest.approx(expected_cash)

        # Position should be closed
        positions = await adapter.get_positions()
        assert len(positions) == 0

    async def test_buy_hold_sell_loss(self):
        adapter = PaperTradingAdapter(initial_cash=100_000.0)

        buy = OrderRequest(
            ticker="TSLA", side=OrderSide.BUY, quantity=20,
            order_type=OrderType.LIMIT, limit_price=250.0,
        )
        await adapter.submit_order(buy)

        sell = OrderRequest(
            ticker="TSLA", side=OrderSide.SELL, quantity=20,
            order_type=OrderType.LIMIT, limit_price=230.0,
        )
        sell_resp = await adapter.submit_order(sell)
        assert sell_resp.raw_response["realized_pnl"] == pytest.approx(-400.0)

        expected_cash = 100_000.0 - 400.0
        assert adapter.account.cash == pytest.approx(expected_cash)


# ---------------------------------------------------------------------------
# Scenario 2: Multi-symbol portfolio construction
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestMultiSymbolPortfolio:
    """Build a diversified portfolio across multiple symbols."""

    async def test_build_three_position_portfolio(self):
        adapter = PaperTradingAdapter(initial_cash=100_000.0)

        orders = [
            ("AAPL", 20, 180.0),
            ("MSFT", 15, 420.0),
            ("GOOGL", 10, 175.0),
        ]
        total_cost = 0.0
        for ticker, qty, price in orders:
            req = OrderRequest(
                ticker=ticker, side=OrderSide.BUY, quantity=qty,
                order_type=OrderType.LIMIT, limit_price=price,
            )
            resp = await adapter.submit_order(req)
            assert resp.status == OrderStatus.FILLED
            total_cost += qty * price

        assert adapter.account.cash == pytest.approx(100_000.0 - total_cost)

        positions = await adapter.get_positions()
        tickers = {p.ticker for p in positions}
        assert tickers == {"AAPL", "MSFT", "GOOGL"}

        # Portfolio value = cash + position value at entry
        assert adapter.account.portfolio_value == pytest.approx(100_000.0)

    async def test_partial_liquidation(self):
        adapter = PaperTradingAdapter(initial_cash=50_000.0)

        # Buy two positions
        await adapter.submit_order(OrderRequest(
            ticker="AAPL", side=OrderSide.BUY, quantity=30,
            order_type=OrderType.LIMIT, limit_price=150.0,
        ))
        await adapter.submit_order(OrderRequest(
            ticker="MSFT", side=OrderSide.BUY, quantity=10,
            order_type=OrderType.LIMIT, limit_price=400.0,
        ))

        # Sell only AAPL
        await adapter.submit_order(OrderRequest(
            ticker="AAPL", side=OrderSide.SELL, quantity=30,
            order_type=OrderType.LIMIT, limit_price=155.0,
        ))

        positions = await adapter.get_positions()
        assert len(positions) == 1
        assert positions[0].ticker == "MSFT"


# ---------------------------------------------------------------------------
# Scenario 3: Risk engine blocks unsafe orders
# ---------------------------------------------------------------------------


class TestRiskEngineBlocking:
    """Verify risk engine prevents orders that violate configured limits."""

    def test_position_size_limit_blocks_large_order(self):
        config = PortfolioRiskConfig(
            position_limits=PositionLimits(max_position_value=5_000.0),
        )
        state = AccountRiskState(
            portfolio_value=100_000.0, cash=100_000.0,
        )
        order = ProposedOrder(
            ticker="AAPL", sector="Technology",
            estimated_value=10_000.0, quantity=50,
        )
        result = evaluate_order(order, config, state)
        assert not result.passed
        assert any(
            c.check_name == "max_position_value" and c.result == RiskCheckResult.FAIL
            for c in result.checks
        )

    def test_sector_concentration_blocks_overweight(self):
        config = PortfolioRiskConfig(
            sector_exposure=SectorExposureLimits(max_sector_pct=0.20),
        )
        state = AccountRiskState(
            portfolio_value=100_000.0,
            positions_by_sector={"Technology": 18_000.0},
        )
        order = ProposedOrder(
            ticker="NVDA", sector="Technology",
            estimated_value=5_000.0, quantity=20,
        )
        result = evaluate_order(order, config, state)
        assert not result.passed

    def test_daily_loss_halt_blocks_further_trading(self):
        config = PortfolioRiskConfig(
            daily_loss=DailyLossLimits(
                max_daily_loss_pct=0.02,
                max_daily_loss_value=2_000.0,
            ),
        )
        state = AccountRiskState(
            portfolio_value=100_000.0,
            daily_pnl=-2_500.0,
        )
        order = ProposedOrder(
            ticker="AAPL", sector="Technology",
            estimated_value=1_000.0, quantity=5,
        )
        result = evaluate_order(order, config, state)
        assert not result.passed
        loss_failures = [
            c for c in result.checks
            if c.check_name.startswith("daily_loss") and c.result == RiskCheckResult.FAIL
        ]
        assert len(loss_failures) >= 1

    def test_news_shock_lockout_blocks_trade(self):
        lockout_expiry = NOW + timedelta(minutes=45)
        config = PortfolioRiskConfig(
            news_shock=NewsShockLockout(enabled=True, lockout_minutes=60),
        )
        state = AccountRiskState(
            portfolio_value=100_000.0,
            locked_symbols={"AAPL": lockout_expiry},
        )
        order = ProposedOrder(
            ticker="AAPL", sector="Technology",
            estimated_value=1_000.0, quantity=5,
        )
        result = evaluate_order(order, config, state, now=NOW)
        assert not result.passed
        assert any(
            c.check_name == "news_shock_lockout" and c.result == RiskCheckResult.FAIL
            for c in result.checks
        )

    def test_symbol_cooldown_blocks_rapid_retrade(self):
        last_trade = NOW - timedelta(minutes=5)
        config = PortfolioRiskConfig(
            symbol_cooldown=SymbolCooldown(cooldown_minutes=15),
        )
        state = AccountRiskState(
            portfolio_value=100_000.0,
            last_trade_times={"AAPL": last_trade},
        )
        order = ProposedOrder(
            ticker="AAPL", sector="Technology",
            estimated_value=1_000.0, quantity=5,
        )
        result = evaluate_order(order, config, state, now=NOW)
        assert not result.passed


# ---------------------------------------------------------------------------
# Scenario 4: Recommendation-driven order flow
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestRecommendationDrivenOrders:
    """Simulate the full path: signals → recommendation → risk check → paper fill."""

    async def test_bullish_recommendation_to_paper_buy(self):
        adapter = PaperTradingAdapter(initial_cash=100_000.0)
        impacts = _bullish_impacts("AAPL", count=4)
        summary, eligibility, rec = _build_trend_and_recommendation(impacts, "AAPL")

        assert rec.action == ActionType.BUY
        assert rec.confidence > 0

        # Risk check the proposed order
        risk_state = _risk_state_from_adapter(adapter)
        proposed = ProposedOrder(
            ticker="AAPL", sector="Technology",
            estimated_value=rec.position_sizing.portfolio_pct * risk_state.portfolio_value,
            quantity=10,
            confidence=rec.confidence,
            recommendation_id=rec.recommendation_id,
        )
        risk_eval = evaluate_order(proposed, PortfolioRiskConfig(), risk_state)
        assert risk_eval.passed

        # Execute the paper order
        order = OrderRequest(
            ticker="AAPL", side=OrderSide.BUY, quantity=10,
            order_type=OrderType.LIMIT, limit_price=180.0,
        )
        resp = await adapter.submit_order(order)
        assert resp.status == OrderStatus.FILLED

    async def test_bearish_recommendation_to_paper_sell(self):
        adapter = PaperTradingAdapter(initial_cash=100_000.0)

        # First buy a position to sell
        await adapter.submit_order(OrderRequest(
            ticker="TSLA", side=OrderSide.BUY, quantity=20,
            order_type=OrderType.LIMIT, limit_price=250.0,
        ))

        # Generate bearish recommendation
        impacts = _bearish_impacts("TSLA", count=3)
        summary, eligibility, rec = _build_trend_and_recommendation(impacts, "TSLA")
        assert rec.action == ActionType.SELL

        # Execute the sell
        sell = OrderRequest(
            ticker="TSLA", side=OrderSide.SELL, quantity=20,
            order_type=OrderType.LIMIT, limit_price=240.0,
        )
        resp = await adapter.submit_order(sell)
        assert resp.status == OrderStatus.FILLED
        assert resp.raw_response["realized_pnl"] == pytest.approx(-200.0)

    async def test_low_confidence_recommendation_is_informational(self):
        """Low-confidence signals should produce informational-only recommendations."""
        impacts = [
            ImpactRow(
                document_id="doc-weak-1",
                confidence=0.40,
                novelty_score=0.3,
                source_credibility=0.5,
                sentiment="positive",
                impact_score=0.3,
                catalyst_type="other",
                key_facts=["Minor update"],
                risks=[],
                published_at=NOW - timedelta(hours=1),
            ),
            ImpactRow(
                document_id="doc-weak-2",
                confidence=0.35,
                novelty_score=0.2,
                source_credibility=0.4,
                sentiment="positive",
                impact_score=0.25,
                catalyst_type="other",
                key_facts=["Routine filing"],
                risks=[],
                published_at=NOW - timedelta(hours=3),
            ),
        ]
        _, _, rec = _build_trend_and_recommendation(impacts, "XYZ")
        assert rec.mode == RecommendationMode.INFORMATIONAL


# ---------------------------------------------------------------------------
# Scenario 5: Idempotent order submission
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestIdempotentOrderSubmission:
    """Verify duplicate orders with the same idempotency key are not double-executed."""

    async def test_duplicate_buy_only_fills_once(self):
        adapter = PaperTradingAdapter(initial_cash=100_000.0)
        order = OrderRequest(
            ticker="AAPL", side=OrderSide.BUY, quantity=10,
            order_type=OrderType.LIMIT, limit_price=150.0,
            idempotency_key="idem-buy-1",
        )

        resp1 = await adapter.submit_order(order)
        resp2 = await adapter.submit_order(order)

        assert resp1.broker_order_id == resp2.broker_order_id
        # Cash deducted only once
        assert adapter.account.cash == pytest.approx(100_000.0 - 1_500.0)
        # Only one position entry
        pos = adapter.account.get_position("AAPL")
        assert pos.quantity == 10


# ---------------------------------------------------------------------------
# Scenario 6: Insufficient funds and shares
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestInsufficientResources:
    """Verify the adapter rejects orders when resources are insufficient."""

    async def test_buy_exceeding_cash_rejected(self):
        adapter = PaperTradingAdapter(initial_cash=5_000.0)
        order = OrderRequest(
            ticker="AAPL", side=OrderSide.BUY, quantity=100,
            order_type=OrderType.LIMIT, limit_price=180.0,
        )
        resp = await adapter.submit_order(order)
        assert resp.status == OrderStatus.REJECTED
        assert resp.error is not None and "Insufficient cash" in resp.error

    async def test_sell_more_than_held_rejected(self):
        adapter = PaperTradingAdapter(initial_cash=100_000.0)
        await adapter.submit_order(OrderRequest(
            ticker="AAPL", side=OrderSide.BUY, quantity=10,
            order_type=OrderType.LIMIT, limit_price=150.0,
        ))
        sell = OrderRequest(
            ticker="AAPL", side=OrderSide.SELL, quantity=20,
            order_type=OrderType.LIMIT, limit_price=155.0,
        )
        resp = await adapter.submit_order(sell)
        assert resp.status == OrderStatus.REJECTED
        assert resp.error is not None and "Insufficient shares" in resp.error


# ---------------------------------------------------------------------------
# Scenario 7: Portfolio drawdown halts trading
# ---------------------------------------------------------------------------


class TestDrawdownHalt:
    """Simulate a losing session that triggers the daily loss circuit breaker."""

    def test_cumulative_losses_trigger_halt(self):
        """After multiple losing trades, the risk engine should block new orders."""
        config = PortfolioRiskConfig(
            daily_loss=DailyLossLimits(
                max_daily_loss_pct=0.03,
                max_daily_loss_value=3_000.0,
                max_daily_trades=50,
            ),
        )

        # Simulate state after several losing trades
        state = AccountRiskState(
            portfolio_value=97_000.0,
            cash=47_000.0,
            daily_pnl=-3_200.0,
            daily_trade_count=8,
        )

        order = ProposedOrder(
            ticker="NVDA", sector="Technology",
            estimated_value=2_000.0, quantity=5,
        )
        result = evaluate_order(order, config, state)
        assert not result.passed
        # Both pct and value limits should be breached
        failed_checks = {
            c.check_name for c in result.checks if c.result == RiskCheckResult.FAIL
        }
        assert "daily_loss_value" in failed_checks


# ---------------------------------------------------------------------------
# Scenario 8: Full session simulation
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestFullTradingSession:
    """Simulate a realistic multi-trade session with mixed outcomes."""

    async def test_morning_session_with_mixed_results(self):
        adapter = PaperTradingAdapter(initial_cash=100_000.0)
        initial_cash = 100_000.0

        # Trade 1: Buy AAPL, sell at profit
        await adapter.submit_order(OrderRequest(
            ticker="AAPL", side=OrderSide.BUY, quantity=30,
            order_type=OrderType.LIMIT, limit_price=180.0,
        ))
        resp1 = await adapter.submit_order(OrderRequest(
            ticker="AAPL", side=OrderSide.SELL, quantity=30,
            order_type=OrderType.LIMIT, limit_price=185.0,
        ))
        pnl_1 = resp1.raw_response["realized_pnl"]
        assert pnl_1 == pytest.approx(150.0)

        # Trade 2: Buy TSLA, sell at loss
        await adapter.submit_order(OrderRequest(
            ticker="TSLA", side=OrderSide.BUY, quantity=10,
            order_type=OrderType.LIMIT, limit_price=250.0,
        ))
        resp2 = await adapter.submit_order(OrderRequest(
            ticker="TSLA", side=OrderSide.SELL, quantity=10,
            order_type=OrderType.LIMIT, limit_price=242.0,
        ))
        pnl_2 = resp2.raw_response["realized_pnl"]
        assert pnl_2 == pytest.approx(-80.0)

        # Trade 3: Buy MSFT, hold (don't sell)
        await adapter.submit_order(OrderRequest(
            ticker="MSFT", side=OrderSide.BUY, quantity=5,
            order_type=OrderType.LIMIT, limit_price=420.0,
        ))

        # Verify final state
        positions = await adapter.get_positions()
        assert len(positions) == 1
        assert positions[0].ticker == "MSFT"

        # Cash = initial + AAPL profit + TSLA loss - MSFT cost
        expected_cash = initial_cash + 150.0 - 80.0 - (5 * 420.0)
        assert adapter.account.cash == pytest.approx(expected_cash)

        # Audit trail should have events for all trades
        event_count = len(adapter.account.order_events)
        # 5 orders × 3 events each (submitted, accepted, fill) = 15
        # (rejected orders get fewer events, but all 5 here are fills)
        assert event_count == 15

    async def test_account_info_reflects_session(self):
        adapter = PaperTradingAdapter(initial_cash=50_000.0, account_id="sim-session")

        await adapter.submit_order(OrderRequest(
            ticker="AAPL", side=OrderSide.BUY, quantity=10,
            order_type=OrderType.LIMIT, limit_price=180.0,
        ))

        acct = await adapter.get_account()
        assert acct.account_id == "sim-session"
        assert acct.mode == TradingMode.PAPER
        assert acct.cash == pytest.approx(50_000.0 - 1_800.0)
        assert acct.portfolio_value == pytest.approx(50_000.0)
