"""D2.1 — capture the REAL DU-account fills + snapshot at an `execute-multistrat` run: capture_fills maps
ib-async Trade objects to FillDTOs; persist_execution writes them (strategy-tagged) + the account snapshot;
both are best-effort (never raise on a broken repo); plan_and_record wires the whole capture end-to-end
when an account_repo + broker are supplied (and is a no-op — no AttributeError — when they are not, so
existing callers that don't pass them are unaffected)."""
from __future__ import annotations

import datetime as dt
from types import SimpleNamespace

import pytest

from fxhnt.adapters.broker.ibkr import AccountSnapshot
from fxhnt.adapters.persistence.ibkr_account_repo import IbkrAccountRepo
from fxhnt.application.exec_capture import capture_fills, persist_execution


class _FakeExecution:
    def __init__(self, side: str, shares: float, price: float, avg_price: float) -> None:
        self.side = side
        self.shares = shares
        self.price = price
        self.avgPrice = avg_price


class _FakeCommissionReport:
    def __init__(self, commission: float) -> None:
        self.commission = commission


class _FakeContract:
    def __init__(self, symbol: str) -> None:
        self.symbol = symbol


class _FakeFill:
    def __init__(self, symbol: str, side: str, shares: float, price: float, avg_price: float,
                 commission: float) -> None:
        self.contract = _FakeContract(symbol)
        self.execution = _FakeExecution(side, shares, price, avg_price)
        self.commissionReport = _FakeCommissionReport(commission)


class _FakeTrade:
    def __init__(self, fills: list[_FakeFill], status: str = "Filled", lmt_price: float | None = None) -> None:
        self.fills = fills
        self.orderStatus = SimpleNamespace(status=status)
        self.order = SimpleNamespace(lmtPrice=lmt_price) if lmt_price is not None else SimpleNamespace()


# ---- capture_fills -----------------------------------------------------------------------------------

def test_capture_fills_maps_trade_fills_to_dtos():
    trade = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)])
    out = capture_fills([trade])
    assert len(out) == 1
    f = out[0]
    assert f.symbol == "SPY"
    assert f.side == "BOT"
    assert f.qty == 10.0
    assert f.price == 452.30
    assert f.fee == 1.25
    assert f.status == "Filled"
    assert f.shortfall_bps == 0.0    # market order — no intended (limit) price to compare against


def test_capture_fills_computes_shortfall_when_intended_price_known():
    trade = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)], lmt_price=452.0)
    out = capture_fills([trade])
    assert out[0].shortfall_bps == pytest.approx((452.30 - 452.0) / 452.0 * 1e4)


def test_capture_fills_market_order_sentinel_lmtprice_is_zero_shortfall_not_minus_10000():
    """ib-async's `Order.lmtPrice` defaults to the IBAPI sentinel UNSET_DOUBLE (1.7976931348623157e+308,
    TRUTHY — not None), which every `MarketOrder` carries. A naive `if intended:` treats it as a real
    intended price and fabricates ~−10000 bps of slippage on EVERY real market fill. Assert the sentinel is
    honestly treated as "no intended price" → shortfall 0.0. Use a REAL ib_async.MarketOrder so the fake
    can't drift from the actual sentinel semantics."""
    from ib_async import MarketOrder

    real_order = MarketOrder("BUY", 10)   # carries lmtPrice == UNSET_DOUBLE
    trade = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)])
    trade.order = real_order              # the actual sentinel, not a hand-typed value
    out = capture_fills([trade])
    assert out[0].shortfall_bps == 0.0    # NOT ~-10000.0


def test_capture_fills_sentinel_double_literal_is_zero_shortfall():
    """Belt-and-braces: a fake carrying the literal UNSET_DOUBLE value (sentinel-faithful) also yields 0.0,
    so the guard is value-based (not merely 'is a MarketOrder')."""
    from ib_async.util import UNSET_DOUBLE

    trade = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)], lmt_price=UNSET_DOUBLE)
    out = capture_fills([trade])
    assert out[0].shortfall_bps == 0.0


def test_capture_fills_handles_multiple_trades_and_fills():
    t1 = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)])
    t2 = _FakeTrade([_FakeFill("IEF", "SLD", 5.0, 99.0, 99.0, 0.50),
                     _FakeFill("IEF", "SLD", 3.0, 99.1, 99.1, 0.30)])
    out = capture_fills([t1, t2])
    assert len(out) == 3
    assert [f.symbol for f in out] == ["SPY", "IEF", "IEF"]


def test_capture_fills_empty_trades_returns_empty():
    assert capture_fills([]) == []


# ---- persist_execution --------------------------------------------------------------------------------

