"""Strategy improvement tracker: SQS computation, journal I/O, leaderboard."""
from __future__ import annotations

import contextlib
import functools
import fcntl
import json
from pathlib import Path
from typing import Any, Iterator

from libs.backtest.domain import (
    ConfigDelta,
    ExperimentRegistry,
    JournalEntry,
    MetricsBundle,
    PromotionScoreWeights,
    RegistryEntry,
    SplitResult,
    SQSWeights,
    SQSv2Weights,
    UnifiedScoreWeights,
)
from libs.common.logging import get_logger
from libs.common.time_utils import utc_now

logger = get_logger(__name__)

_DEFAULT_WEIGHTS = SQSWeights()
_DEFAULT_V2_WEIGHTS = SQSv2Weights()
_DEFAULT_PROMOTION_WEIGHTS = PromotionScoreWeights()
_DEFAULT_UNIFIED_WEIGHTS = UnifiedScoreWeights()


# ---------------------------------------------------------------------------
# SQS computation (pure functions, no I/O)
# ---------------------------------------------------------------------------


def _normalize(value: float | None, low: float, high: float) -> float:
    """Linear normalise *value* to 0-100 between *low* (0 pts) and *high* (100 pts)."""
    if value is None:
        return 0.0
    if high == low:
        return 50.0
    score = (value - low) / (high - low) * 100.0
    return max(0.0, min(100.0, score))


def _normalize_inverse(value: float | None, low: float, high: float) -> float:
    """Like _normalize but lower values are better (e.g. drawdown)."""
    if value is None:
        return 0.0
    if high == low:
        return 50.0
    # low = worst (0 pts), high = best (100 pts) — but for inverse metrics
    # low value = good, high value = bad.  Swap the interpretation.
    score = (low - value) / (low - high) * 100.0
    return max(0.0, min(100.0, score))


def _normalize_band(
    value: float | None,
    low_bad: float,
    low_good: float,
    high_good: float,
    high_bad: float,
) -> float:
    """Score 0-100 with an optimal middle band."""
    if value is None:
        return 0.0
    if value <= low_bad or value >= high_bad:
        return 0.0
    if low_good <= value <= high_good:
        return 100.0
    if value < low_good:
        return (value - low_bad) / (low_good - low_bad) * 100.0
    return (high_bad - value) / (high_bad - high_good) * 100.0


def _calibrate_sqs(value: float | None) -> float | None:
    """Compress raw integrated scores into a harsher absolute-looking range."""
    if value is None:
        return None
    return round(max(0.0, value * 0.70 - 7.5), 1)


def _apply_single_split_penalty(value: float | None) -> float | None:
    """Discount scores that have no valid/test confirmation pair."""
    if value is None:
        return None
    return round(value * 0.80, 1)


def compute_sqs(
    metrics: MetricsBundle,
    weights: SQSWeights | None = None,
) -> tuple[float, dict[str, float]]:
    """Compute Strategy Quality Score from test-split metrics.

    Returns (sqs_score, breakdown_dict).
    """
    w = weights or _DEFAULT_WEIGHTS

    # --- Profitability (40%) ---
    pf_score = _normalize(metrics.profit_factor, low=0.8, high=2.0)
    ret_score = _normalize(metrics.total_return_pct, low=-5.0, high=5.0)
    profitability = pf_score * 0.6 + ret_score * 0.4

    # --- Risk (25%) ---
    dd_score = _normalize_inverse(metrics.max_drawdown_pct, low=10.0, high=1.0)
    sharpe_score = _normalize(metrics.sharpe_ratio, low=-1.0, high=2.0)
    risk = dd_score * 0.5 + sharpe_score * 0.5

    # --- Consistency (20%) ---
    wr_score = _normalize(metrics.win_rate, low=0.35, high=0.65)
    mwr_score = _normalize(metrics.monthly_win_rate, low=0.30, high=0.70)
    consistency = wr_score * 0.5 + mwr_score * 0.5

    # --- Robustness (15%) ---
    r2_score = _normalize(metrics.equity_curve_r_squared, low=0.0, high=0.80)
    tc_score = _normalize(float(metrics.trade_count), low=10.0, high=100.0)
    robustness = r2_score * 0.5 + tc_score * 0.5

    # Weighted total
    sqs = (
        profitability * w.profitability
        + risk * w.risk
        + consistency * w.consistency
        + robustness * w.robustness
    )

    # Low-trade penalty
    if metrics.trade_count < w.low_trade_penalty_threshold:
        sqs *= w.low_trade_penalty_factor

    sqs = round(sqs, 1)
    breakdown = {
        "profitability": round(profitability, 1),
        "risk": round(risk, 1),
        "consistency": round(consistency, 1),
        "robustness": round(robustness, 1),
    }
    return sqs, breakdown


