"""Branch summary computation and rendering.

A BranchSummary is a compact, structured view of a rooted subtree of the
experiment DAG. It acts as the shared search-state abstraction consumed by
``propose`` (choosing where to expand next) and ``evaluate`` (updating belief
about branch promise) and inspected by ``abreka branch summary``.
"""

from __future__ import annotations

from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal

from abreka.metrics import parse_metric_ref
from abreka.persistence import experiment_dir, load_backlog_index

HealthStatus = Literal[
    "underexplored",
    "improving",
    "plateauing",
    "mixed",
    "declining",
    "noisy",
    "stalled",
]

RecommendedAction = Literal[
    "exploit",
    "discriminate",
    "explore_elsewhere",
    "stop_or_pause",
    "validate",
]

RecommendedAttention = Literal["high", "medium", "low"]


_ACTIVE_STATUSES = {"proposed", "implementing", "testing", "running"}
_FINDINGS_SNIPPET_LEN = 300
_FINDINGS_SNIPPET_COUNT = 3
_TREND_N = 3
_DEFAULT_TOP_TAGS = 5


@dataclass
class BranchSummary:
    """Structured summary of a rooted subtree in the experiment DAG."""

    # Identity
    root_id: str
    root_hypothesis: str = ""
    root_method: str = ""
    root_tags: list[str] = field(default_factory=list)

    # Membership / shape
    subtree_size: int = 0
    depth_max: int = 0
    leaf_ids: list[str] = field(default_factory=list)
    frontier_ids: list[str] = field(default_factory=list)
    frontier_values: dict[str, float | None] = field(default_factory=dict)
    completed_ids: list[str] = field(default_factory=list)
    failed_ids: list[str] = field(default_factory=list)
    active_ids: list[str] = field(default_factory=list)

    # Status counts
    n_completed: int = 0
    n_failed: int = 0
    n_active: int = 0

    # Time / recency
    most_recent_id: str | None = None
    most_recent_completed_id: str | None = None
    created_at_root: str | None = None
    created_at_latest: str | None = None

    # Metric summary
    primary_metric: str = ""
    metric_direction: str = "maximize"
    best_exp_id: str | None = None
    best_metric_value: float | None = None
    root_metric_value: float | None = None
    latest_completed_metric_value: float | None = None
    net_gain_from_root: float | None = None
    best_leaf_id: str | None = None
    best_leaf_metric_value: float | None = None

    # Comparative summary
    global_best_id: str | None = None
    global_best_metric_value: float | None = None
    gap_to_global_best: float | None = None
    is_global_best_in_subtree: bool = False

    # Trend summary
    recent_completed_ids: list[str] = field(default_factory=list)
    recent_parent_deltas: list[float | None] = field(default_factory=list)
    avg_recent_delta: float | None = None
    new_bests_in_last_n: int = 0
    improved_over_parent_ratio: float | None = None

    # Failure / reliability
    failure_rate: float | None = None
    repeated_failure_modes: list[str] = field(default_factory=list)
    has_many_inconclusive_runs: bool = False

    # Tag / motif summary
    top_tags: list[str] = field(default_factory=list)
    common_method_terms: list[str] = field(default_factory=list)
    explore_fraction: float | None = None

    # Findings summary
    recent_findings_snippets: list[str] = field(default_factory=list)
    notable_positive_ids: list[str] = field(default_factory=list)
    notable_negative_ids: list[str] = field(default_factory=list)

    # Backlog linkage
    open_backlog_count: int = 0
    top_backlog_items: list[str] = field(default_factory=list)

    # Heuristic interpretation
    health_status: HealthStatus = "underexplored"
    health_reasons: list[str] = field(default_factory=list)
    recommended_attention: RecommendedAttention = "medium"

    # Opportunity / uncertainty
    needs_disambiguation: bool = False
    possible_confounds: list[str] = field(default_factory=list)
    recommended_next_action: RecommendedAction = "validate"

    # Convenience flags
    is_singleton: bool = False

    def to_dict(self) -> dict:
        """Return a JSON-serializable dict representation."""
        return {
            "root_id": self.root_id,
            "root_hypothesis": self.root_hypothesis,
            "root_method": self.root_method,
            "root_tags": list(self.root_tags),
            "subtree_size": self.subtree_size,
            "depth_max": self.depth_max,
            "leaf_ids": list(self.leaf_ids),
            "frontier_ids": list(self.frontier_ids),
            "frontier_values": dict(self.frontier_values),
            "completed_ids": list(self.completed_ids),
            "failed_ids": list(self.failed_ids),
            "active_ids": list(self.active_ids),
            "n_completed": self.n_completed,
            "n_failed": self.n_failed,
            "n_active": self.n_active,
            "most_recent_id": self.most_recent_id,
            "most_recent_completed_id": self.most_recent_completed_id,
            "created_at_root": self.created_at_root,
            "created_at_latest": self.created_at_latest,
            "primary_metric": self.primary_metric,
            "metric_direction": self.metric_direction,
            "best_exp_id": self.best_exp_id,
            "best_metric_value": self.best_metric_value,
            "root_metric_value": self.root_metric_value,
            "latest_completed_metric_value": self.latest_completed_metric_value,
            "net_gain_from_root": self.net_gain_from_root,
            "best_leaf_id": self.best_leaf_id,
            "best_leaf_metric_value": self.best_leaf_metric_value,
            "global_best_id": self.global_best_id,
            "global_best_metric_value": self.global_best_metric_value,
            "gap_to_global_best": self.gap_to_global_best,
            "is_global_best_in_subtree": self.is_global_best_in_subtree,
            "recent_completed_ids": list(self.recent_completed_ids),
            "recent_parent_deltas": list(self.recent_parent_deltas),
            "avg_recent_delta": self.avg_recent_delta,
            "new_bests_in_last_n": self.new_bests_in_last_n,
            "improved_over_parent_ratio": self.improved_over_parent_ratio,
            "failure_rate": self.failure_rate,
            "repeated_failure_modes": list(self.repeated_failure_modes),
            "has_many_inconclusive_runs": self.has_many_inconclusive_runs,
            "top_tags": list(self.top_tags),
            "common_method_terms": list(self.common_method_terms),
            "explore_fraction": self.explore_fraction,
            "recent_findings_snippets": list(self.recent_findings_snippets),
            "notable_positive_ids": list(self.notable_positive_ids),
            "notable_negative_ids": list(self.notable_negative_ids),
            "open_backlog_count": self.open_backlog_count,
            "top_backlog_items": list(self.top_backlog_items),
            "health_status": self.health_status,
            "health_reasons": list(self.health_reasons),
            "recommended_attention": self.recommended_attention,
            "needs_disambiguation": self.needs_disambiguation,
            "possible_confounds": list(self.possible_confounds),
            "recommended_next_action": self.recommended_next_action,
            "is_singleton": self.is_singleton,
        }


