"""MCP tools for managing PR review comments."""

from __future__ import annotations

import logging
import re
from typing import TYPE_CHECKING, Literal

from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool

from codereviewbuddy import gh, github_api
from codereviewbuddy.config import get_config
from codereviewbuddy.models import (
    CommentStatus,
    ReviewComment,
    ReviewSummary,
    ReviewThread,
    TriageItem,
    TriageResult,
)
from codereviewbuddy.tools.stack import discover_stack

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    from typing import Any

    from fastmcp.server.context import Context

# GraphQL query to fetch review threads for a PR (paginated)
_THREADS_QUERY = """
query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
  repository(owner: $owner, name: $repo) {
    pullRequest(number: $pr) {
      title
      url
      reviewThreads(first: 100, after: $cursor) {
        pageInfo { hasNextPage endCursor }
        nodes {
          id
          isResolved
          comments(first: 10) {
            nodes {
              author { login }
              body
              createdAt
              path
              line
              url
            }
          }
        }
      }
    }
  }
}
"""


def _check_graphql_errors(result: dict[str, Any], context: str) -> None:
    """Raise GhError if a GraphQL response contains errors."""
    errors = result.get("errors")
    if errors:
        msg = f"GraphQL error in {context}: {errors[0].get('message', errors)}"
        raise gh.GhError(msg)


# -- Body stripping (issue #99) ------------------------------------------------

# HTML comment blocks injected by reviewer bots (badges, metadata, etc.)
_HTML_COMMENT_BLOCK_RE = re.compile(r"<!--.*?-->", re.DOTALL)

# <details>...</details> → keep only the <summary> text
_DETAILS_BLOCK_RE = re.compile(
    r"<details[^>]*>\s*<summary[^>]*>(.*?)</summary>.*?</details>",
    re.DOTALL,
)

# Any remaining HTML tags
_HTML_TAG_RE = re.compile(r"<[^>]+>")

# Collapse 3+ consecutive blank lines into 2
_BLANK_LINES_RE = re.compile(r"\n{3,}")

_MAX_BODY_LENGTH = 2000


def _strip_comment_body(body: str) -> str:
    """Strip reviewer badge HTML, collapse <details> blocks, remove tags.

    This keeps comment bodies small enough for LLM context windows while
    preserving the actual review content.
    """
    # 1. Remove HTML comment blocks (badge metadata, tracking pixels, etc.)
    body = _HTML_COMMENT_BLOCK_RE.sub("", body)

    # 2. Collapse <details> blocks to just their <summary> text
    body = _DETAILS_BLOCK_RE.sub(r"[details: \1]", body)

    # 3. Strip remaining HTML tags
    body = _HTML_TAG_RE.sub("", body)

    # 4. Clean up whitespace
    body = _BLANK_LINES_RE.sub("\n\n", body)
    body = body.strip()

    # 5. Truncate extremely long bodies
    if len(body) > _MAX_BODY_LENGTH:
        body = body[:_MAX_BODY_LENGTH] + "… [truncated]"

    return body


def _parse_threads(raw_threads: list[dict[str, Any]], pr_number: int) -> list[ReviewThread]:
    """Parse raw GraphQL thread nodes into ReviewThread models."""
    threads = []
    for node in raw_threads:
        comments_raw = node.get("comments", {}).get("nodes", [])
        if not comments_raw:
            continue

        first_comment = comments_raw[0]
        author = (first_comment.get("author") or {}).get("login", "unknown")
        file_path = first_comment.get("path")

        comments = [
            ReviewComment(
                author=(c.get("author") or {}).get("login", "unknown"),
                body=_strip_comment_body(c.get("body", "")),
                created_at=c.get("createdAt"),
                url=c.get("url", ""),
            )
            for c in comments_raw
        ]

        threads.append(
            ReviewThread(
                thread_id=node["id"],
                pr_number=pr_number,
                status=CommentStatus.RESOLVED if node.get("isResolved") else CommentStatus.UNRESOLVED,
                file=file_path,
                line=first_comment.get("line"),
                reviewer=author,
                comments=comments,
            )
        )
    return threads


