"""GitHub webhook handler: auto-close issues, update Peon tasks, and write changelog on PR merge.

On ``pull_request.closed`` events where ``merged=true`` this handler:

1. Parses the PR body for linked issue references (``fixes #N``, ``closes #N``,
   ``resolves #N``) and closes those issues via the GitHub Issues API.
2. Queries the Peon database for tasks whose ``pr_url`` matches the merged PR
   URL and marks them as ``merged``.
3. Generates a changelog entry from the PR title (following conventional commit
   format) and appends it to ``CHANGELOG.md`` in the repository via a GitHub
   API commit.

Configuration
-------------
GitHub App authentication is used for API calls (configured via
``GITHUB_APP_ID`` and ``GITHUB_APP_PRIVATE_KEY_PATH``).

``PEON_DATABASE_URL``
    PostgreSQL connection URL.  Resolved via ``get_pool()`` from ``peon_mcp.db``.
"""

from __future__ import annotations

import base64
import logging
import re
from datetime import UTC, datetime
from typing import Any

import httpx

from peon_mcp.db import get_pool
from peon_mcp.github_webhooks.dispatcher import register_handler
from peon_mcp.github_webhooks.pr_reviewer import _api_headers, _get_token

logger = logging.getLogger(__name__)

_GITHUB_API_BASE = "https://api.github.com"

# Regex matching GitHub's supported closing keywords (all forms):
# close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved
# Also matches full GitHub issue URLs.
_ISSUE_REF_RE = re.compile(
    r"(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)\s+"
    r"(?:https?://github\.com/[^/]+/[^/]+/issues/|#)(\d+)",
    re.IGNORECASE,
)

# Conventional commit type → changelog section heading
_CC_TYPE_TO_SECTION: dict[str, str] = {
    "feat": "Added",
    "fix": "Fixed",
    "docs": "Documentation",
    "style": "Style",
    "refactor": "Changed",
    "perf": "Performance",
    "test": "Testing",
    "build": "Build",
    "ci": "CI/CD",
    "chore": "Chore",
    "revert": "Reverted",
}

# Matches: type(optional-scope)!?: description
_CC_TITLE_RE = re.compile(r"^(?P<type>\w+)(?:\([^)]+\))?!?:\s*(?P<desc>.+)$")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _parse_linked_issues(body: str) -> list[int]:
    """Return sorted list of unique issue numbers referenced in *body*."""
    return sorted({int(m) for m in _ISSUE_REF_RE.findall(body)})


def _conventional_section(title: str) -> tuple[str, str]:
    """Parse a conventional commit title into (section_heading, cleaned_description).

    Falls back to ``("Changed", title)`` for non-conventional titles.
    """
    m = _CC_TITLE_RE.match(title.strip())
    if m:
        cc_type = m.group("type").lower()
        section = _CC_TYPE_TO_SECTION.get(cc_type, "Changed")
        return section, m.group("desc").strip()
    return "Changed", title.strip()


def _build_changelog_entry(
    pr_title: str,
    pr_number: int,
    pr_url: str,
    repo: str,
    merged_at: str,
) -> str:
    """Return a Markdown changelog block for a single merged PR.

    Format::

        ## [unreleased] - 2026-02-24

        ### Added

        - My feature description ([#42](https://github.com/org/repo/pull/42))
    """
    try:
        date_str = datetime.fromisoformat(merged_at.rstrip("Z")).strftime("%Y-%m-%d")
    except (ValueError, AttributeError):
        date_str = datetime.now(UTC).strftime("%Y-%m-%d")

    section, description = _conventional_section(pr_title)
    entry = (
        f"## [unreleased] - {date_str}\n\n"
        f"### {section}\n\n"
        f"- {description} ([#{pr_number}]({pr_url}))\n"
    )
    return entry


# ---------------------------------------------------------------------------
# GitHub API helpers
# ---------------------------------------------------------------------------


async def _close_issue(token: str, repo: str, issue_number: int) -> None:
    """Close a GitHub issue via the REST API."""
    url = f"{_GITHUB_API_BASE}/repos/{repo}/issues/{issue_number}"
    async with httpx.AsyncClient(timeout=15.0) as client:
        resp = await client.patch(
            url,
            headers=_api_headers(token),
            json={"state": "closed"},
        )
    if resp.status_code not in (200, 201):
        logger.warning(
            "pr_merged: failed to close issue %s#%d — %d: %s",
            repo,
            issue_number,
            resp.status_code,
            resp.text[:200],
        )
    else:
        logger.info("pr_merged: closed issue %s#%d", repo, issue_number)