# ---------------------------------------------------------------------------
# Subtree / DAG helpers
# ---------------------------------------------------------------------------


def get_subtree_ids(index: dict, root_id: str) -> set[str]:
    """Return *root_id* plus all transitive descendants in the experiment DAG."""
    if root_id not in index:
        raise ValueError(f"Experiment {root_id!r} not found in index.")
    children: dict[str, list[str]] = {}
    for eid, entry in index.items():
        for pid in entry.get("parents", []):
            children.setdefault(pid, []).append(eid)
    result: set[str] = set()
    queue = [root_id]
    while queue:
        eid = queue.pop()
        if eid in result:
            continue
        result.add(eid)
        queue.extend(children.get(eid, []))
    return result


def _subtree_depth(index: dict, root_id: str, subtree_ids: set[str]) -> int:
    """Return the maximum depth from ``root_id`` to any descendant, in edges."""
    children: dict[str, list[str]] = {}
    for eid in subtree_ids:
        for pid in index[eid].get("parents", []):
            if pid in subtree_ids:
                children.setdefault(pid, []).append(eid)
    # DFS; subtree may contain cycles only in malformed data, so guard.
    seen: set[str] = set()

    def _dfs(node: str) -> int:
        if node in seen:
            return 0
        seen.add(node)
        kids = children.get(node, [])
        if not kids:
            return 0
        return 1 + max(_dfs(c) for c in kids)

    return _dfs(root_id)


def _is_better(a: float | None, b: float | None, maximize: bool) -> bool:
    """Return True if ``a`` is strictly better than ``b`` under direction."""
    if a is None:
        return False
    if b is None:
        return True
    return a > b if maximize else a < b


