"""Shared builder for full-content PR diffs.

Fetches a PR's changed files and their full original/modified contents so the
UI can render syntax-highlighted diffs with pierre's ``MultiFileDiff``. Used by
both the thread PR diff endpoint (user token) and the review diff endpoint
(App installation token).
"""

from __future__ import annotations

import asyncio
from typing import Any
from urllib.parse import quote

import httpx
from fastapi import HTTPException

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

PR_DIFF_MAX_FILES = 50
PR_DIFF_MAX_FILE_BYTES = 200_000
PR_DIFF_FETCH_CONCURRENCY = 5


async def _fetch_file_at_ref(
    client: httpx.AsyncClient,
    semaphore: asyncio.Semaphore,
    full_name: str,
    path: str,
    ref: str,
) -> str | None:
    async with semaphore:
        response = await client.get(
            f"{_GITHUB_API}/repos/{full_name}/contents/{quote(path, safe='/')}",
            params={"ref": ref},
            headers={"Accept": "application/vnd.github.raw+json"},
        )
    if response.status_code == 404:
        return ""
    if response.status_code != 200:
        return None
    if len(response.content) > PR_DIFF_MAX_FILE_BYTES:
        return None
    try:
        return response.content.decode("utf-8")
    except UnicodeDecodeError:
        return None


async def build_pr_diff_files(
    client: httpx.AsyncClient,
    full_name: str,
    pr_number: int,
) -> dict[str, Any]:
    """Return ``{base_sha, head_sha, truncated, files}`` for a PR.

    Each file carries full ``originalContent``/``modifiedContent`` (or ``None``
    for binary/oversized blobs, flagged via ``unrenderable``). ``client`` must
    already be configured with auth headers.
    """
    pull_response = await client.get(f"{_GITHUB_API}/repos/{full_name}/pulls/{pr_number}")
    if pull_response.status_code == 404:
        raise HTTPException(404, "pull request not found")
    if pull_response.status_code != 200:
        raise HTTPException(502, f"github API error ({pull_response.status_code})")
    pull = pull_response.json()
    base_sha = pull.get("base", {}).get("sha")
    head_sha = pull.get("head", {}).get("sha")
    if not isinstance(base_sha, str) or not isinstance(head_sha, str):
        raise HTTPException(502, "github API returned an unexpected pull request payload")

    files_response = await client.get(
        f"{_GITHUB_API}/repos/{full_name}/pulls/{pr_number}/files",
        params={"per_page": 100},
    )
    if files_response.status_code != 200:
        raise HTTPException(502, f"github API error ({files_response.status_code})")
    raw_files = files_response.json()
    if not isinstance(raw_files, list):
        raise HTTPException(502, "github API returned an unexpected files payload")

    truncated = len(raw_files) > PR_DIFF_MAX_FILES
    raw_files = raw_files[:PR_DIFF_MAX_FILES]

    semaphore = asyncio.Semaphore(PR_DIFF_FETCH_CONCURRENCY)

    async def build_entry(raw: dict[str, Any]) -> dict[str, Any] | None:
        path = raw.get("filename")
        if not isinstance(path, str):
            return None
        status = raw.get("status") if isinstance(raw.get("status"), str) else "modified"
        previous = raw.get("previous_filename")
        original_path = previous if isinstance(previous, str) else path

        original: str | None = ""
        modified: str | None = ""
        if status != "added":
            original = await _fetch_file_at_ref(
                client, semaphore, full_name, original_path, base_sha
            )
        if status != "removed":
            modified = await _fetch_file_at_ref(client, semaphore, full_name, path, head_sha)

        return {
            "path": path,
            "previousPath": previous if isinstance(previous, str) else None,
            "status": status,
            "additions": raw.get("additions") if isinstance(raw.get("additions"), int) else 0,
            "deletions": raw.get("deletions") if isinstance(raw.get("deletions"), int) else 0,
            "originalContent": original,
            "modifiedContent": modified,
            # Binary or oversized blobs come back as None — the client renders a
            # placeholder instead of file contents.
            "unrenderable": original is None or modified is None,
        }

    entries = await asyncio.gather(*(build_entry(raw) for raw in raw_files))

    return {
        "base_sha": base_sha,
        "head_sha": head_sha,
        "truncated": truncated,
        "files": [entry for entry in entries if entry is not None],
    }
