"""GitHub helpers for `close_triaged_public_issues` (#249, per #233 Rule B).

Implements the post-release auto-close flow:
- List closed dev-repo Issues with `area:public-mirror` in the milestone.
- Parse public-mirror URLs from Issue bodies (canonical body-cross-link
  form per #233 OQ1).
- Detect PEP 440 pre-release version suffixes (alpha / beta / rc) so
  the caller can skip label application per #233 OQ5 while still
  posting the comment.
- Find / create the `fixedin:<release>` label on the mirror.

The handler in `server/handlers/triage.py` orchestrates these helpers
plus the existing `add_issue_comment` / `close_issue` write tools.
"""
from __future__ import annotations

import re

from .core import GitHubError, _graphql


# PEP-440-style pre-release suffix on a semver-ish version. Matches:
#   2.3.0a1, 2.3.0b2, 2.3.0rc1, 2.3.0a10
# Does not match:
#   2.3.0 (GA), 2.3.0.post1 (post-release), 2.3.0.dev0 (dev release)
#
# Per #233 OQ5: only GA + hotfix releases get the `fixedin:<release>`
# label. Pre-release auto-closes still post a comment naming the
# version, but skip the label because pre-release tags churn and
# superseded labels would leave stale metadata.
_PRE_RELEASE_RE = re.compile(r"^\d+\.\d+\.\d+(a|b|rc)\d+$")


def is_pre_release_version(version: str) -> bool:
    """Return True if `version` is a PEP 440 alpha/beta/rc pre-release.

    Used by `close_triaged_public_issues` to decide whether to apply
    the `fixedin:<release>` label (skipped on pre-releases per #233 OQ5).
    """
    if not version.strip():
        return False
    return bool(_PRE_RELEASE_RE.match(version.strip()))


# Match any GitHub Issue URL — canonical form per #233 OQ1 is a body
# markdown link in the dev-repo Issue's `## Origin` section. The
# pattern captures owner, repo, and number; the caller filters by
# publish-target match.
_GITHUB_ISSUE_URL_RE = re.compile(
    r"https://github\.com/([\w.-]+)/([\w.-]+)/issues/(\d+)"
)


def extract_public_mirror_urls(
    body: str,
    publish_targets: list[str],
) -> list[dict]:
    """Extract public-mirror Issue URLs from a dev-repo Issue body.

    Scans the body for any `https://github.com/<owner>/<repo>/issues/<n>`
    URL. Filters to URLs whose `<owner>/<repo>` matches one of the
    project's `publish_targets` (typically `config.repos.public` plus
    any `related_repos` with `relationship: publish_target`).

    Returns a list of `{"owner": str, "repo": str, "number": int,
    "url": str}` — deduplicated by URL.
    """
    if not body:
        return []
    seen: set[str] = set()
    out: list[dict] = []
    targets_lower = {t.lower() for t in publish_targets}
    for match in _GITHUB_ISSUE_URL_RE.finditer(body):
        owner, repo, number_str = match.group(1), match.group(2), match.group(3)
        full = f"{owner}/{repo}".lower()
        if full not in targets_lower:
            continue
        url = match.group(0)
        if url in seen:
            continue
        seen.add(url)
        out.append({
            "owner": owner,
            "repo": repo,
            "number": int(number_str),
            "url": url,
        })
    return out


async def list_triaged_closed_issues(
    token: str,
    owner: str,
    repo: str,
    version: str,
    limit: int = 100,
) -> list[dict]:
    """List closed dev-repo Issues in the milestone with `area:public-mirror`.

    Uses the GraphQL `search` query (mirroring the pattern in
    `github/recovery.py` and the upcoming #236 `search_issues` tool)
    so the filter (`is:issue is:closed milestone:"X"
    label:area:public-mirror`) runs server-side.

    Returns a list of `{number, title, url, body}` per match.
    """
    if not owner.strip() or not repo.strip():
        raise ValueError("owner and repo must not be empty.")
    if not version.strip():
        raise ValueError("version must not be empty.")
    if limit < 1 or limit > 100:
        raise ValueError("limit must be between 1 and 100.")

    query_string = (
        f"is:issue is:closed repo:{owner}/{repo} "
        f'milestone:"{version}" label:area:public-mirror'
    )
    gql = """
    query($q: String!, $first: Int!) {
      search(query: $q, type: ISSUE, first: $first) {
        nodes {
          ... on Issue {
            number
            title
            url
            body
          }
        }
      }
    }
    """
    data = await _graphql(token, gql, {"q": query_string, "first": limit})
    nodes = (data.get("search") or {}).get("nodes") or []
    return [
        {
            "number": n["number"],
            "title": n["title"],
            "url": n["url"],
            "body": n.get("body", ""),
        }
        for n in nodes
        if n
    ]


