"""Backtest report explorer — browse, drill, and compare report JSONs.

Stdlib only (tkinter). Launch from repo root or this folder:

    python reports/compare_reports.py
"""

from __future__ import annotations

import glob
import json
import os
import tkinter as tk
from tkinter import font as tkfont
from tkinter import messagebox, ttk
from typing import Any, Callable

HERE = os.path.dirname(os.path.abspath(__file__))

# ---------------------------------------------------------------------------
# Theme
# ---------------------------------------------------------------------------

THEME = {
    "bg": "#0f1419",
    "panel": "#1a2332",
    "panel2": "#243044",
    "border": "#2d3a4f",
    "text": "#e7ecf3",
    "muted": "#8b9bb4",
    "accent": "#3b82f6",
    "accent2": "#60a5fa",
    "good": "#22c55e",
    "good_bg": "#14532d",
    "bad": "#ef4444",
    "warn": "#f59e0b",
    "prod": "#fbbf24",
    "row_alt": "#15202b",
    "select": "#1e3a5f",
    "chart_bg": "#0b1220",
    "grid": "#1e293b",
}

CHART_COLORS = [
    "#3b82f6",
    "#22c55e",
    "#f59e0b",
    "#a78bfa",
    "#f472b6",
    "#2dd4bf",
    "#fb7185",
    "#94a3b8",
    "#eab308",
    "#38bdf8",
]

# ---------------------------------------------------------------------------
# Column / section schema
# ---------------------------------------------------------------------------

# label, key, format, higher_is_better (None = not rankable)
PORTFOLIO_COLS = [
    ("CAGR %", "cagr_pct", "{:+.1f}", True),
    # Stored as positive magnitude in reports; display as a drawdown.
    ("Max DD %", "max_drawdown_pct", "-{:.1f}", False),
    ("Sharpe", "sharpe", "{:.2f}", True),
    ("Total ret %", "total_return_pct", "{:+.1f}", True),
    ("SPY %", "spy_return_pct", "{:+.1f}", None),
    ("Trades", "trades", "{:.0f}", None),
    ("Win %", "win_rate", "{:.1f}", True),
    ("Avg PnL", "avg_trade_pnl", "{:+.1f}", True),
    ("Hold d", "avg_hold_days", "{:.1f}", None),
    ("Best R", "best_trade_r", "{:+.2f}", None),
    ("Worst R", "worst_trade_r", "{:+.2f}", None),
]

TRADE_STATS_COLS = [
    ("Total", "total", "{:.0f}", None),
    ("Wins", "wins", "{:.0f}", None),
    ("Losses", "losses", "{:.0f}", None),
    ("Expired", "expired", "{:.0f}", None),
    ("Hit %", "hit_rate", "{:.1f}", True),
    ("Win %", "win_rate", "{:.1f}", True),
    ("Avg R", "avg_r", "{:+.3f}", True),
    ("Net avg R", "net_avg_r", "{:+.3f}", True),
    ("Net total R", "net_total_r", "{:+.1f}", True),
    ("PF", "profit_factor", "{:.2f}", True),
    ("Net R/day", "net_r_per_day", "{:+.4f}", True),
    ("Median net R", "median_net_r", "{:+.3f}", None),
    ("Hold d", "avg_hold_days", "{:.1f}", None),
    ("Net ex-top5", "net_avg_r_ex_top5", "{:+.3f}", True),
    ("Best R", "best_r", "{:+.2f}", None),
    ("Worst R", "worst_r", "{:+.2f}", None),
]

SIGNAL_COLS = [
    ("Signal", "signal", "{}", None),
    ("Weeks", "weeks", "{:.0f}", None),
    ("Cross-sec", "avg_cross_section", "{:.1f}", None),
    ("Mean IC", "mean_ic", "{:+.4f}", True),
    ("IC t-stat", "ic_t_stat", "{:+.2f}", True),
    ("IC>0 %", "ic_positive_pct", "{:.1f}", True),
    ("Q-spread", "mean_quintile_spread", "{:+.4f}", True),
    ("Reliable", "reliable", "{}", None),
]

# Section extractors: each returns (columns, rows, note)
# columns use same 4-tuple schema; rows are plain dicts with optional _meta keys.


def _flatten_metrics(item: dict, name_key: str | None = None) -> dict:
    row = dict(item)
    if name_key and name_key in item:
        row["_name"] = item[name_key]
    # Flatten exit_reasons into a short string
    er = item.get("exit_reasons")
    if isinstance(er, dict) and er:
        row["exit_reasons_str"] = ", ".join(f"{k}:{v}" for k, v in sorted(er.items()))
    return row


def _as_list(block: Any, *keys: str) -> list:
    if block is None:
        return []
    if isinstance(block, list):
        return block
    if isinstance(block, dict):
        for k in keys:
            v = block.get(k)
            if isinstance(v, list):
                return v
    return []


def extract_portfolio_monitor(data: dict) -> tuple[list, list[dict], str]:
    block = data.get("portfolio_monitor") or {}
    rows = []
    for item in _as_list(block, "runs"):
        if not isinstance(item, dict):
            continue
        row = _flatten_metrics(item)
        row["_name"] = item.get("label") or item.get("strategy") or "?"
        row["_id"] = item.get("strategy") or row["_name"]
        row["_lookback"] = item.get("lookback") or "-"
        row["_production"] = bool(item.get("is_production"))
        rows.append(row)
    cols = [
        ("Run", "_name", "{}", None),
        ("Lookback", "_lookback", "{}", None),
        ("Exit", "exit_policy", "{}", None),
        *PORTFOLIO_COLS,
        ("Exits", "exit_reasons_str", "{}", None),
    ]
    return cols, rows, block.get("note") or ""


def extract_strategy_variants(data: dict) -> tuple[list, list[dict], str]:
    block = data.get("strategy_variants") or {}
    rows = []
    for item in _as_list(block, "variants"):
        if not isinstance(item, dict):
            continue
        row = _flatten_metrics(item)
        row["_name"] = item.get("label") or item.get("variant") or "?"
        rows.append(row)
    cols = [
        ("Variant", "_name", "{}", None),
        ("Ranking", "ranking", "{}", None),
        ("Cutoff", "cutoff", "{:.0f}", None),
        ("Max pos", "max_positions", "{:.0f}", None),
        *PORTFOLIO_COLS,
    ]
    return cols, rows, block.get("note") or ""


def extract_exit_policies(data: dict) -> tuple[list, list[dict], str]:
    block = data.get("exit_policy_variants") or {}
    rows = []
    for item in _as_list(block, "variants"):
        if not isinstance(item, dict):
            continue
        row = _flatten_metrics(item)
        row["_name"] = item.get("label") or item.get("exit_policy") or "?"
        rows.append(row)
    cols = [
        ("Exit policy", "_name", "{}", None),
        *PORTFOLIO_COLS,
        ("Exits", "exit_reasons_str", "{}", None),
    ]
    return cols, rows, block.get("note") or ""


def extract_portfolio_sim(data: dict) -> tuple[list, list[dict], str]:
    block = data.get("portfolio_sim") or {}
    rows = []
    for item in _as_list(block, "policies"):
        if not isinstance(item, dict):
            continue
        row = _flatten_metrics(item)
        row["_name"] = item.get("label") or item.get("policy") or "?"
        rows.append(row)
    cols = [
        ("Policy", "_name", "{}", None),
        *PORTFOLIO_COLS,
        ("Exits", "exit_reasons_str", "{}", None),
    ]
    note = block.get("note") or ""
    params = block.get("params")
    if isinstance(params, dict) and params:
        note = (note + "  |  params: " + json.dumps(params)).strip(" |")
    return cols, rows, note


def extract_sweep(data: dict) -> tuple[list, list[dict], str]:
    rows = []
    for item in data.get("sweep") or []:
        if not isinstance(item, dict):
            continue
        row = dict(item)
        row["_name"] = f"p{item.get('min_momentum_percentile', '?')}"
        rows.append(row)
    cols = [
        ("Cutoff", "min_momentum_percentile", "{:.0f}", None),
        *TRADE_STATS_COLS,
    ]
    return cols, rows, "Residual-momentum percentile sweep (trade-level expectancy)."