def _primary_value(entry: dict, split: str, key: str) -> float | None:
    val = entry.get("metrics", {}).get(split, {}).get(key)
    if val is None:
        return None
    try:
        return float(val)
    except (TypeError, ValueError):
        return None


def _signed_delta(child: float | None, parent: float | None, maximize: bool) -> float | None:
    """Return delta oriented so positive means improvement."""
    if child is None or parent is None:
        return None
    raw = child - parent
    return raw if maximize else -raw


# ---------------------------------------------------------------------------
# Main computation
# ---------------------------------------------------------------------------


def compute_branch_summary(
    index: dict,
    abreka_dir: Path,
    root_id: str,
    primary_metric: str,
    metric_direction: str,
    *,
    trend_n: int = _TREND_N,
) -> BranchSummary:
    """Compute a ``BranchSummary`` for the subtree rooted at *root_id*.

    ``index`` is the in-memory index loaded via :func:`abreka.config.load_index`.
    ``abreka_dir`` is used to read findings files and backlog index.
    """
    if root_id not in index:
        raise ValueError(f"Experiment {root_id!r} not found in index.")

    split, key = parse_metric_ref(primary_metric)
    maximize = metric_direction == "maximize"

    subtree_ids = get_subtree_ids(index, root_id)
    root_entry = index[root_id]

    summary = BranchSummary(
        root_id=root_id,
        root_hypothesis=root_entry.get("hypothesis", ""),
        root_method=root_entry.get("method", ""),
        root_tags=list(root_entry.get("tags", [])),
        subtree_size=len(subtree_ids),
        primary_metric=primary_metric,
        metric_direction=metric_direction,
        created_at_root=root_entry.get("created_at"),
    )

    # Partition membership
    completed_ids: list[str] = []
    failed_ids: list[str] = []
    active_ids: list[str] = []
    for eid in sorted(subtree_ids):
        status = index[eid].get("status", "")
        if status == "completed":
            completed_ids.append(eid)
        elif status == "failed":
            failed_ids.append(eid)
        elif status in _ACTIVE_STATUSES:
            active_ids.append(eid)

    summary.completed_ids = completed_ids
    summary.failed_ids = failed_ids
    summary.active_ids = active_ids
    summary.n_completed = len(completed_ids)
    summary.n_failed = len(failed_ids)
    summary.n_active = len(active_ids)
    summary.is_singleton = len(subtree_ids) == 1

    # Depth + leaves + frontier
    summary.depth_max = _subtree_depth(index, root_id, subtree_ids)

    children_in_subtree: dict[str, list[str]] = {}
    for eid in subtree_ids:
        for pid in index[eid].get("parents", []):
            if pid in subtree_ids:
                children_in_subtree.setdefault(pid, []).append(eid)

    summary.leaf_ids = sorted(
        eid for eid in subtree_ids if not children_in_subtree.get(eid)
    )

    # Frontier: completed experiments with no completed children in subtree
    completed_children: dict[str, list[str]] = {}
    for eid in completed_ids:
        for pid in index[eid].get("parents", []):
            if pid in subtree_ids:
                completed_children.setdefault(pid, []).append(eid)
    summary.frontier_ids = sorted(
        eid for eid in completed_ids if not completed_children.get(eid)
    )
    summary.frontier_values = {
        eid: _primary_value(index[eid], split, key) for eid in summary.frontier_ids
    }

    # Most recent (any status) + most recent completed
    if subtree_ids:
        summary.most_recent_id = max(
            subtree_ids, key=lambda e: index[e].get("created_at", "")
        )
        summary.created_at_latest = index[summary.most_recent_id].get("created_at")
    if completed_ids:
        summary.most_recent_completed_id = max(
            completed_ids, key=lambda e: index[e].get("created_at", "")
        )

    # Metric summary
    def _completed_value(eid: str) -> float | None:
        return _primary_value(index[eid], split, key)

    completed_with_value = [(eid, _completed_value(eid)) for eid in completed_ids]
    completed_with_value = [(e, v) for e, v in completed_with_value if v is not None]

    if completed_with_value:
        best_eid, best_val = max(
            completed_with_value, key=lambda pair: pair[1] if maximize else -pair[1]
        )
        summary.best_exp_id = best_eid
        summary.best_metric_value = best_val

    summary.root_metric_value = _completed_value(root_id) if index[root_id].get("status") == "completed" else None

    if summary.most_recent_completed_id:
        summary.latest_completed_metric_value = _completed_value(summary.most_recent_completed_id)

    if summary.best_metric_value is not None and summary.root_metric_value is not None:
        summary.net_gain_from_root = _signed_delta(
            summary.best_metric_value, summary.root_metric_value, maximize
        )

    # Best leaf (may equal best)
    leaf_completed = [
        (eid, _completed_value(eid))
        for eid in summary.leaf_ids
        if index[eid].get("status") == "completed" and _completed_value(eid) is not None
    ]
    if leaf_completed:
        best_leaf_eid, best_leaf_val = max(
            leaf_completed, key=lambda pair: pair[1] if maximize else -pair[1]
        )
        summary.best_leaf_id = best_leaf_eid
        summary.best_leaf_metric_value = best_leaf_val

    # Global comparison across entire index
    global_candidates = [
        (eid, _primary_value(entry, split, key))
        for eid, entry in index.items()
        if entry.get("status") == "completed"
    ]
    global_candidates = [(e, v) for e, v in global_candidates if v is not None]
    if global_candidates:
        g_best_eid, g_best_val = max(
            global_candidates, key=lambda pair: pair[1] if maximize else -pair[1]
        )
        summary.global_best_id = g_best_eid
        summary.global_best_metric_value = g_best_val
        if summary.best_metric_value is not None:
            gap = _signed_delta(g_best_val, summary.best_metric_value, maximize)
            # "distance from global best" — always non-negative
            summary.gap_to_global_best = abs(gap) if gap is not None else None
        summary.is_global_best_in_subtree = g_best_eid in subtree_ids

    # Trend computation
    # Sort completed by created_at (oldest → newest) and compute parent deltas.
    completed_sorted = sorted(
        completed_ids, key=lambda e: index[e].get("created_at", "")
    )

    parent_delta_by_eid: dict[str, float | None] = {}
    for eid in completed_sorted:
        parents = [p for p in index[eid].get("parents", []) if p in subtree_ids]
        parent_vals = [
            _completed_value(p)
            for p in parents
            if index[p].get("status") == "completed"
        ]
        parent_vals = [v for v in parent_vals if v is not None]
        child_val = _completed_value(eid)
        if not parent_vals or child_val is None:
            parent_delta_by_eid[eid] = None
            continue
        # Compare to best parent in branch
        parent_best = max(parent_vals) if maximize else min(parent_vals)
        parent_delta_by_eid[eid] = _signed_delta(child_val, parent_best, maximize)

    recent = completed_sorted[-trend_n:]
    summary.recent_completed_ids = recent
    summary.recent_parent_deltas = [parent_delta_by_eid.get(e) for e in recent]

    non_null_deltas = [d for d in summary.recent_parent_deltas if d is not None]
    if non_null_deltas:
        summary.avg_recent_delta = sum(non_null_deltas) / len(non_null_deltas)
        improved = sum(1 for d in non_null_deltas if d > 0)
        summary.improved_over_parent_ratio = improved / len(non_null_deltas)

    # New subtree bests in last N: walk completed in chronological order and
    # count how many of the last N experiments set a new branch best.
    running_best: float | None = None
    new_bests_flags: dict[str, bool] = {}
    for eid in completed_sorted:
        val = _completed_value(eid)
        if val is None:
            new_bests_flags[eid] = False
            continue
        if _is_better(val, running_best, maximize):
            running_best = val
            new_bests_flags[eid] = True
        else:
            new_bests_flags[eid] = False
    summary.new_bests_in_last_n = sum(1 for e in recent if new_bests_flags.get(e))

    # Failure / reliability
    total_terminal = summary.n_completed + summary.n_failed
    if total_terminal:
        summary.failure_rate = summary.n_failed / total_terminal

    # Tag / motif summary
    tag_counter: Counter[str] = Counter()
    for eid in subtree_ids:
        for tag in index[eid].get("tags", []):
            tag_counter[tag] += 1
    summary.top_tags = [t for t, _ in tag_counter.most_common(_DEFAULT_TOP_TAGS)]

    # Common method terms: very simple word tokenization, top N excluding trivial stopwords
    term_counter: Counter[str] = Counter()
    for eid in subtree_ids:
        method = index[eid].get("method", "").lower()
        for tok in _tokenize_method(method):
            term_counter[tok] += 1
    summary.common_method_terms = [t for t, _ in term_counter.most_common(5)]

    if subtree_ids:
        n_explore = sum(
            1 for eid in subtree_ids if "explore" in index[eid].get("tags", [])
        )
        summary.explore_fraction = n_explore / len(subtree_ids)

    # Findings snippets
    summary.recent_findings_snippets = _gather_findings_snippets(
        abreka_dir, recent, _FINDINGS_SNIPPET_COUNT
    )

    # Notable positive/negative: top 2 by delta magnitude in branch
    pos_deltas = [(e, d) for e, d in parent_delta_by_eid.items() if d is not None and d > 0]
    neg_deltas = [(e, d) for e, d in parent_delta_by_eid.items() if d is not None and d < 0]
    pos_deltas.sort(key=lambda pair: pair[1], reverse=True)
    neg_deltas.sort(key=lambda pair: pair[1])
    summary.notable_positive_ids = [e for e, _ in pos_deltas[:2]]
    summary.notable_negative_ids = [e for e, _ in neg_deltas[:2]]

    # Backlog linkage
    try:
        backlog_index = load_backlog_index(abreka_dir)
    except Exception:
        backlog_index = {}
    open_items = [
        e for e in backlog_index.values()
        if e.get("status") == "open" and e.get("source_exp") in subtree_ids
    ]
    summary.open_backlog_count = len(open_items)
    open_items.sort(key=lambda e: (-e.get("priority", 0), e.get("id", "")))
    summary.top_backlog_items = [
        f"B{e['id']}: {e.get('title', '')}" for e in open_items[:3]
    ]

    # Health classification
    summary.health_status, summary.health_reasons = _classify_health(summary)

    # Opportunity heuristics
    summary.needs_disambiguation = _detect_needs_disambiguation(index, subtree_ids, parent_delta_by_eid)
    summary.recommended_next_action = _recommend_next_action(summary)
    summary.recommended_attention = _recommend_attention(summary)

    return summary


