"""Issue-side handlers: create_issue, update_issue_toc, move_project_item,
close_issue_with_comment, search_issues, close_triaged_public_issues."""
from __future__ import annotations

import asyncio

from mcp.types import TextContent

from ...context import ServerContext
from ...github.triage import (
    add_label_to_issue,
    ensure_fixedin_label,
    extract_public_mirror_urls,
    is_pre_release_version,
    list_triaged_closed_issues,
)
from ...models import (
    CreateIssueResult,
    OperationError,
    SearchIssueHit,
    SearchIssuesResult,
)
from ...parsers import parse_owner_repo
from ...utils import check_overwrite_size, resolve_new_body
# Late binding — see helpers.py docstring.
from active_claude_github_mcp import server as _s
from ..helpers import TYPE_LABELS, err, fetch_project_toc_body, ok


async def create_issue_tool(
    ctx: ServerContext,
    title: str,
    body: str,
    labels: list[str],
    discussion_origin_id: str,
    owner: str,
    repo: str,
    milestone: str | int | None = None,
) -> list[TextContent]:
    seen: set[str] = set()
    type_labels: set[str] = set()
    for name in labels:
        if not isinstance(name, str) or not name.strip():
            raise ValueError(
                f"Each label must be a non-empty string, got: {name!r}"
            )
        if name in seen:
            raise ValueError(f"Duplicate label: {name!r}")
        seen.add(name)
        if name in TYPE_LABELS:
            type_labels.add(name)
    if len(type_labels) != 1:
        raise ValueError(
            "Exactly one type label is required from "
            f"{sorted(TYPE_LABELS)}. Got: {sorted(type_labels) or '(none)'}"
        )

    # #179 — repo + label lookups are independent. #232 layers the
    # optional milestone lookup into the same gather: still one round-
    # trip if all three resolve concurrently.
    lookups = [
        _s.get_repo_node_id(ctx.github_token, owner, repo),
        *(_s.get_label_id(ctx.github_token, owner, repo, name) for name in labels),
    ]
    if milestone is not None:
        lookups.append(
            _s.find_milestone_node_id(ctx.github_token, owner, repo, milestone)
        )
    lookup_results = await asyncio.gather(*lookups)
    repo_id = lookup_results[0]
    label_ids = list(lookup_results[1 : 1 + len(labels)])
    milestone_id = (
        lookup_results[1 + len(labels)] if milestone is not None else None
    )

    issue = await _s.create_issue(
        ctx.github_token, repo_id, title, body, label_ids,
        milestone_id=milestone_id,
    )
    project_item_id = await _s.add_item_to_project(
        ctx.github_token, ctx.config.project_board.id, issue["id"]
    )
    ctx.activity.issues_created += 1

    return ok(
        CreateIssueResult(
            issue_id=issue["id"],
            issue_number=issue["number"],
            issue_url=issue["url"],
            project_item_id=project_item_id,
            project_toc_body=await fetch_project_toc_body(
                ctx.github_token, ctx.config.project_discussion_id
            ),
        ).model_dump()
    )


async def update_issue_toc(
    ctx: ServerContext,
    issue_id: str,
    new_body: str | None,
    new_body_path: str | None,
    force: bool,
) -> list[TextContent]:
    body = resolve_new_body(new_body, new_body_path)
    current_body = await _s.get_issue_body(ctx.github_token, issue_id)
    check_overwrite_size(current_body, body, force)
    await _s.update_issue_body(ctx.github_token, issue_id, body)
    return ok("Issue opening post updated.")


async def move_project_item(
    ctx: ServerContext,
    column_name: str,
    issue_id: str,
) -> list[TextContent]:
    board = ctx.config.project_board
    if board.status_field_id and board.status_options:
        status_field_id = board.status_field_id
        options = board.status_options
    else:
        field_info = await _s.get_project_status_field(ctx.github_token, board.id)
        status_field_id = field_info["field_id"]
        options = field_info["options"]
    option_id = options.get(column_name)
    if not option_id:
        return err(
            OperationError(
                success=False,
                error=f"Column '{column_name}' not found on board.",
                buffered=False,
                buffer_file=None,
                retry_tool=None,
                retry_args=None,
            )
        )
    project_item_id = await _s.get_project_item_id(
        ctx.github_token, issue_id, board.id
    )
    await _s.move_project_item(
        ctx.github_token, board.id, project_item_id, status_field_id, option_id
    )
    return ok(f"Moved to {column_name}.")