def compute_sqs_v2(
    metrics: MetricsBundle,
    weights: SQSv2Weights | None = None,
) -> tuple[float | None, dict[str, float]]:
    """Compute SQS v2, adding a capital-efficiency sleeve to the score."""
    if metrics.avg_gross_exposure_pct is None or metrics.days_in_market_pct is None:
        return None, {}

    w = weights or _DEFAULT_V2_WEIGHTS

    pf_score = _normalize(metrics.profit_factor, low=0.8, high=2.0)
    ret_score = _normalize(metrics.total_return_pct, low=-5.0, high=5.0)
    profitability = pf_score * 0.6 + ret_score * 0.4

    dd_score = _normalize_inverse(metrics.max_drawdown_pct, low=10.0, high=1.0)
    sharpe_score = _normalize(metrics.sharpe_ratio, low=-1.0, high=2.0)
    risk = dd_score * 0.5 + sharpe_score * 0.5

    wr_score = _normalize(metrics.win_rate, low=0.35, high=0.65)
    mwr_score = _normalize(metrics.monthly_win_rate, low=0.30, high=0.70)
    consistency = wr_score * 0.5 + mwr_score * 0.5

    r2_score = _normalize(metrics.equity_curve_r_squared, low=0.0, high=0.80)
    tc_score = _normalize(float(metrics.trade_count), low=10.0, high=100.0)
    robustness = r2_score * 0.5 + tc_score * 0.5

    return_on_gross_exposure = None
    if metrics.total_return_pct is not None and metrics.avg_gross_exposure_pct > 0:
        return_on_gross_exposure = metrics.total_return_pct / metrics.avg_gross_exposure_pct
    roe_score = _normalize(return_on_gross_exposure, low=0.0, high=0.50)
    dim_score = _normalize_band(
        metrics.days_in_market_pct,
        low_bad=10.0,
        low_good=40.0,
        high_good=80.0,
        high_bad=100.0,
    )
    capital_efficiency = roe_score * 0.7 + dim_score * 0.3

    sqs_v2 = (
        profitability * w.profitability
        + risk * w.risk
        + consistency * w.consistency
        + robustness * w.robustness
        + capital_efficiency * w.capital_efficiency
    )

    if metrics.trade_count < w.low_trade_penalty_threshold:
        sqs_v2 *= w.low_trade_penalty_factor

    sqs_v2 = round(sqs_v2, 1)
    breakdown = {
        "profitability": round(profitability, 1),
        "risk": round(risk, 1),
        "consistency": round(consistency, 1),
        "robustness": round(robustness, 1),
        "capital_efficiency": round(capital_efficiency, 1),
    }
    return sqs_v2, breakdown


# ---------------------------------------------------------------------------
# Helper builders
# ---------------------------------------------------------------------------


def build_split_result(split_name: str, run_id: str, metrics: MetricsBundle) -> SplitResult:
    """Create a SplitResult from MetricsBundle."""
    return SplitResult(
        run_id=run_id,
        trade_count=metrics.trade_count,
        profit_factor=metrics.profit_factor,
        total_return_pct=metrics.total_return_pct,
        win_rate=metrics.win_rate,
        max_drawdown_pct=metrics.max_drawdown_pct,
        sharpe_ratio=metrics.sharpe_ratio,
        monthly_win_rate=metrics.monthly_win_rate,
        equity_curve_r_squared=metrics.equity_curve_r_squared,
        avg_gross_exposure_pct=metrics.avg_gross_exposure_pct,
        avg_net_exposure_pct=metrics.avg_net_exposure_pct,
        days_in_market_pct=metrics.days_in_market_pct,
    )


def _metrics_from_split_result(result: SplitResult | None) -> MetricsBundle | None:
    if result is None:
        return None
    payload = result.model_dump(exclude={"run_id"})
    return MetricsBundle.model_validate(payload)