# ---------------------------------------------------------------------------
# Heuristics
# ---------------------------------------------------------------------------


_STOPWORDS = {
    "a", "an", "the", "and", "or", "of", "to", "with", "for", "on", "in", "at",
    "from", "by", "as", "is", "it", "be", "will", "that", "this", "using", "use",
    "train", "trained", "training", "use", "used", "model", "experiment", "baseline",
    "over", "into", "can", "than", "each", "per", "under",
}


def _tokenize_method(method: str) -> list[str]:
    """Crude method tokenizer: lowercase alpha words >= 4 chars, minus stopwords."""
    import re

    tokens = re.findall(r"[a-zA-Z][a-zA-Z\-]{3,}", method.lower())
    return [t for t in tokens if t not in _STOPWORDS]


def _gather_findings_snippets(
    abreka_dir: Path, exp_ids: list[str], limit: int
) -> list[str]:
    """Read findings.md for a handful of recent experiments and return snippets."""
    snippets: list[str] = []
    for eid in reversed(exp_ids):  # newest first
        if len(snippets) >= limit:
            break
        findings_path = experiment_dir(abreka_dir, eid) / "findings.md"
        if not findings_path.is_file():
            continue
        content = findings_path.read_text().strip()
        if not content:
            continue
        snippet = content[:_FINDINGS_SNIPPET_LEN]
        if len(content) > _FINDINGS_SNIPPET_LEN:
            snippet += "..."
        snippets.append(f"{eid}: {snippet}")
    return snippets


