"""CLI for strategy improvement tracking: record, leaderboard, check-duplicate."""
from __future__ import annotations

import argparse
import sys
from pathlib import Path

from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table

from libs.backtest.domain import (
    ConfigDelta,
    JournalEntry,
    ResetCommonWindowSummary,
    RobustnessMatrixSummary,
    SplitResult,
    WalkForwardSummary,
)
from libs.backtest.tracker import (
    _hydrate_split_result,
    append_journal_entry,
    attach_out_of_time_robustness_summary,
    attach_reset_common_window_summary,
    attach_robustness_summary,
    attach_walk_forward_summary,
    build_split_result,
    check_duplicate,
    compute_deployment_score,
    compute_public_sqs,
    compute_public_sqs_v2,
    compute_promotion_score,
    compute_oot_robustness_gate,
    compute_reset_common_window_score,
    compute_robustness_gate,
    compute_rqs,
    compute_sqs,
    compute_sqs_v2,
    compute_unified_score,
    compute_wfqs,
    compute_wfqs_v2,
    filter_registry_entries,
    get_next_entry_id,
    journal_lock,
    load_journal,
    refresh_public_scores,
    rebuild_registry,
    scan_runs_for_experiment,
    sync_official_manifests,
)
from libs.common.time_utils import utc_now

_console = Console(width=140)
_DEFAULT_JOURNAL_DIR = "journal"
_COL_NAME = 38


def _fmt(val: float | None, fmt: str) -> str:
    return format(val, fmt) if val is not None else "-"


def _ratio(numerator: float | None, denominator: float | None) -> float | None:
    if numerator is None or denominator is None or denominator == 0:
        return None
    return numerator / denominator


def _entry_matches_target(entry: JournalEntry, target: str) -> bool:
    target_upper = target.upper()
    return entry.entry_id == target_upper or entry.experiment_name == target


def _ordered_splits(results: dict[str, SplitResult]) -> list[tuple[str, SplitResult]]:
    preferred = ["train", "valid", "test"]
    ordered: list[tuple[str, SplitResult]] = []
    seen: set[str] = set()
    for name in preferred:
        if name in results:
            ordered.append((name, results[name]))
            seen.add(name)
    for name in sorted(results.keys()):
        if name not in seen:
            ordered.append((name, results[name]))
    return ordered


def _sync_and_rebuild(
    journal_path: Path,
    registry_path: Path,
    leaderboard_path: Path,
) -> object:
    sync_official_manifests(journal_path, Path("runs"))
    return rebuild_registry(journal_path, registry_path, leaderboard_path)


def _load_optional_wfv_summary(path_str: str | None) -> WalkForwardSummary | None:
    if not path_str:
        return None
    path = Path(path_str)
    if not path.exists():
        print(f"ERROR: walk-forward summary not found: {path}")
        sys.exit(1)
    return WalkForwardSummary.model_validate_json(path.read_text())


def _load_optional_robustness_summary(path_str: str | None) -> RobustnessMatrixSummary | None:
    if not path_str:
        return None
    path = Path(path_str)
    if not path.exists():
        print(f"ERROR: robustness summary not found: {path}")
        sys.exit(1)
    return RobustnessMatrixSummary.model_validate_json(path.read_text())


def _load_optional_reset_common_window_summary(
    path_str: str | None,
) -> ResetCommonWindowSummary | None:
    if not path_str:
        return None
    path = Path(path_str)
    if not path.exists():
        print(f"ERROR: reset common-window summary not found: {path}")
        sys.exit(1)
    return ResetCommonWindowSummary.model_validate_json(path.read_text())


def _score_sort_key(sort_by: str, entry) -> tuple:
    if sort_by == "promotion":
        return (
            entry.promotion_score is None,
            -(entry.promotion_score or 0.0),
            -(entry.sqs_score or 0.0),
        )
    if sort_by in {"deployment", "dep"}:
        return (
            entry.deployment_score is None,
            -(entry.deployment_score or 0.0),
            entry.wfqs_score is None,
            -(entry.wfqs_score or 0.0),
            -(entry.rqs_score or 0.0),
            -(entry.sqs_score or 0.0),
        )
    if sort_by == "wfqs":
        return (
            entry.wfqs_score is None,
            -(entry.wfqs_score or 0.0),
            -(entry.deployment_score or 0.0),
            -(entry.rqs_score or 0.0),
            -(entry.sqs_score or 0.0),
        )
    if sort_by == "rqs":
        return (
            entry.rqs_score is None,
            -(entry.rqs_score or 0.0),
            -(entry.deployment_score or 0.0),
            -(entry.sqs_score or 0.0),
        )
    if sort_by == "unified":
        return (
            entry.unified_score is None,
            -(entry.unified_score or 0.0),
            -(entry.sqs_score or 0.0),
        )
    if sort_by == "sqs2":
        return (
            entry.wfqs_v2_score is None,
            -(entry.wfqs_v2_score or 0.0),
            -(entry.wfqs_score or 0.0),
            -(entry.sqs_score or 0.0),
        )
    return (-(entry.sqs_score or 0.0),)


def _validation_pending_reason(entry: JournalEntry) -> str | None:
    missing: list[str] = []
    if entry.walk_forward_summary is None:
        missing.append("WFV")
    if entry.robustness_matrix_summary is None:
        missing.append("robustness")
    if entry.out_of_time_robustness_summary is None:
        missing.append("OOT robustness")
    if not missing:
        return None
    return "missing " + " + ".join(missing)


def _title_for_sort(sort_by: str) -> str:
    if sort_by == "promotion":
        return "Promotion 기준 내림차순"
    if sort_by in {"deployment", "dep"}:
        return "Deployment 기준 내림차순"
    if sort_by == "wfqs":
        return "WFQS 기준 내림차순"
    if sort_by == "rqs":
        return "RQS 기준 내림차순"
    if sort_by == "unified":
        return "Unified 기준 내림차순"
    if sort_by == "sqs2":
        return "WFQS v2 기준 내림차순"
    return "SQS 기준 내림차순"


