import numpy as np
from fxhnt.application.funding_backtest_runner import (
    FundingBacktestRunner,
    _book,
    evaluate_funding_matrix,
)


def _panel():
    panel = {}
    fund = {"HI": 0.0015, "MID": 0.0008, "LO": 0.0001, "NEG": -0.0010}
    for sym, f in fund.items():
        panel[sym] = {1000 + i: (1.0, 5e6, f) for i in range(200)}
    return panel


def test_runner_produces_series_per_mode():
    runner = FundingBacktestRunner(_panel(), min_qvol=1e6, min_history=20,
                                   lookback_days=7, quantile=0.5, cost_bps=8.0, slip_coef=0.0)
    res = runner.run()
    assert set(res.returns_by_mode) == {"long_tilt", "market_neutral", "executable"}
    for series in res.returns_by_mode.values():
        assert len(series) > 50 and np.isfinite(series).all()
    assert float(np.sum(res.returns_by_mode["long_tilt"])) > 0.0


def test_runner_emits_dates_parallel_to_returns():
    res = FundingBacktestRunner(_panel(), min_qvol=1e6, min_history=20,
                                lookback_days=7, quantile=0.5, cost_bps=8.0, slip_coef=0.0).run()
    # dates is shared across modes (all modes book on the same rebalance days)
    for series in res.returns_by_mode.values():
        assert len(res.dates) == len(series)
    # dates are strictly ascending epoch-days
    assert res.dates == sorted(res.dates)
    assert len(set(res.dates)) == len(res.dates)
    assert all(isinstance(d, int) for d in res.dates)


def test_runner_deterministic():
    r1 = FundingBacktestRunner(_panel(), min_qvol=1e6, min_history=20, lookback_days=7,
                               quantile=0.5, cost_bps=8.0, slip_coef=0.0).run().returns_by_mode["long_tilt"]
    r2 = FundingBacktestRunner(_panel(), min_qvol=1e6, min_history=20, lookback_days=7,
                               quantile=0.5, cost_bps=8.0, slip_coef=0.0).run().returns_by_mode["long_tilt"]
    assert np.array_equal(r1, r2)


def test_result_reports_panel_days_and_n_rebalances():
    # Panel of 4 coins, 200 bars each. min_history=20 => first ~19 days skipped (<4 elig
    # by history), but once all 4 qualify every remaining day is an active rebalance.
    res = FundingBacktestRunner(_panel(), min_qvol=1e6, min_history=20, lookback_days=7,
                                quantile=0.5, cost_bps=8.0, slip_coef=0.0).run()
    n_rebals = len(res.returns_by_mode["long_tilt"])
    assert res.n_rebalances == n_rebals
    # panel_days is the full panel span (200 distinct days), strictly more than active rebalances
    assert res.panel_days == 200
    assert res.panel_days > res.n_rebalances


def _drop_panel():
    """Synthetic panel where FALL is liquid (and top-quantile long, funding=0.0030) for an early
    window, then its qvol craters so its trailing-30 mean falls below min_qvol and it leaves
    `elig` while STILL HELD in prev_w — exactly the dropped-out-holding case FIX #1 covers."""
    panel: dict[str, dict[int, tuple[float, float, float]]] = {}
    n_days, thr = 90, 25
    # Four always-liquid coins so the cross-section stays >= 4 names after FALL drops out.
    fund = {"HI": 0.0020, "MID2": 0.0010, "MID": 0.0005, "NEG": -0.0015}
    for sym, f in fund.items():
        panel[sym] = {1000 + i: (1.0, 5e6, f) for i in range(n_days)}
    fall: dict[int, tuple[float, float, float]] = {}
    for i in range(n_days):
        qv = 5e6 if i < thr else 1.0          # liquid early, then near-zero qvol
        fall[1000 + i] = (1.0, qv, 0.0030)    # highest funding -> top-quantile long while eligible
    panel["FALL"] = fall
    return panel