def _classify_health(summary: BranchSummary) -> tuple[HealthStatus, list[str]]:
    """Return ``(health_status, reasons)`` based on summary counts and trend."""
    reasons: list[str] = []

    # Stalled: no active experiments and no completed runs at all
    if summary.n_completed == 0 and summary.n_active == 0 and summary.n_failed > 0:
        reasons.append("All experiments in branch have failed; no active runs.")
        return "stalled", reasons

    # Underexplored: very few completed
    if summary.n_completed <= 2:
        reasons.append(
            f"Only {summary.n_completed} completed experiment(s); branch is underexplored."
        )
        return "underexplored", reasons

    deltas = [d for d in summary.recent_parent_deltas if d is not None]
    avg = summary.avg_recent_delta
    ratio = summary.improved_over_parent_ratio
    new_bests = summary.new_bests_in_last_n

    # Noisy: large alternating swings among recent deltas
    if deltas and len(deltas) >= 2:
        signs = [d > 0 for d in deltas]
        alternations = sum(
            1 for i in range(1, len(signs)) if signs[i] != signs[i - 1]
        )
        swing = max(abs(d) for d in deltas)
        if alternations >= len(deltas) - 1 and swing >= 0.05:
            reasons.append(
                f"Recent deltas alternate in sign with swings up to {swing:.4g}."
            )
            return "noisy", reasons

    # Improving: either a fresh branch best, or a strong positive parent-delta trend.
    has_fresh_best = new_bests >= 1 and (avg is None or avg >= 0) and (ratio is None or ratio > 0.5)
    has_strong_trend = (
        avg is not None and avg > 0
        and ratio is not None and ratio >= 0.6
        and (not deltas or max(deltas) > 0)
    )
    if has_fresh_best or has_strong_trend:
        if new_bests >= 1:
            reasons.append(
                f"{new_bests} new subtree best(s) in last {len(summary.recent_completed_ids)} completed."
            )
        if avg is not None:
            reasons.append(f"Average recent delta vs parent: {avg:+.4g}.")
        if ratio is not None:
            reasons.append(f"Improved over parent in {ratio * 100:.0f}% of recent runs.")
        return "improving", reasons

    # Declining — require the average recent drop to be meaningful, not dust.
    if avg is not None and avg < -0.003 and new_bests == 0 and (ratio is None or ratio < 0.5):
        reasons.append(f"Average recent delta vs parent: {avg:+.4g} (negative).")
        reasons.append(f"No new subtree best in last {len(summary.recent_completed_ids)} completed.")
        return "declining", reasons

    # Plateauing: enough completed, no new best, recent deltas near zero
    if summary.n_completed >= 4 and new_bests == 0 and (avg is None or abs(avg) < 0.003):
        reasons.append(
            f"No new subtree best in last {len(summary.recent_completed_ids)} completed experiments."
        )
        if avg is not None:
            reasons.append(f"Average recent delta vs parent near zero ({avg:+.4g}).")
        return "plateauing", reasons

    # Fall through: mixed
    reasons.append("Recent runs show mixed outcomes with no strong direction.")
    if new_bests:
        reasons.append(f"{new_bests} new subtree best(s) in recent runs.")
    if avg is not None:
        reasons.append(f"Average recent delta vs parent: {avg:+.4g}.")
    return "mixed", reasons


