"""Tests for WU2 — Universe pre-filter, narrative weight, and watchlist event integration.

Test spec:
  - _filter_markets() must exclude a symbol when market cap is $130M (above cap) even if
    vol/MC ratio is fine.
  - _filter_markets() must pass a symbol with market cap $15M even when
    circ_supply_ratio=0.50 (bypass for MC < $20M).
  - _filter_markets() must pass a symbol through when get_market_data() returns None
    (fail-open).
  - WatchlistManager._do_promotions() with an EventMonitor returning ["TOKENUSDT"] must
    promote that symbol regardless of its score in the scores list.
  - ath_proximity_risk must be True when days_since_ath=45, False when days_since_ath=90.

Additional coverage:
  - TokenMetadata dataclass is a plain dataclass with correct defaults.
  - _narrative_weight() returns +0.05 for matching categories, 0.0 otherwise.
  - _filter_markets() vol/MC ratio gate blocks low-ratio symbols.
  - _filter_markets() circ_supply_ratio gate blocks high-ratio symbols when MC >= $20M.
"""

from __future__ import annotations

import sys
import os
from datetime import datetime, timezone
from unittest.mock import MagicMock

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from src.micro_scanner.token_metadata import TokenMetadata
from src.micro_scanner.universe import UniverseScanner
from src.micro_scanner.config import MicroConfig
from src.micro_scanner.contracts import CandidateScore
from src.micro_scanner.watchlist import WatchlistManager


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


def _make_spot_market(base: str, **overrides) -> dict:
    """Return a minimal spot market dict for use with _filter_markets."""
    m: dict = {
        "type": "spot",
        "spot": True,
        "quote": "USDT",
        "active": True,
        "base": base,
        "info": {},
    }
    m.update(overrides)
    return m


def _make_coingecko_client(market_data: dict | None) -> MagicMock:
    """Return a mock CoinGeckoExtendedClient."""
    client = MagicMock()
    client.get_market_data.return_value = market_data
    return client


def _make_event_monitor(drain_result: list[str]) -> MagicMock:
    """Return a mock EventMonitor."""
    monitor = MagicMock()
    monitor.drain_promotions.return_value = drain_result
    return monitor


def _make_score(
    symbol: str, score: float = 0.40, status: str = "dormant"
) -> CandidateScore:
    return CandidateScore(
        symbol=symbol,
        score=score,
        scored_at=datetime.now(timezone.utc),
        data_freshness={"_v": 1},
        is_valid=True,
        status=status,  # type: ignore[arg-type]
    )


def _make_conn(watchlist_size: int = 0, open_positions: set | None = None) -> MagicMock:
    """Build a mock psycopg.Connection for promotion path testing."""
    conn = MagicMock()

    count_row = {"count": watchlist_size}
    open_pos_rows = [{"symbol": s} for s in (open_positions or set())]

    size_result = MagicMock()
    size_result.fetchone.return_value = count_row
    size_result.rowcount = 0

    open_pos_result = MagicMock()
    open_pos_result.fetchall.return_value = open_pos_rows
    open_pos_result.rowcount = 0

    def execute_side_effect(sql, *args, **kwargs):
        sql_stripped = sql.strip() if isinstance(sql, str) else str(sql)
        if "COUNT(*)" in sql_stripped:
            return size_result
        if "micro_positions" in sql_stripped:
            return open_pos_result
        if "INSERT INTO micro_coin_candidates" in sql_stripped:
            r = MagicMock()
            r.rowcount = 0
            return r
        if (
            "UPDATE micro_coin_candidates" in sql_stripped
            and "SET status = 'active'" in sql_stripped
        ):
            r = MagicMock()
            r.rowcount = 1
            return r
        # Other UPDATEs (ath_proximity_risk, unlock_event, deferred)
        r = MagicMock()
        r.rowcount = 0
        return r

    conn.execute.side_effect = execute_side_effect
    conn.commit.return_value = None
    return conn


# ---------------------------------------------------------------------------
# TokenMetadata tests
# ---------------------------------------------------------------------------


