"""Pure utility helpers used by the MCP server (#82 Phase 1).

This module holds the small, IO-free helpers that previously lived
in `server.py` but are not coupled to any monkey-patchable github.py
symbol. Anything that calls `validate_node_id`, `get_discussion_body`,
`resolve_thread_id`, etc. belongs in `server.py` (where the patch
target lives) — keep this module to data structures and pure
functions only.

Dep budget: stdlib + this package's own modules (no github.*, no mcp,
no anthropic). That keeps the `:utils` py_library a near-leaf in the
Bazel dep graph so unit tests of these helpers don't re-run on
unrelated github / handler edits.
"""
from __future__ import annotations

from dataclasses import dataclass, field

# ----- Node-ID validation surface ------------------------------------------

# Tool name → list of argument keys that carry GitHub node IDs.
# Each listed value is validated via a cheap existence query before
# the tool runs. Empty / missing values are skipped (optional params).
#
# The validator function itself (`_validate_tool_node_ids`) lives in
# server.py because it calls `validate_node_id`, which tests
# monkey-patch via `active_claude_github_mcp.server.validate_node_id`.
NODE_ID_PARAMS: dict[str, list[str]] = {
    "post_turn": ["thread_id"],
    "create_thread": ["discussion_origin_id"],
    "create_issue": ["discussion_origin_id"],
    "update_issue_toc": ["issue_id"],
    "move_project_item": ["issue_id"],
    "mark_discussion_decided": ["discussion_id", "decision_comment_id"],
    "fork_discussion": ["original_id", "fork_point_comment_id"],
    "detect_theme_change": ["thread_id"],
    "get_thread_comments": ["thread_id"],
    "close_issue_with_comment": ["issue_id"],
}


# ----- Confidence threshold (#61) ------------------------------------------

_CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3}


def meets_confidence_threshold(observed: str, threshold: str) -> bool:
    """Return True if `observed` confidence is at or above `threshold` (#61).

    Both must be in {"low", "medium", "high"}. Raises ValueError for
    any other input so a bad caller-supplied threshold is caught early.
    """
    if observed not in _CONFIDENCE_RANK:
        raise ValueError(
            f"observed must be 'low', 'medium', or 'high'; got: {observed!r}"
        )
    if threshold not in _CONFIDENCE_RANK:
        raise ValueError(
            f"threshold must be 'low', 'medium', or 'high'; got: {threshold!r}"
        )
    return _CONFIDENCE_RANK[observed] >= _CONFIDENCE_RANK[threshold]


# ----- Per-session activity tracking (#62, #88) ----------------------------

@dataclass
class SessionActivity:
    """Per-session mutation counters populated by tool handlers (#62, #88).

    Replaces the ad-hoc dict formerly captured by `build_server`'s
    closure. Every mutation below is annotated with the reader:
    close_session composes its default summary by calling
    `compose_session_summary(activity, ...)`.

    Concurrency model: one instance per `build_server()` call, i.e.
    per MCP stdio subprocess. The skill's session-lock discussion
    enforces one open session at a time within a subprocess, and the
    MCP stdio transport dispatches handlers serially — so no locking
    is needed as long as those assumptions hold. If the server ever
    multiplexes sessions within one process, switch to a
    `contextvars.ContextVar[SessionActivity]` activated per request.
    """

    turns_posted: int = 0
    turns_by_thread: dict[str, int] = field(default_factory=dict)
    discussions_created: int = 0
    issues_created: int = 0
    forks_executed: int = 0
    discussions_decided: int = 0
    tool_errors: int = 0


def compose_session_summary(
    activity: SessionActivity,
    skill_update_candidates: int,
    has_pending_buffer: bool,
) -> str:
    """Build a one-paragraph close_session summary from a SessionActivity (#62, #88).

    Returns a short, human-readable summary mirroring the template in
    the skill's §Session End: turns posted, threads created / decided,
    forks, skill-update candidates, known caveats. Empty sessions
    return a single explanatory line.
    """
    parts: list[str] = []
    if activity.turns_posted:
        n_threads = len(activity.turns_by_thread)
        parts.append(
            f"{activity.turns_posted} turn(s) across "
            f"{n_threads} thread(s)"
        )
    if activity.discussions_created or activity.issues_created:
        parts.append(
            f"created: {activity.discussions_created} Discussion(s), "
            f"{activity.issues_created} Issue(s)"
        )
    if activity.forks_executed:
        parts.append(f"{activity.forks_executed} fork(s)")
    if activity.discussions_decided:
        parts.append(f"{activity.discussions_decided} Discussion(s) decided")
    if skill_update_candidates:
        parts.append(f"{skill_update_candidates} skill update candidate(s)")

    caveats: list[str] = []
    if activity.tool_errors:
        caveats.append(f"{activity.tool_errors} tool error(s) during session")
    if has_pending_buffer:
        caveats.append("pending buffer non-empty at close")
    if caveats:
        parts.append("caveats: " + "; ".join(caveats))

    if not parts:
        return "No tracked activity this session."
    return "; ".join(parts) + "."


# ----- TOC overwrite guardrail (#48) ---------------------------------------

def check_overwrite_size(
    current_body: str,
    new_body: str,
    force: bool,
) -> None:
    """Refuse TOC overwrites that look like accidental clobbers.

    Fires for two cases:
      1. current > 5000 chars AND new < 500 chars (hard cliff — small
         placeholder against large body).
      2. current > 1000 chars AND new < 50% of current (soft ratio —
         proportional shrink that's likely an error).

    Pass force=True to skip the check for legitimate shrinks.
    """
    if force:
        return
    current_len = len(current_body)
    new_len = len(new_body)
    if current_len > 5000 and new_len < 500:
        raise ValueError(
            f"Refusing overwrite: new_body is {new_len} chars vs current "
            f"{current_len} chars. Pass force=true for legitimate shrinks."
        )
    if current_len > 1000 and new_len * 2 < current_len:
        pct = (new_len * 100) // max(current_len, 1)
        raise ValueError(
            f"Refusing overwrite: new_body is {new_len} chars ({pct}% of "
            f"current {current_len}). Pass force=true for legitimate shrinks."
        )