def _detect_needs_disambiguation(
    index: dict,
    subtree_ids: set[str],
    parent_delta_by_eid: dict[str, float | None],
) -> bool:
    """Cheap heuristic: within a branch, if recent siblings share a parent but
    their tag sets diverge strongly, call it "needs disambiguation".
    """
    # Group completed experiments by parent
    by_parent: dict[str, list[str]] = {}
    for eid in subtree_ids:
        entry = index[eid]
        if entry.get("status") != "completed":
            continue
        for pid in entry.get("parents", []):
            if pid in subtree_ids:
                by_parent.setdefault(pid, []).append(eid)
    for siblings in by_parent.values():
        if len(siblings) < 3:
            continue
        tag_sets = [set(index[e].get("tags", [])) for e in siblings]
        union = set().union(*tag_sets)
        if not union:
            continue
        # Jaccard-style overlap: if average pairwise intersection is small,
        # siblings are divergent → likely confounded when read as one branch.
        if len(union) >= 4:
            return True
    return False


def _recommend_next_action(summary: BranchSummary) -> RecommendedAction:
    """Pick a recommended next action from the summary."""
    if summary.health_status == "stalled":
        return "stop_or_pause"
    if summary.health_status == "underexplored":
        return "validate"
    if summary.needs_disambiguation and summary.health_status in ("improving", "mixed", "noisy"):
        return "discriminate"
    if summary.health_status == "improving":
        return "exploit"
    if summary.health_status == "plateauing":
        return "explore_elsewhere"
    if summary.health_status == "declining":
        return "stop_or_pause"
    if summary.health_status == "noisy":
        return "discriminate"
    return "validate"