@functools.lru_cache(maxsize=512)
def _load_run_metrics_summary(run_id: str) -> dict[str, Any] | None:
    metrics_path = Path("runs") / run_id / "metrics" / "metrics_summary.json"
    if not metrics_path.exists():
        return None
    summary = json.loads(metrics_path.read_text())
    needed_fields = (
        "avg_gross_exposure_pct",
        "avg_net_exposure_pct",
        "days_in_market_pct",
    )
    if all(summary.get(field) is not None for field in needed_fields):
        return summary

    equity_curve_path = Path("runs") / run_id / "artifacts" / "daily_equity_curve.parquet"
    if not equity_curve_path.exists():
        return summary

    try:
        import pyarrow.parquet as pq

        rows = pq.read_table(
            equity_curve_path,
            columns=["equity", "gross_exposure", "net_exposure"],
        ).to_pylist()
    except Exception:
        return summary

    exposure_rows = [row for row in rows if float(row["equity"]) > 0]
    if not exposure_rows:
        return summary

    if summary.get("avg_gross_exposure_pct") is None:
        summary["avg_gross_exposure_pct"] = sum(
            float(row["gross_exposure"]) / float(row["equity"]) * 100.0
            for row in exposure_rows
        ) / len(exposure_rows)
    if summary.get("avg_net_exposure_pct") is None:
        summary["avg_net_exposure_pct"] = sum(
            float(row["net_exposure"]) / float(row["equity"]) * 100.0
            for row in exposure_rows
        ) / len(exposure_rows)
    if summary.get("days_in_market_pct") is None:
        summary["days_in_market_pct"] = (
            sum(1 for row in rows if float(row["gross_exposure"]) > 0) / len(rows) * 100.0
        )

    return summary


def _hydrate_split_result(result: SplitResult | None) -> SplitResult | None:
    if result is None:
        return None
    needed_fields = (
        "avg_gross_exposure_pct",
        "avg_net_exposure_pct",
        "days_in_market_pct",
    )
    if all(getattr(result, field) is not None for field in needed_fields):
        return result
    summary = _load_run_metrics_summary(result.run_id)
    if summary is None:
        return result
    updates = {
        field: summary.get(field)
        for field in needed_fields
        if getattr(result, field) is None and summary.get(field) is not None
    }
    if not updates:
        return result
    return result.model_copy(update=updates)


def _compute_split_quality_score(
    result: SplitResult | None,
) -> tuple[float | None, dict[str, float], str | None]:
    metrics = _metrics_from_split_result(_hydrate_split_result(result))
    if metrics is None:
        return None, {}, None
    sqs_v2_score, sqs_v2_breakdown = compute_sqs_v2(metrics)
    if sqs_v2_score is not None:
        return sqs_v2_score, sqs_v2_breakdown, "sqs_v2"
    sqs_score, sqs_breakdown = compute_sqs(metrics)
    return sqs_score, sqs_breakdown, "sqs"


def compute_unified_split_quality(
    metrics: MetricsBundle,
    weights: UnifiedScoreWeights | None = None,
) -> tuple[float | None, dict[str, float]]:
    """Compute a stricter split-level quality score."""
    if metrics.avg_gross_exposure_pct is None or metrics.days_in_market_pct is None:
        return None, {}

    w = weights or _DEFAULT_UNIFIED_WEIGHTS

    pf_score = _normalize(metrics.profit_factor, low=1.0, high=3.0)
    ret_score = _normalize(metrics.total_return_pct, low=0.0, high=3.0)
    profitability = pf_score * 0.5 + ret_score * 0.5

    dd_score = _normalize_inverse(metrics.max_drawdown_pct, low=5.0, high=0.5)
    sharpe_score = _normalize(metrics.sharpe_ratio, low=0.0, high=3.5)
    risk = dd_score * 0.5 + sharpe_score * 0.5

    wr_score = _normalize(metrics.win_rate, low=0.50, high=0.65)
    mwr_score = _normalize(metrics.monthly_win_rate, low=0.45, high=0.75)
    consistency = wr_score * 0.5 + mwr_score * 0.5

    r2_score = _normalize(metrics.equity_curve_r_squared, low=0.10, high=0.90)
    tc_score = _normalize(float(metrics.trade_count), low=20.0, high=80.0)
    robustness = r2_score * 0.5 + tc_score * 0.5

    return_on_gross_exposure = None
    if metrics.total_return_pct is not None and metrics.avg_gross_exposure_pct > 0:
        return_on_gross_exposure = metrics.total_return_pct / metrics.avg_gross_exposure_pct
    roe_score = _normalize(return_on_gross_exposure, low=0.05, high=0.40)
    dim_score = _normalize_band(
        metrics.days_in_market_pct,
        low_bad=20.0,
        low_good=45.0,
        high_good=75.0,
        high_bad=90.0,
    )
    capital_efficiency = roe_score * 0.7 + dim_score * 0.3

    split_quality = (
        profitability * w.split_profitability
        + risk * w.split_risk
        + consistency * w.split_consistency
        + robustness * w.split_robustness
        + capital_efficiency * w.split_capital_efficiency
    )
    if metrics.trade_count < 20:
        split_quality *= 0.75

    split_quality = round(split_quality, 1)
    breakdown = {
        "profitability": round(profitability, 1),
        "risk": round(risk, 1),
        "consistency": round(consistency, 1),
        "robustness": round(robustness, 1),
        "capital_efficiency": round(capital_efficiency, 1),
    }
    return split_quality, breakdown