def cmd_record(args: argparse.Namespace) -> None:
    """Record an experiment to the improvement journal."""
    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    registry_path = journal_dir / "experiment_registry.json"
    leaderboard_path = journal_dir / "LEADERBOARD.md"

    runs_dir = Path(args.runs_dir)
    if not runs_dir.exists():
        print(f"ERROR: runs directory not found: {runs_dir}")
        sys.exit(1)

    split_runs = scan_runs_for_experiment(runs_dir, args.experiment)
    if not split_runs:
        print(f"ERROR: no runs found for experiment '{args.experiment}' in {runs_dir}")
        sys.exit(1)

    results: dict[str, SplitResult] = {}
    for split_name, (run_id, metrics) in split_runs.items():
        results[split_name] = build_split_result(split_name, run_id, metrics)

    test_metrics = None
    for preferred in ["test", "valid", "train"]:
        if preferred in split_runs:
            _, test_metrics = split_runs[preferred]
            break

    sqs_score = None
    sqs_breakdown: dict[str, float] = {}
    sqs_v2_score = None
    sqs_v2_breakdown: dict[str, float] = {}
    if test_metrics is not None:
        sqs_score, sqs_breakdown = compute_sqs(test_metrics)
        sqs_v2_score, sqs_v2_breakdown = compute_sqs_v2(test_metrics)

    promotion_score, promotion_breakdown = compute_promotion_score(
        results.get("test"),
        results.get("valid"),
    )
    unified_score, unified_breakdown = compute_unified_score(
        results.get("test"),
        results.get("valid"),
    )
    rqs_score, rqs_breakdown = compute_rqs(
        results.get("train"),
        results.get("valid"),
        results.get("test"),
    )

    walk_forward_summary = _load_optional_wfv_summary(args.walk_forward_summary)
    robustness_matrix_summary = _load_optional_robustness_summary(args.robustness_summary)
    out_of_time_robustness_summary = _load_optional_robustness_summary(
        getattr(args, "out_of_time_robustness_summary", None)
    )
    reset_common_window_summary = _load_optional_reset_common_window_summary(
        getattr(args, "reset_common_window_summary", None)
    )

    wfqs_score = None
    wfqs_breakdown: dict[str, float] = {}
    wfqs_v2_score = None
    wfqs_v2_breakdown: dict[str, float] = {}
    deployment_score = None
    deployment_breakdown: dict[str, float] = {}
    if walk_forward_summary is not None:
        wfqs_score, wfqs_breakdown = compute_wfqs(walk_forward_summary)
        wfqs_v2_score, wfqs_v2_breakdown = compute_wfqs_v2(walk_forward_summary)
        deployment_score, deployment_breakdown = compute_deployment_score(
            results.get("train"),
            results.get("valid"),
            results.get("test"),
            walk_forward_summary,
            rqs_score=rqs_score,
            wfqs_score=wfqs_score,
        )

    public_sqs, public_breakdown, public_source = compute_public_sqs(
        results.get("train"),
        results.get("valid"),
        results.get("test"),
        walk_forward_summary=walk_forward_summary,
        robustness_matrix_summary=robustness_matrix_summary,
        out_of_time_robustness_summary=out_of_time_robustness_summary,
        reset_common_window_summary=reset_common_window_summary,
        rqs_score=rqs_score,
        wfqs_score=wfqs_v2_score,
    )
    stress_sqs, stress_breakdown, _ = compute_public_sqs_v2(
        results.get("train"),
        results.get("valid"),
        results.get("test"),
        walk_forward_summary=walk_forward_summary,
        robustness_matrix_summary=robustness_matrix_summary,
        out_of_time_robustness_summary=out_of_time_robustness_summary,
        rqs_score=rqs_score,
        wfqs_v2_score=wfqs_v2_score,
    )
    if public_sqs is not None:
        sqs_score = public_sqs
        sqs_breakdown = public_breakdown

    reset_common_window_score = None
    reset_common_window_breakdown: dict[str, float] = {}
    if reset_common_window_summary is not None:
        reset_common_window_score, reset_common_window_breakdown = compute_reset_common_window_score(
            reset_common_window_summary
        )

    config_delta = None
    if args.baseline:
        config_delta = ConfigDelta(base_experiment=args.baseline, changes={})

    tags = [tag for tag in args.experiment.replace("-", "_").split("_") if tag]

    with journal_lock(journal_path):
        dupes = check_duplicate(journal_path, args.experiment)
        if dupes and not args.force:
            print(f"WARNING: experiment '{args.experiment}' already in journal ({len(dupes)} entries).")
            print("Use --force to add anyway.")
            sys.exit(1)

        entry_id = get_next_entry_id(journal_path)
        entry = JournalEntry(
            entry_id=entry_id,
            timestamp=utc_now().isoformat(),
            experiment_name=args.experiment,
            hypothesis=args.hypothesis or "",
            config_delta=config_delta,
            results=results,
            walk_forward_summary=walk_forward_summary,
            robustness_matrix_summary=robustness_matrix_summary,
            out_of_time_robustness_summary=out_of_time_robustness_summary,
            reset_common_window_summary=reset_common_window_summary,
            sqs_score=sqs_score,
            sqs_breakdown=sqs_breakdown,
            stress_sqs_score=stress_sqs,
            stress_sqs_breakdown=stress_breakdown,
            sqs_v2_score=sqs_v2_score,
            sqs_v2_breakdown=sqs_v2_breakdown,
            promotion_score=promotion_score,
            promotion_breakdown=promotion_breakdown,
            unified_score=unified_score,
            unified_breakdown=unified_breakdown,
            rqs_score=rqs_score,
            rqs_breakdown=rqs_breakdown,
            wfqs_score=wfqs_score,
            wfqs_breakdown=wfqs_breakdown,
            wfqs_v2_score=wfqs_v2_score,
            wfqs_v2_breakdown=wfqs_v2_breakdown,
            deployment_score=deployment_score,
            deployment_breakdown=deployment_breakdown,
            reset_common_window_score=reset_common_window_score,
            reset_common_window_breakdown=reset_common_window_breakdown,
            verdict=args.verdict or "unknown",
            verdict_reasoning=args.reasoning or "",
            next_direction=args.next or "",
            tags=tags,
        )

        append_journal_entry(journal_path, entry)
        _sync_and_rebuild(journal_path, registry_path, leaderboard_path)

    if sqs_score is None:
        print(f"Recorded {entry_id}: {args.experiment} (SQS=pending validation)")
    else:
        source_label = f", source={public_source}" if public_source else ""
        stress_label = f", stress={stress_sqs:.1f}" if stress_sqs is not None else ""
        print(f"Recorded {entry_id}: {args.experiment} (SQS={sqs_score}{stress_label}{source_label})")
    for split_name, sr in _ordered_splits(results):
        pf = f"PF={sr.profit_factor:.2f}" if sr.profit_factor is not None else "PF=-"
        ret = f"Ret={sr.total_return_pct:+.2f}%" if sr.total_return_pct is not None else "Ret=-"
        print(f"  {split_name}: {sr.trade_count} trades, {pf}, {ret}")
    print(f"Leaderboard updated: {leaderboard_path}")