async def find_label_id_or_none(
    token: str,
    owner: str,
    repo: str,
    label_name: str,
) -> str | None:
    """Look up a label by name; return None instead of raising on miss.

    Complement to `github.issues.get_label_id` which raises
    `GitHubError` on miss. This non-raising variant is what the
    label-create-if-absent flow needs (existence check, then create
    if absent).
    """
    if not label_name.strip():
        raise ValueError("label_name must not be empty.")
    query = """
    query GetLabel($owner: String!, $repo: String!, $name: String!) {
      repository(owner: $owner, name: $repo) {
        label(name: $name) { id }
      }
    }
    """
    data = await _graphql(
        token, query, {"owner": owner, "repo": repo, "name": label_name}
    )
    label = (data.get("repository") or {}).get("label")
    if not label:
        return None
    return label["id"]


_FIXEDIN_LABEL_COLOR = "0E8A16"


def fixedin_label_description(version: str) -> str:
    return (
        f"Closed because the fix shipped in release v{version}; "
        "reporter should upgrade to pick up the change."
    )


async def ensure_fixedin_label(
    token: str,
    repo_id: str,
    owner: str,
    repo: str,
    version: str,
) -> str:
    """Find or create the `fixedin:<version>` label on the mirror repo.

    Per #233 §`fixedin:<release>` label management:
    - Color `0E8A16` (green).
    - Description: "Closed because the fix shipped in release
      v<version>; reporter should upgrade to pick up the change."
    - Created on demand on first auto-close against a given release.

    `repo_id` is the GraphQL node ID of the mirror repo (needed for
    the createLabel mutation). `owner` / `repo` strings are the
    `nameWithOwner` form used by the existence check. Caller resolves
    via `get_repo_node_id` before calling.
    """
    label_name = f"fixedin:{version}"
    existing = await find_label_id_or_none(token, owner, repo, label_name)
    if existing is not None:
        return existing

    # Create. Mirror the helper in `github/scaffold.py:create_label` —
    # but inline here so this module doesn't need to import scaffold.
    create_query = """
    mutation($repoId: ID!, $name: String!, $color: String!, $description: String!) {
      createLabel(input: {
        repositoryId: $repoId,
        name: $name,
        color: $color,
        description: $description
      }) {
        label { id }
      }
    }
    """
    data = await _graphql(
        token,
        create_query,
        {
            "repoId": repo_id,
            "name": label_name,
            "color": _FIXEDIN_LABEL_COLOR,
            "description": fixedin_label_description(version),
        },
    )
    label = (data.get("createLabel") or {}).get("label") or {}
    label_id = label.get("id")
    if not label_id:
        raise GitHubError(
            f"Failed to create label {label_name!r} on {owner}/{repo}."
        )
    return label_id


async def add_label_to_issue(
    token: str,
    issue_id: str,
    label_id: str,
) -> None:
    """Apply a label to an Issue via `addLabelsToLabelable` mutation."""
    if not issue_id.strip() or not label_id.strip():
        raise ValueError("issue_id and label_id must not be empty.")
    query = """
    mutation($labelable: ID!, $labels: [ID!]!) {
      addLabelsToLabelable(input: {labelableId: $labelable, labelIds: $labels}) {
        clientMutationId
      }
    }
    """
    await _graphql(
        token, query, {"labelable": issue_id, "labels": [label_id]}
    )
