"""
Unit tests for dashboard_writer pure helpers: realized-events accounting,
asset_stats day filter, and the daily roll-up that feeds the chart.

Run:
    cd ~/apex_v16
    python -m pytest tests/test_dashboard_writer.py -q
"""

from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from dashboard_writer import DashboardWriter


# Synthetic realized-event log:
#   2026-05-13  full   6E   bal 100_000  (baseline, no point emitted)
#   2026-05-13  full   6E   bal 100_120        -> +120 gross
#   2026-05-14  partial 6E partial_pnl_usd=40  -> +40  gross
#   2026-05-14  full    6B  bal 100_080        -> -80  gross (loss)
#
# 6E commission round-trip @ 1 contract = $6.48; 6B @ 1 = $6.48.
EVENTS = [
    {"ts": "2026-05-13T15:00:00+00:00", "symbol": "6E",
     "kind": "full", "balance_post": 100_000.0,
     "partial_pnl_usd": None, "contracts": 1},
    {"ts": "2026-05-13T17:30:00+00:00", "symbol": "6E",
     "kind": "full", "balance_post": 100_120.0,
     "partial_pnl_usd": None, "contracts": 1},
    {"ts": "2026-05-14T09:30:00+00:00", "symbol": "6E",
     "kind": "partial", "balance_post": None,
     "partial_pnl_usd": 40.0, "contracts": 1},
    {"ts": "2026-05-14T10:15:00+00:00", "symbol": "6B",
     "kind": "full", "balance_post": 100_080.0,
     "partial_pnl_usd": None, "contracts": 1},
]


def test_trade_history_fuses_partial_with_full_close():
    # Per-trade output: the 6E partial (+40) on 14-may rolls into the
    # 6B full on the same day? No — different symbol. The 6E partial
    # is in-flight (no matching full), so it must NOT appear as a row.
    # Only the two completed full closes (one per symbol) emit rows.
    rows = DashboardWriter._trade_history(EVENTS)
    assert [r["symbol"] for r in rows] == ["6E", "6B"]
    # 6E completed at 13/05 17:30 with +120 gross (full only, no partial).
    assert rows[0]["pnl_usd"] == 120.0
    assert rows[0]["kind"] == "win"
    assert rows[0]["has_partial"] is False
    # 6B completed at 14/05 10:15 with -80 gross (full only).
    assert rows[1]["pnl_usd"] == -80.0
    assert rows[1]["kind"] == "loss"
    assert rows[1]["has_partial"] is False
    # Cumulative threads through: 120, 40.
    assert [r["cumulative_pnl"] for r in rows] == [120.0, 40.0]
    print("trade_history emits one row per completed trade (in-flight partials excluded)")


def test_asset_stats_today_filters_by_full_close_ts():
    # Cumulative view: every symbol whose full close completed
    # appears. The 6E partial on 14/05 has no matching full close, so
    # 6E aggregates contain only the 13-may completed trade.
    all_stats = DashboardWriter._asset_stats(EVENTS)
    assert set(all_stats) == {"6E", "6B"}
    assert all_stats["6E"]["trades"] == 1   # 13/05 full close
    assert all_stats["6E"]["wins"]   == 1
    assert all_stats["6E"]["losses"] == 0
    assert all_stats["6E"]["pnl"]    == 120.0
    assert all_stats["6B"]["trades"] == 1
    assert all_stats["6B"]["losses"] == 1
    assert all_stats["6B"]["pnl"]    == -80.0

    # Today-only view: only the 6B trade completed on 14-may; 6E
    # partial is in-flight so 6E is absent.
    today = DashboardWriter._asset_stats(EVENTS, ts_prefix="2026-05-14")
    assert set(today) == {"6B"}
    assert today["6B"]["trades"] == 1
    assert today["6B"]["losses"] == 1
    assert today["6B"]["pnl"]    == -80.0

    # Day with no completed trades: empty dict, no crash.
    empty = DashboardWriter._asset_stats(EVENTS, ts_prefix="2026-05-15")
    assert empty == {}
    print("asset_stats filters on the trade's full-close ts (in-flight trades omitted)")


def test_trade_history_daily_buckets_by_utc_date():
    rows = DashboardWriter._trade_history(EVENTS)
    daily = DashboardWriter._trade_history_daily(rows)

    # One entry per UTC calendar day in ascending order.
    assert [d["date"] for d in daily] == ["2026-05-13", "2026-05-14"]

    # 2026-05-13: single +120 gross row (the 6E full close).
    assert daily[0]["pnl_day"] == 120.0
    assert daily[0]["cumulative_pnl"] == 120.0

    # 2026-05-14: single -80 gross row (the 6B full close); the 6E
    # partial is in-flight and contributes nothing here.
    assert daily[1]["pnl_day"] == -80.0
    assert daily[1]["cumulative_pnl"] == 40.0

    # Net cumulative threads through commissions deducted on each row.
    assert daily[1]["cumulative_pnl_net"] < daily[1]["cumulative_pnl"]
    print("trade_history_daily rolls up per-trade rows into per-day points")