def cmd_leaderboard(args: argparse.Namespace) -> None:
    """Show or regenerate the leaderboard."""
    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    registry_path = journal_dir / "experiment_registry.json"
    leaderboard_path = journal_dir / "LEADERBOARD.md"

    if not journal_path.exists():
        journal_path.parent.mkdir(parents=True, exist_ok=True)
        journal_path.touch()

    with journal_lock(journal_path):
        registry = _sync_and_rebuild(journal_path, registry_path, leaderboard_path)
    ranked_source = filter_registry_entries(
        registry.entries,
        include_retired=getattr(args, "include_retired", False),
    )
    displayed_leaderboard_path = leaderboard_path

    sort_by = getattr(args, "sort", "sqs")
    ranked_entries = sorted(ranked_source, key=lambda entry: _score_sort_key(sort_by, entry))
    total = len(ranked_entries)
    top_n = getattr(args, "top", 10)
    diagnostics = sort_by in {"deployment", "dep", "wfqs", "rqs", "promotion", "unified", "sqs2"}
    title = f"[bold cyan]Top {top_n} / {total}[/]  [dim]· {_title_for_sort(sort_by)} · Tr=train  V=valid  T=test[/]"

    tbl = Table(
        box=box.SIMPLE_HEAD,
        show_header=True,
        header_style="bold yellow",
        row_styles=["", "dim"],
        padding=(0, 1),
        title=title,
        title_justify="left",
        expand=False,
    )
    # Load experiment IDs from index for display
    _exp_id_map: dict[str, str] = {}
    try:
        import json as _json
        _idx = Path("configs/experiments/.index.json")
        if _idx.exists():
            _idx_data = _json.loads(_idx.read_text())
            for _ename, _emeta in _idx_data.get("experiments", {}).items():
                eid = _emeta.get("id")
                if eid is not None:
                    _exp_id_map[_ename] = str(eid)
    except Exception:
        pass

    tbl.add_column("ID", justify="right", style="bold dim", no_wrap=True, min_width=4)
    tbl.add_column("#", justify="right", style="bold", no_wrap=True, min_width=3)
    tbl.add_column("Experiment", no_wrap=True, min_width=30)
    tbl.add_column("SQS", justify="right", style="bold cyan", no_wrap=True, min_width=5)
    tbl.add_column("RCW", justify="right", style="bright_magenta", no_wrap=True, min_width=5)
    if diagnostics:
        if sort_by == "promotion":
            tbl.add_column("Promo", justify="right", style="bright_magenta", no_wrap=True, min_width=5)
        else:
            tbl.add_column("DEP", justify="right", style="bold green", no_wrap=True, min_width=5)
            tbl.add_column("WFQS", justify="right", style="bright_cyan", no_wrap=True, min_width=5)
            tbl.add_column("RQS", justify="right", style="bold magenta", no_wrap=True, min_width=5)
    tbl.add_column("Tr.Ret%", justify="right", style="bright_blue", no_wrap=True, min_width=7)
    tbl.add_column("V.Ret%", justify="right", style="green", no_wrap=True, min_width=6)
    tbl.add_column("T.Ret%", justify="right", no_wrap=True, min_width=6)
    tbl.add_column("T.Ann%", justify="right", no_wrap=True, min_width=7)
    tbl.add_column("T.DD%", justify="right", no_wrap=True, min_width=5)
    tbl.add_column("T.Gross%", justify="right", no_wrap=True, min_width=8)
    tbl.add_column("T.DIM%", justify="right", no_wrap=True, min_width=6)
    tbl.add_column("T.R/G", justify="right", no_wrap=True, min_width=5)

    for rank, entry in enumerate(ranked_entries[:top_n], 1):
        name = entry.experiment_name
        if len(name) > _COL_NAME:
            name = name[: _COL_NAME - 1] + "…"
        row = [
            _exp_id_map.get(entry.experiment_name, "—"),
            str(rank),
            name,
            _fmt(entry.sqs_score, ".1f"),
            _fmt(entry.reset_common_window_score, ".1f"),
        ]
        if diagnostics:
            if sort_by == "promotion":
                row.append(_fmt(entry.promotion_score, ".1f"))
            else:
                row.extend([
                    _fmt(entry.deployment_score, ".1f"),
                    _fmt(entry.wfqs_score, ".1f"),
                    _fmt(entry.rqs_score, ".1f"),
                ])
        row.extend([
            _fmt(entry.train_total_return_pct, "+.1f"),
            _fmt(entry.valid_total_return_pct, "+.1f"),
            _fmt(entry.total_return_pct, "+.1f"),
            _fmt(entry.annualized_return_pct, "+.1f"),
            _fmt(entry.max_drawdown_pct, ".1f"),
            _fmt(entry.avg_gross_exposure_pct, ".1f"),
            _fmt(entry.days_in_market_pct, ".1f"),
            _fmt(_ratio(entry.total_return_pct, entry.avg_gross_exposure_pct), ".2f"),
        ])
        tbl.add_row(*row)

    _console.print()
    _console.print(tbl)
    _console.print(f"  [dim]LEADERBOARD.md → {displayed_leaderboard_path}[/]\n")


