"""TiingoUniverseSource — top-N most-liquid US common stocks by trailing dollar-volume.
NO live network: the two fetch helpers (`_fetch_candidates` supported-tickers, `_fetch_volumes`
IEX snapshot) are monkeypatched to return fixed fixtures.

Invariants proven here:
  (1) the supported-tickers candidate filter drops non-Stock assetType, non-US/foreign-currency,
      and delisted (stale endDate) rows; valid active US stocks are kept.
  (2) the FULL filtered candidate set is ranked by dollar-volume (last × volume), descending —
      no alphabetical/file-order pre-slice (a late-in-file liquid name beats an early illiquid one).
  (3) top_n(n) returns at most n tickers.
  (4) max_candidates is a runaway safety guard only — it never trips for a normal set.
"""
from __future__ import annotations

import datetime as dt
from typing import Any

from fxhnt.adapters.data.tiingo_universe import TiingoUniverseSource

_TODAY = dt.date.today()
_RECENT = _TODAY.isoformat()
_STALE = (_TODAY - dt.timedelta(days=400)).isoformat()


def _candidate_rows() -> list[dict[str, str]]:
    """supported_tickers.csv rows (active US stocks + a non-stock, a foreign-ccy, a delisted)."""
    return [
        # valid active US common stocks (max endDate = _RECENT)
        {"ticker": "A", "exchange": "NYSE", "assetType": "Stock",
         "priceCurrency": "USD", "startDate": "2000-01-01", "endDate": _RECENT},
        {"ticker": "B", "exchange": "NASDAQ", "assetType": "Stock",
         "priceCurrency": "USD", "startDate": "2000-01-01", "endDate": _RECENT},
        {"ticker": "C", "exchange": "NYSE ARCA", "assetType": "Stock",
         "priceCurrency": "USD", "startDate": "2000-01-01", "endDate": _RECENT},
        # DROP: not a Stock (ETF)
        {"ticker": "SPY", "exchange": "NYSE ARCA", "assetType": "ETF",
         "priceCurrency": "USD", "startDate": "2000-01-01", "endDate": _RECENT},
        # DROP: foreign price currency
        {"ticker": "FOREIGN", "exchange": "NYSE", "assetType": "Stock",
         "priceCurrency": "EUR", "startDate": "2000-01-01", "endDate": _RECENT},
        # DROP: delisted (stale endDate, far before the file max)
        {"ticker": "DEAD", "exchange": "NASDAQ", "assetType": "Stock",
         "priceCurrency": "USD", "startDate": "2000-01-01", "endDate": _STALE},
        # DROP: unsupported exchange (OTC)
        {"ticker": "OTCJUNK", "exchange": "OTC", "assetType": "Stock",
         "priceCurrency": "USD", "startDate": "2000-01-01", "endDate": _RECENT},
    ]


def test_candidate_filter_drops_non_stock_foreign_and_delisted() -> None:
    src = TiingoUniverseSource(api_key="x")
    src._fetch_candidates = lambda: _candidate_rows()  # type: ignore[method-assign]  # noqa: E731

    kept = src._candidates()

    assert set(kept) == {"A", "B", "C"}
    assert "SPY" not in kept       # non-Stock dropped
    assert "FOREIGN" not in kept   # non-USD dropped
    assert "DEAD" not in kept      # delisted dropped
    assert "OTCJUNK" not in kept   # unsupported exchange dropped


def test_top_n_ranks_by_dollar_volume_descending() -> None:
    src = TiingoUniverseSource(api_key="x")
    src._fetch_candidates = lambda: _candidate_rows()  # type: ignore[method-assign]  # noqa: E731

    # C has highest last×volume, A medium, B lowest.
    volumes = [
        {"ticker": "A", "last": 50.0, "volume": 1_000_000},    # 50M
        {"ticker": "B", "tngoLast": 10.0, "volume": 1_000_000},  # 10M (uses tngoLast)
        {"ticker": "C", "last": 100.0, "volume": 1_000_000},   # 100M
    ]

    captured: dict[str, list[str]] = {}

    def fake_volumes(tickers: list[str]) -> list[dict[str, Any]]:
        captured["tickers"] = tickers
        return volumes

    src._fetch_volumes = fake_volumes  # type: ignore[method-assign]

    assert src.top_n(2) == ["C", "A"]
    # only the filtered candidates were priced
    assert set(captured["tickers"]) == {"A", "B", "C"}


def test_top_n_returns_at_most_n() -> None:
    src = TiingoUniverseSource(api_key="x")
    src._fetch_candidates = lambda: _candidate_rows()  # type: ignore[method-assign]  # noqa: E731
    src._fetch_volumes = lambda tickers: [  # type: ignore[method-assign]  # noqa: E731
        {"ticker": "A", "last": 50.0, "volume": 1_000_000},
        {"ticker": "B", "last": 10.0, "volume": 1_000_000},
        {"ticker": "C", "last": 100.0, "volume": 1_000_000},
    ]

    assert len(src.top_n(2)) == 2
    assert len(src.top_n(1)) == 1
    # n larger than the universe → at most the available count
    assert len(src.top_n(99)) == 3