# Map GitHub review states to our comment status
_REVIEW_STATE_MAP: dict[str, CommentStatus] = {
    "APPROVED": CommentStatus.RESOLVED,
    "DISMISSED": CommentStatus.RESOLVED,
    "CHANGES_REQUESTED": CommentStatus.UNRESOLVED,
    "COMMENTED": CommentStatus.UNRESOLVED,
}


async def _get_pr_reviews(
    owner: str,
    repo: str,
    pr_number: int,
    cwd: str | None = None,  # noqa: ARG001
) -> list[ReviewThread]:
    """Fetch PR-level reviews (approvals, change requests, review summaries).

    These appear on the PR conversation tab but are NOT inline code threads.
    """
    result = await github_api.rest(f"/repos/{owner}/{repo}/pulls/{pr_number}/reviews?per_page=100", paginate=True)
    if not result:
        return []

    threads: list[ReviewThread] = []
    for review in result:
        login = (review.get("user") or {}).get("login", "unknown")
        raw_body = (review.get("body") or "").strip()
        if not raw_body:
            continue

        state = review.get("state", "COMMENTED")
        status = _REVIEW_STATE_MAP.get(state, CommentStatus.UNRESOLVED)

        threads.append(
            ReviewThread(
                thread_id=review.get("node_id", ""),
                pr_number=pr_number,
                status=status,
                file=None,
                line=None,
                reviewer=login,
                comments=[
                    ReviewComment(
                        author=login,
                        body=_strip_comment_body(raw_body),
                        created_at=review.get("submitted_at"),
                    ),
                ],
                is_pr_review=True,
            )
        )
    return threads


async def _get_pr_issue_comments(
    owner: str,
    repo: str,
    pr_number: int,
    cwd: str | None = None,  # noqa: ARG001
) -> list[ReviewThread]:
    """Fetch regular PR comments from bots (e.g. codecov, netlify, vercel).

    These are IssueComment nodes posted on the PR conversation tab — not review
    threads or PR reviews. Without this, bot feedback like coverage reports and
    deployment previews is invisible.
    """
    result = await github_api.rest(f"/repos/{owner}/{repo}/issues/{pr_number}/comments?per_page=100", paginate=True)
    if not result:
        return []

    threads: list[ReviewThread] = []
    for comment in result:
        login = (comment.get("user") or {}).get("login", "unknown")
        # Only include bot comments (login ends with [bot] or user type is Bot)
        user_type = (comment.get("user") or {}).get("type", "")
        is_bot = user_type == "Bot" or login.endswith("[bot]")
        if not is_bot:
            continue

        raw_body = (comment.get("body") or "").strip()
        if not raw_body:
            continue

        threads.append(
            ReviewThread(
                thread_id=comment.get("node_id", ""),
                pr_number=pr_number,
                status=CommentStatus.UNRESOLVED,
                file=None,
                line=None,
                reviewer=login,
                comments=[
                    ReviewComment(
                        author=login,
                        body=_strip_comment_body(raw_body),
                        created_at=comment.get("created_at"),
                    ),
                ],
                is_pr_review=True,
            )
        )
    return threads