def cmd_show(args: argparse.Namespace) -> None:
    """Show details of a specific journal entry."""
    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    registry_path = journal_dir / "experiment_registry.json"
    leaderboard_path = journal_dir / "LEADERBOARD.md"
    with journal_lock(journal_path):
        _sync_and_rebuild(journal_path, registry_path, leaderboard_path)
        entries = load_journal(journal_path)

    target = args.entry_id
    found = [entry for entry in entries if _entry_matches_target(entry, target)]
    if not found:
        found = [entry for entry in entries if target.lower() in entry.experiment_name.lower()]
    if not found:
        print(f"No entry found for: {target}")
        sys.exit(1)

    for entry in found:
        train_result = _hydrate_split_result(entry.results.get("train"))
        valid_result = _hydrate_split_result(entry.results.get("valid"))
        test_result = _hydrate_split_result(entry.results.get("test"))
        fresh_rqs_score, fresh_rqs_breakdown = compute_rqs(
            train_result,
            valid_result,
            test_result,
        )
        fresh_wfqs_score, fresh_wfqs_breakdown = compute_wfqs(entry.walk_forward_summary)
        fresh_wfqs_v2_score, _ = compute_wfqs_v2(entry.walk_forward_summary)
        fresh_deployment_score, fresh_deployment_breakdown = compute_deployment_score(
            train_result,
            valid_result,
            test_result,
            entry.walk_forward_summary,
            rqs_score=fresh_rqs_score,
            wfqs_score=fresh_wfqs_score,
        )
        public_sqs, public_breakdown, public_source = compute_public_sqs(
            train_result,
            valid_result,
            test_result,
            walk_forward_summary=entry.walk_forward_summary,
            robustness_matrix_summary=entry.robustness_matrix_summary,
            out_of_time_robustness_summary=entry.out_of_time_robustness_summary,
            common_window_summary=entry.common_window_summary,
            reset_common_window_summary=entry.reset_common_window_summary,
            multi_capital_common_window_summary=entry.multi_capital_common_window_summary,
            rqs_score=fresh_rqs_score,
            wfqs_score=fresh_wfqs_v2_score,
        )
        stress_sqs, _, _ = compute_public_sqs_v2(
            train_result,
            valid_result,
            test_result,
            walk_forward_summary=entry.walk_forward_summary,
            robustness_matrix_summary=entry.robustness_matrix_summary,
            out_of_time_robustness_summary=entry.out_of_time_robustness_summary,
            rqs_score=fresh_rqs_score,
            wfqs_v2_score=fresh_wfqs_v2_score,
        )
        if public_sqs is None:
            public_breakdown = public_breakdown or {}
            public_source = public_source or "pending_validation"

        print(f"\n{entry.entry_id} — {entry.experiment_name}")
        print(f"  Timestamp:  {entry.timestamp}")
        print(f"  Hypothesis: {entry.hypothesis}")
        print(f"  Verdict:    {entry.verdict}")
        pending_reason = _validation_pending_reason(entry)
        if public_sqs is None:
            print(f"  SQS:        pending [{public_source}]")
            if pending_reason:
                print(f"  Validation: {pending_reason}")
        else:
            print(f"  SQS:        {public_sqs} [{public_source}]")
            if stress_sqs is not None:
                print(f"  Stress SQS: {stress_sqs}")

        if entry.config_delta:
            print(f"  Baseline:   {entry.config_delta.base_experiment}")
            for key, value in entry.config_delta.changes.items():
                print(f"    {key}: {value}")

        hydrated_results = {
            "train": train_result,
            "valid": valid_result,
            "test": test_result,
            **{
                name: result
                for name, result in entry.results.items()
                if name not in {"train", "valid", "test"}
            },
        }
        for split_name, split_result in _ordered_splits(hydrated_results):
            pf = f"PF={split_result.profit_factor:.2f}" if split_result.profit_factor is not None else "PF=-"
            ret = f"Ret={split_result.total_return_pct:+.2f}%" if split_result.total_return_pct is not None else "Ret=-"
            ann = (
                f"Ann={split_result.annualized_return_pct:+.2f}%"
                if split_result.annualized_return_pct is not None
                else "Ann=-"
            )
            dd = f"DD={split_result.max_drawdown_pct:.2f}%" if split_result.max_drawdown_pct is not None else "DD=-"
            gross = (
                f"Gross={split_result.avg_gross_exposure_pct:.2f}%"
                if split_result.avg_gross_exposure_pct is not None
                else "Gross=-"
            )
            dim = (
                f"DIM={split_result.days_in_market_pct:.2f}%"
                if split_result.days_in_market_pct is not None
                else "DIM=-"
            )
            ret_on_gross = _ratio(split_result.total_return_pct, split_result.avg_gross_exposure_pct)
            rog = f"R/G={ret_on_gross:.2f}" if ret_on_gross is not None else "R/G=-"
            print(f"  {split_name}: {split_result.trade_count} trades, {pf}, {ret}, {ann}, {dd}, {gross}, {dim}, {rog}")

        if entry.walk_forward_summary is not None:
            summary = entry.walk_forward_summary
            test = summary.test_aggregate
            gap = summary.gap_stats
            print(
                "  WFV:        "
                f"{summary.fold_count} folds, "
                f"mean={_fmt(test.mean_return_pct, '+.2f')}%, "
                f"median={_fmt(test.median_return_pct, '+.2f')}%, "
                f"worst={_fmt(test.worst_return_pct, '+.2f')}%, "
                f"positive={_fmt(test.positive_fold_rate_pct, '.1f')}%"
            )
            print(
                "  WFV Gap:    "
                f"mean train-test gap={_fmt(gap.mean_train_test_return_gap_pct, '.2f')}%, "
                f"worst={_fmt(gap.worst_train_test_return_gap_pct, '.2f')}%"
            )

        if entry.robustness_matrix_summary is not None:
            summary = entry.robustness_matrix_summary
            h63 = next((item for item in summary.horizon_summaries if item.horizon_days == 63), None)
            h252 = next((item for item in summary.horizon_summaries if item.horizon_days == 252), None)
            print(
                "  Robust:     "
                f"windows={summary.overall_window_count}, "
                f"positive={_fmt(summary.overall_positive_window_rate_pct, '.1f')}%, "
                f"worst={_fmt(summary.overall_worst_return_pct, '+.2f')}%, "
                f"63d med={_fmt(h63.median_return_pct if h63 else None, '+.2f')}%, "
                f"252d med={_fmt(h252.median_return_pct if h252 else None, '+.2f')}%"
            )
            for horizon in summary.horizon_summaries:
                print(
                    "    "
                    f"{horizon.horizon_days:>3}d: "
                    f"n={horizon.window_count}, "
                    f"mean={_fmt(horizon.mean_return_pct, '+.2f')}%, "
                    f"median={_fmt(horizon.median_return_pct, '+.2f')}%, "
                    f"worst={_fmt(horizon.worst_return_pct, '+.2f')}%, "
                    f"positive={_fmt(horizon.positive_window_rate_pct, '.1f')}%, "
                    f"dd={_fmt(horizon.mean_max_drawdown_pct, '.2f')}%"
                )
        if entry.out_of_time_robustness_summary is not None:
            summary = entry.out_of_time_robustness_summary
            h63 = next((item for item in summary.horizon_summaries if item.horizon_days == 63), None)
            h252 = next((item for item in summary.horizon_summaries if item.horizon_days == 252), None)
            print(
                "  OOT Robust: "
                f"windows={summary.overall_window_count}, "
                f"positive={_fmt(summary.overall_positive_window_rate_pct, '.1f')}%, "
                f"worst={_fmt(summary.overall_worst_return_pct, '+.2f')}%, "
                f"63d med={_fmt(h63.median_return_pct if h63 else None, '+.2f')}%, "
                f"252d med={_fmt(h252.median_return_pct if h252 else None, '+.2f')}%"
            )

        if args.diagnostics:
            promotion_score = entry.promotion_score
            promotion_breakdown = entry.promotion_breakdown
            if promotion_score is None:
                promotion_score, promotion_breakdown = compute_promotion_score(
                    test_result,
                    valid_result,
                )
            unified_score = entry.unified_score
            unified_breakdown = entry.unified_breakdown
            if unified_score is None:
                unified_score, unified_breakdown = compute_unified_score(
                    test_result,
                    valid_result,
                )
            rqs_score = fresh_rqs_score
            rqs_breakdown = fresh_rqs_breakdown
            wfqs_score = fresh_wfqs_score
            wfqs_breakdown = fresh_wfqs_breakdown
            deployment_score = fresh_deployment_score
            deployment_breakdown = fresh_deployment_breakdown
            robustness_gate_factor, robustness_breakdown = compute_robustness_gate(
                entry.robustness_matrix_summary,
            )
            oot_gate_factor, oot_breakdown = compute_oot_robustness_gate(
                entry.out_of_time_robustness_summary,
            )
            print("  Diagnostics:")
            print(f"    Public SQS: {public_sqs} {public_breakdown}")
            if rqs_score is not None:
                print(f"    RQS:        {rqs_score} {rqs_breakdown}")
            if wfqs_score is not None:
                print(f"    WFQS:       {wfqs_score} {wfqs_breakdown}")
            wfqs_v2_score = entry.wfqs_v2_score
            wfqs_v2_breakdown = entry.wfqs_v2_breakdown
            if wfqs_v2_score is None:
                wfqs_v2_score, wfqs_v2_breakdown = compute_wfqs_v2(entry.walk_forward_summary)
            if wfqs_v2_score is not None:
                print(f"    WFQS v2:    {wfqs_v2_score} {wfqs_v2_breakdown}")
            if deployment_score is not None:
                print(f"    Deploy:     {deployment_score} {deployment_breakdown}")
            if promotion_score is not None:
                print(f"    Promotion:  {promotion_score} {promotion_breakdown}")
            if unified_score is not None:
                print(f"    Unified:    {unified_score} {unified_breakdown}")
            if entry.robustness_matrix_summary is not None:
                print(f"    RobustGate: {robustness_gate_factor:.2f} {robustness_breakdown}")
            if entry.out_of_time_robustness_summary is not None:
                print(f"    OOTGate:    {oot_gate_factor:.2f} {oot_breakdown}")

        if entry.verdict_reasoning:
            print(f"  Reasoning:  {entry.verdict_reasoning}")
        if entry.next_direction:
            print(f"  Next:       {entry.next_direction}")