async def _update_changelog(
    token: str, repo: str, entry: str, pr_number: int
) -> None:
    """Prepend *entry* to ``CHANGELOG.md`` via a GitHub API commit.

    If ``CHANGELOG.md`` does not exist it is created from scratch.
    """
    url = f"{_GITHUB_API_BASE}/repos/{repo}/contents/CHANGELOG.md"
    headers = _api_headers(token)

    async with httpx.AsyncClient(timeout=15.0) as client:
        # Fetch existing file (to get SHA for update, and current content).
        get_resp = await client.get(url, headers=headers)

        existing_content = ""
        file_sha: str | None = None

        if get_resp.status_code == 200:
            data = get_resp.json()
            file_sha = data.get("sha")
            raw = data.get("content", "")
            # GitHub returns base64-encoded content with newlines.
            try:
                existing_content = base64.b64decode(raw).decode("utf-8")
            except Exception:
                existing_content = ""
        elif get_resp.status_code != 404:
            logger.warning(
                "pr_merged: unexpected status %d fetching CHANGELOG.md for %s",
                get_resp.status_code,
                repo,
            )
            return

        new_content = entry + "\n" + existing_content if existing_content else entry
        encoded = base64.b64encode(new_content.encode("utf-8")).decode("ascii")

        body: dict[str, Any] = {
            "message": f"chore: update CHANGELOG.md for PR #{pr_number}",
            "content": encoded,
        }
        if file_sha:
            body["sha"] = file_sha

        put_resp = await client.put(url, headers=headers, json=body)

    if put_resp.status_code not in (200, 201):
        logger.warning(
            "pr_merged: failed to update CHANGELOG.md for %s — %d: %s",
            repo,
            put_resp.status_code,
            put_resp.text[:300],
        )
    else:
        logger.info("pr_merged: updated CHANGELOG.md for %s PR #%d", repo, pr_number)


# ---------------------------------------------------------------------------
# Peon DB helpers
# ---------------------------------------------------------------------------


async def _mark_peon_tasks_merged(pr_url: str, lines_added: int, lines_deleted: int) -> None:
    """Find Peon tasks linked to *pr_url* and mark them as ``merged``.

    Directly updates the database to avoid the REST API guard that
    requires non-zero lines/tokens (unavailable in the webhook context).
    Tasks already in ``cancelled`` or ``merged`` state are skipped; ``done``
    tasks are transitioned to ``merged`` so the merge event is always recorded.
    """
    completed_at = datetime.now(UTC)

    try:
        pool = get_pool()
        async with pool.acquire() as conn:
            # Match on exact URL or suffix match (some stored URLs may omit
            # trailing slashes or differ by https/http prefix).
            rows = await conn.fetch(
                "SELECT id, status FROM tasks WHERE pr_url = $1 OR pr_url ILIKE $2",
                pr_url, f"%{pr_url}",
            )
            if not rows:
                logger.warning("pr_merged: no Peon tasks found for pr_url=%s", pr_url)
                return

            for row in rows:
                task_id = row["id"]
                if row["status"] in ("cancelled", "merged"):
                    logger.info(
                        "pr_merged: task #%d already in terminal state %s; skipping",
                        task_id,
                        row["status"],
                    )
                    continue

                await conn.execute(
                    """UPDATE tasks
                       SET status = 'merged',
                           completed_at = $1,
                           lines_added = CASE WHEN lines_added = 0 THEN $2 ELSE lines_added END,
                           lines_deleted = CASE WHEN lines_deleted = 0 THEN $3 ELSE lines_deleted END,
                           tokens_used = CASE WHEN tokens_used = 0 THEN 1 ELSE tokens_used END
                       WHERE id = $4""",
                    completed_at, max(lines_added, 1), max(lines_deleted, 0), task_id,
                )
                logger.warning("pr_merged: marked Peon task #%d as merged (was: %s)", task_id, row["status"])

    except Exception:
        logger.exception("pr_merged: error updating Peon tasks for pr_url=%s", pr_url)


# ---------------------------------------------------------------------------
# Webhook handler
# ---------------------------------------------------------------------------


@register_handler("pull_request")
async def handle_pull_request_merged(delivery_id: str, payload: dict[str, Any]) -> None:
    """Handle ``pull_request.closed`` events where the PR was merged.

    Ignores non-``closed`` actions and unmerged (just closed) PRs.
    """
    action = payload.get("action")
    if action != "closed":
        return

    pr = payload.get("pull_request") or {}
    if not pr.get("merged"):
        return  # PR was closed without merging — nothing to do.

    pr_number: int | None = pr.get("number")
    pr_url: str = pr.get("html_url", "")
    pr_title: str = pr.get("title", "")
    pr_body: str = pr.get("body") or ""
    merged_at: str = pr.get("merged_at") or ""
    lines_added: int = pr.get("additions", 0)
    lines_deleted: int = pr.get("deletions", 0)
    repo: str = (payload.get("repository") or {}).get("full_name", "")

    if not pr_number or not repo:
        logger.warning(
            "pr_merged: delivery %s — missing PR number or repository in payload",
            delivery_id,
        )
        return

    owner = repo.split("/")[0] if "/" in repo else ""
    token = _get_token(owner=owner)
    if not token:
        logger.warning(
            "pr_merged: delivery %s — no GitHub App token available; "
            "skipping post-merge actions",
            delivery_id,
        )
        return

    # 1. Close linked issues.
    linked_issues = _parse_linked_issues(pr_body)
    if linked_issues:
        logger.info(
            "pr_merged: delivery %s — closing %d linked issue(s): %s",
            delivery_id,
            len(linked_issues),
            linked_issues,
        )
        for issue_number in linked_issues:
            await _close_issue(token, repo, issue_number)
    else:
        logger.info(
            "pr_merged: delivery %s — no linked issues found in PR #%d body",
            delivery_id,
            pr_number,
        )

    # 2. Mark linked Peon tasks as merged.
    if pr_url:
        await _mark_peon_tasks_merged(pr_url, lines_added, lines_deleted)

    # 3. Append changelog entry.
    if pr_title:
        entry = _build_changelog_entry(pr_title, pr_number, pr_url, repo, merged_at)
        await _update_changelog(token, repo, entry, pr_number)
    else:
        logger.info(
            "pr_merged: delivery %s — PR #%d has no title; skipping changelog",
            delivery_id,
            pr_number,
        )
