"""The factory's promotion + allocation step. Forward-survivors are admitted to the deployed book only if
they pass the diversification gate (left-tail corr + marginal Sharpe) vs the CURRENT deployed book, bounded
by governance; then the whole deployed book is re-allocated by HRP. Pure orchestration over an injected
store (duck-typed: deployed()/forward()/set_status()/returns()) so it tests offline.

Admission is SEQUENTIAL: each candidate is re-checked against the running blend of (HRP-weighted book +
already-admitted-this-cycle) so two candidates uncorrelated to the book but correlated to each other do not
both get in."""
from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np

from fxhnt.application.factory.governance import promote_budget
from fxhnt.domain.diversification.gate import diversification_gate
from fxhnt.domain.diversification.hrp import hrp_weights


@dataclass(frozen=True, slots=True)
class LoopConfig:
    max_tail_corr: float
    min_marginal: float
    tail_frac: float
    min_forward_days: int
    max_deployed: int
    max_new_per_cycle: int
    kill_switch: bool


@dataclass
class LoopResult:
    promoted: list[str] = field(default_factory=list)
    weights: dict[str, float] = field(default_factory=dict)
    deployed_before: int = 0
    deployed_after: int = 0
    forward_count: int = 0


class FactoryLoop:
    def __init__(self, store, cfg: LoopConfig) -> None:
        self._store = store
        self._cfg = cfg

    def _weighted_blend(self, rets: dict, weights: dict) -> np.ndarray:
        """HRP-weighted blend of return series; trims to common tail length."""
        items = [(rets[k], weights.get(k, 0.0)) for k in rets if len(rets[k]) > 0]
        if not items:
            return np.array([])
        n = min(len(r) for r, _ in items)
        wsum = sum(w for _, w in items) or 1.0
        return np.sum([(w / wsum) * r[-n:] for r, w in items], axis=0)

    def allocate_and_promote(self) -> LoopResult:
        cfg = self._cfg
        res = LoopResult()
        deployed = self._store.deployed()
        res.deployed_before = len(deployed)
        budget = promote_budget(kill_switch=cfg.kill_switch, deployed=len(deployed),
                                max_deployed=cfg.max_deployed, max_new_per_cycle=cfg.max_new_per_cycle)

        # Fix 2+4: start-of-cycle book proxy = HRP-weighted blend of deployed sleeves with enough history
        dep_rets = {r["id"]: self._store.returns(r["id"]) for r in deployed
                    if len(self._store.returns(r["id"])) >= cfg.min_forward_days}
        dep_w = hrp_weights(dep_rets) if dep_rets else {}
        book = self._weighted_blend(dep_rets, dep_w)

        # Rank eligible survivors by marginal vs the start book
        survivors = []
        for r in self._store.forward():
            sleeve = self._store.returns(r["id"])
            if len(sleeve) < cfg.min_forward_days:
                continue
            res.forward_count += 1
            if book.size == 0:
                # Empty book: founding admit — no correlation to compare against yet
                survivors.append((r["id"], 1.0, sleeve))
                continue
            v = diversification_gate(sleeve, book, max_tail_corr=cfg.max_tail_corr,
                                     min_marginal=cfg.min_marginal, tail_frac=cfg.tail_frac)
            if v.admit:
                survivors.append((r["id"], v.marginal, sleeve))

        # Sort descending by marginal so the highest-value uncorrelated candidate goes first
        survivors.sort(key=lambda t: t[1], reverse=True)

        # Fix 2: sequential re-check against the running mean of ALREADY-ADMITTED-THIS-CYCLE candidates.
        # Comparing only against admitted-this-cycle (not the book) prevents two mutually-correlated
        # candidates that each pass the initial book gate from both slipping through in one cycle.
        # The first candidate is admitted freely (book gate was already cleared); subsequent ones must
        # pass the gate vs the equal-weight mean of previously-admitted sleeves.
        admitted_this_cycle: list[np.ndarray] = []
        for sid, _, sleeve in survivors:
            if len(res.promoted) >= budget:
                break
            if not admitted_this_cycle:
                # First admit of this cycle: book correlation was already cleared above; admit freely.
                ok = True
            else:
                n = min(len(s) for s in admitted_this_cycle)
                running = np.mean([s[-n:] for s in admitted_this_cycle], axis=0)
                n2 = min(len(running), len(sleeve))
                v2 = diversification_gate(sleeve[-n2:], running[-n2:], max_tail_corr=cfg.max_tail_corr,
                                          min_marginal=cfg.min_marginal, tail_frac=cfg.tail_frac)
                ok = v2.admit
            if ok:
                self._store.set_status(sid, "DEPLOYED")
                res.promoted.append(sid)
                admitted_this_cycle.append(sleeve)

        # Fix 3: HRP inclusion requires >= min_forward_days (not >= 2); fetch returns once per sleeve
        deployed_now = self._store.deployed()
        res.deployed_after = len(deployed_now)
        # Fix 7: fetch each sleeve's returns ONCE to avoid double I/O
        all_rets = {r["id"]: self._store.returns(r["id"]) for r in deployed_now}
        rets = {sid: arr for sid, arr in all_rets.items() if len(arr) >= cfg.min_forward_days}
        res.weights = hrp_weights(rets) if rets else {}
        return res


class StrategyStoreLoopAdapter:
    """Adapts the real SqlStrategyStore to the FactoryLoop's duck-typed interface."""
    def __init__(self, store) -> None:
        from fxhnt.domain.factory import StrategyStatus
        self._store = store
        self._S = StrategyStatus

    def deployed(self):
        return [{"id": r.strategy_id} for r in self._store.list(status=self._S.DEPLOYED)]

    def forward(self):
        return [{"id": r.strategy_id, "returns": r.forward_returns}
                for r in self._store.list(status=self._S.FORWARD)]

    def set_status(self, sid, status):
        # Fix 5: assert the only valid transition so a future demotion path cannot silently promote
        assert status == "DEPLOYED", f"loop only promotes to DEPLOYED, got {status}"
        self._store.set_status(sid, self._S.DEPLOYED)

    def returns(self, sid):
        import numpy as np
        rec = self._store.get(sid)
        return np.array(rec.forward_returns if rec else [])