def compute_promotion_score(
    test_result: SplitResult | None,
    valid_result: SplitResult | None,
    weights: PromotionScoreWeights | None = None,
) -> tuple[float | None, dict[str, float]]:
    """Compute a promotion score using valid/test quality and a floor term."""
    test_score, _, _ = _compute_split_quality_score(test_result)
    valid_score, _, _ = _compute_split_quality_score(valid_result)
    if test_score is None or valid_score is None:
        return None, {}

    w = weights or _DEFAULT_PROMOTION_WEIGHTS
    floor_score = min(test_score, valid_score)
    promotion_score = (
        valid_score * w.valid_quality
        + test_score * w.test_quality
        + floor_score * w.floor_quality
    )
    breakdown = {
        "valid_quality": round(valid_score, 1),
        "test_quality": round(test_score, 1),
        "floor_quality": round(floor_score, 1),
    }
    return round(promotion_score, 1), breakdown


def compute_unified_score(
    test_result: SplitResult | None,
    valid_result: SplitResult | None,
    weights: UnifiedScoreWeights | None = None,
) -> tuple[float | None, dict[str, float]]:
    """Compute one integrated score for real promotion decisions."""
    test_result = _hydrate_split_result(test_result)
    valid_result = _hydrate_split_result(valid_result)
    test_metrics = _metrics_from_split_result(test_result)
    valid_metrics = _metrics_from_split_result(valid_result)
    if test_metrics is None or valid_metrics is None:
        return None, {}

    w = weights or _DEFAULT_UNIFIED_WEIGHTS
    test_quality, _ = compute_unified_split_quality(test_metrics, w)
    valid_quality, _ = compute_unified_split_quality(valid_metrics, w)
    if test_quality is None or valid_quality is None:
        return None, {}

    floor_quality = min(test_quality, valid_quality)
    gap_quality = _normalize_inverse(abs(valid_quality - test_quality), low=35.0, high=5.0)
    unified_score = (
        valid_quality * w.valid_quality
        + test_quality * w.test_quality
        + floor_quality * w.floor_quality
        + gap_quality * w.gap_quality
    )
    breakdown = {
        "valid_quality": round(valid_quality, 1),
        "test_quality": round(test_quality, 1),
        "floor_quality": round(floor_quality, 1),
        "gap_quality": round(gap_quality, 1),
    }
    return _calibrate_sqs(unified_score), breakdown


def compute_public_sqs(
    test_result: SplitResult | None,
    valid_result: SplitResult | None,
) -> tuple[float | None, dict[str, float], str | None]:
    """Return the public-facing SQS.

    Prefer the stricter integrated score when both valid/test are available.
    Fall back to the best available single-split quality score otherwise,
    using the same harsher calibration band.
    """
    integrated_score, integrated_breakdown = compute_unified_score(test_result, valid_result)
    if integrated_score is not None:
        return integrated_score, integrated_breakdown, "integrated"

    split_score, split_breakdown, split_source = _compute_split_quality_score(test_result)
    if split_score is not None:
        return _apply_single_split_penalty(_calibrate_sqs(split_score)), split_breakdown, split_source

    return None, {}, None


def compute_config_delta(
    current: dict[str, Any],
    baseline: dict[str, Any],
    baseline_name: str,
) -> ConfigDelta:
    """Compute a flat diff between two config dicts (one level deep)."""
    changes: dict[str, str] = {}
    _diff_recursive(baseline, current, prefix="", changes=changes)
    return ConfigDelta(base_experiment=baseline_name, changes=changes)


