"""Discovery search — the agentic 'data-scientist army'. Sweeps a matrix of (market × strategy × params)
through the gauntlet and keeps only the survivors.

The discipline that makes this honest at scale: the deflated Sharpe is computed over the FULL search.
Every candidate is judged with n_trials = the total number searched and sr_variance = the variance of
the per-period Sharpes across ALL candidates. So a 'winner' must clear the bar it would clear by luck
across the whole search — not just on its own. This is what separates a search engine from a
false-positive factory.

The CandidateGenerator is a seam: today a deterministic GridGenerator; tomorrow an LLM agent proposing
hypotheses implements the same contract.
"""
from __future__ import annotations

import datetime as dt
from typing import Protocol

import numpy as np
from pydantic import BaseModel, Field

from fxhnt.config import Settings
from fxhnt.domain.backtest import run_backtest
from fxhnt.domain.gauntlet import effective_trials, evaluate, per_period_sharpe
from fxhnt.domain.models import Market, ResearchRun, StrategySpec, run_id
from fxhnt.domain.strategies import get_strategy
from fxhnt.domain.validation import fdr_correct
from fxhnt.ports.data import DataProvider
from fxhnt.ports.repository import AnalyticalStore, OperationalRepository

_MIN_HISTORY = 300


class CandidateGenerator(Protocol):
    def candidates(self) -> list[tuple[Market, StrategySpec]]:
        """Propose (market, strategy) candidates to search."""
        ...


class GridGenerator:
    """Deterministic cartesian sweep: every market × every (strategy kind, param dict)."""

    def __init__(self, markets: list[Market], grids: dict[str, list[dict[str, float]]]) -> None:
        self._markets = markets
        self._grids = grids

    def candidates(self) -> list[tuple[Market, StrategySpec]]:
        out: list[tuple[Market, StrategySpec]] = []
        for market in self._markets:
            for kind, param_list in self._grids.items():
                for params in param_list:
                    out.append((market, StrategySpec(kind=kind, params=params)))
        return out


class DiscoveryReport(BaseModel):
    n_candidates: int
    n_effective_trials: float   # correlation-corrected independent trial count (the deflation uses this)
    sr_variance: float
    n_passed_gauntlet: int = 0  # passed the per-candidate gauntlet (before across-search FDR)
    n_survivors: int = 0        # ALSO survived FDR over the whole search — the final set
    survivors: list[ResearchRun] = Field(default_factory=list)


class DiscoveryService:
    def __init__(self, data: DataProvider, operational: OperationalRepository,
                 analytical: AnalyticalStore, settings: Settings) -> None:
        self._data = data
        self._op = operational
        self._an = analytical
        self._s = settings

    def _prices(self, market: Market):  # type: ignore[no-untyped-def]
        ps = self._an.load_prices(market)
        if ps is None or len(ps) < _MIN_HISTORY:
            ps = self._data.fetch(market)
            self._an.save_prices(ps)
        return ps

    def search(self, generator: CandidateGenerator, *, persist: bool = True) -> DiscoveryReport:
        candidates = generator.candidates()
        oos_frac = self._s.gauntlet.oos_fraction

        # phase 1 — backtest every candidate, collect the in-sample returns + per-period Sharpes
        rows: list[tuple[Market, StrategySpec, object, object, np.ndarray, int]] = []
        is_sharpes: list[float] = []
        is_returns: list[np.ndarray] = []
        for market, spec in candidates:
            prices = self._prices(market)
            bt = run_backtest(prices, spec, self._s.cost_bps_per_turnover)
            r = bt.returns
            split = int((1.0 - oos_frac) * len(r))
            rows.append((market, spec, prices, bt, r, split))
            is_sharpes.append(per_period_sharpe(r[:split]))
            is_returns.append(r[:split])

        # the multiple-testing correction, calibrated: deflate by the EFFECTIVE (correlation-corrected)
        # number of independent trials, not the raw count — correlated grid params aren't independent tests.
        sr_variance = float(np.var(is_sharpes)) if len(is_sharpes) > 1 else 0.0
        n_eff = effective_trials(is_returns)
        n_trials_eff = max(1, round(n_eff))

        # phase 2 — judge each candidate against the effective-search bar
        g = self._s.gauntlet
        now = dt.datetime.now(dt.timezone.utc)
        judged: list[tuple[ResearchRun, object, np.ndarray]] = []
        for market, spec, prices, bt, r, split in rows:
            strategy = get_strategy(spec.kind)
            verdict = evaluate(
                r[:split], r[split:], n_trials=n_trials_eff, sr_variance=sr_variance,
                dsr_min=g.dsr_min, oos_min_sharpe=g.oos_min_sharpe, max_is_oos_decay=g.max_is_oos_decay,
                has_economic_rationale=bool(getattr(strategy, "rationale", "")),
            )
            run = ResearchRun(run_id=run_id(market, spec), market=market, spec=spec, stats=bt.stats,
                              verdict=verdict, n_trials=n_trials_eff, created_at=now)
            judged.append((run, prices, r))

        # phase 3 — FDR over the WHOLE search: Benjamini-Hochberg on every candidate's p-value at
        # alpha = 1 − dsr_min. A candidate survives only if it passes its own gauntlet AND survives FDR —
        # the multiple-testing complement to the per-candidate deflated Sharpe.
        pvalues = [run.verdict.pvalue for run, _, _ in judged]
        fdr = fdr_correct(pvalues, method="bh", alpha=1.0 - g.dsr_min) if len(pvalues) > 1 else None
        n_passed = sum(run.verdict.passed for run, _, _ in judged)
        survivors: list[ResearchRun] = []
        for i, (run, prices, r) in enumerate(judged):
            fdr_ok = fdr.rejected[i] if fdr is not None else True
            is_survivor = run.verdict.passed and fdr_ok
            if persist:
                self._op.save_run(run)  # the verdict (cheap, SQLite) is kept for every candidate — audit
                if is_survivor:
                    self._an.save_returns(run.run_id, prices.dates, r)  # only survivors keep their timeseries
            if is_survivor:
                survivors.append(run)

        return DiscoveryReport(n_candidates=len(candidates), n_effective_trials=n_eff, sr_variance=sr_variance,
                               n_passed_gauntlet=n_passed, n_survivors=len(survivors), survivors=survivors)