async def _fetch_raw_threads(
    owner: str,
    repo_name: str,
    pr_number: int,
    cwd: str | None,  # noqa: ARG001
    ctx: Context | None,
) -> list[dict[str, Any]]:
    """Paginate through all review threads for a PR via GraphQL."""
    raw_threads: list[dict[str, Any]] = []
    cursor = None
    page = 0

    while True:
        page += 1
        if ctx:
            await ctx.report_progress(progress=page, total=None)
            await ctx.info(f"Fetching review threads for PR #{pr_number} (page {page})")

        variables: dict[str, Any] = {"owner": owner, "repo": repo_name, "pr": pr_number}
        if cursor:
            variables["cursor"] = cursor

        result = await github_api.graphql(_THREADS_QUERY, variables=variables)
        pr_data = result.get("data", {}).get("repository", {}).get("pullRequest") or {}
        threads_data = pr_data.get("reviewThreads", {})
        raw_threads.extend(threads_data.get("nodes", []))

        page_info = threads_data.get("pageInfo", {})
        if page_info.get("hasNextPage") and page_info.get("endCursor"):
            cursor = page_info["endCursor"]
        else:
            break

    return raw_threads


async def _collect_all_threads(
    owner: str,
    repo_name: str,
    pr_number: int,
    cwd: str | None,
    ctx: Context | None,
) -> list[ReviewThread]:
    """Fetch inline threads, PR-level reviews, and bot comments."""
    raw_threads = await _fetch_raw_threads(owner, repo_name, pr_number, cwd, ctx)
    threads = _parse_threads(raw_threads, pr_number)

    # Include PR-level reviews (approvals, review summaries)
    pr_reviews = await _get_pr_reviews(owner, repo_name, pr_number, cwd=cwd)
    threads.extend(pr_reviews)

    # Include regular PR comments from bots (e.g. codecov, netlify, vercel)
    bot_comments = await _get_pr_issue_comments(owner, repo_name, pr_number, cwd=cwd)
    threads.extend(bot_comments)

    return threads


async def _collect_inline_threads_only(  # noqa: PLR0913, PLR0917
    owner: str,
    repo_name: str,
    pr_number: int,
    status: str | None,
    cwd: str | None,
    ctx: Context | None,
) -> list[ReviewThread]:
    """Lightweight thread fetch — inline threads only, no staleness, no PR reviews, no bot comments.

    Skips commit fetch, compare API (staleness), PR-level reviews, bot comments,
    and stack discovery.  Suitable for callers that only need thread status,
    comment bodies, and reviewer identification (e.g. triage).
    """
    raw_threads = await _fetch_raw_threads(owner, repo_name, pr_number, cwd, ctx)
    threads = _parse_threads(raw_threads, pr_number)

    if status:
        target = CommentStatus(status)
        threads = [t for t in threads if t.status == target]

    return threads


async def list_review_comments(
    pr_number: int,
    repo: str | None = None,
    status: str | None = None,
    cwd: str | None = None,
    ctx: Context | None = None,
) -> ReviewSummary:
    """List all review threads for a PR.

    Args:
        pr_number: The PR number to fetch comments for.
        repo: Repository in "owner/repo" format. Auto-detected if not provided.
        status: Filter by "resolved" or "unresolved". Returns all if not set.
        cwd: Working directory for git operations.
        ctx: FastMCP context for progress reporting. Injected by server tools.

    Returns:
        ReviewSummary with threads and stack info.
    """
    if repo:
        owner, repo_name = github_api.parse_repo(repo)
    else:
        owner, repo_name = await call_sync_fn_in_threadpool(gh.get_repo_info, cwd=cwd)

    threads = await _collect_all_threads(owner, repo_name, pr_number, cwd, ctx)

    # Filter threads by status if requested
    if status:
        target = CommentStatus(status)
        threads = [t for t in threads if t.status == target]

    if ctx:
        await ctx.info(f"Found {len(threads)} review threads for PR #{pr_number}")

    # Discover PR stack (cached per session) — best-effort, don't fail the request
    try:
        full_repo = f"{owner}/{repo_name}"
        stack_prs = await discover_stack(pr_number, repo=full_repo, cwd=cwd, ctx=ctx)
    except Exception:
        stack_prs = []

    # Build next_steps and message based on review state
    next_steps: list[str] = []
    message = ""
    unresolved = [t for t in threads if t.status == CommentStatus.UNRESOLVED]

    if not threads:
        message = f"No review threads found on PR #{pr_number}."
    elif not unresolved:
        message = f"All {len(threads)} review threads on PR #{pr_number} are resolved."
    elif stack_prs and len(stack_prs) > 1:
        pr_nums = [p.pr_number for p in stack_prs]
        next_steps.append(f"Call triage_review_comments(pr_numbers={pr_nums}) for actionable threads across the stack.")
    else:
        next_steps.append(f"Call triage_review_comments(pr_numbers=[{pr_number}]) for actionable-only view.")

    return ReviewSummary(
        threads=threads,
        stack=stack_prs,
        next_steps=next_steps,
        message=message,
    )