def test_persist_execution_writes_fills_and_snapshot(tmp_path):
    repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/t.db")
    repo.migrate()
    trade = _FakeTrade([_FakeFill("SPY", "BOT", 10.0, 452.30, 452.31, 1.25)])
    fills = capture_fills([trade])
    snapshot = AccountSnapshot(nlv=1_000_000.0, cash=500.0, gross=999_500.0, positions={"SPY": 10.0})
    at = dt.datetime(2026, 7, 14, 15, 0)

    persist_execution(repo, strategy_id="multistrat", rebalance_id="2026-07-14", fills=fills,
                      snapshot=snapshot, at=at)

    stored = repo.fills_for("multistrat", limit=10)
    assert len(stored) == 1
    assert stored[0].symbol == "SPY" and stored[0].strategy_id == "multistrat"
    assert stored[0].rebalance_id == "2026-07-14"
    assert repo.nav_series() == [("2026-07-14", 1_000_000.0)]
    assert repo.latest_positions() == {"SPY": 10.0}


class _RaisingRepo:
    def record_fills(self, **kwargs):
        raise RuntimeError("db is down")

    def replace_snapshot(self, *args, **kwargs):
        raise RuntimeError("db is down")


def test_persist_execution_is_best_effort_never_raises():
    persist_execution(_RaisingRepo(), strategy_id="multistrat", rebalance_id="x", fills=[],
                      snapshot=AccountSnapshot(nlv=0.0, cash=0.0, gross=0.0, positions={}),
                      at=dt.datetime(2026, 7, 14))   # must not raise


class _FillsOnlyRaisingRepo:
    """Fills write raises; the snapshot write must still happen (independent guards)."""
    def __init__(self) -> None:
        self.snapshot_calls: list[tuple] = []

    def record_fills(self, **kwargs):
        raise RuntimeError("fills write failed")

    def replace_snapshot(self, *args, **kwargs):
        self.snapshot_calls.append((args, kwargs))


def test_persist_execution_snapshot_write_survives_a_fills_failure():
    repo = _FillsOnlyRaisingRepo()
    persist_execution(repo, strategy_id="multistrat", rebalance_id="x", fills=[],
                      snapshot=AccountSnapshot(nlv=1.0, cash=1.0, gross=1.0, positions={}),
                      at=dt.datetime(2026, 7, 14))
    assert len(repo.snapshot_calls) == 1


# ---- broker: account_snapshot + trade collection -------------------------------------------------------

def test_ibkr_broker_account_snapshot_reads_summary_and_positions():
    from fxhnt.adapters.broker.ibkr import IbkrBroker

    broker = IbkrBroker()
    fake_ib = SimpleNamespace(
        isConnected=lambda: True,
        accountSummary=lambda: [SimpleNamespace(tag="NetLiquidation", value="1028594.0"),
                                SimpleNamespace(tag="TotalCashValue", value="500.0"),
                                SimpleNamespace(tag="GrossPositionValue", value="1000000.0")],
        positions=lambda: [SimpleNamespace(contract=SimpleNamespace(symbol="SPY"), position=10.0)])
    broker._ib = fake_ib
    snap = broker.account_snapshot()
    assert snap.nlv == 1028594.0
    assert snap.cash == 500.0
    assert snap.gross == 1000000.0
    assert snap.positions == {"SPY": 10.0}


def test_ibkr_broker_pop_trades_returns_and_clears_placed_trades():
    from fxhnt.adapters.broker.ibkr import IbkrBroker

    broker = IbkrBroker()
    assert broker.pop_trades() == []          # nothing placed yet
    fake_trade = _FakeTrade([_FakeFill("SPY", "BOT", 1.0, 100.0, 100.0, 0.1)])
    broker._trades.append(fake_trade)
    popped = broker.pop_trades()
    assert popped == [fake_trade]
    assert broker.pop_trades() == []          # cleared after pop


# ---- plan_and_record wiring: capture only fires when account_repo + broker are supplied ------------------

class _FakePlanWithTrades:
    def __init__(self, trades):
        self.nlv = 100_000.0
        self.orders: list = []
        self.notes: list = []
        self.blocked = False
        self.executed = True
        self.trades = trades
        self.rebalance_id = ""


class _FakeExecSvcWithTrades:
    def __init__(self, trades):
        self._trades = trades

    def rebalance_weights(self, name, weights, prices, *, max_gross, execute, contract_specs=None, sizing_capital=None):
        return _FakePlanWithTrades(self._trades)


class _FakeBrokerWithSnapshot:
    def account_snapshot(self):
        return AccountSnapshot(nlv=100_000.0, cash=1_000.0, gross=99_000.0, positions={"SPY": 12.0})


def test_plan_and_record_persists_capture_when_repo_and_broker_supplied(tmp_path):
    from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
    from fxhnt.application.multistrat_exec_record import plan_and_record

    fwd_repo = ForwardNavRepo(f"sqlite:///{tmp_path}/f.db")
    fwd_repo.migrate()
    account_repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/a.db")
    account_repo.migrate()
    at = dt.datetime(2026, 7, 14, 15, 0)
    trade = _FakeTrade([_FakeFill("SPY", "BOT", 12.0, 500.0, 500.0, 1.0)])

    plan, env = plan_and_record(
        repo=fwd_repo, exec_svc=_FakeExecSvcWithTrades([trade]), within_weights={"SPY": 1.0},
        prices={"SPY": 500.0}, nlv=100_000.0, today="2026-07-14", at=at, max_gross=2.0, execute=True,
        account_is_paper=True, account_repo=account_repo, broker=_FakeBrokerWithSnapshot())

    stored = account_repo.fills_for("multistrat", limit=10)
    assert len(stored) == 1 and stored[0].symbol == "SPY"
    assert account_repo.nav_series() == [("2026-07-14", 100_000.0)]
    assert not plan.blocked