def test_trade_history_daily_empty_when_no_rows():
    assert DashboardWriter._trade_history_daily([]) == []
    print("trade_history_daily on empty input returns []")


def test_asset_stats_ts_prefix_respects_utc_midnight_boundary():
    # Events that bracket UTC midnight: the 23:59 trade closes
    # "yesterday", the 00:00 trade closes "today". The filter must
    # split them exactly on the full-close ts and not bleed cross-day.
    events = [
        {"ts": "2026-05-13T15:00:00+00:00", "symbol": "6E",
         "kind": "full", "balance_post": 100_000.0,
         "partial_pnl_usd": None, "contracts": 1},
        {"ts": "2026-05-13T23:59:59+00:00", "symbol": "6E",
         "kind": "full", "balance_post": 100_050.0,
         "partial_pnl_usd": None, "contracts": 1},
        {"ts": "2026-05-14T00:00:00+00:00", "symbol": "6E",
         "kind": "full", "balance_post": 100_070.0,
         "partial_pnl_usd": None, "contracts": 1},
    ]
    yesterday = DashboardWriter._asset_stats(events, ts_prefix="2026-05-13")
    today     = DashboardWriter._asset_stats(events, ts_prefix="2026-05-14")

    # 13/05: only the 23:59 trade (the 15:00 row was the baseline).
    assert yesterday["6E"]["trades"] == 1
    assert yesterday["6E"]["pnl"] == 50.0

    # 14/05: only the 00:00 trade (+20 gross from the 23:59 cursor).
    assert today["6E"]["trades"] == 1
    assert today["6E"]["pnl"] == 20.0
    assert today["6E"]["wins"] == 1
    print("ts_prefix splits cleanly on the UTC-midnight boundary")


def test_partial_plus_be_residual_counts_as_one_win():
    # A trade that takes a +$50 partial then closes its residual at BE
    # ($0 delta on the broker balance) must aggregate to one WIN,
    # not 1W + 1S. This is the load-bearing behavior driving the
    # accounting refactor — keeps a profitable trade from being
    # mis-counted by W/L statistics.
    events = [
        # Baseline.
        {"ts": "2026-05-14T08:00:00+00:00", "symbol": "MGC",
         "kind": "full", "balance_post": 100_000.0,
         "partial_pnl_usd": None, "contracts": 1},
        # Partial: +$50.
        {"ts": "2026-05-14T09:00:00+00:00", "symbol": "MGC",
         "kind": "partial", "balance_post": None,
         "partial_pnl_usd": 50.0, "contracts": 1},
        # Residual closes at BE (balance unchanged from post-partial).
        {"ts": "2026-05-14T10:00:00+00:00", "symbol": "MGC",
         "kind": "full", "balance_post": 100_050.0,
         "partial_pnl_usd": None, "contracts": 1},
    ]
    stats = DashboardWriter._asset_stats(events, ts_prefix="2026-05-14")
    assert stats["MGC"]["trades"] == 1, "partial+BE must collapse into 1 trade"
    assert stats["MGC"]["wins"]   == 1, "partial+BE total +$50 → WIN"
    assert stats["MGC"]["losses"] == 0
    assert stats["MGC"]["pnl"]    == 50.0

    # And the trade_history reflects the same outcome.
    rows = DashboardWriter._trade_history(events)
    assert len(rows) == 1
    assert rows[0]["kind"] == "win"
    assert rows[0]["pnl_usd"] == 50.0
    assert rows[0]["has_partial"] is True
    print("partial profit + BE residual aggregates to one WIN, not 1W+1S")


def test_broker_pnl_overrides_stale_balance_delta():
    # When orchestrator emits a pnl_discrepancy with pnl_source="broker"
    # next to a balance_confirmed, the balance delta is known stale
    # (the 0.5s settle window after EXTERNAL_CLOSE wasn't enough). The
    # writer must prefer the broker-reported pnl. Otherwise the chart
    # and asset_stats lie about the trade outcome.
    events = [
        # Baseline.
        {"ts": "2026-05-14T08:00:00+00:00", "symbol": "6E",
         "kind": "full", "balance_post": 100_000.0,
         "partial_pnl_usd": None, "contracts": 1,
         "broker_pnl": None, "pnl_source": ""},
        # EXTERNAL_CLOSE: balance delta collapses to 0 (stale post-fill
        # snapshot), but broker.recent_trades reports +$75.
        {"ts": "2026-05-14T10:00:00+00:00", "symbol": "6E",
         "kind": "full", "balance_post": 100_000.0,
         "partial_pnl_usd": None, "contracts": 1,
         "broker_pnl": 75.0, "pnl_source": "broker"},
    ]
    rows = DashboardWriter._trade_history(events)
    assert len(rows) == 1
    assert rows[0]["pnl_usd"] == 75.0,    "broker value must win over the stale delta"
    assert rows[0]["kind"]    == "win"

    stats = DashboardWriter._asset_stats(events, ts_prefix="2026-05-14")
    assert stats["6E"]["pnl"]  == 75.0
    assert stats["6E"]["wins"] == 1
    print("broker_pnl (pnl_source=broker) overrides the stale balance delta")