def _recommend_attention(summary: BranchSummary) -> RecommendedAttention:
    """Rank attention from health and gap-to-global-best."""
    if summary.health_status in ("stalled", "declining"):
        return "low"
    if summary.health_status == "improving":
        return "high"
    if summary.is_global_best_in_subtree:
        return "high"
    if summary.gap_to_global_best is not None and summary.gap_to_global_best <= 0.01:
        return "high"
    if summary.health_status == "underexplored":
        return "medium"
    return "medium"


# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------


def _fmt_metric(val: float | None) -> str:
    return "-" if val is None else f"{val:.4g}"


def _fmt_signed(val: float | None) -> str:
    return "-" if val is None else f"{val:+.4g}"


def render_branch_summary(summary: BranchSummary, mode: str = "default") -> str:
    """Render a ``BranchSummary`` to markdown.

    ``mode`` is one of ``"default"``, ``"compact"``, or ``"verbose"``.
    """
    if mode == "compact":
        return _render_compact(summary)
    if mode == "verbose":
        return _render_default(summary, verbose=True)
    return _render_default(summary, verbose=False)


def _render_compact(s: BranchSummary) -> str:
    lines = [f"### Branch {s.root_id}"]
    lines.append(
        f"- Size: {s.subtree_size} experiments "
        f"({s.n_completed} completed, {s.n_failed} failed, {s.n_active} active)"
    )
    if s.best_exp_id is not None:
        lines.append(
            f"- Best: {s.best_exp_id} ({s.primary_metric}={_fmt_metric(s.best_metric_value)})"
        )
    if s.frontier_ids:
        lines.append(f"- Frontier: {', '.join(s.frontier_ids)}")
    lines.append(f"- Health: {s.health_status}")
    lines.append(f"- Attention: {s.recommended_attention}")
    if s.gap_to_global_best is not None:
        lines.append(f"- Gap to global best: {_fmt_metric(s.gap_to_global_best)}")
    lines.append(f"- Suggested next action: {s.recommended_next_action}")
    return "\n".join(lines)


