"""D2.2/2.3 (Task 3) — the holdings DETAIL page: `/strategy/multistrat` renders the REAL IBKR paper-account
section (what the book is holding right now, recent trades with honest cost-to-trade as a %, the
value-over-time curve with the plan overlaid, and the shared exec_vs_sim "behind/ahead of plan" pill).

Plain-language HARD RULE: no raw registry id (`multistrat`/`vrp`/`bybit_4edge`/`multistrat_exec`), no `bps`,
no "vs sim" ever appears in the rendered HTML — the recon signal is "X% behind/ahead of plan" only.

NO network — in-memory SQLite repos, seeded directly via `TestClient`."""
from __future__ import annotations

import datetime as dt
import re

from fastapi.testclient import TestClient

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.adapters.persistence.ibkr_account_repo import IbkrAccountRepo
from fxhnt.adapters.web.app import create_app
from fxhnt.application.exec_capture import FillDTO
from fxhnt.application.forward_models import ForwardNavRow as Row
from fxhnt.application.forward_models import ForwardSummary

_RAW_IDS = (">multistrat<", ">vrp<", ">bybit_4edge<", ">multistrat_exec<", ">vrp_exec<")


def _visible_text(html: str) -> str:
    """The user-FACING text of a rendered page: `<style>`/`<script>` blocks dropped and every HTML tag
    stripped, so CSS class names (`class="b decaying"`), the nav href (`/paper/sim`), and other markup
    internals don't false-trip a plain-language copy assertion — only what a reader actually sees is left.
    Lower-cased for case-insensitive matching."""
    html = re.sub(r"<(style|script)\b[^>]*>.*?</\1>", " ", html, flags=re.DOTALL | re.IGNORECASE)
    return re.sub(r"<[^>]+>", " ", html).lower()


def _seed_forward(with_exec: bool = True) -> ForwardNavRepo:
    repo = ForwardNavRepo("sqlite://")
    repo.migrate()
    at = dt.datetime(2026, 7, 14, 12, 0)
    base = dt.date(2026, 6, 1)
    nav = 1.0
    rows = []
    for i in range(20):
        nav *= 1.0015
        rows.append(Row("multistrat", (base + dt.timedelta(days=i)).isoformat(), 0.0015, nav))
    repo.upsert_rows(rows, at)
    repo.upsert_summary(ForwardSummary("multistrat", "2026-06-20", 20, nav, nav - 1.0, 0.9, -0.02),
                        "PASS", "reconciles clean", at)
    if with_exec:
        repo.upsert_summary(ForwardSummary("multistrat_exec", "2026-06-20", 20, nav * 0.99,
                                           nav * 0.99 - 1.0, 0.8, -0.02), "PASS", "reconciles clean", at)
    return repo


def _seed_ibkr(with_fills: bool = True) -> IbkrAccountRepo:
    repo = IbkrAccountRepo("sqlite://")
    repo.migrate()
    at1 = dt.datetime(2026, 6, 1, 15, 0)
    at2 = dt.datetime(2026, 6, 20, 15, 0)
    repo.replace_snapshot("2026-06-01", nlv=1_000_000.0, cash=1000.0, gross=999_000.0,
                          positions={"SPY": 100.0, "IEF": 200.0}, at=at1)
    repo.replace_snapshot("2026-06-20", nlv=1_015_000.0, cash=500.0, gross=1_014_500.0,
                          positions={"SPY": 100.0, "IEF": 200.0}, at=at2)
    if with_fills:
        repo.record_fills(strategy_id="multistrat", rebalance_id="r1",
                          fills=[FillDTO(symbol="SPY", side="BOT", qty=100.0, price=450.0, fee=1.0,
                                         status="Filled", shortfall_bps=0.0),
                                 FillDTO(symbol="IEF", side="BOT", qty=200.0, price=95.0, fee=0.5,
                                         status="Filled", shortfall_bps=0.0)],
                          at=at1)
    return repo


def _client(fwd: ForwardNavRepo, ibkr: IbkrAccountRepo) -> TestClient:
    return TestClient(create_app(fwd, ibkr_account_repo=ibkr))


