"""D4 (Task 5) — the consolidated `/paper` "Paper books" landing: BOTH paper books as clickable summary
cards (the crypto 4-edge book, simulated `PAPER`, and the IBKR paper account, also `PAPER`), each linking to
its own holdings detail. The crypto book's previous `/paper` content (headline/live book/trades/edges) moves
verbatim to `/paper/crypto` — its data/behavior is UNCHANGED, only its URL. The IBKR book's detail page
(`/strategy/multistrat`, Task 3's `strategy.html`) additionally renders the per-strategy breakdown table:
one row per strategy trading on the shared real account — a dimmed "not started yet" row for a
registered-but-not-yet-executing strategy (a synthetic `newstrat` stands in for that here), and the table
SCALES BY ROW (proven by a synthetic second executing strategy, not a redesign).

NOTE: an ARCHIVED registry entry is EXCLUDED from the account breakdown (`_active_ibkr_account_sids`), so it
never renders a row even though its fills stay captured in the repo (VRP was the original real-world case —
ARCHIVED/FALSIFIED, shelved 2026-07-15 — but the code itself has since been fully removed; the exclusion
mechanism is proven here with a synthetic archived entry instead).

Plain-language HARD RULE (Task 3/4 precedent): no raw registry id / `bps` / "vs sim" / "divergence" may ever
appear in the rendered `/paper` landing — asserted against the VISIBLE text.

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.persistence.paper_repo import PaperRepo
from fxhnt.adapters.web.app import create_app
from fxhnt.application import dashboard_service
from fxhnt.application.dashboard_service import DashboardService
from fxhnt.application.exec_capture import FillDTO
from fxhnt.application.forward_models import ForwardSummary
from fxhnt.ports.live_price import FakeLivePrice
from fxhnt.registry import STRATEGY_REGISTRY


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 / href internals don't false-trip a plain-language copy assertion (mirrors
    Task 3/4's helper)."""
    html = re.sub(r"<(style|script)\b[^>]*>.*?</\1>", " ", html, flags=re.DOTALL | re.IGNORECASE)
    return re.sub(r"<[^>]+>", " ", html).lower()


def _seed_forward(*, vrp_executing: bool = False, extra_exec_sids: tuple[str, ...] = ()) -> ForwardNavRepo:
    repo = ForwardNavRepo("sqlite://")
    repo.migrate()
    at = dt.datetime(2026, 7, 14, 12, 0)
    repo.upsert_summary(ForwardSummary("multistrat", "2026-07-14", 20, 1.038, 0.038, 0.9, -0.02),
                        "WAIT", "20/20 forward days", at)
    repo.upsert_summary(ForwardSummary("multistrat_exec", "2026-07-14", 20, 1.031, 0.031, 0.8, -0.02),
                        "WAIT", "20/20 forward days", at)
    repo.upsert_summary(ForwardSummary("vrp", "2026-07-14", 5, 1.01, 0.01, 0.5, -0.01),
                        "WAIT", "5/20 forward days", at)
    if vrp_executing:
        repo.upsert_summary(ForwardSummary("vrp_exec", "2026-07-14", 5, 1.009, 0.009, 0.5, -0.01),
                            "WAIT", "5/20 forward days", at)
    for sid in extra_exec_sids:
        repo.upsert_summary(ForwardSummary(sid, "2026-07-14", 10, 1.02, 0.02, 0.7, -0.01),
                            "WAIT", "10/20 forward days", at)
        repo.upsert_summary(ForwardSummary(f"{sid}_exec", "2026-07-14", 10, 1.015, 0.015, 0.6, -0.01),
                            "WAIT", "10/20 forward days", at)
    return repo