class TestTokenMetadata:
    """TokenMetadata is a plain dataclass — no Pydantic."""

    def test_is_dataclass(self):
        import dataclasses

        assert dataclasses.is_dataclass(TokenMetadata)

    def test_default_values(self):
        tm = TokenMetadata(symbol="BTCUSDT")
        assert tm.symbol == "BTCUSDT"
        assert tm.market_cap_usd is None
        assert tm.volume_24h_usd is None
        assert tm.circ_supply_ratio is None
        assert tm.days_since_ath is None
        assert tm.categories == []
        assert tm.ath_proximity_risk is False
        assert tm.unlock_event is False
        assert tm.has_perps is False

    def test_categories_independent_per_instance(self):
        """Default list must not be shared between instances."""
        a = TokenMetadata(symbol="AAVEUSDT")
        b = TokenMetadata(symbol="UNIUSDT")
        a.categories.append("defi")
        assert b.categories == []

    def test_explicit_values(self):
        tm = TokenMetadata(
            symbol="ETHUSDT",
            market_cap_usd=50_000_000.0,
            volume_24h_usd=10_000_000.0,
            circ_supply_ratio=0.80,
            days_since_ath=30,
            categories=["layer-1"],
            ath_proximity_risk=True,
            unlock_event=True,
            has_perps=True,
        )
        assert tm.ath_proximity_risk is True
        assert tm.unlock_event is True
        assert tm.has_perps is True
        assert tm.days_since_ath == 30

    def test_not_pydantic(self):
        """Must not be a Pydantic BaseModel."""
        try:
            from pydantic import BaseModel

            assert not isinstance(TokenMetadata(symbol="X"), BaseModel)
        except ImportError:
            pass  # pydantic not installed — trivially passes


# ---------------------------------------------------------------------------
# UniverseScanner structural filter tests
# ---------------------------------------------------------------------------


class TestUniverseStructuralFilter:
    """_filter_markets() structural filter using CoinGecko enrichment."""

    def test_market_cap_above_120m_excluded(self):
        """Symbol with MC=$130M must be excluded even if vol/MC ratio is fine."""
        market_data = {
            "market_cap_usd": 130_000_000.0,
            "volume_24h": 30_000_000.0,  # ratio = 0.23 — fine
            "circ_supply_ratio": 0.20,
            "days_since_ath": 100,
            "categories": [],
        }
        client = _make_coingecko_client(market_data)
        scanner = UniverseScanner(coingecko_client=client)

        markets = [_make_spot_market("BIG")]
        result = scanner._filter_markets(markets)

        assert "BIGUSDT" not in result

    def test_market_cap_below_8m_excluded(self):
        """Symbol with MC=$5M must be excluded."""
        market_data = {
            "market_cap_usd": 5_000_000.0,
            "volume_24h": 1_000_000.0,
            "circ_supply_ratio": 0.20,
            "days_since_ath": 100,
            "categories": [],
        }
        client = _make_coingecko_client(market_data)
        scanner = UniverseScanner(coingecko_client=client)

        markets = [_make_spot_market("TINY")]
        result = scanner._filter_markets(markets)

        assert "TINYUSDT" not in result

    def test_mc_15m_bypasses_circ_supply_ratio_check(self):
        """MC=$15M (<$20M) must pass even with circ_supply_ratio=0.50 (above 0.35 threshold)."""
        market_data = {
            "market_cap_usd": 15_000_000.0,
            "volume_24h": 4_000_000.0,  # ratio = 0.267 — fine
            "circ_supply_ratio": 0.50,  # would fail if MC >= $20M
            "days_since_ath": 100,
            "categories": [],
        }
        client = _make_coingecko_client(market_data)
        scanner = UniverseScanner(coingecko_client=client)

        markets = [_make_spot_market("SMALL")]
        result = scanner._filter_markets(markets)

        assert "SMALLUSDT" in result

    def test_mc_25m_with_high_circ_supply_ratio_excluded(self):
        """MC=$25M (>=$20M) with circ_supply_ratio=0.50 must be excluded."""
        market_data = {
            "market_cap_usd": 25_000_000.0,
            "volume_24h": 6_000_000.0,
            "circ_supply_ratio": 0.50,
            "days_since_ath": 100,
            "categories": [],
        }
        client = _make_coingecko_client(market_data)
        scanner = UniverseScanner(coingecko_client=client)

        markets = [_make_spot_market("MED")]
        result = scanner._filter_markets(markets)

        assert "MEDUSDT" not in result

    def test_fail_open_when_get_market_data_returns_none(self):
        """Symbol passes through when get_market_data() returns None (fail-open)."""
        client = _make_coingecko_client(None)
        scanner = UniverseScanner(coingecko_client=client)

        markets = [_make_spot_market("NOENRICH")]
        result = scanner._filter_markets(markets)

        assert "NOENRICHUSDT" in result

    def test_fail_open_when_no_coingecko_client(self):
        """Symbol passes through when no coingecko_client is injected."""
        scanner = UniverseScanner()  # no client

        markets = [_make_spot_market("NOCLIENT")]
        result = scanner._filter_markets(markets)

        assert "NOCLIENTUSDT" in result

    def test_vol_mc_ratio_too_low_excluded(self):
        """Symbol with vol/MC <= 0.15 must be excluded."""
        market_data = {
            "market_cap_usd": 50_000_000.0,
            "volume_24h": 5_000_000.0,  # ratio = 0.10 — below 0.15
            "circ_supply_ratio": 0.20,
            "days_since_ath": 100,
            "categories": [],
        }
        client = _make_coingecko_client(market_data)
        scanner = UniverseScanner(coingecko_client=client)

        markets = [_make_spot_market("LOWVOL")]
        result = scanner._filter_markets(markets)

        assert "LOWVOLUSDT" not in result

    def test_valid_symbol_passes_all_filters(self):
        """A symbol meeting all criteria is included."""
        market_data = {
            "market_cap_usd": 40_000_000.0,
            "volume_24h": 10_000_000.0,  # ratio = 0.25
            "circ_supply_ratio": 0.25,
            "days_since_ath": 100,
            "categories": ["defi"],
        }
        client = _make_coingecko_client(market_data)
        scanner = UniverseScanner(coingecko_client=client)

        markets = [_make_spot_market("GOOD")]
        result = scanner._filter_markets(markets)

        assert "GOODUSDT" in result

    def test_existing_filters_still_applied(self):
        """Pre-existing filters (stablecoins, leveraged tokens) still work with new params."""
        scanner = UniverseScanner()

        markets = [
            _make_spot_market("USDC"),  # stablecoin
            _make_spot_market("BTCUP"),  # leveraged
            _make_spot_market("ETH"),  # valid
        ]
        result = scanner._filter_markets(markets)

        assert "USDCUSDT" not in result
        assert "BTCUPUSDT" not in result
        assert "ETHUSDT" in result