async def close_issue_with_comment(
    ctx: ServerContext,
    issue_id: str,
    comment: str,
    state_reason: str,
) -> list[TextContent]:
    if not comment.strip():
        raise ValueError("comment must not be empty.")
    # Post the comment first so it appears above the close event
    # on the Issue timeline (#59).
    await _s.add_issue_comment(ctx.github_token, issue_id, comment)
    closed = await _s.close_issue(
        ctx.github_token, issue_id, state_reason=state_reason
    )
    return ok(
        {
            "issue_url": closed["url"],
            "closed_at": closed["closed_at"],
        }
    )


async def search_issues_tool(
    ctx: ServerContext,
    query: str,
    state: str,
    labels: list[str] | None,
    milestone: str | None,
    owner: str,
    repo: str,
    limit: int,
) -> list[TextContent]:
    """Wrap GitHub's GraphQL Issue search with shaped output (#236).

    Surfaced from [#235](https://github.com/telejester-claude-skills/telejester-claude-skills-dev/discussions/235)
    §OQ3 (Decided 2026-04-28) as the canonical replacement for the
    dropped ## Issues table grep capability after the TOC reframe lands.
    Read-only; failures propagate without buffering (read tools don't
    buffer per `recording.md` §Failure Handling).

    `query` is GitHub search-syntax — the caller can include their own
    operators (`in:body`, `author:`, `created:>2026-01-01`, etc.). The
    structured params (`state`, `labels`, `milestone`) are layered on
    top to produce the final query string, which is included in the
    response so callers can debug without a separate roundtrip.
    """
    raw = await _s.search_issues(
        ctx.github_token,
        user_query=query,
        owner=owner,
        repo=repo,
        state=state,
        labels=labels,
        milestone=milestone,
        limit=limit,
    )
    hits = [SearchIssueHit(**hit) for hit in raw["results"]]
    return ok(
        SearchIssuesResult(
            results=hits,
            total_count=raw["total_count"],
            query=raw["query"],
        ).model_dump()
    )
def _publish_targets(ctx: ServerContext) -> list[str]:
    """Collect publish-target `owner/repo` paths from project config.

    Per #233 OQ1: only public-mirror URLs that resolve to a configured
    publish target count as triage cross-links. Includes the main
    `repos.public` plus any `related_repos` with `publish_target`
    relationship (so subsystem mirrors are also recognized).
    """
    targets: list[str] = [ctx.config.repos.public]
    for related in ctx.config.related_repos:
        if related.relationship == "publish_target":
            targets.append(related.repo)
    return targets


def _close_comment_body(
    version: str,
    dev_issue_url: str,
    is_pre_release: bool,
) -> str:
    """Compose the closing comment body posted on the public-mirror Issue.

    Mirrors the Rule A comment template applied to #2 in this project
    (the precedent for Rule B's automation).
    """
    pre_release_note = (
        " (pre-release; the GA release will be tagged when the line "
        "is finalized)"
    ) if is_pre_release else ""
    return (
        f"Fixed in **v{version}**{pre_release_note}.\n\n"
        f"The fix landed via the dev-repo Issue at {dev_issue_url}. "
        "To pick up the fix, upgrade to the released version.\n\n"
        "Closed automatically by `close_triaged_public_issues` "
        "(#249) per the §Public Issue triage closure rules in "
        "[multi-repo.md](https://github.com/telejester-claude-skills/telejester-claude-skills-dev/blob/main/.claude/skills/active-claude-github/multi-repo.md#closure-rules-233)."
    )


