"""Analyst fleet: runs specialists, dedups proposals vs SeenCandidate, count every distinct
hypothesis in the global trial total (the honest N the gauntlet's DSR bar scales with). A bad
analyst is skipped, not fatal."""
from __future__ import annotations

from fxhnt.application.factory.analyst_fleet import AnalystFleet
from fxhnt.application.factory.analysts import FakeAnalyst, LlmAnalyst, Proposal


class FakeStore:
    def __init__(self): self.seen = set(); self.trials = 0
    def is_seen(self, k): return k in self.seen
    def mark_seen(self, k): self.seen.add(k)
    def add_trials(self, n): self.trials += n


class BoomAnalyst:
    name = "boom"
    def propose(self, universe): raise RuntimeError("boom")


class _FakeChatResponse:
    def __init__(self, content: str) -> None:
        self.content = content


class _FakeChatModel:
    def __init__(self, content: str) -> None:
        self._content = content

    def invoke(self, prompt: str) -> _FakeChatResponse:
        return _FakeChatResponse(self._content)


# ── Core dedup-only contract (M1: gather is read-only, no counting) ──────────────


def test_fleet_dedups_within_batch() -> None:
    """gather() deduplicates proposals within a single batch — same key appears only once."""
    a = FakeAnalyst("momentum", [Proposal("ES", "trend", {"window": 100.0}),
                                 Proposal("ES", "trend", {"window": 100.0})])  # dup
    store = FakeStore()
    fleet = AnalystFleet([a], store)
    props = fleet.gather(universe=["ES", "NQ"])
    assert len(props) == 1                          # dup removed
    # M1: gather does NOT call add_trials or mark_seen — counting authority is the hunt
    assert store.trials == 0
    assert len(store.seen) == 0


def test_bad_analyst_is_skipped() -> None:
    good = FakeAnalyst("carry", [Proposal("GC", "carry", {})])
    store = FakeStore()
    props = AnalystFleet([BoomAnalyst(), good], store).gather(universe=["GC"])
    assert len(props) == 1                          # boom skipped, good kept
    assert store.trials == 0                        # no counting in gather


def test_cross_analyst_dedup() -> None:
    """Two different analysts proposing the same (market, kind, params) → only ONE fresh entry.
    gather does NOT call add_trials (M1 — single counting authority is the hunt)."""
    shared = Proposal("ES", "trend", {"window": 100.0})
    a1 = FakeAnalyst("specialist-A", [shared])
    a2 = FakeAnalyst("specialist-B", [shared])
    store = FakeStore()
    fleet = AnalystFleet([a1, a2], store)
    fresh = fleet.gather(universe=["ES"])
    assert len(fresh) == 1
    assert store.trials == 0                        # gather never counts
    assert len(store.seen) == 0                     # gather never marks seen


def test_cross_cycle_dedup_filters_already_seen() -> None:
    """A proposal whose key is already in the store (pre-seeded as seen from a prior cycle)
    must NOT be returned. gather leaves trials == 0 and does not add to store.seen."""
    p = Proposal("ES", "trend", {"window": 100.0})
    store = FakeStore()
    store.seen.add(p.key())          # pre-seed: store already knows this key from a prior cycle
    fleet = AnalystFleet([FakeAnalyst("mom", [p])], store)
    fresh = fleet.gather(universe=["ES"])
    assert fresh == []
    assert store.trials == 0         # add_trials must NOT have been called
    assert len(store.seen) == 1      # store unchanged (only the pre-seeded key)


def test_fresh_proposals_not_marked_seen_by_gather() -> None:
    """gather returns fresh proposals without marking them seen — the hunt does that when it
    evaluates them.  If gather marked them, the hunt would see them as already tested and skip."""
    p = Proposal("ES", "trend", {"window": 200.0})
    store = FakeStore()
    fleet = AnalystFleet([FakeAnalyst("trend", [p])], store)
    fresh = fleet.gather(universe=["ES"])
    assert len(fresh) == 1
    assert p.key() not in store.seen   # not pre-emptively marked — the hunt owns mark_seen


# ── Canonical key format (M1: shared namespace via candidate_key) ────────────────────


def test_proposal_key_uses_candidate_key_format() -> None:
    """Proposal.key() must use the canonical candidate_key format (market:kind:k=v)
    so it matches the key the FleetOrchestrator uses for dedup."""
    from fxhnt.domain.factory import candidate_key
    p = Proposal("ES", "trend", {"window": 100.0})
    assert p.key() == candidate_key("ES", "trend", {"window": 100.0})


# ── LLM analyst parsing ────────────────────────────────────────────────────────


def test_llm_constrains_kind_and_drops_nonfinite() -> None:
    """LlmAnalyst only emits proposals whose kind is in the allowed (backtestable) set; it drops empty kinds,
    hallucinated/unknown kinds, and non-finite param values."""
    llm_output = "\n".join([
        "ES||window=100",                 # empty kind → dropped
        "ES|FUTURES_CONTRACTS|x=1",       # hallucinated kind not in allowed set → dropped
        "ES|momentum|window=50",          # 'momentum' not a backtestable kind → dropped
        "ES|trend|window=inf",            # valid kind, non-finite param dropped → params == {}
        "ES|trend|window=50",             # fully valid
        "ES|MEAN_REVERSION|z=2",          # case-insensitive: normalizes to 'mean_reversion' (allowed)
    ])
    chat = _FakeChatModel(llm_output)
    analyst = LlmAnalyst("trend", chat, kinds=["trend", "mean_reversion"])
    proposals = analyst.propose(["ES"])
    kinds = [p.kind for p in proposals]
    assert "" not in kinds and "futures_contracts" not in kinds and "momentum" not in kinds
    assert kinds.count("trend") == 2 and "mean_reversion" in kinds   # only allowed kinds, normalized
    inf_trend = next(p for p in proposals if p.kind == "trend" and not p.params)
    assert "window" not in inf_trend.params                          # the inf param was dropped