# ---------------------------------------------------------------------------
# UniverseScanner narrative weight tests
# ---------------------------------------------------------------------------


class TestNarrativeWeight:
    """_narrative_weight() static method."""

    def test_matching_category_returns_bonus(self):
        bonus = UniverseScanner._narrative_weight(["defi", "staking"], config=None)
        assert bonus == 0.05

    def test_layer1_category_returns_bonus(self):
        bonus = UniverseScanner._narrative_weight(["layer-1"], config=None)
        assert bonus == 0.05

    def test_layer2_category_returns_bonus(self):
        bonus = UniverseScanner._narrative_weight(["layer-2"], config=None)
        assert bonus == 0.05

    def test_non_matching_category_returns_zero(self):
        bonus = UniverseScanner._narrative_weight(["meme", "gaming"], config=None)
        assert bonus == 0.0

    def test_empty_categories_returns_zero(self):
        bonus = UniverseScanner._narrative_weight([], config=None)
        assert bonus == 0.0

    def test_custom_config_boost_categories(self):
        """Config with custom boost_categories overrides defaults."""
        config = MagicMock()
        config.narrative_boost_categories = ["gaming", "nft"]
        bonus = UniverseScanner._narrative_weight(["gaming"], config=config)
        assert bonus == 0.05

    def test_custom_config_no_match(self):
        config = MagicMock()
        config.narrative_boost_categories = ["gaming", "nft"]
        bonus = UniverseScanner._narrative_weight(["defi"], config=config)
        assert bonus == 0.0

    def test_case_insensitive_match(self):
        """Category matching is case-insensitive."""
        bonus = UniverseScanner._narrative_weight(["DeFi"], config=None)
        assert bonus == 0.05

    def test_getattr_fallback_when_config_has_no_attribute(self):
        """When config lacks narrative_boost_categories, defaults are used."""
        config = object()  # plain object, no attribute
        bonus = UniverseScanner._narrative_weight(["layer-1"], config=config)
        assert bonus == 0.05


# ---------------------------------------------------------------------------
# WatchlistManager event-driven promotion tests
# ---------------------------------------------------------------------------


