"""PR risk assessment handler for pull_request.opened webhook events.

Calculates a risk score when a PR is opened, based on:
- Total lines changed (additions + deletions)
- Number of files touched
- Whether critical paths are modified (migrations, auth, config, deploy, CI/CD)

Applies a risk label (low/medium/high/critical) to the PR and posts a
comment with a breakdown of what triggered each score component.
"""

from __future__ import annotations

import asyncio
import logging
import re
from typing import Any

from peon_mcp.github_webhooks.dispatcher import register_handler

logger = logging.getLogger(__name__)

# Risk label metadata: name and hex color (no leading #)
_RISK_LABELS: dict[str, dict[str, str]] = {
    "low": {"name": "risk: low", "color": "0e8a16"},
    "medium": {"name": "risk: medium", "color": "e4a11b"},
    "high": {"name": "risk: high", "color": "d93f0b"},
    "critical": {"name": "risk: critical", "color": "b60205"},
}

# Risk emoji for comment header
_RISK_EMOJI: dict[str, str] = {
    "low": "🟢",
    "medium": "🟡",
    "high": "🟠",
    "critical": "🔴",
}

# Critical path definitions: (regex_pattern, human_label, score_weight)
# weight=0 entries are matched but contribute no score points (informational)
_CRITICAL_PATHS: list[tuple[str, str, int]] = [
    (r"db\.py$|migrations?/|alembic/|_migration\.py$", "Database migrations/schema", 2),
    (r"auth|security|middleware|jwt|token|session|permissions?", "Auth/security", 2),
    (r"\.github/|\.circleci/|\.travis|Jenkinsfile|gitlab-ci", "CI/CD config", 2),
    (r"Dockerfile|docker-compose|\.dockerignore", "Container config", 1),
    (r"deploy|Makefile|\.sh$|\.bash$", "Deploy/build scripts", 1),
    (r"pyproject\.toml|setup\.py|setup\.cfg|requirements.*\.txt$|package\.json$|uv\.lock$", "Dependencies", 1),
    (r"config\.py$|settings\.py$|\.env|constants\.py$", "Config/constants", 1),
]


def compute_risk_score(
    lines_changed: int,
    files_changed: int,
    critical_hits: list[tuple[str, str, int]],
) -> tuple[str, list[str]]:
    """Compute the risk level and human-readable reasons.

    Args:
        lines_changed: Total additions + deletions.
        files_changed: Number of files modified.
        critical_hits: List of (filepath, description, weight) for matched critical paths.

    Returns:
        Tuple of (risk_level, reasons) where risk_level is one of
        "low", "medium", "high", "critical" and reasons is a list of
        human-readable strings explaining the score.
    """
    score = 0
    reasons: list[str] = []

    # Lines changed
    if lines_changed >= 1000:
        score += 4
        reasons.append(f"Large diff: {lines_changed:,} lines changed")
    elif lines_changed >= 500:
        score += 2
        reasons.append(f"Significant diff: {lines_changed:,} lines changed")
    elif lines_changed >= 100:
        score += 1
        reasons.append(f"Moderate diff: {lines_changed:,} lines changed")

    # Files touched
    if files_changed >= 50:
        score += 4
        reasons.append(f"Many files touched: {files_changed} files")
    elif files_changed >= 20:
        score += 2
        reasons.append(f"Several files touched: {files_changed} files")
    elif files_changed >= 5:
        score += 1
        reasons.append(f"Multiple files touched: {files_changed} files")

    # Critical path hits
    seen_descriptions: set[str] = set()
    for filepath, description, weight in critical_hits:
        if description not in seen_descriptions:
            score += weight
            reasons.append(f"Critical path ({description}): `{filepath}`")
            seen_descriptions.add(description)

    # Map total score to risk level
    if score >= 4:
        level = "critical"
    elif score >= 2:
        level = "high"
    elif score >= 1:
        level = "medium"
    else:
        level = "low"

    if not reasons:
        reasons.append("Small, focused change with no critical path modifications")

    return level, reasons


def match_critical_paths(files: list[str]) -> list[tuple[str, str, int]]:
    """Match file paths against critical path patterns.

    Returns a list of (filepath, description, weight) for each match.
    Each category is reported at most once (using the first matched file).
    """
    matched: list[tuple[str, str, int]] = []
    seen_descriptions: set[str] = set()

    for filepath in files:
        for pattern, description, weight in _CRITICAL_PATHS:
            if re.search(pattern, filepath, re.IGNORECASE):
                if description not in seen_descriptions:
                    matched.append((filepath, description, weight))
                    seen_descriptions.add(description)
                break  # Each file matches at most one category

    return matched