def _seed_ibkr(*, extra_fills_for: tuple[str, ...] = ()) -> IbkrAccountRepo:
    repo = IbkrAccountRepo("sqlite://")
    repo.migrate()
    at1 = dt.datetime(2026, 7, 10, 15, 0)
    at2 = dt.datetime(2026, 7, 14, 15, 0)
    repo.replace_snapshot("2026-07-10", 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-07-14", nlv=1_031_000.0, cash=500.0, gross=1_030_500.0,
                          positions={"SPY": 100.0, "IEF": 200.0}, at=at2)
    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)
    for sid in extra_fills_for:
        repo.record_fills(strategy_id=sid, rebalance_id="r2",
                          fills=[FillDTO(symbol="GLD", side="BOT", qty=10.0, price=180.0, fee=0.2,
                                         status="Filled", shortfall_bps=0.0)],
                          at=at2)
    return repo


# --- DashboardService: ibkr_account_view() + paper_books() -------------------------------------------


def test_ibkr_account_view_one_executing_row_and_dimmed_not_started_row(monkeypatch) -> None:
    # A synthetic `archivedstrat` (registry entry with archived=True) is EXCLUDED from the account
    # breakdown. A synthetic non-archived `newstrat` (registered against the account but with NO captured
    # fills) stands in as the dimmed "not started yet" row, so the one-executing / one-dimmed shape is still
    # covered — and the archived book is proven ABSENT.
    monkeypatch.setitem(STRATEGY_REGISTRY, "archivedstrat",
                        {"archived": True, "display_name": "Options income (put spreads)",
                         "sleeve": "equity-vol", "gate_spec": {}})
    monkeypatch.setattr(dashboard_service, "_IBKR_ACCOUNT_SIDS",
                        frozenset({"multistrat", "archivedstrat", "newstrat"}))
    svc = DashboardService(_seed_forward(), _seed_ibkr())   # only multistrat has fills; newstrat does not
    view = svc.ibkr_account_view()
    assert view is not None
    ids = {r.strategy_id for r in view.per_strategy}
    assert ids == {"multistrat", "newstrat"}   # archived entry is excluded
    multistrat = next(r for r in view.per_strategy if r.strategy_id == "multistrat")
    newstrat = next(r for r in view.per_strategy if r.strategy_id == "newstrat")
    assert multistrat.executing is True
    assert multistrat.recon is not None and multistrat.recon.has_exec is True
    assert multistrat.fill_count == 2
    assert multistrat.position_count == 2
    assert multistrat.display_name == "ETF Portfolio"
    assert newstrat.executing is False
    assert newstrat.recon is None
    # the archived book never surfaces its label
    assert all(r.display_name != "Options income (put spreads)" for r in view.per_strategy)
    assert view.total_nav_series[-1] == ("2026-07-14", 1_031_000.0)
    assert view.all_positions == {"SPY": 100.0, "IEF": 200.0}


def test_ibkr_account_view_scale_test_adds_new_executing_row_no_redesign(monkeypatch) -> None:
    # Seed a SYNTHETIC third strategy (not in STRATEGY_REGISTRY) trading on the same account, and register
    # it against the account via the module constant — proves the breakdown scales by ROW, not a redesign.
    monkeypatch.setitem(STRATEGY_REGISTRY, "archivedstrat",
                        {"archived": True, "display_name": "Options income (put spreads)",
                         "sleeve": "equity-vol", "gate_spec": {}})
    monkeypatch.setattr(dashboard_service, "_IBKR_ACCOUNT_SIDS",
                        frozenset({"multistrat", "archivedstrat", "newstrat"}))
    fwd = _seed_forward(extra_exec_sids=("newstrat",))
    ibkr = _seed_ibkr(extra_fills_for=("newstrat",))
    svc = DashboardService(fwd, ibkr)
    view = svc.ibkr_account_view()
    assert view is not None
    # archived entry is excluded; the visible set is the two live account books (multistrat + synthetic newstrat)
    assert len(view.per_strategy) == 2
    assert all(r.strategy_id != "archivedstrat" for r in view.per_strategy)
    executing_ids = {r.strategy_id for r in view.per_strategy if r.executing}
    assert executing_ids == {"multistrat", "newstrat"}
    newstrat = next(r for r in view.per_strategy if r.strategy_id == "newstrat")
    assert newstrat.recon is not None and newstrat.recon.has_exec is True
    assert newstrat.fill_count == 1 and newstrat.position_count == 1