class TestEventDrivenPromotion:
    """EventMonitor.drain_promotions() results bypass normal score threshold."""

    def test_event_symbol_promoted_regardless_of_score(self):
        """Symbol from drain_promotions() is promoted even with score=0.0."""
        cfg = MicroConfig(
            score_entry_threshold=0.70,
            watchlist_max=10,
            flood_threshold=20,
            flood_promote_top_n=5,
        )
        event_monitor = _make_event_monitor(["TOKENUSDT"])
        manager = WatchlistManager(cfg, event_monitor=event_monitor)

        # TOKENUSDT has a very low score — normally wouldn't be promoted
        scores = [_make_score("TOKENUSDT", score=0.10)]
        conn = _make_conn(watchlist_size=0)

        promoted = manager._do_promotions(scores, conn)

        assert "TOKENUSDT" in promoted

    def test_event_symbol_promoted_when_not_in_scores_list(self):
        """Symbol from drain_promotions() is promoted even if absent from scores list."""
        cfg = MicroConfig(
            score_entry_threshold=0.70,
            watchlist_max=10,
            flood_threshold=20,
            flood_promote_top_n=5,
        )
        event_monitor = _make_event_monitor(["NEWLISTINGUSDT"])
        manager = WatchlistManager(cfg, event_monitor=event_monitor)

        scores: list[CandidateScore] = []  # empty — no normal candidates
        conn = _make_conn(watchlist_size=0)

        promoted = manager._do_promotions(scores, conn)

        assert "NEWLISTINGUSDT" in promoted

    def test_no_event_monitor_works_normally(self):
        """WatchlistManager without event_monitor falls back to normal flow."""
        cfg = MicroConfig(
            score_entry_threshold=0.70,
            watchlist_max=10,
            flood_threshold=20,
            flood_promote_top_n=5,
        )
        manager = WatchlistManager(cfg)  # no event_monitor

        scores = [_make_score("GOODUSDT", score=0.80)]
        conn = _make_conn(watchlist_size=0)

        promoted = manager._do_promotions(scores, conn)

        assert "GOODUSDT" in promoted

    def test_event_monitor_drained_on_each_cycle(self):
        """drain_promotions() is called exactly once per _do_promotions() call."""
        cfg = MicroConfig(
            score_entry_threshold=0.70,
            watchlist_max=10,
            flood_threshold=20,
            flood_promote_top_n=5,
        )
        event_monitor = _make_event_monitor([])
        manager = WatchlistManager(cfg, event_monitor=event_monitor)

        conn = _make_conn(watchlist_size=0)
        manager._do_promotions([], conn)
        manager._do_promotions([], conn)

        assert event_monitor.drain_promotions.call_count == 2

    def test_event_monitor_drain_exception_handled_gracefully(self):
        """If drain_promotions() raises, _do_promotions() continues without crashing."""
        cfg = MicroConfig(
            score_entry_threshold=0.70,
            watchlist_max=10,
            flood_threshold=20,
            flood_promote_top_n=5,
        )
        event_monitor = MagicMock()
        event_monitor.drain_promotions.side_effect = RuntimeError("API down")
        manager = WatchlistManager(cfg, event_monitor=event_monitor)

        scores = [_make_score("SAFEUSDT", score=0.80)]
        conn = _make_conn(watchlist_size=0)

        # Must not raise
        promoted = manager._do_promotions(scores, conn)

        # Normal promotion still works
        assert "SAFEUSDT" in promoted


# ---------------------------------------------------------------------------
# WatchlistManager ATH proximity risk tests
# ---------------------------------------------------------------------------