def extract_gate_ablation(data: dict) -> tuple[list, list[dict], str]:
    rows = []
    for item in data.get("gate_ablation") or []:
        if not isinstance(item, dict):
            continue
        row = dict(item)
        row["_name"] = item.get("variant") or "?"
        rows.append(row)
    cols = [
        ("Variant", "_name", "{}", None),
        *TRADE_STATS_COLS,
        ("Hold net R", "hold_net_avg_r", "{:+.3f}", True),
        ("Hold total R", "hold_total_r", "{:+.1f}", True),
    ]
    return cols, rows, data.get("gate_ablation_note") or ""


def extract_time_exit(data: dict) -> tuple[list, list[dict], str]:
    rows = []
    for item in data.get("time_exit_sweep") or []:
        if not isinstance(item, dict):
            continue
        row = dict(item)
        row["_name"] = f"{item.get('hold_days', '?')}d"
        rows.append(row)
    cols = [
        ("Hold days", "hold_days", "{:.0f}", None),
        *TRADE_STATS_COLS,
    ]
    return cols, rows, "Fixed-hold exit sweep on qualified setups."


def extract_signal_eval(data: dict) -> tuple[list, list[dict], str]:
    rows = []
    for item in data.get("signal_eval") or []:
        if not isinstance(item, dict):
            continue
        row = dict(item)
        row["_name"] = item.get("signal") or "?"
        rows.append(row)
    return SIGNAL_COLS, rows, data.get("signal_eval_note") or ""


def extract_overall(data: dict) -> tuple[list, list[dict], str]:
    rows = []
    for key, label in (
        ("overall_qualified", "Qualified"),
        ("overall_all", "All setups"),
    ):
        block = data.get(key)
        if isinstance(block, dict):
            row = dict(block)
            row["_name"] = label
            rows.append(row)
    by_dir = data.get("by_direction") or {}
    if isinstance(by_dir, dict):
        for direction, block in by_dir.items():
            if isinstance(block, dict):
                row = dict(block)
                row["_name"] = f"Direction: {direction}"
                rows.append(row)
    cols = [("Bucket", "_name", "{}", None), *TRADE_STATS_COLS]
    return cols, rows, "Trade-level expectancy: qualified gate vs all candidates vs by direction."


def extract_holdout(data: dict) -> tuple[list, list[dict], str]:
    block = data.get("holdout")
    if not isinstance(block, dict):
        return [], [], ""
    rows = []
    for item in block.get("rows") or []:
        if not isinstance(item, dict):
            continue
        row = _flatten_metrics(item)
        row["_name"] = item.get("window") or "?"
        rows.append(row)
    cols = [
        ("Window", "_name", "{}", None),
        ("Exit", "exit_policy", "{}", None),
        ("Start", "start_date", "{}", None),
        ("End", "end_date", "{}", None),
        *PORTFOLIO_COLS,
    ]
    note = block.get("note") or ""
    split = block.get("split_date")
    strategy = block.get("strategy")
    if split or strategy:
        note = f"split={split}  strategy={strategy}  |  {note}".strip(" |")
    return cols, rows, note


def extract_min_rr_sweep(data: dict) -> tuple[list, list[dict], str]:
    block = data.get("min_rr_sweep")
    if not isinstance(block, dict):
        return [], [], ""
    rows = []
    for item in block.get("rows") or []:
        if not isinstance(item, dict):
            continue
        row = _flatten_metrics(item)
        row["_name"] = f"RR≥{item.get('min_rr', '?')}"
        row["_production"] = bool(item.get("is_live"))
        rows.append(row)
    cols = [
        ("Min R:R", "min_rr", "{:.1f}", None),
        ("Live", "is_live", "{}", None),
        ("Qualified", "qualified_setups", "{:.0f}", None),
        *PORTFOLIO_COLS,
    ]
    note = block.get("note") or ""
    meta = []
    if block.get("live_min_rr") is not None:
        meta.append(f"live_min_rr={block['live_min_rr']}")
    if block.get("window"):
        meta.append(f"window={block['window']}")
    if block.get("entries_from"):
        meta.append(f"entries_from={block['entries_from']}")
    if meta:
        note = "  ".join(meta) + ("  |  " + note if note else "")
    return cols, rows, note


def extract_atr_trail_sweep(data: dict) -> tuple[list, list[dict], str]:
    rows = []
    for item in data.get("atr_trail_sweep") or []:
        if not isinstance(item, dict):
            continue
        row = _flatten_metrics(item)
        row["_name"] = f"ATR×{item.get('atr_mult', '?')}"
        rows.append(row)
    cols = [("ATR mult", "atr_mult", "{:.1f}", None), *PORTFOLIO_COLS]
    return cols, rows, "ATR trailing-stop multiplier sweep."


def extract_regime_overlay(data: dict) -> tuple[list, list[dict], str]:
    rows = []
    for item in data.get("regime_overlay") or []:
        if not isinstance(item, dict):
            continue
        row = _flatten_metrics(item)
        row["_name"] = str(item.get("regime_filter") or "?")
        rows.append(row)
    cols = [("Regime filter", "_name", "{}", None), *PORTFOLIO_COLS]
    return cols, rows, "Regime overlay comparison."


def extract_cutoff_book(data: dict) -> tuple[list, list[dict], str]:
    rows = []
    for item in data.get("cutoff_book_sweep") or []:
        if not isinstance(item, dict):
            continue
        row = _flatten_metrics(item)
        row["_name"] = f"cut{item.get('cutoff', '?')}/max{item.get('max_positions', '?')}"
        rows.append(row)
    cols = [
        ("Cutoff", "cutoff", "{:.0f}", None),
        ("Max pos", "max_positions", "{:.0f}", None),
        *PORTFOLIO_COLS,
    ]
    return cols, rows, "Momentum cutoff × book-size grid."


def extract_sizing(data: dict) -> tuple[list, list[dict], str]:
    rows = []
    for item in data.get("sizing_test") or []:
        if not isinstance(item, dict):
            continue
        row = _flatten_metrics(item)
        row["_name"] = str(item.get("sizing_mode") or "?")
        rows.append(row)
    cols = [
        ("Sizing", "_name", "{}", None),
        ("Risk/trade", "risk_per_trade", "{:.2f}", None),
        ("Avg exposure %", "avg_exposure_pct", "{:.1f}", None),
        *PORTFOLIO_COLS,
    ]
    return cols, rows, "Position sizing modes."


def extract_blue_sky(data: dict) -> tuple[list, list[dict], str]:
    block = data.get("blue_sky_projected")
    if not isinstance(block, dict):
        return [], [], ""
    rows = []
    for key, label in (
        ("qualified_stats", "Qualified stats"),
        ("generated_stats", "Generated stats"),
        ("portfolio_atr_trail3", "Portfolio ATR×3"),
    ):
        item = block.get(key)
        if isinstance(item, dict):
            row = _flatten_metrics(item)
            row["_name"] = label
            rows.append(row)
    # summary counts as a pseudo-row
    if block.get("generated") is not None or block.get("qualified") is not None:
        rows.insert(
            0,
            {
                "_name": "Counts",
                "generated": block.get("generated"),
                "qualified": block.get("qualified"),
            },
        )
    cols = [
        ("Bucket", "_name", "{}", None),
        ("Generated", "generated", "{:.0f}", None),
        ("Qualified", "qualified", "{:.0f}", None),
        *PORTFOLIO_COLS,
        *TRADE_STATS_COLS,
    ]
    # de-dupe column keys keeping first
    seen: set[str] = set()
    uniq = []
    for c in cols:
        if c[1] not in seen:
            seen.add(c[1])
            uniq.append(c)
    return uniq, rows, "Blue-sky target projection experiment."


SECTIONS: dict[str, Callable[[dict], tuple[list, list[dict], str]]] = {
    "Overview (expectancy)": extract_overall,
    "Portfolio monitor": extract_portfolio_monitor,
    "Strategy variants": extract_strategy_variants,
    "Exit policies": extract_exit_policies,
    "Portfolio sim": extract_portfolio_sim,
    "Momentum sweep": extract_sweep,
    "Gate ablation": extract_gate_ablation,
    "Time-exit sweep": extract_time_exit,
    "Signal edge (IC)": extract_signal_eval,
    "Holdout (train/test)": extract_holdout,
    "Min R:R sweep": extract_min_rr_sweep,
    "ATR trail sweep": extract_atr_trail_sweep,
    "Regime overlay": extract_regime_overlay,
    "Cutoff × book size": extract_cutoff_book,
    "Position sizing": extract_sizing,
    "Blue-sky targets": extract_blue_sky,
}