def test_holdings_table_shows_plain_asset_descriptions() -> None:
    c = _client(_seed_forward(), _seed_ibkr())
    html = c.get("/strategy/multistrat").text
    assert "Holdings right now" in html   # redesigned: the collapsed <details><summary> label
    assert "SPY" in html and "US stocks" in html
    assert "IEF" in html and "US Treasuries" in html


def test_recent_trades_show_cost_to_trade_as_a_percent() -> None:
    c = _client(_seed_forward(), _seed_ibkr())
    html = c.get("/strategy/multistrat").text
    assert "Recent trades" in html
    # honest market-order shortfall (Task 2): rendered as a real 0.00% value, never a fabricated non-zero.
    assert "0.00%" in html
    assert "Buy" in html


def test_value_over_time_curve_and_behind_plan_pill_render() -> None:
    c = _client(_seed_forward(), _seed_ibkr())
    html = c.get("/strategy/multistrat").text
    assert "Value over time" in html
    assert "<svg" in html
    assert "badge-exec paper" in html   # the "Following the plan" block carries the PAPER exec badge
    assert "behind plan" in html   # exec (2.0%) trails sim (3.0%+) in the seeded fixture


def test_following_the_plan_card_supersedes_the_exec_twin_card_for_an_ibkr_book() -> None:
    # Whole-branch-review fix, now folded into the redesign: for an IBKR book with an exec twin
    # (multistrat/vrp), the page used to be able to render BOTH a "Following the plan" card (from d.recon,
    # sourced from the real captured fills/holdings) AND a separate pre-existing exec-twin card (from
    # exec_twin_sid/exec_divergence_pct) — duplicating the same "vs plan" % under two labels, with two
    # different cost-to-trade numbers on one page. The redesign collapsed these into ONE card — "Real trades
    # vs the plan", sourced from d.recon only — so it renders exactly once and the exec-twin fields never
    # surface a second, separate card.
    c = _client(_seed_forward(with_exec=True), _seed_ibkr())
    html = c.get("/strategy/multistrat").text
    assert html.count("Real trades vs the plan") == 1
    # the card's own "tracking the plan" kv still renders (sourced from d.recon, not the exec-twin fields).
    assert html.count("Tracking the plan") == 1


def test_exec_twin_card_does_not_render_for_a_non_ibkr_book_with_no_recon() -> None:
    # The guarded exec-twin "Real trades vs the plan" card renders ONLY when the executed twin has a forward
    # summary (exec_return_pct). With NO exec-twin summary seeded (only the modeled bybit_4edge), there is no
    # exec data, so the card must NOT render — and the page must still render cleanly with no crash.
    fwd = _seed_forward(with_exec=False)
    at = dt.datetime(2026, 7, 14, 12, 0)
    fwd.upsert_summary(ForwardSummary("bybit_4edge", "2026-07-10", 5, 1.0031, 0.0031, 1.0, -0.05),
                       "WAIT", "", at)
    c = _client(fwd, _seed_ibkr(with_fills=False))
    r = c.get("/strategy/bybit_4edge")
    assert r.status_code == 200
    assert "Real trades vs the plan" not in r.text


def test_no_raw_ids_no_bps_no_divergence_no_bare_sim_in_rendered_html() -> None:
    # Seed the fixture WITH an executed twin: d.recon.has_exec is true for multistrat (real IBKR fills
    # captured), so the exec-twin "Real trades vs the plan" card is now SUPPRESSED (superseded by the new
    # "Following the plan" card, sourced from d.recon) — the comprehensive plain-language assertion still
    # covers the whole page, not just this feature's own card.
    c = _client(_seed_forward(with_exec=True), _seed_ibkr())
    html = c.get("/strategy/multistrat").text
    for raw in _RAW_IDS:
        assert raw not in html, f"raw id leaked as text: {raw}"
    # The forbidden JARGON is asserted against the VISIBLE text (markup internals — CSS classes, the
    # /paper/sim nav href — are not user-facing copy and mustn't false-trip the check).
    text = _visible_text(html)
    assert "bps" not in text
    assert "divergence" not in text
    assert "decay" not in text
    assert "vs sim" not in text and "vs. sim" not in text
    assert re.search(r"\bsim\b", text) is None, "bare word 'sim' leaked into the visible copy"