def test_broker_pnl_overrides_partial_tick_math():
    # Partial close: the closer's tick math (partial_pnl_usd) uses
    # tech_now.price, not the real broker fill, so it diverges from
    # TopstepX. Orchestrator now logs broker_pnl on partials too —
    # writer must prefer it when pnl_source="broker".
    events = [
        {"ts": "2026-05-14T08:00:00+00:00", "symbol": "6E",
         "kind": "full", "balance_post": 100_000.0,
         "partial_pnl_usd": None, "contracts": 1,
         "broker_pnl": None, "pnl_source": ""},
        # Partial: closer tick math says +$43.75, broker says +$75.
        {"ts": "2026-05-14T09:00:00+00:00", "symbol": "6E",
         "kind": "partial", "balance_post": None,
         "partial_pnl_usd": 43.75, "contracts": 1,
         "broker_pnl": 75.0, "pnl_source": "broker"},
        # Residual closes BE.
        {"ts": "2026-05-14T10:00:00+00:00", "symbol": "6E",
         "kind": "full", "balance_post": 100_075.0,
         "partial_pnl_usd": None, "contracts": 1,
         "broker_pnl": None, "pnl_source": ""},
    ]
    rows = DashboardWriter._trade_history(events)
    assert len(rows) == 1
    assert rows[0]["pnl_usd"] == 75.0,    "broker $75 must override tick-math $43.75"
    assert rows[0]["kind"]    == "win"
    assert rows[0]["has_partial"] is True
    print("broker_pnl on partial overrides the closer's tick math")


def test_broker_pnl_ignored_when_source_is_tick_math():
    # When pnl_discrepancy fires for a bot-driven exit (_handle_exit),
    # `estimated_pnl` is the closer's tick math, not the broker — and
    # `pnl_source` is unset. Tick math on the exit_price=tech_now.price
    # is no better than the balance delta on these paths, so we leave
    # the original delta logic in place.
    events = [
        {"ts": "2026-05-14T08:00:00+00:00", "symbol": "6E",
         "kind": "full", "balance_post": 100_000.0,
         "partial_pnl_usd": None, "contracts": 1,
         "broker_pnl": None, "pnl_source": ""},
        # Bot-driven exit with non-zero balance delta and a tick-math
        # discrepancy attached (pnl_source != "broker"). Prefer the
        # balance delta.
        {"ts": "2026-05-14T10:00:00+00:00", "symbol": "6E",
         "kind": "full", "balance_post": 100_040.0,
         "partial_pnl_usd": None, "contracts": 1,
         "broker_pnl": -12.5, "pnl_source": ""},
    ]
    rows = DashboardWriter._trade_history(events)
    assert len(rows) == 1
    assert rows[0]["pnl_usd"] == 40.0, "tick-math discrepancy must NOT override the balance delta"
    print("non-broker pnl_discrepancy does not override the balance delta")


def test_in_flight_partial_without_full_close_is_excluded():
    # A partial that has not yet been followed by a full close is
    # in-flight: its P&L lives on the open position card, not in the
    # closed-trade stats. The writer must NOT count it.
    events = [
        {"ts": "2026-05-14T08:00:00+00:00", "symbol": "MGC",
         "kind": "full", "balance_post": 100_000.0,
         "partial_pnl_usd": None, "contracts": 1},
        {"ts": "2026-05-14T09:00:00+00:00", "symbol": "MGC",
         "kind": "partial", "balance_post": None,
         "partial_pnl_usd": 30.0, "contracts": 1},
        # No matching full close — trade is in-flight.
    ]
    stats = DashboardWriter._asset_stats(events, ts_prefix="2026-05-14")
    assert stats == {}, "in-flight partial must not appear in closed stats"
    rows = DashboardWriter._trade_history(events)
    assert rows == [], "in-flight partial must not appear in trade_history"
    print("in-flight partial without full close is excluded from completed stats")


if __name__ == "__main__":
    test_trade_history_fuses_partial_with_full_close()
    test_asset_stats_today_filters_by_full_close_ts()
    test_trade_history_daily_buckets_by_utc_date()
    test_trade_history_daily_empty_when_no_rows()
    test_asset_stats_ts_prefix_respects_utc_midnight_boundary()
    test_partial_plus_be_residual_counts_as_one_win()
    test_broker_pnl_overrides_stale_balance_delta()
    test_broker_pnl_overrides_partial_tick_math()
    test_broker_pnl_ignored_when_source_is_tick_math()
    test_in_flight_partial_without_full_close_is_excluded()
    print("ALL DASHBOARD_WRITER TESTS PASSED")
