"""Integration: factory-cycle chain — GENERATE → JUDGE → PROMOTE → ALLOCATE.

Tests the end-to-end wiring using deterministic fakes (no LLM, no real data, no network).
Two test tiers:
  1. Pure-unit: proposal→Territory bridge helper tested in isolation.
  2. End-to-end chain: FakeAnalyst + FakeDataProvider drives the whole cycle function and
     asserts that proposals are counted, a survivor reaches FORWARD, and FactoryLoop
     promotes/allocates (or at minimum the chain runs without error on an empty pool).
"""
from __future__ import annotations

import datetime as dt
from collections import defaultdict

import numpy as np
import pytest

from fxhnt.adapters.persistence import DuckDbAnalyticalStore, SqlStrategyStore
from fxhnt.adapters.persistence.factory_store import FactoryStore
from fxhnt.application.factory.analyst_fleet import AnalystFleet
from fxhnt.application.factory.analysts import FakeAnalyst, Proposal
from fxhnt.application.factory.factory_loop import FactoryLoop, LoopConfig, StrategyStoreLoopAdapter
from fxhnt.application.fleet import FleetOrchestrator, Territory
from fxhnt.application.forward import ForwardValidationService
from fxhnt.config import Settings
from fxhnt.domain.models import AssetClass, Market, PriceSeries

_NOW = dt.datetime(2026, 6, 14, tzinfo=dt.timezone.utc)

# ── helpers ──────────────────────────────────────────────────────────────────


def _cfg(**kw) -> LoopConfig:
    base = dict(max_tail_corr=0.5, min_marginal=0.0, tail_frac=0.1, min_forward_days=1,
                max_deployed=20, max_new_per_cycle=5, kill_switch=False)
    base.update(kw)
    return LoopConfig(**base)


def _proposals_to_territories(proposals: list[Proposal], asset_class: AssetClass) -> list[Territory]:
    """The proposal→Territory bridge used in the factory-cycle command.

    Tests this pure helper in isolation so we can verify the shape of the bridge
    without running the full chain.
    """
    by_kind: dict[str, list[Proposal]] = defaultdict(list)
    for p in proposals:
        by_kind[p.kind].append(p)
    return [
        Territory(
            name=f"analyst-{kind}",
            kind=kind,
            markets=sorted({p.market for p in props}),
            asset_class=asset_class,
            param_grid=[p.params for p in props],
        )
        for kind, props in by_kind.items()
    ]


class _StableUpData:
    """Fake DataProvider that returns a steady uptrend — guarantees at least one regime passes."""

    name = "fake-stable"

    def fetch(self, market: Market, start=None, end=None) -> PriceSeries:
        rng = np.random.default_rng(sum(ord(c) for c in market.symbol))
        close = 100.0 * np.cumprod(1.0 + rng.normal(0.0015, 0.008, 1600))
        return PriceSeries(market=market, dates=tuple(str(i) for i in range(1600)), close=close)


# ── test 1: proposal→Territory bridge ────────────────────────────────────────


def test_proposal_territory_bridge_groups_by_kind() -> None:
    """Proposals from multiple analysts are bridged into one Territory per kind."""
    props = [
        Proposal("ES", "trend", {"window": 100.0}),
        Proposal("NQ", "trend", {"window": 200.0}),
        Proposal("GC", "momentum", {"lookback": 20.0}),
    ]
    territories = _proposals_to_territories(props, AssetClass.FUTURE)
    by_name = {t.name: t for t in territories}

    assert "analyst-trend" in by_name
    assert "analyst-momentum" in by_name

    trend_t = by_name["analyst-trend"]
    assert set(trend_t.markets) == {"ES", "NQ"}
    assert len(trend_t.param_grid) == 2  # one entry per proposal

    mom_t = by_name["analyst-momentum"]
    assert mom_t.markets == ["GC"]
    assert trend_t.asset_class == AssetClass.FUTURE


def test_empty_proposals_yield_no_territories() -> None:
    assert _proposals_to_territories([], AssetClass.FUTURE) == []


def test_proposal_bridge_deduplicates_markets() -> None:
    """Two proposals for the same market in the same kind → markets list has it once."""
    props = [
        Proposal("ES", "trend", {"window": 100.0}),
        Proposal("ES", "trend", {"window": 200.0}),
    ]
    territories = _proposals_to_territories(props, AssetClass.FUTURE)
    assert len(territories) == 1
    assert territories[0].markets == ["ES"]


# ── test 2: analyst-fleet gather is read-only (M1: counting authority is the hunt) ────


