"""Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``).

A dashboard 'top pick' is the highest residual-momentum *qualified* long setup. Sentiment
can never move a ticker's activation percentile (the gate's core axis) — only its
confidence and EV ranking. So the tickers that are, or could become with positive
sentiment, a top pick are exactly the residual-momentum leaders that already carry a
tradeable long setup over the R:R floor. These tests pin that priority tier
(always refreshed, cap-exempt) and the capped filler tier behind it.
"""

from __future__ import annotations

from datetime import datetime, timedelta, timezone

import pytest

from app import scheduler
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore
from app.models.sentiment import SentimentScore
from app.models.settings import SystemSetting
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.models.watchlist import WatchlistEntry


@pytest.fixture
async def session():
    from tests.conftest import _test_session_factory

    async with _test_session_factory() as s:
        yield s


async def _add_ticker(session, symbol: str) -> Ticker:
    t = Ticker(symbol=symbol)
    session.add(t)
    await session.flush()
    return t


async def _add_setup(
    session,
    ticker: Ticker,
    *,
    direction: str = "long",
    momentum_percentile: float | None = 95.0,
    rr_ratio: float = 2.0,
    detected_at: datetime | None = None,
) -> TradeSetup:
    session.add(TradeSetup(
        ticker_id=ticker.id,
        direction=direction,
        entry_price=100.0,
        stop_loss=95.0,
        target=110.0,
        rr_ratio=rr_ratio,
        composite_score=60.0,
        momentum_percentile=momentum_percentile,
        detected_at=detected_at or datetime.now(timezone.utc),
    ))
    await session.commit()


async def _add_composite(session, ticker: Ticker, score: float) -> None:
    session.add(CompositeScore(
        ticker_id=ticker.id,
        score=score,
        is_stale=False,
        weights_json="{}",
        computed_at=datetime.now(timezone.utc),
    ))
    await session.commit()


async def _add_watchlist(session, ticker: Ticker) -> None:
    session.add(WatchlistEntry(
        user_id=1,
        ticker_id=ticker.id,
        entry_type="manual",
        added_at=datetime.now(timezone.utc),
    ))
    await session.commit()


async def _add_open_trade(session, ticker: Ticker) -> None:
    session.add(PaperTrade(
        user_id=1,
        ticker_id=ticker.id,
        direction="long",
        entry_price=100.0,
        shares=10.0,
        stop_loss=95.0,
        target=110.0,
        status="open",
        opened_at=datetime.now(timezone.utc),
    ))
    await session.commit()


async def _add_sentiment(session, ticker: Ticker, hours_ago: float) -> None:
    session.add(SentimentScore(
        ticker_id=ticker.id,
        classification="bullish",
        confidence=80,
        source="test",
        timestamp=datetime.now(timezone.utc) - timedelta(hours=hours_ago),
    ))
    await session.commit()


async def _set_setting(session, key: str, value: str) -> None:
    session.add(SystemSetting(key=key, value=value, updated_at=datetime.now(timezone.utc)))
    await session.commit()


async def test_top_pick_feeder_included_below_cutoff_excluded(session):
    """A momentum leader with a tradeable long setup over the R:R floor is fetched;
    one whose setup is below the gate's percentile is not."""
    feeder = await _add_ticker(session, "FEEDER")
    await _add_setup(session, feeder, momentum_percentile=95.0)
    laggard = await _add_ticker(session, "LAGGARD")
    await _add_setup(session, laggard, momentum_percentile=50.0)  # below the gate
    await _set_setting(session, "activation_min_momentum_percentile", "80")

    symbols = await scheduler._get_sentiment_priority_tickers(session)

    assert "FEEDER" in symbols
    assert "LAGGARD" not in symbols


async def test_leader_without_a_setup_excluded(session):
    """A ticker with no long setup can't be a top pick, so it's no longer pulled in
    on momentum alone — the budget goes to actual top-pick feeders."""
    await _add_ticker(session, "NOSETUP")
    await _set_setting(session, "activation_min_momentum_percentile", "80")

    symbols = await scheduler._get_sentiment_priority_tickers(session)

    assert "NOSETUP" not in symbols