DEFAULT_SECTION = "Portfolio monitor"


# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------


def load_reports(folder: str | None = None) -> list[dict]:
    folder = folder or HERE
    reports: list[dict] = []
    for path in glob.glob(os.path.join(folder, "backtest-*.json")):
        try:
            with open(path, encoding="utf-8") as fh:
                data = json.load(fh)
        except (OSError, json.JSONDecodeError) as exc:
            print(f"skipping {os.path.basename(path)}: {exc}")
            continue
        base = os.path.basename(path)
        slug = base[len("backtest-") : -len(".json")] if base.startswith("backtest-") else base
        prod = _production_headline(data)
        generated_at = data.get("generated_at") or ""
        reports.append(
            {
                "path": path,
                "file": base,
                "slug": slug,
                "data": data,
                # Full ISO timestamp for correct newest-first ordering (date+time).
                "generated_at": generated_at,
                "generated": generated_at[:16].replace("T", " ") if generated_at else "",
                "mtime": os.path.getmtime(path),
                "qualified": data.get("qualified"),
                "candidates": data.get("candidates"),
                "tickers": data.get("tickers"),
                "headline": (data.get("recommendation") or {}).get("headline") or "",
                "prod": prod,
            }
        )
    # Newest first: prefer report generated_at, fall back to file mtime.
    reports.sort(
        key=lambda r: (r.get("generated_at") or "", r.get("mtime") or 0),
        reverse=True,
    )
    return reports


def _production_headline(data: dict) -> dict:
    """Pull production 'all' lookback metrics if present."""
    block = data.get("portfolio_monitor") or {}
    for item in _as_list(block, "runs"):
        if (
            isinstance(item, dict)
            and item.get("is_production")
            and item.get("lookback") in ("all", None)
        ):
            return {
                "cagr": item.get("cagr_pct"),
                "sharpe": item.get("sharpe"),
                "dd": item.get("max_drawdown_pct"),
                "ret": item.get("total_return_pct"),
                "spy": item.get("spy_return_pct"),
                "trades": item.get("trades"),
                "strategy": item.get("strategy") or item.get("label"),
            }
    # fallback: first production any lookback
    for item in _as_list(block, "runs"):
        if isinstance(item, dict) and item.get("is_production"):
            return {
                "cagr": item.get("cagr_pct"),
                "sharpe": item.get("sharpe"),
                "dd": item.get("max_drawdown_pct"),
                "ret": item.get("total_return_pct"),
                "spy": item.get("spy_return_pct"),
                "trades": item.get("trades"),
                "strategy": item.get("strategy") or item.get("label"),
            }
    return {}


def fmt_val(val: Any, fmt: str) -> str:
    if val is None or val == "":
        return "—"
    if isinstance(val, bool):
        return "yes" if val else "no"
    if isinstance(val, (int, float)):
        try:
            return fmt.format(val)
        except (ValueError, TypeError):
            return str(val)
    return str(val)


def numeric_sort_key(val: Any, reverse: bool) -> tuple:
    if isinstance(val, bool):
        return (1, float(val))
    if isinstance(val, (int, float)):
        return (1, float(val))
    s = str(val or "")
    return (0, s.lower()) if not reverse else (0, s.lower())


# ---------------------------------------------------------------------------
# UI helpers
# ---------------------------------------------------------------------------


class SortableTree(ttk.Treeview):
    """Treeview with click-to-sort and optional best-row highlight."""

    def __init__(self, master, **kwargs):
        super().__init__(master, **kwargs)
        self._rows: list[dict] = []
        self._cols_meta: list[tuple] = []
        self._sort_col: str | None = None
        self._sort_desc = True
        self._best_key: str | None = None
        self._best_higher: bool | None = None
        self.bind("<Double-1>", self._on_double)

    def set_columns(self, columns: list[tuple]) -> None:
        self._cols_meta = columns
        labels = [c[0] for c in columns]
        self["columns"] = labels
        self["show"] = "headings"
        for label, key, _fmt, hib in columns:
            self.heading(label, text=label, command=lambda c=label: self.sort_by(c))
            # width heuristics
            if key in ("_name", "label", "description", "variant", "signal"):
                w, anchor = 220, "w"
            elif key in ("_report", "exit_reasons_str"):
                w, anchor = 160, "w"
            elif key in ("_lookback", "lookback"):
                w, anchor = 70, "center"
            else:
                w, anchor = 88, "e"
            self.column(label, width=w, minwidth=50, anchor=anchor, stretch=True)

    def populate(
        self,
        rows: list[dict],
        best_key: str | None = None,
        best_higher: bool | None = None,
        default_sort: str | None = None,
    ) -> None:
        self._rows = list(rows)
        self._best_key = best_key
        self._best_higher = best_higher
        if default_sort and self._sort_col is None:
            self._sort_col = default_sort
            self._sort_desc = True
        self._render()

    def sort_by(self, label: str) -> None:
        if self._sort_col == label:
            self._sort_desc = not self._sort_desc
        else:
            self._sort_col = label
            self._sort_desc = True
        self._render()

    def _col_by_label(self, label: str) -> tuple | None:
        for c in self._cols_meta:
            if c[0] == label:
                return c
        return None

    def _render(self) -> None:
        self.delete(*self.get_children())
        rows = list(self._rows)

        sort_label = self._sort_col
        reverse = self._sort_desc
        if sort_label:
            meta = self._col_by_label(sort_label)
            if meta:
                key = meta[1]
                rows.sort(
                    key=lambda r: numeric_sort_key(r.get(key), reverse),
                    reverse=reverse,
                )

        best_id = None
        if self._best_key and self._best_higher is not None:
            ranked = [
                r
                for r in rows
                if isinstance(r.get(self._best_key), (int, float))
            ]
            if ranked:
                best = (max if self._best_higher else min)(
                    ranked, key=lambda r: r[self._best_key]
                )
                best_id = id(best)

        for i, row in enumerate(rows):
            values = []
            for label, key, fmt, _hib in self._cols_meta:
                values.append(fmt_val(row.get(key), fmt))
            tags = []
            if i % 2:
                tags.append("alt")
            if row.get("_production"):
                tags.append("prod")
            if best_id is not None and id(row) == best_id:
                tags.append("best")
            self.insert("", "end", values=values, tags=tags, iid=str(i))

        self._display_rows = rows

    def selected_row(self) -> dict | None:
        sel = self.selection()
        if not sel:
            return None
        try:
            idx = int(sel[0])
            return self._display_rows[idx]
        except (ValueError, IndexError, AttributeError):
            return None

    def _on_double(self, _event=None) -> None:
        # bubbles via virtual event
        self.event_generate("<<RowActivate>>")