def test_analyst_fleet_gather_is_readonly(tmp_path) -> None:
    """AnalystFleet.gather() is read-only: it returns fresh proposals but does NOT call
    add_trials or mark_seen — those are the hunt's responsibility (M1: single counting authority)."""
    settings = Settings(analytical_path=str(tmp_path / "an.duckdb"),
                        operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
    store = SqlStrategyStore(settings.operational_dsn)
    analyst = FakeAnalyst("momentum", [
        Proposal("ES", "trend", {"window": 100.0}),
        Proposal("NQ", "mean_reversion", {"window": 20.0, "z": 1.0}),
    ])
    fleet = AnalystFleet([analyst], store)
    proposals = fleet.gather(universe=["ES", "NQ"])

    assert len(proposals) == 2
    # M1: gather must NOT increment trials or mark anything seen
    assert store.total_trials() == 0
    assert not store.is_seen(proposals[0].key())
    assert not store.is_seen(proposals[1].key())


# ── test 3: end-to-end chain ──────────────────────────────────────────────────


def test_factory_cycle_chain_end_to_end(tmp_path) -> None:
    """Full chain: analyst gather → territory bridge → hunt → forward → loop.

    Uses a FakeAnalyst with a deterministic proposal + FakeDataProvider with a stable
    uptrend that will produce at least one regime-specialist in the hunt.  Asserts:
      - proposals are counted in global_trials via the analyst fleet
      - FleetOrchestrator.hunt() receives the analyst territories and runs
      - ForwardValidationService.run() runs (marks, reviews, promotes)
      - FactoryLoop.allocate_and_promote() runs without error
      - FactoryStore.record_cycle() persists the correct proposed count
    """
    settings = Settings(analytical_path=str(tmp_path / "an.duckdb"),
                        operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
    store = SqlStrategyStore(settings.operational_dsn)
    fs = FactoryStore(settings.operational_dsn)
    data = _StableUpData()
    analytical = DuckDbAnalyticalStore(settings.analytical_path)

    # Analyst proposes two hypotheses
    analyst = FakeAnalyst("momentum", [
        Proposal("AAA", "trend", {"window": 100.0}),
        Proposal("BBB", "trend", {"window": 200.0}),
    ])
    analyst_fleet = AnalystFleet([analyst], store)
    proposals = analyst_fleet.gather(universe=["AAA", "BBB"])

    assert len(proposals) == 2
    # M1: gather is read-only; trials are 0 until the hunt runs
    assert store.total_trials() == 0

    # Bridge to territories
    analyst_territories = _proposals_to_territories(proposals, AssetClass.FUTURE)

    # Hunt — single counting authority (M1): marks seen + increments trials for each candidate evaluated
    hunter = FleetOrchestrator(data, analytical, store, settings)  # type: ignore[arg-type]
    fleet_report = hunter.hunt(analyst_territories)

    # After hunt: candidates were tested and the hunt counted them
    assert fleet_report.territories == len(analyst_territories)
    # Trials must now be > 0 (hunt counted the candidates it evaluated)
    assert store.total_trials() > 0

    # Forward-validation step
    fwd_svc = ForwardValidationService(data, analytical, store, settings,  # type: ignore[arg-type]
                                       slots=5, min_forward_days=1)
    fwd_report = fwd_svc.run()
    # No assertions on exact counts (depends on hunt finding a survivor) — just assert it runs
    assert fwd_report.marked >= 0

    # FactoryLoop step
    cfg = _cfg(min_forward_days=1)
    loop = FactoryLoop(StrategyStoreLoopAdapter(store), cfg)
    res = loop.allocate_and_promote()
    assert res.deployed_after >= 0  # no crash

    # Persist funnel (simulating --execute)
    now = dt.datetime.now(dt.UTC).replace(tzinfo=None)
    cyc = "fc-test"
    fs.record_cycle(
        cycle_id=cyc,
        proposed=len(proposals),
        tested=fleet_report.candidates_tested,
        survived=fleet_report.specialists_found,
        forward=fwd_report.forward_tracking,
        deployed=res.deployed_after,
        retired=fwd_report.retired,
        global_trials=store.total_trials(),
        at=now,
    )
    fs.record_allocation(cyc, res.weights, now)

    latest = fs.latest_cycle()
    assert latest is not None
    assert latest.proposed == 2  # exactly the two analyst proposals
    assert latest.tested == fleet_report.candidates_tested
    assert latest.global_trials == store.total_trials()


def test_factory_cycle_no_llm_gracefully_proceeds(tmp_path) -> None:
    """If the LLM is unavailable the analyst fleet yields 0 proposals; the chain still proceeds
    through hunt (with no analyst territories, but standing territories would be added in the
    real CLI).  Here we just verify that 0 proposals → 0 analyst territories → empty bridge."""
    proposals: list[Proposal] = []  # simulates LLM failure / no analysts
    territories = _proposals_to_territories(proposals, AssetClass.FUTURE)
    assert territories == []
    # The chain can still run with standing territories (added in CLI from _default_territories)
    # This test just verifies the bridge doesn't crash on an empty proposal list.


def test_factory_cycle_analyst_dedup_across_standing_territories(tmp_path) -> None:
    """Proposals that duplicate standing-hunter candidates (same seen key) are filtered by
    the AnalystFleet — gather() does NOT return them.  The shared candidate_key format ensures
    a key marked by the hunt (canonical format) is recognized by gather() (M1: unified namespace)."""
    settings = Settings(analytical_path=str(tmp_path / "an.duckdb"),
                        operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
    store = SqlStrategyStore(settings.operational_dsn)

    # Simulate the standing hunt having already tested ES trend window=100.0
    # and marked it seen using candidate_key (the shared canonical format).
    dup_proposal = Proposal("ES", "trend", {"window": 100.0})
    store.mark_seen(dup_proposal.key())  # p.key() now delegates to candidate_key
    store.add_trials(1)  # standing hunter counted it

    analyst = FakeAnalyst("mom", [dup_proposal,
                                  Proposal("NQ", "trend", {"window": 200.0})])  # NQ is fresh
    fleet = AnalystFleet([analyst], store)
    fresh = fleet.gather(universe=["ES", "NQ"])

    assert len(fresh) == 1  # only the NQ proposal is fresh (ES already seen)
    assert fresh[0].market == "NQ"
    # M1: gather is read-only — trials remain at 1 (only from the standing hunter)
    assert store.total_trials() == 1  # gather did NOT add its own count
    assert not store.is_seen(fresh[0].key())  # fresh proposal not yet marked (hunt will do it)