def _diff_recursive(
    old: dict[str, Any],
    new: dict[str, Any],
    prefix: str,
    changes: dict[str, str],
) -> None:
    all_keys = set(old.keys()) | set(new.keys())
    for key in sorted(all_keys):
        full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
        old_val = old.get(key)
        new_val = new.get(key)
        if isinstance(old_val, dict) and isinstance(new_val, dict):
            _diff_recursive(old_val, new_val, full_key, changes)
        elif old_val != new_val:
            changes[full_key] = f"{old_val} \u2192 {new_val}"


# ---------------------------------------------------------------------------
# Journal I/O
# ---------------------------------------------------------------------------


def get_next_entry_id(journal_path: Path) -> str:
    """Return next entry ID like 'IMP-0001'."""
    entries = load_journal(journal_path)
    return f"IMP-{len(entries) + 1:04d}"


@contextlib.contextmanager
def journal_lock(journal_path: Path) -> Iterator[None]:
    """Serialize journal mutations across concurrent record commands."""
    journal_path.parent.mkdir(parents=True, exist_ok=True)
    lock_path = journal_path.with_suffix(f"{journal_path.suffix}.lock")
    with lock_path.open("a+") as lock_file:
        fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
        try:
            yield
        finally:
            fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)


def append_journal_entry(journal_path: Path, entry: JournalEntry) -> None:
    """Append a single JournalEntry as one JSON line."""
    journal_path.parent.mkdir(parents=True, exist_ok=True)
    with open(journal_path, "a") as f:
        f.write(entry.model_dump_json() + "\n")
    logger.info("journal_entry_appended", entry_id=entry.entry_id, experiment=entry.experiment_name)


def load_journal(journal_path: Path) -> list[JournalEntry]:
    """Load all journal entries from JSONL file."""
    if not journal_path.exists():
        return []
    entries: list[JournalEntry] = []
    for line in journal_path.read_text().strip().splitlines():
        line = line.strip()
        if line:
            entries.append(JournalEntry.model_validate_json(line))
    return entries


# ---------------------------------------------------------------------------
# Registry / Leaderboard
# ---------------------------------------------------------------------------


def rebuild_registry(
    journal_path: Path,
    registry_path: Path,
    leaderboard_path: Path,
) -> ExperimentRegistry:
    """Rebuild experiment_registry.json and LEADERBOARD.md from journal."""
    entries = load_journal(journal_path)

    registry_entries: list[RegistryEntry] = []
    for je in entries:
        test_result = _hydrate_split_result(je.results.get("test"))
        valid_result = _hydrate_split_result(je.results.get("valid"))
        computed_sqs_v2 = None
        test_metrics = _metrics_from_split_result(test_result)
        if test_metrics is not None:
            computed_sqs_v2, _ = compute_sqs_v2(test_metrics)
        computed_promotion_score, _ = compute_promotion_score(test_result, valid_result)
        computed_unified_score, _ = compute_unified_score(test_result, valid_result)
        computed_public_sqs, _, _ = compute_public_sqs(test_result, valid_result)
        canonical_sqs = computed_public_sqs if computed_public_sqs is not None else je.sqs_score or 0.0
        registry_entries.append(
            RegistryEntry(
                entry_id=je.entry_id,
                experiment_name=je.experiment_name,
                sqs_score=canonical_sqs,
                sqs_v2_score=je.sqs_v2_score if je.sqs_v2_score is not None else computed_sqs_v2,
                promotion_score=(
                    je.promotion_score if je.promotion_score is not None else computed_promotion_score
                ),
                unified_score=(
                    je.unified_score if je.unified_score is not None else computed_unified_score
                ),
                profit_factor=test_result.profit_factor if test_result else None,
                total_return_pct=test_result.total_return_pct if test_result else None,
                win_rate=test_result.win_rate if test_result else None,
                sharpe_ratio=test_result.sharpe_ratio if test_result else None,
                max_drawdown_pct=test_result.max_drawdown_pct if test_result else None,
                trade_count=test_result.trade_count if test_result else 0,
                avg_gross_exposure_pct=test_result.avg_gross_exposure_pct if test_result else None,
                avg_net_exposure_pct=test_result.avg_net_exposure_pct if test_result else None,
                days_in_market_pct=test_result.days_in_market_pct if test_result else None,
                valid_profit_factor=valid_result.profit_factor if valid_result else None,
                valid_total_return_pct=valid_result.total_return_pct if valid_result else None,
                valid_win_rate=valid_result.win_rate if valid_result else None,
                valid_sharpe_ratio=valid_result.sharpe_ratio if valid_result else None,
                valid_max_drawdown_pct=valid_result.max_drawdown_pct if valid_result else None,
                valid_trade_count=valid_result.trade_count if valid_result else 0,
                valid_avg_gross_exposure_pct=valid_result.avg_gross_exposure_pct if valid_result else None,
                valid_avg_net_exposure_pct=valid_result.avg_net_exposure_pct if valid_result else None,
                valid_days_in_market_pct=valid_result.days_in_market_pct if valid_result else None,
                timestamp=je.timestamp,
            )
        )

    # Sort by canonical SQS descending, then promotion.
    registry_entries.sort(
        key=lambda e: (
            -(e.sqs_score or 0.0),
            e.promotion_score is None,
            -(e.promotion_score or 0.0),
        )
    )

    registry = ExperimentRegistry(
        entries=registry_entries,
        updated_at=utc_now().isoformat(),
    )

    # Write registry JSON
    registry_path.parent.mkdir(parents=True, exist_ok=True)
    registry_path.write_text(registry.model_dump_json(indent=2))

    # Write LEADERBOARD.md
    _write_leaderboard_md(leaderboard_path, registry, entries)

    logger.info("registry_rebuilt", count=len(registry_entries))
    return registry