async def test_short_only_setup_excluded(session):
    """The gate is long-only while active; a short setup can never be a top pick,
    so positive sentiment can't promote it and it stays out of scope."""
    t = await _add_ticker(session, "SHORTY")
    await _add_setup(session, t, direction="short", momentum_percentile=95.0)
    await _set_setting(session, "activation_min_momentum_percentile", "80")

    symbols = await scheduler._get_sentiment_priority_tickers(session)

    assert "SHORTY" not in symbols


async def test_long_setup_below_rr_floor_excluded(session):
    """A long leader whose setup doesn't clear the R:R floor isn't tradeable as a
    top pick regardless of sentiment."""
    t = await _add_ticker(session, "THINRR")
    await _add_setup(session, t, momentum_percentile=95.0, rr_ratio=0.5)
    await _set_setting(session, "activation_min_momentum_percentile", "80")
    await _set_setting(session, "activation_min_rr", "1.2")

    symbols = await scheduler._get_sentiment_priority_tickers(session)

    assert "THINRR" not in symbols


async def test_gate_disabled_no_priority_tier(session):
    """With the momentum gate off there is no leader axis to anchor on, so a strong
    long setup is not pulled in on its own — scope falls back to the filler set."""
    t = await _add_ticker(session, "FEEDER")
    await _add_setup(session, t, momentum_percentile=95.0)
    await _set_setting(session, "activation_min_momentum_percentile", "0")

    symbols = await scheduler._get_sentiment_priority_tickers(session)

    assert "FEEDER" not in symbols


async def test_fresh_feeder_skipped_stale_refetched(session):
    """A feeder refreshed within the fresh window is skipped; one past it is
    re-fetched."""
    fresh = await _add_ticker(session, "FRESH")
    await _add_setup(session, fresh, momentum_percentile=95.0)
    await _add_sentiment(session, fresh, hours_ago=1.0)
    stale = await _add_ticker(session, "STALE")
    await _add_setup(session, stale, momentum_percentile=95.0)
    await _add_sentiment(session, stale, hours_ago=settings_fresh_hours() + 50)
    await _set_setting(session, "activation_min_momentum_percentile", "80")

    symbols = await scheduler._get_sentiment_priority_tickers(session)

    assert "FRESH" not in symbols
    assert "STALE" in symbols


async def test_watchlist_and_open_trades_always_included(session):
    """The curated watchlist and open paper trades are always in scope — they're
    the set we never want shown without sentiment, independent of any top pick."""
    await _set_setting(session, "activation_min_momentum_percentile", "80")
    wl = await _add_ticker(session, "WATCHED")
    await _add_watchlist(session, wl)
    held = await _add_ticker(session, "HELD")
    await _add_open_trade(session, held)

    symbols = await scheduler._get_sentiment_priority_tickers(session)

    assert "WATCHED" in symbols
    assert "HELD" in symbols


async def test_dismissed_watchlist_entry_excluded(session):
    """A dismissed watchlist entry is not refreshed."""
    await _set_setting(session, "activation_min_momentum_percentile", "80")
    t = await _add_ticker(session, "DISMISSED")
    session.add(WatchlistEntry(
        user_id=1,
        ticker_id=t.id,
        entry_type="dismissed",
        added_at=datetime.now(timezone.utc),
    ))
    await session.commit()

    symbols = await scheduler._get_sentiment_priority_tickers(session)

    assert "DISMISSED" not in symbols


async def test_no_per_run_cap_everything_stale_is_fetched(session, monkeypatch):
    """No truncation: every stale name in the relevant set is returned, however
    many there are (the cap was removed)."""
    await _set_setting(session, "activation_min_momentum_percentile", "80")
    feeders = [f"F{i:02d}" for i in range(30)]  # well past the old cap of 25
    for sym in feeders:
        t = await _add_ticker(session, sym)
        await _add_setup(session, t, momentum_percentile=95.0)
    filler = await _add_ticker(session, "FILL")
    await _add_composite(session, filler, score=99.0)

    symbols = await scheduler._get_sentiment_priority_tickers(session)

    assert set(feeders).issubset(set(symbols))  # all feeders, no truncation
    assert "FILL" in symbols  # filler fetched too — nothing crowded out


def settings_fresh_hours() -> float:
    return float(scheduler.settings.sentiment_fresh_hours)