async def list_stack_review_comments(
    pr_numbers: list[int],
    repo: str | None = None,
    status: str | None = None,
    cwd: str | None = None,
    ctx: Context | None = None,
) -> dict[int, ReviewSummary]:
    """List review threads for multiple PRs in a stack, grouped by PR number.

    Collapses N tool calls into 1 for the common stacked-PR review workflow.

    Args:
        pr_numbers: List of PR numbers to fetch comments for.
        repo: Repository in "owner/repo" format. Auto-detected if not provided.
        status: Filter by "resolved" or "unresolved". Returns all if not set.
        cwd: Working directory for git operations.
        ctx: FastMCP context for progress reporting. Injected by server tools.

    Returns:
        Dict mapping each PR number to its ReviewSummary.
    """
    results: dict[int, ReviewSummary] = {}
    total = len(pr_numbers)
    for i, pr_number in enumerate(pr_numbers):
        if ctx and total:
            await ctx.report_progress(progress=i, total=total)
        results[pr_number] = await list_review_comments(pr_number, repo=repo, status=status, cwd=cwd, ctx=ctx)
    if ctx and total:
        await ctx.report_progress(progress=total, total=total)
    return results


async def reply_to_comment(
    pr_number: int | None,
    thread_id: str,
    body: str,
    repo: str | None = None,
    cwd: str | None = None,
) -> str:
    """Reply to a specific review thread, PR-level review, or issue comment.

    Supports inline review threads (PRRT_ IDs), PR-level reviews (PRR_ IDs),
    and issue comments (IC_ IDs, e.g. bot comments from codecov/netlify).
    For PRRT_ threads, replies via the GraphQL addPullRequestReviewThreadReply mutation.
    For PRR_/IC_ IDs, posts a regular PR comment via the issues comments API.

    Args:
        pr_number: PR number.
        thread_id: The thread ID to reply to (PRRT_..., PRR_..., or IC_...).
        body: Reply text.
        repo: Repository in "owner/repo" format. Auto-detected if not provided.
        cwd: Working directory.

    Returns:
        Confirmation message.
    """
    # PRRT_ threads use GraphQL with only the thread ID — no repo or pr_number needed
    if thread_id.startswith("PRRT_"):
        return await _reply_to_review_thread(pr_number, thread_id, body, cwd=cwd)

    # IC_ and PRR_ paths need owner/repo + pr_number for the issues comments API
    if thread_id.startswith(("IC_", "PRR_")):
        if pr_number is None:
            msg = "pr_number is required for IC_ and PRR_ thread replies"
            raise ValueError(msg)
        if repo:
            owner, repo_name = github_api.parse_repo(repo)
        else:
            owner, repo_name = await call_sync_fn_in_threadpool(gh.get_repo_info, cwd=cwd)
        kind = "bot comment" if thread_id.startswith("IC_") else "PR-level review"
        return await _reply_to_pr_comment(pr_number, owner, repo_name, body, kind=kind, cwd=cwd)

    msg = f"Unsupported thread ID prefix: {thread_id!r}. Expected PRRT_, IC_, or PRR_."
    raise gh.GhError(msg)