def test_dropped_out_holding_charged_nonzero_exit_cost(monkeypatch):
    # FIX #1: a held coin (FALL) that drops OUT of `elig` must still be charged its exit cost.
    # Spy on _book to capture the cost dict on the rebalance where FALL is in prev_w but no
    # longer in the new weights (the forced-exit day) and assert a strictly positive exit cost.
    import fxhnt.application.funding_backtest_runner as mod

    captured: list[dict[str, float]] = []
    orig = mod._book

    def spy(w, prev_w, funding_next, cost):
        if "FALL" in prev_w and "FALL" not in w:   # FALL held last period, gone this period
            captured.append(dict(cost))
        return orig(w, prev_w, funding_next, cost)

    monkeypatch.setattr(mod, "_book", spy)
    FundingBacktestRunner(_drop_panel(), min_qvol=1e6, min_history=20, lookback_days=7,
                          quantile=0.5, cost_bps=8.0, slip_coef=0.0005, slip_cap_bps=50.0).run()

    assert captured, "expected at least one forced-exit rebalance (FALL held then dropped out)"
    exit_cost = captured[0].get("FALL", 0.0)
    assert exit_cost > 0.0, "dropped-out holding must carry a non-zero exit cost (FIX #1)"
    # FALL's last-known qvol on the drop day is the post-crater 1.0 -> slippage hits the cap,
    # so the exit cost is the conservative maximum (taker fee + slip_cap), strictly positive.
    assert exit_cost == 0.0008 + 0.005   # cost_bps 8 -> 8e-4 ; slip_cap 50bps -> 5e-3


def test_runner_handles_panel_gaps_without_keyerror():
    panel = {}
    fund = {"HI": 0.0015, "MID": 0.0008, "LO": 0.0001, "NEG": -0.0010}
    for sym, f in fund.items():
        panel[sym] = {1000 + i: (1.0, 5e6, f) for i in range(200)}
    # GAPCOIN: liquid + long history but MISSING several interior union-days (gaps) and stops early (delists)
    panel["GAP"] = {1000 + i: (1.0, 5e6, 0.0012) for i in range(200) if i not in (50, 51, 120) and i < 180}
    runner = FundingBacktestRunner(panel, min_qvol=1e6, min_history=20, lookback_days=7,
                                   quantile=0.5, cost_bps=8.0, slip_coef=0.0005)
    res = runner.run()                     # must NOT raise KeyError
    for series in res.returns_by_mode.values():
        assert len(series) > 50 and np.isfinite(series).all()


def test_book_carry_and_turnover():
    # carry on the NEW book = sum w * funding_next
    w = {"A": 0.5, "B": -0.5}
    prev: dict[str, float] = {}                 # first rebalance: prev empty -> full setup turnover
    funding_next = {"A": 0.004, "B": -0.002}
    cost = {"A": 0.001, "B": 0.001}
    r = _book(w, prev, funding_next, cost)
    carry = 0.5 * 0.004 + (-0.5) * (-0.002)      # = 0.003
    turnover = (abs(0.5 - 0.0) * 0.001 + abs(-0.5 - 0.0) * 0.001) / 2.0   # full setup = 0.0005
    assert abs(r - (carry - turnover)) < 1e-12

    # Steady-state: identical book -> zero turnover, pure carry.
    r2 = _book(w, w, funding_next, cost)
    assert abs(r2 - carry) < 1e-12

    # Full rotation A->B charges turnover on both legs.
    r3 = _book({"B": 1.0}, {"A": 1.0}, {"B": 0.0}, {"A": 0.002, "B": 0.002})
    expected_turn = (abs(1.0) * 0.002 + abs(-1.0) * 0.002) / 2.0   # = 0.002
    assert abs(r3 - (0.0 - expected_turn)) < 1e-12


def test_evaluate_matrix_runs_all_cells():
    panel = _panel()      # the existing helper in this test file (HI/MID/LO/NEG, 200 days)
    report = evaluate_funding_matrix(panel, floors=[1e6, 5e6], lookback_days=7, quantile=0.5,
                                     cost_bps=8.0, slip_coef=0.0, oos_fraction=0.4)
    assert len(report["cells"]) == 6                 # 3 modes x 2 floors
    for cell in report["cells"].values():
        assert {"mode", "floor", "stats", "verdict"} <= set(cell)
        assert {"sharpe", "cagr", "max_drawdown", "n_obs"} <= set(cell["stats"])
        assert {"passed", "dsr", "is_sharpe", "oos_sharpe", "n_trials"} <= set(cell["verdict"])
        assert cell["verdict"]["n_trials"] == 6      # multiple-testing across the whole matrix
    import json
    json.dumps(report)                                # must be JSON-serializable (no NaN/np types)