def test_ibkr_account_view_empty_account_returns_none() -> None:
    fwd = ForwardNavRepo("sqlite://")
    fwd.migrate()
    ibkr = IbkrAccountRepo("sqlite://")
    ibkr.migrate()
    svc = DashboardService(fwd, ibkr)
    assert svc.ibkr_account_view() is None


def test_ibkr_account_view_no_repo_wired_returns_none() -> None:
    svc = DashboardService(_seed_forward())
    assert svc.ibkr_account_view() is None


def test_paper_books_wires_ibkr_rollup_and_account_view() -> None:
    # CARRY-FORWARD: Task 4 left IbkrAccountRollup computed but consumed by no template — paper_books()
    # is the wiring: it must reuse (not recompute) the same rollup.
    svc = DashboardService(_seed_forward(), _seed_ibkr())
    books = svc.paper_books()
    assert books["ibkr_rollup"] is not None
    assert books["ibkr_rollup"].total_nav == 1_031_000.0
    assert books["ibkr_rollup"].n_executing == 1
    assert books["ibkr_view"] is not None
    # only multistrat is a VISIBLE account book (vrp archived/excluded) -> exactly one breakdown row
    assert len(books["ibkr_view"].per_strategy) == 1
    assert books["ibkr_view"].per_strategy[0].strategy_id == "multistrat"


def test_paper_books_empty_account_gives_none_rollup_and_view() -> None:
    fwd = ForwardNavRepo("sqlite://")
    fwd.migrate()
    ibkr = IbkrAccountRepo("sqlite://")
    ibkr.migrate()
    svc = DashboardService(fwd, ibkr)
    books = svc.paper_books()
    assert books["ibkr_rollup"] is None
    assert books["ibkr_view"] is None


# --- Web: the consolidated /paper landing --------------------------------------------------------------


def _client(fwd: ForwardNavRepo, ibkr: IbkrAccountRepo | None = None) -> TestClient:
    binance = PaperRepo("sqlite://")
    binance.migrate()
    bybit = PaperRepo("sqlite://")
    bybit.migrate()
    return TestClient(create_app(fwd, paper_repo=binance, bybit_paper_repo=bybit,
                                 bybit_live=FakeLivePrice({}), ibkr_account_repo=ibkr))


def test_paper_landing_shows_both_book_cards_linked_to_detail() -> None:
    c = _client(_seed_forward(), _seed_ibkr())
    r = c.get("/paper")
    assert r.status_code == 200
    text = _visible_text(r.text)
    assert "paper books" in text
    # Crypto card: PAPER badge, links to its own detail (the moved former /paper content).
    assert "crypto" in text
    assert 'href="/paper/crypto"' in r.text
    assert "badge-exec paper" in r.text
    # IBKR card: PAPER badge (same as the crypto card — no separate REAL PAPER), account value / N strategies.
    assert r.text.count("badge-exec paper") >= 2   # both cards carry the PAPER exec badge
    assert "account value" in text
    assert "1 " in text and "strateg" in text   # "1 strategy trading"
    assert 'href="/strategy/multistrat"' in r.text


def test_paper_landing_shows_ibkr_holdings_count_and_behind_plan() -> None:
    c = _client(_seed_forward(), _seed_ibkr())
    text = _visible_text(c.get("/paper").text)
    assert "2 holdings" in text
    assert "0.7% behind plan" in text   # exec 3.1% vs sim 3.8% -> 0.7% behind, same math as Task 4


def test_paper_landing_empty_ibkr_account_shows_clean_empty_state_no_500() -> None:
    empty_ibkr = IbkrAccountRepo("sqlite://")
    empty_ibkr.migrate()
    c = _client(_seed_forward(), empty_ibkr)
    r = c.get("/paper")
    assert r.status_code == 200
    text = _visible_text(r.text)
    assert "not connected yet" in text or "no account data yet" in text