def _render_default(s: BranchSummary, *, verbose: bool) -> str:
    lines: list[str] = []
    lines.append(f"Branch Summary: {s.root_id}")
    if s.root_hypothesis:
        hyp = s.root_hypothesis if verbose else _short(s.root_hypothesis, 120)
        lines.append(f"Root: \"{hyp}\"")
    if s.root_tags:
        lines.append(f"Tags: {', '.join(s.root_tags)}")
    lines.append("")

    lines.append("Subtree")
    lines.append(f"- Size: {s.subtree_size} experiments")
    lines.append(f"- Completed: {s.n_completed}")
    lines.append(f"- Failed: {s.n_failed}")
    lines.append(f"- Active: {s.n_active}")
    lines.append(f"- Depth: {s.depth_max}")
    lines.append("")

    lines.append("Performance")
    lines.append(f"- Primary metric: {s.primary_metric} ({s.metric_direction})")
    if s.root_metric_value is not None:
        lines.append(f"- Root: {_fmt_metric(s.root_metric_value)} ({s.root_id})")
    if s.best_exp_id is not None:
        lines.append(
            f"- Best in subtree: {_fmt_metric(s.best_metric_value)} ({s.best_exp_id})"
        )
    if s.latest_completed_metric_value is not None:
        lines.append(
            f"- Latest completed: {_fmt_metric(s.latest_completed_metric_value)} "
            f"({s.most_recent_completed_id})"
        )
    if s.net_gain_from_root is not None:
        lines.append(f"- Net gain from root: {_fmt_signed(s.net_gain_from_root)}")
    if s.global_best_id is not None:
        lines.append(
            f"- Global best: {_fmt_metric(s.global_best_metric_value)} ({s.global_best_id})"
        )
    if s.gap_to_global_best is not None:
        lines.append(f"- Gap to global best: {_fmt_metric(s.gap_to_global_best)}")
    if s.is_global_best_in_subtree:
        lines.append("- Global best is in this subtree.")
    lines.append("")

    if s.frontier_ids:
        lines.append("Frontier")
        ids = s.frontier_ids if verbose else s.frontier_ids[:10]
        for eid in ids:
            val = s.frontier_values.get(eid)
            if val is not None:
                lines.append(f"- {eid}  {s.primary_metric}={_fmt_metric(val)}")
            else:
                lines.append(f"- {eid}")
        if not verbose and len(s.frontier_ids) > 10:
            lines.append(f"- ... and {len(s.frontier_ids) - 10} more")
        lines.append("")

    if s.recent_parent_deltas:
        lines.append("Trend")
        deltas_str = ", ".join(_fmt_signed(d) for d in s.recent_parent_deltas)
        lines.append(f"- Recent deltas vs parent: {deltas_str}")
        if s.improved_over_parent_ratio is not None:
            improved = int(round(s.improved_over_parent_ratio * len(s.recent_parent_deltas)))
            lines.append(
                f"- Improved over parent: {improved}/{len(s.recent_parent_deltas)}"
            )
        lines.append(
            f"- New subtree bests in last {len(s.recent_completed_ids)}: {s.new_bests_in_last_n}"
        )
        lines.append("")

    lines.append("Health")
    lines.append(f"- Status: {s.health_status}")
    lines.append(f"- Attention: {s.recommended_attention}")
    if s.health_reasons:
        lines.append("- Reasons:")
        for r in s.health_reasons:
            lines.append(f"  - {r}")
    lines.append("")

    if s.needs_disambiguation or s.possible_confounds:
        lines.append("Uncertainty")
        lines.append(f"- Needs disambiguation: {'yes' if s.needs_disambiguation else 'no'}")
        if s.possible_confounds:
            lines.append("- Possible confounds:")
            for c in s.possible_confounds:
                lines.append(f"  - {c}")
        lines.append("")

    if s.open_backlog_count:
        lines.append("Backlog")
        lines.append(f"- Open items: {s.open_backlog_count}")
        for item in s.top_backlog_items:
            lines.append(f"- {item}")
        lines.append("")

    if verbose and s.recent_findings_snippets:
        lines.append("Recent Findings")
        for snip in s.recent_findings_snippets:
            lines.append(f"- {snip}")
        lines.append("")

    if verbose and s.top_tags:
        lines.append(f"Top tags: {', '.join(s.top_tags)}")
    if verbose and s.common_method_terms:
        lines.append(f"Common method terms: {', '.join(s.common_method_terms)}")
    if verbose:
        lines.append("")

    lines.append("Suggested next action")
    lines.append(f"- {s.recommended_next_action}")

    return "\n".join(lines).rstrip() + "\n"


def render_branch_overview(
    summaries: list[BranchSummary], sort_key: str = "attention"
) -> str:
    """Render a compact multi-branch overview used by propose and ``--all``."""
    if not summaries:
        return "No branches with enough history to summarize."

    sorted_summaries = _sort_summaries(summaries, sort_key)
    sections = ["## Branch Overview", ""]
    for s in sorted_summaries:
        sections.append(_render_compact(s))
        sections.append("")
    return "\n".join(sections).rstrip() + "\n"


def _sort_summaries(
    summaries: list[BranchSummary], sort_key: str
) -> list[BranchSummary]:
    attention_rank = {"high": 0, "medium": 1, "low": 2}
    health_rank = {
        "improving": 0,
        "mixed": 1,
        "noisy": 2,
        "underexplored": 3,
        "plateauing": 4,
        "declining": 5,
        "stalled": 6,
    }
    if sort_key == "best":
        return sorted(
            summaries,
            key=lambda s: (
                s.best_metric_value is None,
                -(s.best_metric_value or 0)
                if s.metric_direction == "maximize"
                else (s.best_metric_value or 0),
            ),
        )
    if sort_key == "recent":
        return sorted(
            summaries,
            key=lambda s: s.created_at_latest or "",
            reverse=True,
        )
    if sort_key == "size":
        return sorted(summaries, key=lambda s: s.subtree_size, reverse=True)
    if sort_key == "health":
        return sorted(summaries, key=lambda s: health_rank.get(s.health_status, 99))
    # default: attention
    return sorted(
        summaries,
        key=lambda s: (
            attention_rank.get(s.recommended_attention, 99),
            -s.subtree_size,
        ),
    )


def _short(text: str, limit: int) -> str:
    text = text.strip()
    if len(text) <= limit:
        return text
    return text[: limit - 3] + "..."