def cmd_check_duplicate(args: argparse.Namespace) -> None:
    """Check if an experiment has been recorded already."""
    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    dupes = check_duplicate(journal_path, args.experiment)
    if dupes:
        print(f"FOUND {len(dupes)} existing entries for '{args.experiment}':")
        for entry in dupes:
            print(f"  {entry.entry_id} ({entry.timestamp[:10]}) — SQS={entry.sqs_score}, verdict={entry.verdict}")
    else:
        print(f"No existing entries for '{args.experiment}'.")


def cmd_attach_wfv(args: argparse.Namespace) -> None:
    """Attach a walk-forward summary to an existing journal entry."""
    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    registry_path = journal_dir / "experiment_registry.json"
    leaderboard_path = journal_dir / "LEADERBOARD.md"
    summary_path = Path(args.summary)

    if not journal_path.exists():
        print("No journal found. Run 'record' first.")
        sys.exit(1)
    if not summary_path.exists():
        print(f"ERROR: walk-forward summary not found: {summary_path}")
        sys.exit(1)

    summary = WalkForwardSummary.model_validate_json(summary_path.read_text())
    with journal_lock(journal_path):
        updated = attach_walk_forward_summary(journal_path, args.target, summary)
        _sync_and_rebuild(journal_path, registry_path, leaderboard_path)

    print(
        f"Attached WFV to {updated.entry_id}: {updated.experiment_name} "
        f"(WFQS={_fmt(updated.wfqs_score, '.1f')}, DEP={_fmt(updated.deployment_score, '.1f')}, folds={summary.fold_count})"
    )
    print(f"Leaderboard updated: {leaderboard_path}")


def cmd_attach_robustness(args: argparse.Namespace) -> None:
    """Attach a robustness matrix summary to an existing journal entry."""
    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    registry_path = journal_dir / "experiment_registry.json"
    leaderboard_path = journal_dir / "LEADERBOARD.md"
    summary_path = Path(args.summary)

    if not journal_path.exists():
        print("No journal found. Run 'record' first.")
        sys.exit(1)
    if not summary_path.exists():
        print(f"ERROR: robustness summary not found: {summary_path}")
        sys.exit(1)

    summary = RobustnessMatrixSummary.model_validate_json(summary_path.read_text())
    with journal_lock(journal_path):
        updated = attach_robustness_summary(journal_path, args.target, summary)
        _sync_and_rebuild(journal_path, registry_path, leaderboard_path)

    gate_factor, _ = compute_robustness_gate(summary)
    print(
        f"Attached robustness matrix to {updated.entry_id}: {updated.experiment_name} "
        f"(windows={summary.overall_window_count}, gate={gate_factor:.2f})"
    )
    print(f"Leaderboard updated: {leaderboard_path}")


def cmd_attach_oot_robustness(args: argparse.Namespace) -> None:
    """Attach out-of-time robustness matrix summary to an existing journal entry."""
    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    registry_path = journal_dir / "experiment_registry.json"
    leaderboard_path = journal_dir / "LEADERBOARD.md"
    summary_path = Path(args.summary)

    if not journal_path.exists():
        print("No journal found. Run 'record' first.")
        sys.exit(1)
    if not summary_path.exists():
        print(f"ERROR: robustness summary not found: {summary_path}")
        sys.exit(1)

    summary = RobustnessMatrixSummary.model_validate_json(summary_path.read_text())
    with journal_lock(journal_path):
        updated = attach_out_of_time_robustness_summary(journal_path, args.target, summary)
        _sync_and_rebuild(journal_path, registry_path, leaderboard_path)

    gate_factor, _ = compute_oot_robustness_gate(summary)
    print(
        f"Attached out-of-time robustness to {updated.entry_id}: {updated.experiment_name} "
        f"(windows={summary.overall_window_count}, gate={gate_factor:.2f})"
    )
    print(f"Leaderboard updated: {leaderboard_path}")


def cmd_attach_reset_common_window(args: argparse.Namespace) -> None:
    """Attach reset common-window summary to an existing journal entry."""
    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    registry_path = journal_dir / "experiment_registry.json"
    leaderboard_path = journal_dir / "LEADERBOARD.md"
    summary_path = Path(args.summary)

    if not journal_path.exists():
        print("No journal found. Run 'record' first.")
        sys.exit(1)
    if not summary_path.exists():
        print(f"ERROR: reset common-window summary not found: {summary_path}")
        sys.exit(1)

    summary = ResetCommonWindowSummary.model_validate_json(summary_path.read_text())
    with journal_lock(journal_path):
        updated = attach_reset_common_window_summary(journal_path, args.target, summary)
        _sync_and_rebuild(journal_path, registry_path, leaderboard_path)

    print(
        f"Attached reset common-window to {updated.entry_id}: {updated.experiment_name} "
        f"(RCW={_fmt(updated.reset_common_window_score, '.1f')}, segments={len(summary.segment_summaries)})"
    )
    print(f"Leaderboard updated: {leaderboard_path}")


def _run_scenario_tests_for_missing(
    journal_path: Path,
    selector,
) -> int:
    """Run scenario-test for entries that match selector but lack scenario_robustness_score.

    Returns the number of entries updated.
    """
    from libs.backtest.scenarios.robustness import run_scenario_test
    from libs.backtest.scenarios.scenarios import SCENARIO_GROUPS
    from libs.backtest.tracker import attach_scenario_robustness, load_journal

    entries = load_journal(journal_path)
    needs_scenario = [
        e for e in entries
        if selector(e)
        and e.scenario_robustness_score is None
        and e.walk_forward_summary is not None  # skip entries without WFV (SQS would be pending anyway)
    ]

    if not needs_scenario:
        return 0

    print(f"\n[scenario-test] {len(needs_scenario)} entries missing RRS — running now...")
    updated = 0
    for entry in needs_scenario:
        print(f"  Running {entry.experiment_name}...", end="", flush=True)
        try:
            report = run_scenario_test(
                experiment_name=entry.experiment_name,
                scenario_names=SCENARIO_GROUPS["all"],
                initial_equity=10_000.0,
            )
            breakdown = {
                "signal_integrity": report.signal_integrity,
                "breadth": report.breadth,
                "drawdown_resilience": report.drawdown_resilience,
                "regime_transition": report.regime_transition,
                "stability": report.stability,
            }
            attach_scenario_robustness(journal_path, entry.experiment_name, report.rrs, breakdown)
            print(f" RRS={report.rrs:.0f}  verdict={report.verdict}")
            updated += 1
        except Exception as exc:
            print(f" ERROR: {exc}")

    return updated