def _write_leaderboard_md(
    path: Path,
    registry: ExperimentRegistry,
    journal_entries: list[JournalEntry],
) -> None:
    lines: list[str] = []
    lines.append("# Strategy Improvement Leaderboard")
    lines.append(f"_Updated: {registry.updated_at}_\n")
    lines.append("| # | Experiment | SQS | [T]PF | [T]Ret% | [T]WR | [T]Sharpe | [T]DD% | [T]N | [T]Gross% | [T]Net% | [T]DIM% | [V]PF | [V]Ret% | [V]WR | [V]Sharpe | [V]DD% | [V]N | [V]Gross% | [V]Net% | [V]DIM% | Date |")
    lines.append("|---|-----------|-----|-------|---------|-------|-----------|--------|------|-----------|---------|---------|-------|---------|-------|-----------|--------|------|-----------|---------|---------|------|")

    for rank, e in enumerate(registry.entries, 1):
        pf = f"{e.profit_factor:.2f}" if e.profit_factor is not None else "-"
        ret = f"{e.total_return_pct:+.1f}" if e.total_return_pct is not None else "-"
        wr = f"{e.win_rate:.0%}" if e.win_rate is not None else "-"
        sharpe = f"{e.sharpe_ratio:.1f}" if e.sharpe_ratio is not None else "-"
        dd = f"{e.max_drawdown_pct:.1f}" if e.max_drawdown_pct is not None else "-"
        tgross = f"{e.avg_gross_exposure_pct:.1f}" if e.avg_gross_exposure_pct is not None else "-"
        tnet = f"{e.avg_net_exposure_pct:+.1f}" if e.avg_net_exposure_pct is not None else "-"
        tdim = f"{e.days_in_market_pct:.1f}" if e.days_in_market_pct is not None else "-"
        vpf = f"{e.valid_profit_factor:.2f}" if e.valid_profit_factor is not None else "-"
        vret = f"{e.valid_total_return_pct:+.1f}" if e.valid_total_return_pct is not None else "-"
        vwr = f"{e.valid_win_rate:.0%}" if e.valid_win_rate is not None else "-"
        vsharpe = f"{e.valid_sharpe_ratio:.1f}" if e.valid_sharpe_ratio is not None else "-"
        vdd = f"{e.valid_max_drawdown_pct:.1f}" if e.valid_max_drawdown_pct is not None else "-"
        vgross = f"{e.valid_avg_gross_exposure_pct:.1f}" if e.valid_avg_gross_exposure_pct is not None else "-"
        vnet = f"{e.valid_avg_net_exposure_pct:+.1f}" if e.valid_avg_net_exposure_pct is not None else "-"
        vdim = f"{e.valid_days_in_market_pct:.1f}" if e.valid_days_in_market_pct is not None else "-"
        ts = e.timestamp[:10] if e.timestamp else "-"
        lines.append(
            f"| {rank} | {e.experiment_name} | {e.sqs_score:.1f}"
            f" | {pf} | {ret} | {wr} | {sharpe} | {dd} | {e.trade_count} | {tgross} | {tnet} | {tdim}"
            f" | {vpf} | {vret} | {vwr} | {vsharpe} | {vdd} | {e.valid_trade_count} | {vgross} | {vnet} | {vdim}"
            f" | {ts} |"
        )

    # Recent entries (last 5)
    recent = list(reversed(journal_entries))[:5]
    if recent:
        registry_by_id = {entry.entry_id: entry for entry in registry.entries}
        lines.append("\n## Recent Entries")
        for je in recent:
            canonical_sqs = registry_by_id.get(je.entry_id).sqs_score if je.entry_id in registry_by_id else je.sqs_score
            lines.append(f"### {je.entry_id} ({je.timestamp[:10]}) \u2014 {je.experiment_name}")
            lines.append(f"Hypothesis: {je.hypothesis}")
            lines.append(f"Verdict: **{je.verdict.upper()}** (SQS {canonical_sqs})")
            if je.verdict_reasoning:
                lines.append(f"Reasoning: {je.verdict_reasoning}")
            if je.next_direction:
                lines.append(f"Next: {je.next_direction}")
            lines.append("")

    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("\n".join(lines) + "\n")