def test_strategy_with_no_captured_exec_data_shows_no_trades_recorded_yet() -> None:
    # multistrat is an IBKR real-account book with no fills / no exec forward summary seeded here. Must render
    # 200 with the honest empty state, never a 500. (This used to use vrp, but vrp is ARCHIVED — its detail
    # 404s now — so multistrat is the visible IBKR-book stand-in for the empty-exec-data path.)
    fwd = ForwardNavRepo("sqlite://")
    fwd.migrate()
    ibkr = IbkrAccountRepo("sqlite://")
    ibkr.migrate()
    r = _client(fwd, ibkr).get("/strategy/multistrat")
    assert r.status_code == 200
    assert "no real trades recorded yet" in r.text   # redesigned copy (added "real" for the new card)


def test_ordinary_non_ibkr_strategy_renders_fine_no_500() -> None:
    c = _client(_seed_forward(), _seed_ibkr())
    r = c.get("/strategy/unlock")
    assert r.status_code == 200
    # no IBKR account section at all for a plain crypto track.
    assert "What it's holding right now" not in r.text


def test_holdings_warns_on_a_stray_non_book_symbol() -> None:
    # A held symbol outside the book's universe (US + UCITS) — e.g. the 2026-07 IBIT leftover from the
    # pre-UCITS US book — must be FLAGGED, not silently carried in the account NLV. SPY is a book instrument
    # (FUND_INSTRUMENTS) so it is NOT flagged; IBIT is.
    fwd = _seed_forward()
    ibkr = IbkrAccountRepo("sqlite://")
    ibkr.migrate()
    at = dt.datetime(2026, 7, 17, 15, 0)
    ibkr.replace_snapshot("2026-07-17", nlv=1_000_000.0, cash=900_000.0, gross=100_000.0,
                          positions={"SPY": 100.0, "IBIT": 699.0}, at=at)
    ibkr.record_fills(strategy_id="multistrat", rebalance_id="r1",
                      fills=[FillDTO(symbol="SPY", side="BOT", qty=100.0, price=450.0, fee=1.0,
                                     status="Filled", shortfall_bps=0.0)], at=at)
    html = _client(fwd, ibkr).get("/strategy/multistrat").text
    assert "outside the book" in html          # the warning banner + the <summary> badge
    banner = re.search(r"Position outside the book:[^<]*", html)
    assert banner is not None and "IBIT" in banner.group(0) and "SPY" not in banner.group(0)


def test_clean_account_holdings_show_no_stray_warning() -> None:
    # SPY + IEF are both book instruments -> no "outside the book" warning renders.
    html = _client(_seed_forward(), _seed_ibkr()).get("/strategy/multistrat").text
    assert "outside the book" not in html


def test_ibkr_reported_ucits_symbol_csbgu0_is_not_flagged_stray() -> None:
    # The IEF UCITS line's config ticker is CBU0, but IBKR REPORTS the holding under its canonical symbol
    # CSBGU0 (probe-verified 2026-07-20). A legitimate CSBGU0 book position must NOT be flagged "outside the
    # book" / dropped from NLV — the expected set keys on `ibkr_symbol`, not just the display ticker. A genuine
    # stray (IBIT) in the SAME account is still flagged.
    fwd = _seed_forward()
    ibkr = IbkrAccountRepo("sqlite://")
    ibkr.migrate()
    at = dt.datetime(2026, 7, 20, 15, 0)
    ibkr.replace_snapshot("2026-07-20", nlv=1_000_000.0, cash=900_000.0, gross=100_000.0,
                          positions={"CSPX": 13.0, "CSBGU0": 55.0, "IBIT": 699.0}, at=at)
    ibkr.record_fills(strategy_id="multistrat", rebalance_id="r1",
                      fills=[FillDTO(symbol="CSBGU0", side="BOT", qty=55.0, price=153.8, fee=1.0,
                                     status="Filled", shortfall_bps=0.0)], at=at)
    html = _client(fwd, ibkr).get("/strategy/multistrat").text
    banner = re.search(r"Position outside the book:[^<]*", html)
    assert banner is not None                          # IBIT is still a genuine stray
    assert "IBIT" in banner.group(0)
    assert "CSBGU0" not in banner.group(0)             # the IEF book line is NOT flagged stray