_REPLY_TO_THREAD_MUTATION = """
mutation($threadId: ID!, $body: String!) {
  addPullRequestReviewThreadReply(input: {
    pullRequestReviewThreadId: $threadId,
    body: $body
  }) {
    comment { id }
  }
}
"""


async def _reply_to_review_thread(
    pr_number: int | None,
    thread_id: str,
    body: str,
    cwd: str | None = None,  # noqa: ARG001
) -> str:
    """Reply to an inline review thread (PRRT_ ID) via GraphQL mutation."""
    await github_api.graphql(
        _REPLY_TO_THREAD_MUTATION,
        variables={"threadId": thread_id, "body": body},
    )
    pr_suffix = f" on PR #{pr_number}" if pr_number is not None else ""
    return f"Replied to thread {thread_id}{pr_suffix}"


async def _reply_to_pr_comment(  # noqa: PLR0913, PLR0917
    pr_number: int,
    owner: str,
    repo_name: str,
    body: str,
    kind: str = "PR-level review",
    cwd: str | None = None,  # noqa: ARG001
) -> str:
    """Reply to a PR-level review or bot comment by posting an issue comment."""
    await github_api.rest(
        f"/repos/{owner}/{repo_name}/issues/{pr_number}/comments",
        method="POST",
        body=body,
    )
    return f"Replied to {kind} on PR #{pr_number}"


# ---------------------------------------------------------------------------
# Triage — actionable threads only (#96)
# ---------------------------------------------------------------------------

_BOLD_TITLE_RE = re.compile(r"\*\*(?:Bug|Info|Warning|Flagged)?:?\s*(.+?)\*\*", re.IGNORECASE)
_ISSUE_REF_RE = re.compile(r"#\d+")
_FOLLOWUP_KEYWORDS = re.compile(r"noted for followup|tracked for later|will address later|followup", re.IGNORECASE)

_SEVERITY_ORDER = {"bug": 0, "flagged": 1, "warning": 2, "info": 3}


def _extract_title(body: str) -> str:
    """Extract a short title from the first bold text in a comment."""
    match = _BOLD_TITLE_RE.search(body)
    return match.group(1).strip() if match else ""


def _has_owner_reply(thread: ReviewThread, owner_logins: frozenset[str]) -> bool:
    """Check if any comment in the thread is from the repo owner / agent."""
    return any(c.author in owner_logins for c in thread.comments)


def _has_followup_without_issue(thread: ReviewThread, owner_logins: frozenset[str]) -> bool:
    """Check if the owner replied with a 'noted for followup' but no issue reference anywhere in the thread."""
    owner_comments = [c for c in thread.comments if c.author in owner_logins]
    has_followup = any(_FOLLOWUP_KEYWORDS.search(c.body) for c in owner_comments)
    if not has_followup:
        return False
    has_issue_ref = any(_ISSUE_REF_RE.search(c.body) for c in owner_comments)
    return not has_issue_ref


def _classify_action(severity: str) -> Literal["fix", "reply"]:
    """Map severity to suggested action."""
    if severity in {"bug", "flagged"}:
        return "fix"
    return "reply"


def _thread_to_triage_item(
    thread: ReviewThread,
    action: Literal["fix", "reply", "create_issue"] = "reply",
) -> TriageItem:
    """Convert a ReviewThread into a TriageItem with severity classification."""
    from codereviewbuddy.tools.stack import _classify_severity  # noqa: PLC0415

    first = thread.comments[0] if thread.comments else None
    body = first.body if first else ""
    severity = _classify_severity(body).value
    if action != "create_issue":
        action = _classify_action(severity)
    return TriageItem(
        thread_id=thread.thread_id,
        pr_number=thread.pr_number,
        file=thread.file,
        line=thread.line,
        reviewer=thread.reviewer,
        severity=severity,
        title=_extract_title(body),
        action=action,
        snippet=body[:200],
        comment_url=first.url if first else "",
    )