# ---------------------------------------------------------------------------
# Run scanning helpers (for CLI record command)
# ---------------------------------------------------------------------------


def scan_runs_for_experiment(
    runs_dir: Path,
    experiment_name: str,
) -> dict[str, tuple[str, MetricsBundle]]:
    """Scan runs_dir for runs matching experiment_name.

    Returns dict of split_name -> (run_id, MetricsBundle).
    Uses manifest.json for experiment matching, metadata.json for split_name,
    falls back to time-ordered grouping by config hash.
    """
    from libs.backtest.domain import MetricsBundle

    matches: list[tuple[Path, str, str]] = []  # (run_dir, run_id, config_hash)

    for run_path in sorted(runs_dir.iterdir()):
        if not run_path.is_dir():
            continue
        manifest_file = run_path / "manifest.json"
        if not manifest_file.exists():
            continue
        manifest_data = json.loads(manifest_file.read_text())
        if manifest_data.get("experiment_name") != experiment_name:
            continue
        metadata_file = run_path / "metadata.json"
        metadata = json.loads(metadata_file.read_text()) if metadata_file.exists() else {}
        run_id = metadata.get("run_id", run_path.name)
        # Extract config hash from run_id (last segment after underscore)
        parts = run_path.name.split("_")
        config_hash = parts[-1] if len(parts) >= 2 else ""
        matches.append((run_path, run_id, config_hash))

    if not matches:
        return {}

    results: dict[str, tuple[str, MetricsBundle]] = {}

    # Check for split_name in metadata first
    has_split_names = False
    for run_path, run_id, _ in matches:
        metadata_file = run_path / "metadata.json"
        if metadata_file.exists():
            metadata = json.loads(metadata_file.read_text())
            if "split_name" in metadata:
                has_split_names = True
                break

    if has_split_names:
        for run_path, run_id, _ in matches:
            metadata = json.loads((run_path / "metadata.json").read_text())
            split = metadata.get("split_name", "unknown")
            metrics_file = run_path / "metrics" / "metrics_summary.json"
            if metrics_file.exists():
                metrics = MetricsBundle.model_validate_json(metrics_file.read_text())
                results[split] = (run_id, metrics)
    else:
        # Fallback: group by config_hash, then assign train/valid/test by time order
        from collections import defaultdict
        groups: dict[str, list[tuple[Path, str]]] = defaultdict(list)
        for run_path, run_id, config_hash in matches:
            groups[config_hash].append((run_path, run_id))

        # Use the largest group (most likely the 3-split set)
        biggest_group = max(groups.values(), key=len) if groups else []
        split_names = ["train", "valid", "test"]
        for i, (run_path, run_id) in enumerate(biggest_group):
            split = split_names[i] if i < len(split_names) else f"extra_{i}"
            metrics_file = run_path / "metrics" / "metrics_summary.json"
            if metrics_file.exists():
                metrics = MetricsBundle.model_validate_json(metrics_file.read_text())
                results[split] = (run_id, metrics)

    return results


def check_duplicate(journal_path: Path, experiment_name: str) -> list[JournalEntry]:
    """Check if experiment_name already exists in journal."""
    entries = load_journal(journal_path)
    return [e for e in entries if e.experiment_name == experiment_name]