def apply_theme(root: tk.Tk) -> None:
    style = ttk.Style(root)
    try:
        style.theme_use("clam")
    except tk.TclError:
        pass

    bg = THEME["bg"]
    panel = THEME["panel"]
    text = THEME["text"]
    muted = THEME["muted"]
    accent = THEME["accent"]
    border = THEME["border"]
    select = THEME["select"]

    root.configure(bg=bg)
    style.configure(".", background=bg, foreground=text, fieldbackground=panel, bordercolor=border)
    style.configure("TFrame", background=bg)
    style.configure("Panel.TFrame", background=panel)
    style.configure("TLabel", background=bg, foreground=text)
    style.configure("Muted.TLabel", background=bg, foreground=muted)
    style.configure("Panel.TLabel", background=panel, foreground=text)
    style.configure("Title.TLabel", background=bg, foreground=text, font=("Segoe UI", 14, "bold"))
    style.configure("Header.TLabel", background=panel, foreground=text, font=("Segoe UI", 11, "bold"))
    style.configure("CardValue.TLabel", background=panel, foreground=THEME["accent2"], font=("Segoe UI", 16, "bold"))
    style.configure("CardLabel.TLabel", background=panel, foreground=muted, font=("Segoe UI", 8))
    style.configure("Good.TLabel", background=bg, foreground=THEME["good"])
    style.configure("Bad.TLabel", background=bg, foreground=THEME["bad"])
    style.configure("TButton", background=panel, foreground=text, borderwidth=1, focusthickness=0, padding=(10, 5))
    style.map("TButton", background=[("active", THEME["panel2"])], foreground=[("active", text)])
    style.configure("Accent.TButton", background=accent, foreground="#fff")
    style.map("Accent.TButton", background=[("active", THEME["accent2"])])
    style.configure("TNotebook", background=bg, borderwidth=0)
    style.configure("TNotebook.Tab", background=panel, foreground=muted, padding=(14, 6), borderwidth=0)
    style.map(
        "TNotebook.Tab",
        background=[("selected", THEME["panel2"])],
        foreground=[("selected", text)],
    )
    style.configure(
        "Treeview",
        background=panel,
        foreground=text,
        fieldbackground=panel,
        borderwidth=0,
        rowheight=26,
        font=("Segoe UI", 9),
    )
    style.configure(
        "Treeview.Heading",
        background=THEME["panel2"],
        foreground=muted,
        relief="flat",
        font=("Segoe UI", 9, "bold"),
    )
    style.map("Treeview", background=[("selected", select)], foreground=[("selected", text)])
    style.configure("TCombobox", fieldbackground=panel, background=panel, foreground=text, arrowcolor=text)
    style.map("TCombobox", fieldbackground=[("readonly", panel)], foreground=[("readonly", text)])
    style.configure("TEntry", fieldbackground=panel, foreground=text, insertcolor=text)
    style.configure("TCheckbutton", background=bg, foreground=text)
    style.configure("TRadiobutton", background=bg, foreground=text)
    style.configure("TSeparator", background=border)
    style.configure("Vertical.TScrollbar", background=panel, troughcolor=bg, bordercolor=border, arrowcolor=muted)
    style.configure("Horizontal.TScrollbar", background=panel, troughcolor=bg, bordercolor=border, arrowcolor=muted)
    style.configure("TPanedwindow", background=bg)
    style.configure("Status.TLabel", background=THEME["panel2"], foreground=muted, padding=(10, 6))


# ---------------------------------------------------------------------------
# Main application
# ---------------------------------------------------------------------------