class _RaisingBrokerSnapshot:
    """A broker whose account_snapshot() raises — exercises plan_and_record's OUTER belt-and-braces guard
    (the failure happens before persist_execution's own internal try/except can catch it)."""
    def account_snapshot(self):
        raise RuntimeError("broker unreachable")


def test_plan_and_record_survives_a_raising_account_repo_end_to_end(tmp_path):
    """Drive a RAISING account_repo (record_fills + replace_snapshot both raise) all the way through
    plan_and_record — the rebalance must still complete and return normally (capture is best-effort,
    non-blocking). Proves the outer try/except in plan_and_record is exercised, not just persist_execution."""
    from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
    from fxhnt.application.multistrat_exec_record import plan_and_record

    fwd_repo = ForwardNavRepo(f"sqlite:///{tmp_path}/fr.db")
    fwd_repo.migrate()
    at = dt.datetime(2026, 7, 14, 15, 0)
    trade = _FakeTrade([_FakeFill("SPY", "BOT", 12.0, 500.0, 500.0, 1.0)])

    plan, env = plan_and_record(
        repo=fwd_repo, exec_svc=_FakeExecSvcWithTrades([trade]), within_weights={"SPY": 1.0},
        prices={"SPY": 500.0}, nlv=100_000.0, today="2026-07-14", at=at, max_gross=2.0, execute=True,
        paper_envelope=100_000.0, account_is_paper=True, account_repo=_RaisingRepo(),
        broker=_FakeBrokerWithSnapshot())
    assert not plan.blocked and env == 100_000.0   # rebalance completed normally despite the broken repo
    # the gate-relevant return basis was still recorded (capture failure is isolated from it)
    assert fwd_repo.get_book_state("multistrat_exec_pos")["envelope"] == 100_000.0


def test_plan_and_record_survives_a_raising_broker_snapshot_end_to_end(tmp_path):
    """A broker whose account_snapshot() raises (before persist_execution is ever reached) must ALSO not
    fail the rebalance — the outer guard covers capture_fills/account_snapshot(), not just persist_execution."""
    from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
    from fxhnt.application.multistrat_exec_record import plan_and_record

    fwd_repo = ForwardNavRepo(f"sqlite:///{tmp_path}/fr2.db")
    fwd_repo.migrate()
    account_repo = IbkrAccountRepo(f"sqlite:///{tmp_path}/ar2.db")
    account_repo.migrate()
    at = dt.datetime(2026, 7, 14, 15, 0)
    trade = _FakeTrade([_FakeFill("SPY", "BOT", 12.0, 500.0, 500.0, 1.0)])

    plan, env = plan_and_record(
        repo=fwd_repo, exec_svc=_FakeExecSvcWithTrades([trade]), within_weights={"SPY": 1.0},
        prices={"SPY": 500.0}, nlv=100_000.0, today="2026-07-14", at=at, max_gross=2.0, execute=True,
        account_is_paper=True, account_repo=account_repo, broker=_RaisingBrokerSnapshot())
    assert not plan.blocked                                    # rebalance completed
    assert account_repo.nav_series() == []                    # nothing captured (snapshot raised) — clean


def test_plan_and_record_skips_capture_when_repo_and_broker_not_supplied(tmp_path):
    """Existing callers (and `_FakePlan`-style test fakes without a `.trades` attribute) must be unaffected —
    no capture attempted, no AttributeError, when account_repo/broker are the default None."""
    from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
    from fxhnt.application.multistrat_exec_record import plan_and_record

    class _PlanNoTrades:
        nlv = 100_000.0
        orders: list = []
        notes: list = []
        blocked = False
        executed = True

    class _ExecSvcNoTrades:
        def rebalance_weights(self, name, weights, prices, *, max_gross, execute, contract_specs=None, sizing_capital=None):
            return _PlanNoTrades()

    fwd_repo = ForwardNavRepo(f"sqlite:///{tmp_path}/f2.db")
    fwd_repo.migrate()
    at = dt.datetime(2026, 7, 14, 15, 0)
    plan, env = plan_and_record(
        repo=fwd_repo, exec_svc=_ExecSvcNoTrades(), within_weights={"SPY": 1.0}, prices={"SPY": 500.0},
        nlv=100_000.0, today="2026-07-14", at=at, max_gross=2.0, execute=True, account_is_paper=True)
    assert not plan.blocked   # no AttributeError from touching plan.trades/plan.rebalance_id