def test_full_filtered_set_is_volume_ranked_not_pre_sliced() -> None:
    # With the default (high) runaway guard, ALL filtered candidates are priced — no file-order
    # pre-slice. 5 candidates, guard untouched → all 5 reach the IEX volume fetch.
    src = TiingoUniverseSource(api_key="x")
    rows = [
        {"ticker": t, "exchange": "NYSE", "assetType": "Stock",
         "priceCurrency": "USD", "startDate": "2000-01-01", "endDate": _RECENT}
        for t in ("A", "B", "C", "D", "E")
    ]
    src._fetch_candidates = lambda: rows  # type: ignore[method-assign]  # noqa: E731

    captured: dict[str, list[str]] = {}

    def fake_volumes(tickers: list[str]) -> list[dict[str, Any]]:
        captured["tickers"] = tickers
        return [{"ticker": t, "last": 1.0, "volume": 1} for t in tickers]

    src._fetch_volumes = fake_volumes  # type: ignore[method-assign]
    src.top_n(2)
    # all 5 priced — the guard did not pre-slice
    assert set(captured["tickers"]) == {"A", "B", "C", "D", "E"}


def test_runaway_guard_only_trips_above_threshold() -> None:
    # The guard is a safety-only cap: it must NOT trip for a normal-sized filtered set, and it
    # DOES trip (file-order drop) only when the filtered set exceeds max_candidates.
    rows = [
        {"ticker": t, "exchange": "NYSE", "assetType": "Stock",
         "priceCurrency": "USD", "startDate": "2000-01-01", "endDate": _RECENT}
        for t in ("A", "B", "C", "D", "E")
    ]

    # guard above the set size → all priced
    src_ok = TiingoUniverseSource(api_key="x", max_candidates=10)
    src_ok._fetch_candidates = lambda: rows  # type: ignore[method-assign]  # noqa: E731
    captured_ok: dict[str, list[str]] = {}

    def fake_ok(tickers: list[str]) -> list[dict[str, Any]]:
        captured_ok["tickers"] = tickers
        return [{"ticker": t, "last": 1.0, "volume": 1} for t in tickers]

    src_ok._fetch_volumes = fake_ok  # type: ignore[method-assign]
    src_ok.top_n(2)
    assert len(captured_ok["tickers"]) == 5  # guard did not trip

    # guard below the set size → trips, drops by file order down to the cap
    src_cap = TiingoUniverseSource(api_key="x", max_candidates=2)
    src_cap._fetch_candidates = lambda: rows  # type: ignore[method-assign]  # noqa: E731
    captured_cap: dict[str, list[str]] = {}

    def fake_cap(tickers: list[str]) -> list[dict[str, Any]]:
        captured_cap["tickers"] = tickers
        return [{"ticker": t, "last": 1.0, "volume": 1} for t in tickers]

    src_cap._fetch_volumes = fake_cap  # type: ignore[method-assign]
    src_cap.top_n(2)
    assert len(captured_cap["tickers"]) == 2  # guard tripped


def test_late_alphabet_high_volume_beats_early_low_volume() -> None:
    # ANTI-BIAS GUARANTEE: a late-in-file high-volume ticker (ZM) must out-rank an early-in-file
    # low-volume ticker (AAA). The old file-order pre-slice would have ranked only the early
    # names and missed ZM; the fix ranks the full filtered set by dollar-volume.
    order = ["AAA", "AAB", "TSLA", "WMT", "ZM"]  # alphabetical file order
    rows = [
        {"ticker": t, "exchange": "NASDAQ", "assetType": "Stock",
         "priceCurrency": "USD", "startDate": "2000-01-01", "endDate": _RECENT}
        for t in order
    ]
    # Default (high) runaway guard → full set priced. Under the OLD buggy file-order pre-slice
    # the liquid late names (TSLA/WMT/ZM) could be dropped before ranking; the fix prices them.
    src = TiingoUniverseSource(api_key="x")
    src._fetch_candidates = lambda: rows  # type: ignore[method-assign]  # noqa: E731

    # Late-in-order names carry the liquidity; early names are illiquid.
    volumes = {
        "AAA": (1.0, 100),          # tiny dollar-volume
        "AAB": (1.0, 100),
        "TSLA": (250.0, 5_000_000),  # huge
        "WMT": (160.0, 3_000_000),
        "ZM": (70.0, 4_000_000),
    }

    captured: dict[str, list[str]] = {}

    def fake_volumes(tickers: list[str]) -> list[dict[str, Any]]:
        captured["tickers"] = tickers
        return [{"ticker": t, "last": volumes[t][0], "volume": volumes[t][1]} for t in tickers]

    src._fetch_volumes = fake_volumes  # type: ignore[method-assign]

    top3 = src.top_n(3)
    # the full filtered set was priced (no file-order pre-slice)
    assert set(captured["tickers"]) == set(order)
    # late-alphabet liquid names selected; early illiquid names excluded.
    # dollar-volume: TSLA 1.25B > WMT 480M > ZM 280M.
    assert top3 == ["TSLA", "WMT", "ZM"]
    assert "AAA" not in top3
    assert "AAB" not in top3