def cmd_rescore_public(args: argparse.Namespace) -> None:
    """Recompute stored public scores for matching journal entries."""
    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    registry_path = journal_dir / "experiment_registry.json"
    leaderboard_path = journal_dir / "LEADERBOARD.md"

    prefix = args.prefix or ""

    def _matches(entry: JournalEntry) -> bool:
        if prefix and not entry.experiment_name.startswith(prefix):
            return False
        if args.min_version is None and args.max_version is None:
            return True
        if not prefix:
            return False
        suffix = entry.experiment_name[len(prefix):]
        try:
            version = float(suffix)
        except ValueError:
            return False
        if args.min_version is not None and version < args.min_version:
            return False
        if args.max_version is not None and version > args.max_version:
            return False
        return True

    with journal_lock(journal_path):
        _run_scenario_tests_for_missing(journal_path, _matches)
        updated_count = refresh_public_scores(journal_path, selector=_matches)
        _sync_and_rebuild(journal_path, registry_path, leaderboard_path)

    print(f"Rescored entries: {updated_count}")
    print(f"Leaderboard updated: {leaderboard_path}")


def cmd_compute_sqs(args: argparse.Namespace) -> None:
    """Compute and display SQS v9 breakdown for a single journal entry."""
    from libs.backtest.tracker import (
        _resolve_journal_target,
        compute_public_sqs_v9,
        compute_rqs,
        compute_wfqs_v2,
    )

    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    registry_path = journal_dir / "experiment_registry.json"
    leaderboard_path = journal_dir / "LEADERBOARD.md"

    entries = load_journal(journal_path)
    try:
        entry = _resolve_journal_target(entries, args.target)
    except ValueError as exc:
        _console.print(f"[red]Error: {exc}[/red]")
        sys.exit(1)

    train_result = _hydrate_split_result(entry.results.get("train"))
    valid_result = _hydrate_split_result(entry.results.get("valid"))
    test_result = _hydrate_split_result(entry.results.get("test"))

    rqs_score, rqs_breakdown = compute_rqs(train_result, valid_result, test_result)
    wfqs_v2_score, wfqs_v2_breakdown = compute_wfqs_v2(entry.walk_forward_summary)

    sqs_score, sqs_breakdown, source = compute_public_sqs_v9(
        train_result, valid_result, test_result,
        walk_forward_summary=entry.walk_forward_summary,
        robustness_matrix_summary=entry.robustness_matrix_summary,
        out_of_time_robustness_summary=entry.out_of_time_robustness_summary,
        common_window_summary=entry.common_window_summary,
        reset_common_window_summary=entry.reset_common_window_summary,
        scenario_robustness_score=entry.scenario_robustness_score,
        rqs_score=rqs_score,
        wfqs_v2_score=wfqs_v2_score,
    )

    def _score_bar(score: float | None) -> str:
        if score is None:
            return "[dim]N/A[/dim]"
        if score >= 70:
            color = "green"
        elif score >= 40:
            color = "yellow"
        else:
            color = "red"
        return f"[{color}]{score:.1f}[/{color}]"

    regime_score = sqs_breakdown.get("regime_score")
    core_score = sqs_breakdown.get("core_score")
    deploy_gate = sqs_breakdown.get("deployment_gate_factor")
    rb_gate = sqs_breakdown.get("gate_factor")
    activity = sqs_breakdown.get("activity_factor")

    regime_src = "scenario_rrs" if entry.scenario_robustness_score is not None else (
        "oot_quality" if entry.out_of_time_robustness_summary is not None else "neutral_50"
    )

    lines = [
        f"[bold]SQS v9 Breakdown[/bold]",
        f"Experiment:  [cyan]{entry.experiment_name}[/cyan]",
        f"Entry ID:    [dim]{entry.entry_id}[/dim]",
        "",
        f"  RQS (35%):          {_score_bar(rqs_score)}",
        f"  WFQS_v2 (40%):      {_score_bar(wfqs_v2_score)}",
        f"  Regime (25%):       {_score_bar(regime_score)}  [dim]← {regime_src}[/dim]",
        f"  Core score:         {_score_bar(core_score)}",
        "",
        f"  Deploy gate:        [dim]{deploy_gate or '?'}[/dim]",
        f"  Robustness gate:    [dim]{rb_gate or '?'}[/dim]",
        f"  Activity factor:    [dim]{activity or '?'}[/dim]",
        "",
    ]

    if sqs_score is not None:
        # Show whether CW was blended
        if "v9_3pillar+reset_cw" in (source or "") or "v9_3pillar+cw" in (source or ""):
            cw_score = sqs_breakdown.get("cw_score") or sqs_breakdown.get("reset_cw_score")
            lines.append(f"  [bold]SQS v9: {_score_bar(sqs_score)}[/bold]  [dim]{source}[/dim]")
        else:
            lines.append(f"  [bold]SQS v9: {_score_bar(sqs_score)}[/bold]  [dim]{source}[/dim]")
    else:
        pending_key = next((k for k in sqs_breakdown if k.startswith("requires_")), None)
        reason = pending_key.replace("requires_", "").replace("_", " ") if pending_key else "missing data"
        lines.append(f"  [bold]SQS v9: [yellow]PENDING[/yellow][/bold]  [dim]waiting for: {reason}[/dim]")

    _console.print()
    _console.print(Panel("\n".join(lines), box=box.ROUNDED, width=70))

    # Optional: show stored vs live comparison
    if entry.sqs_score is not None and sqs_score is not None:
        delta = sqs_score - entry.sqs_score
        sign = "+" if delta >= 0 else ""
        _console.print(f"  [dim]Stored SQS: {entry.sqs_score:.1f}  →  Live: {sqs_score:.1f}  (delta {sign}{delta:.1f})[/dim]")

    if args.save:
        from libs.backtest.tracker import refresh_public_scores, _sync_and_rebuild
        target_name = entry.experiment_name

        def _only_this(e: JournalEntry) -> bool:
            return e.experiment_name == target_name

        with journal_lock(journal_path):
            refresh_public_scores(journal_path, selector=_only_this)
            _sync_and_rebuild(journal_path, registry_path, leaderboard_path)
        _console.print(f"  [green]Saved and leaderboard rebuilt.[/green]")
    _console.print()