def test_paper_landing_no_ibkr_repo_wired_no_500() -> None:
    c = _client(_seed_forward(), None)
    r = c.get("/paper")
    assert r.status_code == 200


def test_paper_landing_does_not_show_crypto_book_detail_directly() -> None:
    # The crypto book's OWN unique detail (its live per-symbol book / recent trades / individual edges) must
    # have moved OFF the landing page onto its own card's target (/paper/crypto).
    c = _client(_seed_forward(), _seed_ibkr())
    r = c.get("/paper")
    assert "individual edges" not in r.text
    assert "recent trades (" not in r.text.lower()


def test_paper_crypto_route_keeps_the_moved_content_unchanged() -> None:
    c = _client(_seed_forward(), _seed_ibkr())
    r = c.get("/paper/crypto")
    assert r.status_code == 200
    assert "Bybit 4-edge book" in r.text
    assert "individual edges" in r.text


def test_paper_landing_no_raw_ids_no_bps_no_divergence_no_bare_sim() -> None:
    c = _client(_seed_forward(), _seed_ibkr())
    html = c.get("/paper").text
    for raw in (">multistrat<", ">vrp<", ">multistrat_exec<"):
        assert raw not in html, f"raw id leaked as text: {raw}"
    text = _visible_text(html)
    assert "bps" not in text
    assert "divergence" 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"


# --- Web: the IBKR account detail page's per-strategy breakdown -----------------------------------------


def test_ibkr_detail_page_shows_per_strategy_breakdown_with_dimmed_not_started_row(monkeypatch) -> None:
    # A synthetic non-archived `newstrat` (no captured fills) is the dimmed "not started yet" row — vrp, the
    # former placeholder, is ARCHIVED and must never render its "options income (put spreads)" label here.
    monkeypatch.setattr(dashboard_service, "_IBKR_ACCOUNT_SIDS",
                        frozenset({"multistrat", "vrp", "newstrat"}))
    # newstrat has NO `_exec` forward summary and no fills -> `exec_vs_sim` reports has_exec=False -> dimmed.
    c = _client(_seed_forward(), _seed_ibkr())
    r = c.get("/strategy/multistrat")
    assert r.status_code == 200
    text = _visible_text(r.text)
    assert "every strategy on this account" in text   # redesigned <summary> label (was "per-strategy breakdown")
    assert "etf portfolio" in text
    assert "options income (put spreads)" not in text   # archived vrp label never appears
    assert "not started yet" in text                     # newstrat is the dimmed placeholder


def test_ibkr_detail_page_scale_test_shows_two_executing_rows(monkeypatch) -> None:
    monkeypatch.setattr(dashboard_service, "_IBKR_ACCOUNT_SIDS",
                        frozenset({"multistrat", "vrp", "newstrat"}))
    fwd = _seed_forward(extra_exec_sids=("newstrat",))
    ibkr = _seed_ibkr(extra_fills_for=("newstrat",))
    c = _client(fwd, ibkr)
    r = c.get("/strategy/multistrat")
    assert r.status_code == 200
    text = _visible_text(r.text)
    assert "etf portfolio" in text
    assert "newstrat" in text          # the synthetic strategy's row rendered (falls back to raw id label)
    # archived vrp is excluded entirely — its row/label never renders even though it's in _IBKR_ACCOUNT_SIDS
    assert "options income (put spreads)" not in text


def test_ibkr_detail_page_empty_account_no_500_no_breakdown_crash() -> None:
    fwd = _seed_forward()
    empty_ibkr = IbkrAccountRepo("sqlite://")
    empty_ibkr.migrate()
    c = _client(fwd, empty_ibkr)
    r = c.get("/strategy/multistrat")
    assert r.status_code == 200


def test_ordinary_non_ibkr_strategy_has_no_per_strategy_breakdown() -> None:
    c = _client(_seed_forward(), _seed_ibkr())
    r = c.get("/strategy/unlock")
    assert r.status_code == 200
    assert "per-strategy" not in _visible_text(r.text)