class App:
    def __init__(self, root: tk.Tk, reports: list[dict]) -> None:
        self.root = root
        self.reports = reports
        self._report_by_slug = {r["slug"]: r for r in reports}
        self._selected_slugs: set[str] = {r["slug"] for r in reports}
        self._focus_slug: str | None = reports[0]["slug"] if reports else None
        self._detail_row: dict | None = None

        root.title("Backtest Report Explorer")
        root.geometry("1480x900")
        root.minsize(1100, 700)
        apply_theme(root)

        self._build()
        self._populate_report_list()
        self.refresh_all()

    # ---- layout -----------------------------------------------------------

    def _build(self) -> None:
        # Top bar
        top = ttk.Frame(self.root, padding=(12, 10))
        top.pack(fill="x")
        ttk.Label(top, text="Backtest Report Explorer", style="Title.TLabel").pack(side="left")
        ttk.Label(
            top,
            text=f"  ·  {len(self.reports)} reports in reports/",
            style="Muted.TLabel",
        ).pack(side="left")
        ttk.Button(top, text="Reload", command=self.reload).pack(side="right", padx=(6, 0))
        ttk.Button(top, text="Select none", command=lambda: self._select_all(False)).pack(side="right", padx=2)
        ttk.Button(top, text="Select all", command=lambda: self._select_all(True)).pack(side="right", padx=2)

        # Body: sidebar + main
        body = ttk.Panedwindow(self.root, orient="horizontal")
        body.pack(fill="both", expand=True, padx=10, pady=(0, 6))

        sidebar = ttk.Frame(body, style="Panel.TFrame", padding=8)
        main = ttk.Frame(body)
        body.add(sidebar, weight=0)
        body.add(main, weight=1)

        # Sidebar
        ttk.Label(sidebar, text="REPORTS", style="Header.TLabel").pack(anchor="w")
        search_fr = ttk.Frame(sidebar, style="Panel.TFrame")
        search_fr.pack(fill="x", pady=(6, 6))
        self.search_var = tk.StringVar()
        self.search_var.trace_add("write", lambda *_: self._populate_report_list())
        ent = ttk.Entry(search_fr, textvariable=self.search_var)
        ent.pack(fill="x")
        ent.insert(0, "")
        # placeholder-ish hint
        ttk.Label(sidebar, text="Filter by name…  Click = focus, ☑ = compare", style="Muted.TLabel").pack(anchor="w")

        list_fr = ttk.Frame(sidebar, style="Panel.TFrame")
        list_fr.pack(fill="both", expand=True, pady=(6, 0))
        self.report_canvas = tk.Canvas(list_fr, bg=THEME["panel"], highlightthickness=0, width=280)
        sb = ttk.Scrollbar(list_fr, orient="vertical", command=self.report_canvas.yview)
        self.report_inner = ttk.Frame(self.report_canvas, style="Panel.TFrame")
        self.report_inner.bind(
            "<Configure>",
            lambda e: self.report_canvas.configure(scrollregion=self.report_canvas.bbox("all")),
        )
        self._list_window = self.report_canvas.create_window((0, 0), window=self.report_inner, anchor="nw")
        self.report_canvas.configure(yscrollcommand=sb.set)
        self.report_canvas.pack(side="left", fill="both", expand=True)
        sb.pack(side="right", fill="y")
        self.report_canvas.bind(
            "<Configure>",
            lambda e: self.report_canvas.itemconfigure(self._list_window, width=e.width),
        )
        # mousewheel when over list
        self.report_canvas.bind("<Enter>", lambda _e: self.report_canvas.bind_all("<MouseWheel>", self._on_wheel))
        self.report_canvas.bind("<Leave>", lambda _e: self.report_canvas.unbind_all("<MouseWheel>"))

        self._check_vars: dict[str, tk.BooleanVar] = {}

        # Main notebook
        self.nb = ttk.Notebook(main)
        self.nb.pack(fill="both", expand=True)

        self.tab_browse = ttk.Frame(self.nb, padding=8)
        self.tab_compare = ttk.Frame(self.nb, padding=8)
        self.tab_equity = ttk.Frame(self.nb, padding=8)
        self.nb.add(self.tab_browse, text="  Browse  ")
        self.nb.add(self.tab_compare, text="  Compare  ")
        self.nb.add(self.tab_equity, text="  Equity curves  ")

        self._build_browse()
        self._build_compare()
        self._build_equity()

        # Status
        self.status = ttk.Label(self.root, text="", style="Status.TLabel", anchor="w")
        self.status.pack(fill="x", side="bottom")

    def _on_wheel(self, event) -> None:
        self.report_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")

    # ---- report list ------------------------------------------------------

    def _populate_report_list(self) -> None:
        for w in self.report_inner.winfo_children():
            w.destroy()
        q = (self.search_var.get() or "").strip().lower()
        # self.reports is already newest-first (by generated_at).
        for rep in self.reports:
            if q and q not in rep["slug"].lower() and q not in (rep["headline"] or "").lower():
                continue
            self._add_report_card(rep)

    def _add_report_card(self, rep: dict) -> None:
        slug = rep["slug"]
        if slug not in self._check_vars:
            self._check_vars[slug] = tk.BooleanVar(value=slug in self._selected_slugs)
        var = self._check_vars[slug]

        card = tk.Frame(
            self.report_inner,
            bg=THEME["panel2"] if slug == self._focus_slug else THEME["panel"],
            highlightbackground=THEME["accent"] if slug == self._focus_slug else THEME["border"],
            highlightthickness=1,
            padx=8,
            pady=6,
        )
        card.pack(fill="x", pady=3, padx=2)

        top = tk.Frame(card, bg=card["bg"])
        top.pack(fill="x")
        cb = tk.Checkbutton(
            top,
            variable=var,
            bg=card["bg"],
            activebackground=card["bg"],
            fg=THEME["text"],
            selectcolor=THEME["panel"],
            activeforeground=THEME["text"],
            highlightthickness=0,
            command=lambda s=slug: self._on_check(s),
        )
        cb.pack(side="left")
        title = tk.Label(
            top,
            text=slug,
            bg=card["bg"],
            fg=THEME["text"],
            font=("Segoe UI", 9, "bold"),
            anchor="w",
            cursor="hand2",
        )
        title.pack(side="left", fill="x", expand=True)
        title.bind("<Button-1>", lambda _e, s=slug: self._focus(s))
        card.bind("<Button-1>", lambda _e, s=slug: self._focus(s))

        meta_parts = []
        if rep["generated"]:
            meta_parts.append(rep["generated"][5:] if len(rep["generated"]) > 5 else rep["generated"])
        if rep["qualified"] is not None:
            meta_parts.append(f"q={rep['qualified']}")
        prod = rep.get("prod") or {}
        if prod.get("sharpe") is not None:
            meta_parts.append(f"Sh {prod['sharpe']:.2f}")
        if prod.get("cagr") is not None:
            meta_parts.append(f"CAGR {prod['cagr']:+.0f}%")
        meta = tk.Label(
            card,
            text="  ·  ".join(meta_parts),
            bg=card["bg"],
            fg=THEME["muted"],
            font=("Segoe UI", 8),
            anchor="w",
            cursor="hand2",
        )
        meta.pack(fill="x")
        meta.bind("<Button-1>", lambda _e, s=slug: self._focus(s))

        if rep.get("headline"):
            hl = tk.Label(
                card,
                text=rep["headline"][:110] + ("…" if len(rep["headline"]) > 110 else ""),
                bg=card["bg"],
                fg=THEME["muted"],
                font=("Segoe UI", 8),
                anchor="w",
                justify="left",
                wraplength=250,
                cursor="hand2",
            )
            hl.pack(fill="x", pady=(2, 0))
            hl.bind("<Button-1>", lambda _e, s=slug: self._focus(s))

    def _on_check(self, slug: str) -> None:
        if self._check_vars[slug].get():
            self._selected_slugs.add(slug)
        else:
            self._selected_slugs.discard(slug)
        self.refresh_all()

    def _focus(self, slug: str) -> None:
        self._focus_slug = slug
        # auto-include focused report in selection
        if slug not in self._selected_slugs:
            self._selected_slugs.add(slug)
            if slug in self._check_vars:
                self._check_vars[slug].set(True)
        self._populate_report_list()
        self.refresh_all()

    def _select_all(self, on: bool) -> None:
        if on:
            self._selected_slugs = {r["slug"] for r in self.reports}
        else:
            self._selected_slugs = set()
            if self._focus_slug:
                self._selected_slugs.add(self._focus_slug)
        for slug, var in self._check_vars.items():
            var.set(slug in self._selected_slugs)
        self.refresh_all()

    def selected_reports(self) -> list[dict]:
        return [r for r in self.reports if r["slug"] in self._selected_slugs]

    def focus_report(self) -> dict | None:
        if self._focus_slug and self._focus_slug in self._report_by_slug:
            return self._report_by_slug[self._focus_slug]
        sel = self.selected_reports()
        # selected_reports preserves newest-first order; prefer first selected.
        return sel[0] if sel else (self.reports[0] if self.reports else None)

    # ---- Browse tab -------------------------------------------------------

    def _build_browse(self) -> None:
        # Header cards + meta
        self.browse_header = ttk.Frame(self.tab_browse)
        self.browse_header.pack(fill="x")

        self.browse_title = ttk.Label(self.browse_header, text="", style="Title.TLabel")
        self.browse_title.pack(anchor="w")
        self.browse_sub = ttk.Label(self.browse_header, text="", style="Muted.TLabel", wraplength=1100)
        self.browse_sub.pack(anchor="w", pady=(2, 6))

        self.cards_fr = ttk.Frame(self.browse_header)
        self.cards_fr.pack(fill="x", pady=(0, 8))

        # Section selector + table + detail
        ctrl = ttk.Frame(self.tab_browse)
        ctrl.pack(fill="x", pady=(0, 6))
        ttk.Label(ctrl, text="Section").pack(side="left")
        self.browse_section = ttk.Combobox(
            ctrl, values=list(SECTIONS), state="readonly", width=28
        )
        self.browse_section.set(DEFAULT_SECTION)
        self.browse_section.pack(side="left", padx=(6, 12))
        self.browse_section.bind("<<ComboboxSelected>>", lambda _e: self.refresh_browse())

        ttk.Label(ctrl, text="Lookback").pack(side="left")
        self.browse_lookback = ttk.Combobox(ctrl, state="readonly", width=12)
        self.browse_lookback.pack(side="left", padx=(6, 12))
        self.browse_lookback.bind("<<ComboboxSelected>>", lambda _e: self.refresh_browse())

        ttk.Label(ctrl, text="Highlight best by").pack(side="left")
        self.browse_metric = ttk.Combobox(ctrl, state="readonly", width=14)
        self.browse_metric.pack(side="left", padx=(6, 0))
        self.browse_metric.bind("<<ComboboxSelected>>", lambda _e: self.refresh_browse())

        self.browse_note = ttk.Label(self.tab_browse, text="", style="Muted.TLabel", wraplength=1200)
        self.browse_note.pack(anchor="w", pady=(0, 4))

        # split table / detail
        paned = ttk.Panedwindow(self.tab_browse, orient="vertical")
        paned.pack(fill="both", expand=True)

        table_fr = ttk.Frame(paned)
        detail_fr = ttk.Frame(paned, style="Panel.TFrame", padding=8)
        paned.add(table_fr, weight=3)
        paned.add(detail_fr, weight=1)

        self.browse_tree = SortableTree(table_fr)
        ysb = ttk.Scrollbar(table_fr, orient="vertical", command=self.browse_tree.yview)
        xsb = ttk.Scrollbar(table_fr, orient="horizontal", command=self.browse_tree.xview)
        self.browse_tree.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
        self.browse_tree.grid(row=0, column=0, sticky="nsew")
        ysb.grid(row=0, column=1, sticky="ns")
        xsb.grid(row=1, column=0, sticky="ew")
        table_fr.rowconfigure(0, weight=1)
        table_fr.columnconfigure(0, weight=1)
        self.browse_tree.tag_configure("best", background=THEME["good_bg"])
        self.browse_tree.tag_configure("prod", foreground=THEME["prod"])
        self.browse_tree.tag_configure("alt", background=THEME["row_alt"])
        self.browse_tree.bind("<<TreeviewSelect>>", lambda _e: self._show_detail(self.browse_tree))
        self.browse_tree.bind("<<RowActivate>>", lambda _e: self._show_detail(self.browse_tree))

        ttk.Label(detail_fr, text="ROW DETAIL  (select a row)", style="Header.TLabel").pack(anchor="w")
        self.detail_text = tk.Text(
            detail_fr,
            height=8,
            wrap="word",
            bg=THEME["panel"],
            fg=THEME["text"],
            insertbackground=THEME["text"],
            relief="flat",
            font=("Consolas", 9),
            padx=6,
            pady=6,
        )
        self.detail_text.pack(fill="both", expand=True, pady=(6, 0))
        self.detail_text.configure(state="disabled")

        # Recommendation block under cards is rebuilt in refresh

    def _metric_options(self, columns: list) -> list[str]:
        return [c[0] for c in columns if c[3] is not None]

    def refresh_browse(self) -> None:
        rep = self.focus_report()
        if not rep:
            self.browse_title.configure(text="No report selected")
            return

        data = rep["data"]
        self.browse_title.configure(text=rep["slug"])
        act = data.get("activation") or {}
        params = data.get("params") or {}
        act_bits = [
            f"mom≥{act.get('min_momentum_percentile')}",
            f"RR≥{act.get('min_rr')}",
            f"conf≥{act.get('min_confidence')}",
        ]
        if act.get("exclude_neutral"):
            act_bits.append("excl neutral")
        if act.get("exclude_conflicts"):
            act_bits.append("excl conflicts")
        sub = (
            f"{rep['generated']}  ·  {rep.get('tickers') or '?'} tickers  ·  "
            f"{rep.get('candidates') or '?'} candidates  ·  {rep.get('qualified') or '?'} qualified  ·  "
            f"activation: {', '.join(str(b) for b in act_bits)}  ·  "
            f"horizon {params.get('horizon_days')}d / cost {params.get('cost_per_side_pct')}%"
        )
        self.browse_sub.configure(text=sub)

        # cards
        for w in self.cards_fr.winfo_children():
            w.destroy()
        prod = rep.get("prod") or {}
        oq = data.get("overall_qualified") or {}
        cards = [
            ("CAGR", f"{prod['cagr']:+.1f}%" if prod.get("cagr") is not None else "—"),
            ("Sharpe", f"{prod['sharpe']:.2f}" if prod.get("sharpe") is not None else "—"),
            ("Max DD", f"{prod['dd']:.1f}%" if prod.get("dd") is not None else "—"),
            ("vs SPY", f"{(prod.get('ret') or 0) - (prod.get('spy') or 0):+.0f}pp" if prod.get("ret") is not None else "—"),
            ("Trades", f"{prod['trades']:.0f}" if prod.get("trades") is not None else "—"),
            ("Net avg R", f"{oq['net_avg_r']:+.3f}" if oq.get("net_avg_r") is not None else "—"),
            ("Hit rate", f"{oq['hit_rate']:.1f}%" if oq.get("hit_rate") is not None else "—"),
            ("PF", f"{oq['profit_factor']:.2f}" if oq.get("profit_factor") is not None else "—"),
        ]
        for label, value in cards:
            self._metric_card(self.cards_fr, label, value)

        # recommendation strip
        rec = data.get("recommendation") or {}
        if rec.get("headline"):
            # store as sub already has meta; append note area in browse_note partially
            pass

        section = self.browse_section.get() or DEFAULT_SECTION
        extractor = SECTIONS.get(section)
        if not extractor:
            return
        columns, rows, note = extractor(data)

        # lookbacks
        lookbacks = sorted({r.get("_lookback") for r in rows if r.get("_lookback") and r.get("_lookback") != "-"})
        if lookbacks:
            vals = ["(all)"] + lookbacks
            self.browse_lookback.configure(values=vals, state="readonly")
            if self.browse_lookback.get() not in vals:
                # prefer "all" lookback if present
                self.browse_lookback.set("all" if "all" in lookbacks else "(all)")
            chosen = self.browse_lookback.get()
            if chosen not in ("(all)", "", "(n/a)"):
                rows = [r for r in rows if r.get("_lookback") == chosen]
        else:
            self.browse_lookback.configure(values=["(n/a)"], state="disabled")
            self.browse_lookback.set("(n/a)")

        # metrics
        metrics = self._metric_options(columns)
        self.browse_metric.configure(values=metrics or ["—"])
        if metrics:
            if self.browse_metric.get() not in metrics:
                # prefer Sharpe / Net avg R
                for pref in ("Sharpe", "Net avg R", "CAGR %", "Mean IC"):
                    if pref in metrics:
                        self.browse_metric.set(pref)
                        break
                else:
                    self.browse_metric.set(metrics[0])
        else:
            self.browse_metric.set("—")

        best_key = best_higher = None
        mlabel = self.browse_metric.get()
        for c in columns:
            if c[0] == mlabel and c[3] is not None:
                best_key, best_higher = c[1], c[3]
                break

        # notes + recommendation
        rec_items = []
        if rec.get("headline"):
            rec_items.append(f"★ {rec['headline']}")
        note_bits = [n for n in (note, data.get("note")) if n]
        self.browse_note.configure(
            text="  |  ".join(rec_items + note_bits) if (rec_items or note_bits) else ""
        )

        self.browse_tree.set_columns(columns)
        self.browse_tree.populate(rows, best_key=best_key, best_higher=best_higher, default_sort=mlabel if metrics else None)

        # recommendation items into detail if empty
        if not self.browse_tree.selected_row():
            self._set_detail(self._recommendation_text(data))

    def _metric_card(self, parent: ttk.Frame, label: str, value: str) -> None:
        fr = tk.Frame(parent, bg=THEME["panel"], highlightbackground=THEME["border"], highlightthickness=1, padx=12, pady=8)
        fr.pack(side="left", padx=(0, 8))
        tk.Label(fr, text=label.upper(), bg=THEME["panel"], fg=THEME["muted"], font=("Segoe UI", 8)).pack(anchor="w")
        tk.Label(fr, text=value, bg=THEME["panel"], fg=THEME["accent2"], font=("Segoe UI", 15, "bold")).pack(anchor="w")

    def _recommendation_text(self, data: dict) -> str:
        lines = []
        rec = data.get("recommendation") or {}
        if rec.get("headline"):
            lines.append(f"RECOMMENDATION\n{rec['headline']}\n")
        for item in rec.get("items") or []:
            if isinstance(item, dict):
                topic = item.get("topic") or ""
                text = item.get("text") or ""
                lines.append(f"  [{topic}] {text}")
        research = data.get("research_recommendation") or {}
        if research.get("items"):
            lines.append("\nRESEARCH")
            for item in research["items"]:
                if isinstance(item, dict):
                    cand = "CANDIDATE" if item.get("candidate") else "no"
                    lines.append(f"  ({cand}) [{item.get('topic','')}] {item.get('text','')}")
            if research.get("note"):
                lines.append(f"  note: {research['note']}")
        if rec.get("note"):
            lines.append(f"\nnote: {rec['note']}")
        if data.get("note"):
            lines.append(f"\nreport note: {data['note']}")
        return "\n".join(lines) if lines else "Select a row for detail, or see recommendation above."

    def _show_detail(self, tree: SortableTree) -> None:
        row = tree.selected_row()
        if not row:
            rep = self.focus_report()
            if rep:
                self._set_detail(self._recommendation_text(rep["data"]))
            return
        self._detail_row = row
        lines = []
        name = row.get("_name") or row.get("label") or row.get("strategy") or ""
        if name:
            lines.append(f"{name}")
            if row.get("_report"):
                lines.append(f"report: {row['_report']}")
            lines.append("")
        # key metrics first
        priority = [
            "strategy", "label", "description", "variant", "exit_policy", "lookback",
            "cagr_pct", "max_drawdown_pct", "sharpe", "total_return_pct", "spy_return_pct",
            "trades", "win_rate", "net_avg_r", "profit_factor", "mean_ic", "reliable",
            "start_date", "end_date", "exit_reasons", "yearly_returns",
        ]
        shown = set()
        for k in priority:
            if k in row and row[k] is not None and not str(k).startswith("_"):
                lines.append(self._fmt_detail_line(k, row[k]))
                shown.add(k)
        # rest
        for k, v in sorted(row.items()):
            if k in shown or str(k).startswith("_") or v is None:
                continue
            if k in ("equity_curve", "benchmark_curve"):
                lines.append(f"{k}: [{len(v)} points]" if isinstance(v, list) else f"{k}: {v}")
                continue
            lines.append(self._fmt_detail_line(k, v))
        self._set_detail("\n".join(lines))

    def _fmt_detail_line(self, key: str, val: Any) -> str:
        if key == "yearly_returns" and isinstance(val, list):
            parts = []
            for y in val:
                if isinstance(y, dict):
                    parts.append(f"{y.get('year')}: {y.get('return_pct'):+.1f}%" if y.get("return_pct") is not None else str(y))
            return f"yearly_returns:  {',  '.join(parts)}"
        if key == "exit_reasons" and isinstance(val, dict):
            return "exit_reasons:  " + ", ".join(f"{k}={v}" for k, v in sorted(val.items()))
        if key == "filters" and isinstance(val, list):
            return f"filters: {json.dumps(val)}"
        if isinstance(val, float):
            return f"{key}: {val:.4g}"
        if isinstance(val, (dict, list)):
            return f"{key}: {json.dumps(val, default=str)[:500]}"
        return f"{key}: {val}"

    def _set_detail(self, text: str) -> None:
        self.detail_text.configure(state="normal")
        self.detail_text.delete("1.0", "end")
        self.detail_text.insert("1.0", text)
        self.detail_text.configure(state="disabled")

    # ---- Compare tab ------------------------------------------------------

    def _build_compare(self) -> None:
        ctrl = ttk.Frame(self.tab_compare)
        ctrl.pack(fill="x", pady=(0, 6))

        ttk.Label(ctrl, text="Section").pack(side="left")
        self.cmp_section = ttk.Combobox(ctrl, values=list(SECTIONS), state="readonly", width=28)
        self.cmp_section.set(DEFAULT_SECTION)
        self.cmp_section.pack(side="left", padx=(6, 12))
        self.cmp_section.bind("<<ComboboxSelected>>", lambda _e: self.refresh_compare())

        ttk.Label(ctrl, text="Lookback").pack(side="left")
        self.cmp_lookback = ttk.Combobox(ctrl, state="readonly", width=12)
        self.cmp_lookback.pack(side="left", padx=(6, 12))
        self.cmp_lookback.bind("<<ComboboxSelected>>", lambda _e: self.refresh_compare())

        ttk.Label(ctrl, text="Best by").pack(side="left")
        self.cmp_metric = ttk.Combobox(ctrl, state="readonly", width=14)
        self.cmp_metric.pack(side="left", padx=(6, 12))
        self.cmp_metric.bind("<<ComboboxSelected>>", lambda _e: self.refresh_compare())

        self.cmp_prod_only = tk.BooleanVar(value=False)
        ttk.Checkbutton(
            ctrl, text="Production rows only", variable=self.cmp_prod_only, command=self.refresh_compare
        ).pack(side="left", padx=(8, 0))

        self.cmp_note = ttk.Label(self.tab_compare, text="", style="Muted.TLabel", wraplength=1200)
        self.cmp_note.pack(anchor="w", pady=(0, 4))

        table_fr = ttk.Frame(self.tab_compare)
        table_fr.pack(fill="both", expand=True)

        self.cmp_tree = SortableTree(table_fr)
        ysb = ttk.Scrollbar(table_fr, orient="vertical", command=self.cmp_tree.yview)
        xsb = ttk.Scrollbar(table_fr, orient="horizontal", command=self.cmp_tree.xview)
        self.cmp_tree.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
        self.cmp_tree.grid(row=0, column=0, sticky="nsew")
        ysb.grid(row=0, column=1, sticky="ns")
        xsb.grid(row=1, column=0, sticky="ew")
        table_fr.rowconfigure(0, weight=1)
        table_fr.columnconfigure(0, weight=1)
        self.cmp_tree.tag_configure("best", background=THEME["good_bg"])
        self.cmp_tree.tag_configure("prod", foreground=THEME["prod"])
        self.cmp_tree.tag_configure("alt", background=THEME["row_alt"])

    def refresh_compare(self) -> None:
        section = self.cmp_section.get() or DEFAULT_SECTION
        extractor = SECTIONS.get(section)
        if not extractor:
            return

        all_rows: list[dict] = []
        notes: list[str] = []
        columns: list = []
        missing: list[str] = []

        for rep in self.selected_reports():
            cols, rows, note = extractor(rep["data"])
            if not rows:
                missing.append(rep["slug"])
                continue
            if not columns:
                columns = [("Report", "_report", "{}", None), *cols]
            for r in rows:
                r = dict(r)
                r["_report"] = rep["slug"]
                all_rows.append(r)
            if note and note not in notes:
                notes.append(note)

        # lookbacks
        lookbacks = sorted({r.get("_lookback") for r in all_rows if r.get("_lookback") and r.get("_lookback") != "-"})
        if lookbacks:
            vals = ["(all)"] + lookbacks
            self.cmp_lookback.configure(values=vals, state="readonly")
            if self.cmp_lookback.get() not in vals:
                self.cmp_lookback.set("all" if "all" in lookbacks else "(all)")
            chosen = self.cmp_lookback.get()
            if chosen not in ("(all)", "", "(n/a)"):
                all_rows = [r for r in all_rows if r.get("_lookback") == chosen]
        else:
            self.cmp_lookback.configure(values=["(n/a)"], state="disabled")
            self.cmp_lookback.set("(n/a)")

        if self.cmp_prod_only.get():
            prod_rows = [r for r in all_rows if r.get("_production")]
            if prod_rows:
                all_rows = prod_rows

        metrics = self._metric_options(columns)
        self.cmp_metric.configure(values=metrics or ["—"])
        if metrics:
            if self.cmp_metric.get() not in metrics:
                for pref in ("Sharpe", "Net avg R", "CAGR %", "Mean IC"):
                    if pref in metrics:
                        self.cmp_metric.set(pref)
                        break
                else:
                    self.cmp_metric.set(metrics[0])
        else:
            self.cmp_metric.set("—")

        best_key = best_higher = None
        mlabel = self.cmp_metric.get()
        for c in columns:
            if c[0] == mlabel and c[3] is not None:
                best_key, best_higher = c[1], c[3]
                break

        note_text = "  |  ".join(notes[:2]) if notes else ""
        if missing:
            note_text = (note_text + f"  ·  no data in: {', '.join(missing)}").strip(" ·")
        self.cmp_note.configure(text=note_text)

        if not columns:
            columns = [("Report", "_report", "{}", None), ("Run", "_name", "{}", None)]
        self.cmp_tree.set_columns(columns)
        self.cmp_tree.populate(
            all_rows,
            best_key=best_key,
            best_higher=best_higher,
            default_sort=mlabel if metrics else None,
        )

        n_rep = len(self.selected_reports())
        self.status.configure(
            text=f"Compare: {len(all_rows)} rows from {n_rep} report(s)  ·  section={section}  ·  "
            f"green = best {mlabel}  ·  gold text = production/live row"
        )

    # ---- Equity tab -------------------------------------------------------

    def _build_equity(self) -> None:
        ctrl = ttk.Frame(self.tab_equity)
        ctrl.pack(fill="x", pady=(0, 6))
        ttk.Label(ctrl, text="Lookback").pack(side="left")
        self.eq_lookback = ttk.Combobox(ctrl, state="readonly", width=10)
        self.eq_lookback.pack(side="left", padx=(6, 12))
        self.eq_lookback.bind("<<ComboboxSelected>>", lambda _e: self.refresh_equity())

        self.eq_prod_only = tk.BooleanVar(value=True)
        ttk.Checkbutton(
            ctrl, text="Production only", variable=self.eq_prod_only, command=self.refresh_equity
        ).pack(side="left", padx=(0, 12))

        self.eq_show_spy = tk.BooleanVar(value=True)
        ttk.Checkbutton(
            ctrl, text="Show SPY benchmark", variable=self.eq_show_spy, command=self.refresh_equity
        ).pack(side="left")

        ttk.Button(ctrl, text="Redraw", command=self.refresh_equity).pack(side="right")

        body = ttk.Panedwindow(self.tab_equity, orient="horizontal")
        body.pack(fill="both", expand=True)

        left = ttk.Frame(body)
        right = ttk.Frame(body)
        body.add(left, weight=0)
        body.add(right, weight=1)

        ttk.Label(left, text="Curves (check to plot)", style="Header.TLabel").pack(anchor="w")
        self.eq_list = tk.Listbox(
            left,
            selectmode="extended",
            bg=THEME["panel"],
            fg=THEME["text"],
            selectbackground=THEME["select"],
            highlightthickness=0,
            borderwidth=0,
            font=("Segoe UI", 9),
            width=42,
            exportselection=False,
        )
        self.eq_list.pack(fill="both", expand=True, pady=(6, 0))
        self.eq_list.bind("<<ListboxSelect>>", lambda _e: self._draw_equity())

        self.eq_canvas = tk.Canvas(right, bg=THEME["chart_bg"], highlightthickness=0)
        self.eq_canvas.pack(fill="both", expand=True)
        self.eq_canvas.bind("<Configure>", lambda _e: self._draw_equity())

        self.eq_legend = ttk.Label(right, text="", style="Muted.TLabel", wraplength=900)
        self.eq_legend.pack(fill="x", pady=(4, 0))

        self._eq_series: list[dict] = []

    def refresh_equity(self) -> None:
        # collect series from selected reports' portfolio_monitor
        lookbacks_seen: set[str] = set()
        series: list[dict] = []
        for rep in self.selected_reports():
            block = rep["data"].get("portfolio_monitor") or {}
            for item in _as_list(block, "runs"):
                if not isinstance(item, dict):
                    continue
                curve = item.get("equity_curve")
                if not curve:
                    continue
                lb = item.get("lookback") or "-"
                lookbacks_seen.add(lb)
                if self.eq_prod_only.get() and not item.get("is_production"):
                    continue
                series.append(
                    {
                        "report": rep["slug"],
                        "name": item.get("label") or item.get("strategy") or "?",
                        "lookback": lb,
                        "is_production": bool(item.get("is_production")),
                        "equity_curve": curve,
                        "benchmark_curve": item.get("benchmark_curve"),
                        "cagr": item.get("cagr_pct"),
                        "sharpe": item.get("sharpe"),
                    }
                )

        lbs = sorted(lookbacks_seen, key=lambda x: {"6m": 0, "1y": 1, "3y": 2, "5y": 3, "all": 4}.get(x, 9))
        if lbs:
            self.eq_lookback.configure(values=lbs, state="readonly")
            if self.eq_lookback.get() not in lbs:
                self.eq_lookback.set("all" if "all" in lbs else lbs[-1])
            series = [s for s in series if s["lookback"] == self.eq_lookback.get()]
        else:
            self.eq_lookback.configure(values=["(n/a)"], state="disabled")
            self.eq_lookback.set("(n/a)")

        self._eq_series = series
        self.eq_list.delete(0, "end")
        for s in series:
            prod = "★ " if s["is_production"] else "  "
            metrics = []
            if s.get("sharpe") is not None:
                metrics.append(f"Sh {s['sharpe']:.2f}")
            if s.get("cagr") is not None:
                metrics.append(f"CAGR {s['cagr']:+.0f}%")
            label = f"{prod}{s['report']}  ·  {s['name']}"
            if metrics:
                label += f"  ({', '.join(metrics)})"
            self.eq_list.insert("end", label)

        # select all by default (cap at 8 for readability)
        n = min(len(series), 8)
        if n:
            self.eq_list.selection_set(0, n - 1)
        self._draw_equity()

    def _draw_equity(self) -> None:
        c = self.eq_canvas
        c.delete("all")
        w = c.winfo_width()
        h = c.winfo_height()
        if w < 40 or h < 40:
            return

        pad_l, pad_r, pad_t, pad_b = 56, 16, 16, 36
        plot_w = w - pad_l - pad_r
        plot_h = h - pad_t - pad_b
        if plot_w < 10 or plot_h < 10:
            return

        idxs = list(self.eq_list.curselection())
        if not idxs:
            c.create_text(
                w / 2, h / 2, text="Select one or more curves in the list", fill=THEME["muted"], font=("Segoe UI", 11)
            )
            return

        # normalize each series to return_pct if present, else equity rebased to 0
        plotted: list[tuple[str, list[tuple[str, float]], str]] = []
        show_spy = self.eq_show_spy.get()
        spy_added = False

        for i, idx in enumerate(idxs):
            if idx >= len(self._eq_series):
                continue
            s = self._eq_series[idx]
            color = CHART_COLORS[i % len(CHART_COLORS)]
            pts = self._curve_to_pct(s["equity_curve"])
            if pts:
                label = f"{s['report']} · {s['name']}"
                plotted.append((label, pts, color))
            if show_spy and not spy_added and s.get("benchmark_curve"):
                bpts = self._curve_to_pct(s["benchmark_curve"])
                if bpts:
                    plotted.append(("SPY", bpts, "#64748b"))
                    spy_added = True

        if not plotted:
            c.create_text(w / 2, h / 2, text="No equity_curve data in selection", fill=THEME["muted"])
            return

        all_y = [y for _, pts, _ in plotted for _, y in pts]
        y_min = min(all_y)
        y_max = max(all_y)
        if y_min == y_max:
            y_min -= 1
            y_max += 1
        # pad 5%
        span = y_max - y_min
        y_min -= span * 0.05
        y_max += span * 0.05

        # use longest series dates as x if possible — align by index after resampling to common length
        # simpler: each series uses its own index 0..n mapped to plot width
        # better: align on date strings
        all_dates = sorted({d for _, pts, _ in plotted for d, _ in pts})
        if not all_dates:
            return
        date_index = {d: i for i, d in enumerate(all_dates)}
        n_dates = len(all_dates)

        def x_of(date: str) -> float:
            return pad_l + (date_index[date] / max(n_dates - 1, 1)) * plot_w

        def y_of(val: float) -> float:
            return pad_t + (1 - (val - y_min) / (y_max - y_min)) * plot_h

        # grid
        c.create_rectangle(pad_l, pad_t, pad_l + plot_w, pad_t + plot_h, outline=THEME["grid"], fill=THEME["chart_bg"])
        for frac in (0, 0.25, 0.5, 0.75, 1.0):
            yv = y_min + (y_max - y_min) * frac
            y = y_of(yv)
            c.create_line(pad_l, y, pad_l + plot_w, y, fill=THEME["grid"])
            c.create_text(pad_l - 8, y, text=f"{yv:+.0f}%", fill=THEME["muted"], anchor="e", font=("Segoe UI", 8))

        # zero line
        if y_min < 0 < y_max:
            y0 = y_of(0)
            c.create_line(pad_l, y0, pad_l + plot_w, y0, fill=THEME["border"], dash=(3, 3))

        # x labels
        for frac in (0, 0.33, 0.66, 1.0):
            di = int(frac * (n_dates - 1))
            d = all_dates[di]
            c.create_text(
                x_of(d), pad_t + plot_h + 12, text=d[:7], fill=THEME["muted"], anchor="n", font=("Segoe UI", 8)
            )

        leg = []
        for label, pts, color in plotted:
            coords = []
            for d, v in pts:
                if d in date_index:
                    coords.extend([x_of(d), y_of(v)])
            if len(coords) >= 4:
                c.create_line(*coords, fill=color, width=2, smooth=False)
            final = pts[-1][1] if pts else 0
            short = label if len(label) < 48 else label[:45] + "…"
            leg.append(f"● {short}  {final:+.1f}%")

        # in-chart legend
        for i, (label, pts, color) in enumerate(plotted[:12]):
            final = pts[-1][1] if pts else 0
            short = label if len(label) < 48 else label[:45] + "…"
            c.create_text(
                pad_l + 8,
                pad_t + 10 + i * 14,
                text=f"● {short}  {final:+.1f}%",
                fill=color,
                anchor="nw",
                font=("Segoe UI", 8),
            )
        self.eq_legend.configure(text="     ".join(leg[:8]))

    def _curve_to_pct(self, curve: list) -> list[tuple[str, float]]:
        pts: list[tuple[str, float]] = []
        if not curve:
            return pts
        # prefer return_pct field; else rebase equity
        first_eq = None
        for p in curve:
            if not isinstance(p, dict):
                continue
            d = p.get("date") or ""
            if p.get("return_pct") is not None:
                pts.append((d, float(p["return_pct"])))
            elif p.get("equity") is not None:
                eq = float(p["equity"])
                if first_eq is None:
                    first_eq = eq if eq else 1.0
                pts.append((d, (eq / first_eq - 1.0) * 100.0))
        return pts

    # ---- global refresh / reload ------------------------------------------

    def refresh_all(self) -> None:
        self.refresh_browse()
        self.refresh_compare()
        self.refresh_equity()
        rep = self.focus_report()
        n = len(self.selected_reports())
        focus = rep["slug"] if rep else "—"
        self.status.configure(
            text=f"Focus: {focus}  ·  {n} report(s) selected for compare/equity  ·  "
            f"Browse = single-report drill-down  ·  Compare = multi-report table  ·  gold = production"
        )

    def reload(self) -> None:
        self.reports = load_reports()
        self._report_by_slug = {r["slug"]: r for r in self.reports}
        # preserve selection where possible
        known = {r["slug"] for r in self.reports}
        self._selected_slugs = {s for s in self._selected_slugs if s in known} or known
        if self._focus_slug not in known:
            self._focus_slug = self.reports[0]["slug"] if self.reports else None
        self._check_vars = {}
        self._populate_report_list()
        self.refresh_all()
        messagebox.showinfo("Reloaded", f"Loaded {len(self.reports)} report(s).")


def main() -> None:
    reports = load_reports()
    if not reports:
        raise SystemExit(f"No backtest-*.json found in {HERE}")
    root = tk.Tk()
    # Prefer Segoe UI where available
    try:
        tkfont.nametofont("TkDefaultFont").configure(family="Segoe UI", size=9)
    except tk.TclError:
        pass
    App(root, reports)
    root.mainloop()


if __name__ == "__main__":
    main()