def cmd_attach_overfit_check(args: argparse.Namespace) -> None:
    """Attach overfit-check score to an existing journal entry."""
    journal_dir = Path(args.journal_dir)
    journal_path = journal_dir / "improvement_journal.jsonl"
    registry_path = journal_dir / "experiment_registry.json"
    leaderboard_path = journal_dir / "LEADERBOARD.md"

    if not journal_path.exists():
        print("No journal found. Run 'record' first.")
        sys.exit(1)

    if args.report:
        import json as _json
        report_path = Path(args.report)
        if not report_path.exists():
            print(f"ERROR: report file not found: {report_path}")
            sys.exit(1)
        data = _json.loads(report_path.read_text())
        score = float(data.get("overall_score", data.get("score", 0.0)))
        breakdown: dict[str, float] = {
            k: float(v) for k, v in data.items()
            if isinstance(v, (int, float)) and k != "overall_score"
        }
    elif args.run:
        from libs.backtest.overfit import run_overfit_check
        from libs.backtest.tracker import load_journal as _lj
        entries = _lj(journal_path)
        try:
            from libs.backtest.tracker import _resolve_journal_target
            selected = _resolve_journal_target(entries, args.target)
        except ValueError as exc:
            print(f"ERROR: {exc}")
            sys.exit(1)
        print(f"Running overfit-check for {selected.experiment_name} (this may take 7-30 min)...")
        result = run_overfit_check(selected.experiment_name, quick=args.quick)
        score = result.overall_score
        breakdown = result.breakdown
    else:
        print("ERROR: specify --report PATH or --run")
        sys.exit(1)

    from libs.backtest.tracker import attach_overfit_check
    with journal_lock(journal_path):
        updated = attach_overfit_check(journal_path, args.target, score, breakdown)
        _sync_and_rebuild(journal_path, registry_path, leaderboard_path)

    print(
        f"Attached overfit-check to {updated.entry_id}: {updated.experiment_name} "
        f"(score={score:.1f})"
    )
    print(f"Leaderboard updated: {leaderboard_path}")


def _print_help() -> None:
    _console.print()
    _console.print(Panel(
        "[bold cyan]fithia2[/] — ACE-F Strategy Research & Paper Trading\n"
        "[dim]백테스트 실험 추적 + Alpaca 페이퍼 트레이딩[/]",
        border_style="cyan",
        padding=(0, 2),
    ))

    table = Table(box=box.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 2))
    table.add_column("Command", style="bold green", no_wrap=True)
    table.add_column("Description")
    table.add_column("Key Options", style="dim")
    table.add_row(
        "leaderboard  [dim]lb[/]",
        "기본 public SQS 순위표 출력 및 LEADERBOARD.md 재생성",
        "-n N  --sort sqs|rqs|wfqs|deployment|promotion  --include-retired",
    )
    table.add_row(
        "record  [dim]rec[/]",
        "실험 결과를 저널에 기록",
        "-e NAME  -H TEXT  --walk-forward-summary PATH  --robustness-summary PATH  --out-of-time-robustness-summary PATH",
    )
    table.add_row(
        "show  [dim]s[/]",
        "특정 저널 항목 상세 조회",
        "ENTRY_ID  --diagnostics",
    )
    table.add_row(
        "paper",
        "Alpaca 페이퍼 트레이딩  [dim](fithia2 paper 로 상세 확인)[/]",
        "start  run  positions  status  trades  ...",
    )
    table.add_row(
        "pipeline",
        "데이터 파이프라인 실행  [dim](fithia2 pipeline 로 상세 확인)[/]",
        "run  [dim]--step poller|fetcher|parser|features|labels[/]",
    )
    table.add_row(
        "exp",
        "실험 관리  [dim](fithia2 exp 로 상세 확인)[/]",
        "create  search  tree  info  diff  promote  retire  validate  migrate",
    )
    _console.print(table)

    _console.print(
        "  [dim]공통 옵션:[/]  [bold]--journal-dir[/] [dim](기본: journal/)[/]"
        "  [bold]--runs-dir[/] [dim](기본: runs/)[/]\n"
    )
    _console.print("  [bold]예시[/]")
    _console.print("    [green]fithia2 leaderboard[/]")
    _console.print("    [green]fithia2 record --experiment pead_v2 --hypothesis '...' --verdict better[/]")
    _console.print("    [green]fithia2 paper[/]")
    _console.print()