async def close_triaged_public_issues(
    ctx: ServerContext,
    version: str,
    dry_run: bool,
    skip_pre_release: bool,
) -> list[TextContent]:
    """Auto-close upstream public-mirror Issues at release time (#233 Rule B, #249).

    Scans closed dev-repo Issues in the milestone matching `version`
    for `area:public-mirror` + body cross-link to a public-mirror Issue.
    For each match, posts a closing comment, applies the
    `fixedin:<version>` label (created on demand; skipped on pre-
    releases per #233 OQ5 when `skip_pre_release=True`), and closes
    the public Issue with `state_reason: completed`.

    `dry_run=True` (default at the dispatch layer) returns the close
    set without any GitHub writes. `dry_run=False` performs the
    full action.
    """
    if not version.strip():
        raise ValueError("version must not be empty.")

    dev_owner, dev_repo = parse_owner_repo(ctx.config.repos.private)
    publish_targets = _publish_targets(ctx)

    # Step 1 — list the closed dev-repo Issues to consider.
    candidates = await list_triaged_closed_issues(
        ctx.github_token, dev_owner, dev_repo, version,
    )

    # Step 2 — match each candidate to a public-mirror URL.
    matched: list[dict] = []
    unmatched: list[dict] = []
    for candidate in candidates:
        urls = extract_public_mirror_urls(
            candidate["body"], publish_targets,
        )
        if not urls:
            unmatched.append({
                "dev_issue": f"{dev_owner}/{dev_repo}#{candidate['number']}",
                "reason": "no public-mirror URL in body",
            })
            continue
        # Use the first match — multiple cross-links to the same
        # publish target imply duplicates the parser already dedupes.
        public = urls[0]
        matched.append({
            "dev_issue": f"{dev_owner}/{dev_repo}#{candidate['number']}",
            "dev_url": candidate["url"],
            "public_owner": public["owner"],
            "public_repo": public["repo"],
            "public_number": public["number"],
            "public_url": public["url"],
        })

    # Step 3 — pre-release gate (per #233 OQ5).
    pre_release = is_pre_release_version(version)
    skip_label = pre_release and skip_pre_release

    label_created_for: set[str] = set()  # repos where we created the label
    label_ids: dict[str, str] = {}        # repo -> label node id

    if dry_run:
        # Annotate the dry-run output with what would have happened.
        for entry in matched:
            entry["label_applied"] = (
                None if skip_label else f"fixedin:{version}"
            )
            entry["comment_posted"] = False
            entry["closed"] = False
        return ok({
            "version": version,
            "is_pre_release": pre_release,
            "skipped_due_to_pre_release": skip_label,
            "scanned_issues": len(candidates),
            "matched": matched,
            "unmatched": unmatched,
            "label_created": False,
            "dry_run": True,
        })

    # Step 4 (live) — for each matched Issue, post comment + apply
    # label (if not skipped) + close.
    for entry in matched:
        public_owner = entry["public_owner"]
        public_repo = entry["public_repo"]
        repo_key = f"{public_owner}/{public_repo}"

        # Resolve mirror node IDs once per repo (repo node + Issue node).
        mirror_repo_id = await _s.get_repo_node_id(
            ctx.github_token, public_owner, public_repo,
        )
        mirror_issue_id = await _s.resolve_thread_id(
            ctx.github_token, public_owner, public_repo,
            entry["public_number"], "issue",
        )

        # Comment + close (always).
        comment_body = _close_comment_body(
            version, entry["dev_url"], pre_release,
        )
        await _s.add_issue_comment(
            ctx.github_token, mirror_issue_id, comment_body,
        )
        entry["comment_posted"] = True

        # Label (skip on pre-release per OQ5).
        if not skip_label:
            if repo_key not in label_ids:
                label_ids[repo_key] = await ensure_fixedin_label(
                    ctx.github_token, mirror_repo_id, public_owner,
                    public_repo, version,
                )
                label_created_for.add(repo_key)
            await add_label_to_issue(
                ctx.github_token, mirror_issue_id, label_ids[repo_key],
            )
            entry["label_applied"] = f"fixedin:{version}"
        else:
            entry["label_applied"] = None

        # Close.
        await _s.close_issue(
            ctx.github_token, mirror_issue_id, state_reason="completed",
        )
        entry["closed"] = True

    return ok({
        "version": version,
        "is_pre_release": pre_release,
        "skipped_due_to_pre_release": skip_label,
        "scanned_issues": len(candidates),
        "matched": matched,
        "unmatched": unmatched,
        "label_created": len(label_created_for) > 0,
        "dry_run": False,
    })