def test_report_self_documents_caveats():
    # The report must carry top-level caveats so the JSON self-documents its limitations
    # (funding-only carry; mixed Sharpe annualization) and stay JSON-serializable.
    import json
    report = evaluate_funding_matrix(_panel(), floors=[1e6], lookback_days=7, quantile=0.5,
                                     cost_bps=8.0, slip_coef=0.0, oos_fraction=0.4)
    assert "caveats" in report
    assert report["caveats"]["funding_only"]
    assert report["caveats"]["sharpe_annualization"]
    assert "FUNDING CARRY ONLY" in report["caveats"]["funding_only"]
    json.dumps(report)                                # caveats must not break serialization


def test_runner_basis_changes_return_vs_funding_only():
    import numpy as np
    from fxhnt.application.funding_backtest_runner import FundingBacktestRunner
    panel = {}
    fund = {"HI": 0.002, "B": 0.001, "C": 0.0005, "D": -0.001}
    for sym, f in fund.items():
        panel[sym] = {1000 + i: (100.0, 5e6, f) for i in range(60)}   # perp flat at 100
    spot = {"HI": {1000 + i: 100.0 - 0.02 * i for i in range(60)}}     # HI spot declines -> long-spot leg loses
    fo = FundingBacktestRunner(panel, min_qvol=1e6, min_history=20, lookback_days=7, quantile=0.5,
                               cost_bps=0.0, slip_coef=0.0).run().returns_by_mode["long_tilt"]
    bm = FundingBacktestRunner(panel, spot=spot, min_qvol=1e6, min_history=20, lookback_days=7,
                               quantile=0.5, cost_bps=0.0, slip_coef=0.0).run().returns_by_mode["long_tilt"]
    assert float(np.sum(bm)) < float(np.sum(fo))    # basis loss on the long-spot leg reduces the return
    assert np.isfinite(bm).all()                     # coins without spot fall back to funding-only, no crash


def test_evaluate_matrix_basis_modeled_flag_and_caveat():
    panel = {}
    for sym, f in {"HI": 0.0015, "B": 0.0008, "C": 0.0001, "D": -0.001}.items():
        panel[sym] = {1000 + i: (100.0, 5e6, f) for i in range(120)}
    spot = {"HI": {1000 + i: 100.0 + 0.01 * i for i in range(120)}}
    from fxhnt.application.funding_backtest_runner import evaluate_funding_matrix
    rep = evaluate_funding_matrix(panel, spot=spot, floors=[1e6], quantile=0.5, cost_bps=0.0, slip_coef=0.0)
    assert rep["basis_modeled"] is True
    assert "FUNDING CARRY ONLY" not in rep["caveats"]["funding_only"]    # caveat reflects basis IS modeled
    # and the funding-only path still flags the caveat:
    rep2 = evaluate_funding_matrix(panel, floors=[1e6], quantile=0.5, cost_bps=0.0, slip_coef=0.0)
    assert rep2["basis_modeled"] is False
    assert "FUNDING CARRY ONLY" in rep2["caveats"]["funding_only"]


def test_runner_funding_only_unchanged_when_no_spot():
    # spot=None must be byte-identical to the pre-basis behavior
    import numpy as np
    from fxhnt.application.funding_backtest_runner import FundingBacktestRunner
    panel = {}
    for sym, f in {"HI": 0.0015, "B": 0.0008, "C": 0.0001, "D": -0.001}.items():
        panel[sym] = {1000 + i: (1.0, 5e6, f) for i in range(200)}
    a = FundingBacktestRunner(panel, min_qvol=1e6, min_history=20, lookback_days=7, quantile=0.5).run()
    b = FundingBacktestRunner(panel, spot=None, min_qvol=1e6, min_history=20, lookback_days=7, quantile=0.5).run()
    for m in a.returns_by_mode:
        assert np.array_equal(a.returns_by_mode[m], b.returns_by_mode[m])