def main() -> None:
    # Delegate `fithia2 exp ...` to the experiment management CLI
    if len(sys.argv) >= 2 and sys.argv[1] == "exp":
        sys.argv = [sys.argv[0]] + sys.argv[2:]
        from apps.experiment.cli import main as exp_main
        exp_main()
        return

    # Delegate `fithia2 overfit-check ...` to the overfitting analysis CLI
    if len(sys.argv) >= 2 and sys.argv[1] == "overfit-check":
        sys.argv = [sys.argv[0]] + sys.argv[2:]
        from apps.overfit.cli import main as overfit_main
        overfit_main()
        return

    # Delegate `fithia2 scenario-test ...` to the synthetic scenario test CLI
    if len(sys.argv) >= 2 and sys.argv[1] == "scenario-test":
        sys.argv = [sys.argv[0]] + sys.argv[2:]
        from apps.scenario.cli import main as scenario_main
        scenario_main()
        return

    # Delegate `fithia2 paper ...` to the paper trader CLI
    if len(sys.argv) >= 2 and sys.argv[1] == "paper":
        sys.argv = [sys.argv[0]] + sys.argv[2:]
        from apps.paper_trader.cli import main as paper_main
        paper_main()
        return

    # fithia2 refresh [snapshot_id] — refresh Parquet snapshot
    if len(sys.argv) >= 2 and sys.argv[1] == "refresh":
        import asyncio
        from apps.paper_trader.backtest_sim import _refresh_snapshot
        from libs.backtest.snapshots import resolve_snapshot
        from rich.console import Console
        console = Console()

        snapshot_id = sys.argv[2] if len(sys.argv) >= 3 else None
        if not snapshot_id:
            # Auto-detect: use the most common snapshot_id from configs
            from pathlib import Path
            import json
            configs_dir = Path("configs/experiments")
            if configs_dir.exists():
                ids: dict[str, int] = {}
                for f in configs_dir.glob("*.json"):
                    try:
                        data = json.loads(f.read_text())
                        sid = data.get("dataset_snapshot_id", "")
                        if sid:
                            ids[sid] = ids.get(sid, 0) + 1
                    except Exception:
                        pass
                if ids:
                    snapshot_id = max(ids, key=ids.get)
                    console.print(f"[dim]Auto-detected snapshot: {snapshot_id}[/]")
            if not snapshot_id:
                console.print("[red]Usage: fithia2 refresh <snapshot_id>[/]")
                sys.exit(1)

        universe_profile = None
        if "midlarge" in snapshot_id:
            universe_profile = "midlarge-liquid-long-v1"
        elif "midwide" in snapshot_id:
            universe_profile = "midwide-liquid-long-v1"
        elif "smallcap" in snapshot_id:
            universe_profile = "smallcap-liquid-long-v1"

        try:
            resolution = resolve_snapshot(snapshot_id)
            if resolution.requested_snapshot_id != resolution.canonical_snapshot_id:
                console.print(
                    f"[bold]Refreshing snapshot:[/] {snapshot_id} "
                    f"[dim](resolved → {resolution.canonical_snapshot_id})[/]"
                )
            else:
                console.print(f"[bold]Refreshing snapshot:[/] {snapshot_id}")
        except Exception:
            console.print(f"[bold]Refreshing snapshot:[/] {snapshot_id}")
        try:
            asyncio.run(_refresh_snapshot(snapshot_id, universe_profile, console=console, manual=True))
            console.print("[bold green]Done.[/]")
        except Exception as exc:
            console.print(f"[bold red]Refresh failed: {exc}[/]")
            sys.exit(1)
        return

    # Delegate `fithia2 pipeline ...` to the pipeline CLI
    if len(sys.argv) >= 2 and sys.argv[1] == "pipeline":
        sys.argv = [sys.argv[0]] + sys.argv[2:]
        from apps.pipeline.cli import main as pipeline_main
        pipeline_main()
        return

    # Delegate `fithia2 web ...` to the web GUI server
    if len(sys.argv) >= 2 and sys.argv[1] == "web":
        from apps.web.main import run_server
        run_server()
        return

    if len(sys.argv) == 1:
        _print_help()
        sys.exit(0)

    parser = argparse.ArgumentParser(description="Strategy Improvement Tracker", add_help=True)
    sub = parser.add_subparsers(dest="command", required=True)

    journal_kwargs = {
        "default": _DEFAULT_JOURNAL_DIR,
        "help": f"Path to journal/ directory (default: {_DEFAULT_JOURNAL_DIR})",
    }

    for name in ("record", "rec"):
        subparser = sub.add_parser(name, help="Record an experiment to the journal")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("--runs-dir", default="runs", help="Path to runs output directory (default: runs)")
        subparser.add_argument("--experiment", "-e", required=True, help="Experiment name (matches manifest)")
        subparser.add_argument("--hypothesis", "-H", help="What you expected this change to do")
        subparser.add_argument("--baseline", "-b", help="Baseline experiment name for comparison")
        subparser.add_argument("--verdict", "-v", choices=["better", "worse", "neutral", "unknown"], default="unknown")
        subparser.add_argument("--reasoning", "-r", help="Why this verdict")
        subparser.add_argument("--next", "-n", help="Next experiment direction")
        subparser.add_argument("--walk-forward-summary", help="Optional path to walk_forward_summary.json")
        subparser.add_argument("--robustness-summary", help="Optional path to robustness_matrix_summary.json")
        subparser.add_argument(
            "--out-of-time-robustness-summary",
            help="Optional path to out-of-time robustness_matrix_summary.json",
        )
        subparser.add_argument(
            "--reset-common-window-summary",
            help="Optional path to reset_common_window_summary.json",
        )
        subparser.add_argument("--force", "-f", action="store_true", help="Allow duplicate experiment names")

    for name in ("leaderboard", "lb"):
        subparser = sub.add_parser(name, help="Show/regenerate the leaderboard")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("--top", "-n", type=int, default=10, help="Show top N entries (default: 10)")
        subparser.add_argument(
            "--sort",
            choices=["sqs", "rqs", "wfqs", "deployment", "dep", "promotion", "unified", "sqs2"],
            default="sqs",
            help="Sort by public SQS (default) or internal diagnostics",
        )
        subparser.add_argument(
            "--include-retired",
            action="store_true",
            help="Include retired pre-IMP-0606 research and retired legacy families",
        )

    for name in ("show", "s"):
        subparser = sub.add_parser(name, help="Show details of a journal entry")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("entry_id", help="Entry ID (IMP-0001) or experiment name")
        subparser.add_argument("--diagnostics", action="store_true", help="Show internal RQS/WFQS/DEP diagnostics")

    for name in ("check-duplicate", "dup"):
        subparser = sub.add_parser(name, help="Check if experiment already recorded")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("--experiment", "-e", required=True, help="Experiment name to check")

    for name in ("attach-wfv", "awf"):
        subparser = sub.add_parser(name, help="Attach walk-forward summary to an existing journal entry")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("target", help="Entry ID or experiment name to update")
        subparser.add_argument("--summary", required=True, help="Path to walk_forward_summary.json")

    for name in ("attach-robustness", "arb"):
        subparser = sub.add_parser(name, help="Attach robustness matrix summary to an existing journal entry")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("target", help="Entry ID or experiment name to update")
        subparser.add_argument("--summary", required=True, help="Path to robustness_matrix_summary.json")

    for name in ("attach-oot-robustness", "aoot"):
        subparser = sub.add_parser(name, help="Attach out-of-time robustness summary to an existing journal entry")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("target", help="Entry ID or experiment name to update")
        subparser.add_argument("--summary", required=True, help="Path to robustness_matrix_summary.json")

    for name in ("attach-reset-common-window", "arcw"):
        subparser = sub.add_parser(name, help="Attach reset common-window summary to an existing journal entry")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("target", help="Entry ID or experiment name to update")
        subparser.add_argument("--summary", required=True, help="Path to reset_common_window_summary.json")

    for name in ("rescore-public", "rsp"):
        subparser = sub.add_parser(name, help="Recompute stored public scores for journal entries")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("--prefix", help="Only rescore experiment names with this prefix")
        subparser.add_argument("--min-version", type=float, help="Minimum numeric suffix after --prefix")
        subparser.add_argument("--max-version", type=float, help="Maximum numeric suffix after --prefix")

    for name in ("compute-sqs", "cs"):
        subparser = sub.add_parser(name, help="Compute and display SQS v9 breakdown for a single entry")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("target", help="Entry ID, experiment name, or partial name")
        subparser.add_argument("--save", action="store_true", help="Save recomputed score back to journal and rebuild leaderboard")

    for name in ("attach-overfit-check", "aoc"):
        subparser = sub.add_parser(name, help="Attach overfit-check score to an existing journal entry")
        subparser.add_argument("--journal-dir", **journal_kwargs)
        subparser.add_argument("target", help="Entry ID or experiment name to update")
        subparser.add_argument("--report", default=None, help="Path to pre-generated overfit-check JSON report")
        subparser.add_argument("--run", action="store_true", help="Run overfit-check directly (7-30 min)")
        subparser.add_argument("--quick", action="store_true", help="Quick mode when using --run")

    args = parser.parse_args()
    dispatch = {
        "record": cmd_record,
        "rec": cmd_record,
        "leaderboard": cmd_leaderboard,
        "lb": cmd_leaderboard,
        "show": cmd_show,
        "s": cmd_show,
        "check-duplicate": cmd_check_duplicate,
        "dup": cmd_check_duplicate,
        "attach-wfv": cmd_attach_wfv,
        "awf": cmd_attach_wfv,
        "attach-robustness": cmd_attach_robustness,
        "arb": cmd_attach_robustness,
        "attach-oot-robustness": cmd_attach_oot_robustness,
        "aoot": cmd_attach_oot_robustness,
        "attach-reset-common-window": cmd_attach_reset_common_window,
        "arcw": cmd_attach_reset_common_window,
        "rescore-public": cmd_rescore_public,
        "rsp": cmd_rescore_public,
        "compute-sqs": cmd_compute_sqs,
        "cs": cmd_compute_sqs,
        "attach-overfit-check": cmd_attach_overfit_check,
        "aoc": cmd_attach_overfit_check,
    }
    dispatch[args.command](args)


if __name__ == "__main__":
    main()