class TestAthProximityRisk:
    """ath_proximity_risk flag is set when days_since_ath < 60."""

    def _promote_symbol(
        self,
        symbol: str,
        days_since_ath: int | None,
    ) -> tuple[MagicMock, list[str]]:
        """Helper: promote a symbol with given days_since_ath, return (conn, promoted)."""
        cfg = MicroConfig(
            score_entry_threshold=0.70,
            watchlist_max=10,
            flood_threshold=20,
            flood_promote_top_n=5,
        )
        if days_since_ath is not None:
            market_data = {
                "market_cap_usd": 40_000_000.0,
                "volume_24h": 10_000_000.0,
                "circ_supply_ratio": 0.20,
                "days_since_ath": days_since_ath,
                "categories": [],
            }
        else:
            market_data = None

        client = _make_coingecko_client(market_data)
        manager = WatchlistManager(cfg, coingecko_client=client)

        scores = [_make_score(symbol, score=0.80)]
        conn = _make_conn(watchlist_size=0)

        promoted = manager._do_promotions(scores, conn)
        return conn, promoted

    def test_ath_risk_true_when_days_since_ath_45(self):
        """ath_proximity_risk flag written to DB when days_since_ath=45 (<60)."""
        conn, promoted = self._promote_symbol("ATHRISKUSDT", days_since_ath=45)

        assert "ATHRISKUSDT" in promoted

        # Verify an UPDATE with ath_proximity_risk=TRUE was issued
        calls = [str(c) for c in conn.execute.call_args_list]
        ath_updates = [c for c in calls if "ath_proximity_risk" in c and "TRUE" in c]
        assert len(ath_updates) > 0, (
            "Expected ath_proximity_risk=TRUE UPDATE to be called"
        )

    def test_ath_risk_false_when_days_since_ath_90(self):
        """ath_proximity_risk flag NOT written when days_since_ath=90 (>=60)."""
        conn, promoted = self._promote_symbol("SAFEATHUSDT", days_since_ath=90)

        assert "SAFEATHUSDT" in promoted

        # No UPDATE with ath_proximity_risk should have been issued
        calls = [str(c) for c in conn.execute.call_args_list]
        ath_updates = [c for c in calls if "ath_proximity_risk" in c and "TRUE" in c]
        assert len(ath_updates) == 0, (
            "ath_proximity_risk=TRUE should NOT be set when days>=60"
        )

    def test_ath_risk_not_set_when_no_coingecko_client(self):
        """No ATH check when coingecko_client is None — no crash."""
        cfg = MicroConfig(
            score_entry_threshold=0.70,
            watchlist_max=10,
            flood_threshold=20,
            flood_promote_top_n=5,
        )
        manager = WatchlistManager(cfg)  # no client

        scores = [_make_score("NOCLIENTUSDT", score=0.80)]
        conn = _make_conn(watchlist_size=0)

        promoted = manager._do_promotions(scores, conn)

        assert "NOCLIENTUSDT" in promoted
        calls = [str(c) for c in conn.execute.call_args_list]
        ath_updates = [c for c in calls if "ath_proximity_risk" in c]
        assert len(ath_updates) == 0

    def test_ath_risk_not_set_when_market_data_none(self):
        """No ATH flag when get_market_data() returns None — fail-open."""
        cfg = MicroConfig(
            score_entry_threshold=0.70,
            watchlist_max=10,
            flood_threshold=20,
            flood_promote_top_n=5,
        )
        client = _make_coingecko_client(None)
        manager = WatchlistManager(cfg, coingecko_client=client)

        scores = [_make_score("NOENRICHUSDT", score=0.80)]
        conn = _make_conn(watchlist_size=0)

        promoted = manager._do_promotions(scores, conn)

        assert "NOENRICHUSDT" in promoted
        calls = [str(c) for c in conn.execute.call_args_list]
        ath_updates = [c for c in calls if "ath_proximity_risk" in c and "TRUE" in c]
        assert len(ath_updates) == 0

    def test_ath_boundary_exactly_60_days_is_safe(self):
        """days_since_ath=60 is NOT at risk (condition is strictly < 60)."""
        conn, promoted = self._promote_symbol("BOUNDARYUSDT", days_since_ath=60)

        assert "BOUNDARYUSDT" in promoted
        calls = [str(c) for c in conn.execute.call_args_list]
        ath_updates = [c for c in calls if "ath_proximity_risk" in c and "TRUE" in c]
        assert len(ath_updates) == 0

    def test_ath_boundary_59_days_is_at_risk(self):
        """days_since_ath=59 IS at risk (< 60)."""
        conn, promoted = self._promote_symbol("RISKBOUNDARYUSDT", days_since_ath=59)

        assert "RISKBOUNDARYUSDT" in promoted
        calls = [str(c) for c in conn.execute.call_args_list]
        ath_updates = [c for c in calls if "ath_proximity_risk" in c and "TRUE" in c]
        assert len(ath_updates) > 0


# ---------------------------------------------------------------------------
# WatchlistManager constructor signature test
# ---------------------------------------------------------------------------


class TestWatchlistManagerConstructor:
    """Constructor accepts optional coingecko_client and event_monitor."""

    def test_no_optional_params(self):
        cfg = MicroConfig()
        manager = WatchlistManager(cfg)
        assert manager._coingecko_client is None
        assert manager._event_monitor is None

    def test_with_coingecko_client(self):
        cfg = MicroConfig()
        client = MagicMock()
        manager = WatchlistManager(cfg, coingecko_client=client)
        assert manager._coingecko_client is client

    def test_with_event_monitor(self):
        cfg = MicroConfig()
        monitor = MagicMock()
        manager = WatchlistManager(cfg, event_monitor=monitor)
        assert manager._event_monitor is monitor

    def test_with_both_optional_params(self):
        cfg = MicroConfig()
        client = MagicMock()
        monitor = MagicMock()
        manager = WatchlistManager(cfg, coingecko_client=client, event_monitor=monitor)
        assert manager._coingecko_client is client
        assert manager._event_monitor is monitor


# ---------------------------------------------------------------------------
# UniverseScanner constructor signature test
# ---------------------------------------------------------------------------


class TestUniverseScannerConstructor:
    """Constructor accepts optional coingecko_client."""

    def test_no_client(self):
        scanner = UniverseScanner()
        assert scanner._coingecko_client is None

    def test_with_client(self):
        client = MagicMock()
        scanner = UniverseScanner(coingecko_client=client)
        assert scanner._coingecko_client is client