async def _run_gh(*args: str) -> tuple[int, str, str]:
    """Run a ``gh`` CLI command and return (returncode, stdout, stderr)."""
    try:
        proc = await asyncio.create_subprocess_exec(
            "gh", *args,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await proc.communicate()
        return proc.returncode, stdout.decode().strip(), stderr.decode().strip()
    except FileNotFoundError:
        return -1, "", "gh CLI not found"


async def _ensure_label(repo: str, name: str, color: str) -> None:
    """Create or update the risk label in the repository."""
    rc, _, stderr = await _run_gh(
        "label", "create", name,
        "--color", color,
        "--force",
        "--repo", repo,
    )
    if rc != 0:
        logger.warning("pr_risk: could not ensure label '%s' on %s: %s", name, repo, stderr)


async def _get_pr_files(repo: str, pr_number: int) -> list[str]:
    """Fetch the list of changed file paths for a PR via the GitHub API."""
    rc, stdout, stderr = await _run_gh(
        "api", f"repos/{repo}/pulls/{pr_number}/files",
        "--jq", ".[].filename",
    )
    if rc != 0:
        logger.warning("pr_risk: failed to fetch PR files for #%d on %s: %s", pr_number, repo, stderr)
        return []
    return [line for line in stdout.splitlines() if line]


async def _add_label_to_pr(repo: str, pr_number: int, label: str) -> None:
    """Add a label to a PR."""
    rc, _, stderr = await _run_gh(
        "pr", "edit", str(pr_number),
        "--add-label", label,
        "--repo", repo,
    )
    if rc != 0:
        logger.warning("pr_risk: failed to label PR #%d on %s: %s", pr_number, repo, stderr)


async def _post_comment(repo: str, pr_number: int, body: str) -> None:
    """Post a comment on a PR."""
    rc, _, stderr = await _run_gh(
        "pr", "comment", str(pr_number),
        "--body", body,
        "--repo", repo,
    )
    if rc != 0:
        logger.warning("pr_risk: failed to comment on PR #%d on %s: %s", pr_number, repo, stderr)


def _build_comment(
    risk_level: str,
    reasons: list[str],
    additions: int,
    deletions: int,
    files_changed: int,
    critical_hits: list[tuple[str, str, int]],
) -> str:
    """Construct the markdown risk assessment comment body."""
    emoji = _RISK_EMOJI.get(risk_level, "⚪")
    lines_changed = additions + deletions
    reason_lines = "\n".join(f"- {r}" for r in reasons)

    return (
        f"## {emoji} PR Risk Assessment: **{risk_level.upper()}**\n\n"
        f"**Score breakdown:**\n{reason_lines}\n\n"
        f"| Metric | Value |\n"
        f"|--------|-------|\n"
        f"| Lines changed | {lines_changed:,} (+{additions:,} / -{deletions:,}) |\n"
        f"| Files touched | {files_changed} |\n"
        f"| Critical path hits | {len(critical_hits)} |\n\n"
        f"*Generated automatically by peon-mcp PR risk assessment.*"
    )


@register_handler("pull_request")
async def handle_pull_request(delivery_id: str, payload: dict[str, Any]) -> None:
    """Handle ``pull_request`` webhook events.

    Only acts on the ``opened`` action. Calculates a risk score, applies
    a risk label, and posts an assessment comment on the PR.
    """
    action = payload.get("action")
    if action != "opened":
        return

    pr = payload.get("pull_request", {})
    repo_info = payload.get("repository", {})

    pr_number: int | None = pr.get("number")
    repo: str | None = repo_info.get("full_name")

    if not pr_number or not repo:
        logger.warning(
            "pr_risk: missing pr_number or repository.full_name in payload delivery=%s",
            delivery_id,
        )
        return

    additions: int = pr.get("additions", 0)
    deletions: int = pr.get("deletions", 0)
    files_changed_count: int = pr.get("changed_files", 0)

    logger.info(
        "pr_risk: assessing PR #%d on %s (delivery=%s, +%d/-%d, %d files)",
        pr_number, repo, delivery_id, additions, deletions, files_changed_count,
    )

    # Fetch file list for critical path detection
    files = await _get_pr_files(repo, pr_number)

    # Analyse which critical paths are touched
    critical_hits = match_critical_paths(files)

    # Compute risk
    risk_level, reasons = compute_risk_score(additions + deletions, files_changed_count, critical_hits)
    label_info = _RISK_LABELS[risk_level]

    logger.info(
        "pr_risk: PR #%d risk=%s score_reasons=%s",
        pr_number, risk_level, reasons,
    )

    # Ensure the label exists in the repo, then apply it
    await _ensure_label(repo, label_info["name"], label_info["color"])
    await _add_label_to_pr(repo, pr_number, label_info["name"])

    # Post the assessment comment
    comment = _build_comment(risk_level, reasons, additions, deletions, files_changed_count, critical_hits)
    await _post_comment(repo, pr_number, comment)