def _build_triage_hints(
    all_items: list[TriageItem],
    needs_fix: int,
    needs_reply: int,
    needs_issue: int,
) -> tuple[list[str], str]:
    """Build next_steps and message for a TriageResult."""
    next_steps: list[str] = []
    message = ""
    if not all_items:
        message = "No actionable threads — all threads have owner replies or are resolved."
    else:
        if needs_fix:
            next_steps.append(f"Fix the {needs_fix} bug/flagged item(s) first, then call reply_to_comment() for each explaining the fix.")
        if needs_reply:
            next_steps.append(f"Reply to the {needs_reply} info/warning thread(s) with reply_to_comment().")
        if needs_issue:
            next_steps.append(f"Call create_issue_from_comment() for the {needs_issue} followup(s) missing a GitHub issue reference.")
    return next_steps, message


async def triage_review_comments(
    pr_numbers: list[int],
    repo: str | None = None,
    owner_logins: list[str] | None = None,
    cwd: str | None = None,
    ctx: Context | None = None,
) -> TriageResult:
    """Return only threads that need agent action — no noise, no full bodies.

    Filters:
    - Unresolved inline threads only (excludes PR-level reviews).
    - Excludes threads that already have an owner reply.
    - Pre-classifies severity using emoji markers.
    - Flags 'noted for followup' replies that don't reference a GH issue.

    Args:
        pr_numbers: PR numbers to triage.
        repo: Repository in "owner/repo" format. Auto-detected if not provided.
        owner_logins: GitHub usernames considered "ours" (agent + human).
            Defaults to ``CRB_OWNER_LOGINS`` config value if not provided.
        cwd: Working directory for git operations.
        ctx: FastMCP context for progress reporting.

    Returns:
        TriageResult with only actionable items, sorted by severity.
    """
    configured_owners = get_config().owner_logins
    resolved = owner_logins if owner_logins is not None else configured_owners
    if not resolved:
        logger.warning("No owner_logins configured — owner-reply filtering is disabled. Set CRB_OWNER_LOGINS to enable.")
    owners = frozenset(resolved)
    items: list[TriageItem] = []
    issue_items: list[TriageItem] = []

    if repo:
        owner, repo_name = github_api.parse_repo(repo)
    else:
        owner, repo_name = await call_sync_fn_in_threadpool(gh.get_repo_info, cwd=cwd)

    total = len(pr_numbers)
    for i, pr_number in enumerate(pr_numbers):
        if ctx and total:
            await ctx.report_progress(i, total)

        threads = await _collect_inline_threads_only(owner, repo_name, pr_number, status="unresolved", cwd=cwd, ctx=ctx)

        for thread in threads:
            # Skip PR-level reviews — they're summary wrappers, not actionable
            if thread.is_pr_review:
                continue

            # Check for "noted for followup" without issue ref (even if owner already replied)
            if _has_followup_without_issue(thread, owners):
                issue_items.append(_thread_to_triage_item(thread, action="create_issue"))
                continue

            # Skip threads that already have an owner reply
            if _has_owner_reply(thread, owners):
                continue

            items.append(_thread_to_triage_item(thread))

    if ctx and total:
        await ctx.report_progress(total, total)

    # Sort all items by severity (bugs first)
    all_items = items + issue_items
    all_items.sort(key=lambda x: _SEVERITY_ORDER.get(x.severity, 99))
    needs_fix = sum(1 for item in all_items if item.action == "fix")
    needs_reply = sum(1 for item in all_items if item.action == "reply")
    needs_issue = len(issue_items)

    next_steps, message = _build_triage_hints(all_items, needs_fix, needs_reply, needs_issue)

    return TriageResult(
        items=all_items,
        needs_fix=needs_fix,
        needs_reply=needs_reply,
        needs_issue=needs_issue,
        total=len(all_items),
        next_steps=next_steps,
        message=message,
    )
