"""Shared helpers for the server package (#82 Phase 2b).

These helpers were at module-level in `server.py` pre-split. They are
used by dispatch in `server.build` and by the handler modules in
`server.handlers.*`. They intentionally import no handler modules.

Tests that monkey-patch names like `active_claude_github_mcp.server.
validate_node_id` continue to work: helpers look up github helpers
through `active_claude_github_mcp.server` (late-bound) rather than
early-binding via `from ..github.foo import bar`. `server/__init__.py`
re-exports every such name so the lookup resolves.
"""
from __future__ import annotations

import json
from pathlib import Path

from mcp.types import TextContent

# Late binding — see module docstring.
from active_claude_github_mcp import server as _s
from ..models import GithubConfig, OperationError
from ..parsers import parse_owner_repo
from ..utils import NODE_ID_PARAMS
from .preflight import check_cross_repo_mutation

_MAX_RETRIES = 3


# (#90) Candidate locations a project may keep its installed skill
# file. Probed in order; first existing path wins.
_SKILL_FILE_CANDIDATES: tuple[Path, ...] = (
    Path(".claude") / "skills" / "active-claude-github.md",
    Path("active-claude-github.md"),
)


def validate_tool_node_ids(name: str, arguments: dict, token: str) -> None:
    """Validate every node-ID argument for the given tool before dispatch.

    Raises InvalidNodeIdError on the first bad ID. Empty / missing
    optional IDs are skipped.
    """
    for param in NODE_ID_PARAMS.get(name, []):
        value = arguments.get(param, "")
        if value:
            _s.validate_node_id(token, param, value)


def resolve_local_skill_path(project_dir: Path) -> Path | None:
    """Return the first existing candidate skill-file path under
    `project_dir`, or None if none exists. (#90)"""
    for rel in _SKILL_FILE_CANDIDATES:
        candidate = project_dir / rel
        if candidate.exists():
            return candidate
    return None


def fetch_project_toc_body(token: str, project_discussion_id: str) -> str | None:
    """Fetch the current Project Discussion body, or None if the fetch
    fails for any reason (#63)."""
    try:
        return _s.get_discussion_body(token, project_discussion_id)
    except (_s.GitHubError, ValueError):
        return None


def resolve_thread_identifier(
    token: str,
    arguments: dict,
    primary_id_param: str,
    kind: str,
    default_owner: str,
    default_repo: str,
) -> str:
    """Return a node ID from either the primary-id arg or (number, owner, repo) (#56)."""
    node_id = arguments.get(primary_id_param, "")
    number = arguments.get("number")

    if node_id and number is not None:
        raise ValueError(
            f"Provide either {primary_id_param} or number, not both."
        )
    if node_id:
        return node_id
    if number is None:
        raise ValueError(
            f"Either {primary_id_param} or number must be provided."
        )
    try:
        number_int = int(number)
    except (TypeError, ValueError) as exc:
        raise ValueError(
            f"number must be an integer, got: {number!r}"
        ) from exc

    owner_arg = arguments.get("owner") or default_owner
    repo_arg = arguments.get("repo") or default_repo
    return _s.resolve_thread_id(token, owner_arg, repo_arg, number_int, kind)


def preflight_cross_repo_mutation(
    config: GithubConfig, arguments: dict
) -> None:
    """Gate mutation tools against `related_repos` policy (#98).

    No-op when the caller did not specify an explicit `owner` / `repo`
    (the tool then targets the project's own dev repo). When explicit,
    defer to `check_cross_repo_mutation` which raises
    `CrossRepoMutationBlocked` for `cousin` / `foreign` targets.
    """
    owner_arg = arguments.get("owner")
    repo_arg = arguments.get("repo")
    if not owner_arg and not repo_arg:
        return

    default_owner, default_repo = parse_owner_repo(config.repos.private)
    owner_final = owner_arg or default_owner
    repo_final = repo_arg or default_repo
    check_cross_repo_mutation(config, owner_final, repo_final)


def ok(data: dict | list | str) -> list[TextContent]:
    """Wrap a successful result as MCP TextContent."""
    if isinstance(data, str):
        return [TextContent(type="text", text=data)]
    return [TextContent(type="text", text=json.dumps(data, indent=2))]


def err(error: OperationError) -> list[TextContent]:
    """Wrap an OperationError as MCP TextContent."""
    return [TextContent(type="text", text=json.dumps(error.model_dump(), indent=2))]


def with_retry(fn, *args, **kwargs):
    """Call fn up to _MAX_RETRIES times; raise the last exception on
    persistent failure."""
    last_exc = None
    for attempt in range(_MAX_RETRIES):
        try:
            return fn(*args, **kwargs)
        except (_s.GitHubError, _s.ClassificationError) as exc:
            last_exc = exc
            if attempt < _MAX_RETRIES - 1:
                continue
    raise last_exc


# Label catalogues used by handlers in multiple modules.
STANDARD_LABELS: tuple[tuple[str, str, str], ...] = (
    ("bug", "d73a4a", "Something isn't working"),
    ("feature", "a2eeef", "New feature or enhancement"),
    ("refactor", "ededed", "Internal restructure with no external behaviour change"),
    ("rfc", "fbca04", "RFC for a significant change"),
    ("chore", "cccccc", "Tooling, deps, config, CI"),
    ("area:skill", "0e8a16", "Changes to active-claude-github.md"),
    ("area:mcp", "0e8a16", "Changes to MCP server code or behaviour"),
    ("area:public-mirror", "5319e7", "Triaged from or targeted at a public mirror"),
    ("area:bootstrap", "5319e7", "Bootstrap sequence"),
)

TYPE_LABELS: frozenset[str] = frozenset({"bug", "rfc", "feature", "refactor", "chore"})
