"""Load live Gitea PR/issue queue state for the web UI dashboard (#429)."""

from __future__ import annotations

import os
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable
from urllib.parse import urlparse

from gitea_auth import api_fetch_page, get_auth_header, repo_api_url

from webui.project_registry import ProjectRecord, load_registry

_CLOSES_RE = re.compile(r"(?:closes|fixes|resolves)\s+#(\d+)", re.I)
_ISSUE_REF_RE = re.compile(r"#(\d+)")
_STALE_DAYS = 14


@dataclass(frozen=True)
class PaginationMeta:
    page: int
    per_page: int
    returned_count: int
    has_more: bool
    is_final_page: bool
    inventory_complete: bool
    pages_fetched: int


@dataclass(frozen=True)
class QueueItem:
    number: int
    title: str
    badges: tuple[str, ...]
    extra: dict[str, str]


@dataclass(frozen=True)
class QueueSnapshot:
    project_id: str
    repo_label: str
    prs: tuple[QueueItem, ...]
    issues: tuple[QueueItem, ...]
    pr_pagination: PaginationMeta | None
    issue_pagination: PaginationMeta | None
    fetch_error: str | None = None


def _host_from_url(remote_host: str) -> str:
    parsed = urlparse(remote_host.strip())
    return parsed.netloc or remote_host.strip().rstrip("/")


def _extract_linked_issue(title: str | None, body: str | None = None) -> int | None:
    for text in (title, body):
        if not text:
            continue
        match = _CLOSES_RE.search(text)
        if match:
            return int(match.group(1))
    if body:
        refs = _ISSUE_REF_RE.findall(body)
        if refs:
            return int(refs[0])
    return None


def _is_stale(updated_at: str | None) -> bool:
    if not updated_at:
        return False
    try:
        normalized = updated_at.replace("Z", "+00:00")
        parsed = datetime.fromisoformat(normalized)
        if parsed.tzinfo is None:
            parsed = parsed.replace(tzinfo=timezone.utc)
        age = datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)
        return age.days >= _STALE_DAYS
    except ValueError:
        return False


def _classify_pr(
    pr: dict,
    *,
    issue_claimed: set[int],
    issue_to_prs: dict[int, list[int]],
) -> tuple[str, ...]:
    badges: list[str] = []
    mergeable = pr.get("mergeable")
    if mergeable is False:
        badges.append("blocked")
    linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
    if linked is not None:
        if linked in issue_claimed:
            badges.append("claimed")
        if len(issue_to_prs.get(linked, [])) > 1:
            badges.append("duplicate")
    if _is_stale(pr.get("updated_at")):
        badges.append("stale")
    if mergeable is True and "blocked" not in badges:
        badges.append("in-review")
    if not badges:
        badges.append("open")
    return tuple(dict.fromkeys(badges))


def _classify_issue(
    issue: dict,
    *,
    linked_prs: list[int],
) -> tuple[str, ...]:
    badges: list[str] = []
    labels = [lb.get("name", "") for lb in issue.get("labels", [])]
    if "status:in-progress" in labels:
        badges.append("claimed")
    if len(linked_prs) > 1:
        badges.append("duplicate")
    elif linked_prs and "claimed" not in badges:
        badges.append("in-review")
    if _is_stale(issue.get("updated_at")):
        badges.append("stale")
    if not badges:
        badges.append("open")
    return tuple(dict.fromkeys(badges))


def _format_pr_item(pr: dict, badges: tuple[str, ...]) -> QueueItem:
    head = pr.get("head") or {}
    base = pr.get("base") or {}
    mergeable = pr.get("mergeable")
    merge_label = (
        "mergeable" if mergeable is True else "conflicted" if mergeable is False else "unknown"
    )
    linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
    return QueueItem(
        number=int(pr["number"]),
        title=str(pr.get("title") or ""),
        badges=badges,
        extra={
            "branch": f"{head.get('ref', '?')} → {base.get('ref', '?')}",
            "head_sha": str(head.get("sha") or "")[:12],
            "mergeable": merge_label,
            "linked_issue": str(linked) if linked is not None else "",
        },
    )


def _format_issue_item(issue: dict, badges: tuple[str, ...]) -> QueueItem:
    labels = ", ".join(lb.get("name", "") for lb in issue.get("labels", []))
    assignee = (issue.get("assignee") or {}).get("login", "")
    return QueueItem(
        number=int(issue["number"]),
        title=str(issue.get("title") or ""),
        badges=badges,
        extra={
            "labels": labels or "—",
            "assignee": assignee or "unassigned",
            "state": str(issue.get("state") or ""),
        },
    )


def _pagination_from_pages(
    *,
    per_page: int,
    pages_fetched: int,
    returned_count: int,
    is_final_page: bool,
) -> PaginationMeta:
    return PaginationMeta(
        page=1,
        per_page=per_page,
        returned_count=returned_count,
        has_more=not is_final_page,
        is_final_page=is_final_page,
        inventory_complete=is_final_page,
        pages_fetched=pages_fetched,
    )


def _fetch_prs(
    host: str,
    org: str,
    repo: str,
    auth: str,
    *,
    per_page: int = 50,
) -> tuple[list[dict], PaginationMeta]:
    url = f"{repo_api_url(host, org, repo)}/pulls?state=open"
    all_raw: list[dict] = []
    pages_fetched = 0
    is_final = False
    page = 1
    while pages_fetched < 20:
        raw_page, meta = api_fetch_page(url, auth, page=page, limit=per_page)
        pages_fetched += 1
        all_raw.extend(raw_page)
        is_final = bool(meta["is_final_page"])
        if is_final:
            break
        page += 1
    pagination = _pagination_from_pages(
        per_page=per_page,
        pages_fetched=pages_fetched,
        returned_count=len(all_raw),
        is_final_page=is_final,
    )
    return all_raw, pagination


def _fetch_issues(
    host: str,
    org: str,
    repo: str,
    auth: str,
    *,
    per_page: int = 50,
) -> tuple[list[dict], PaginationMeta]:
    url = f"{repo_api_url(host, org, repo)}/issues?state=open&type=issues"
    all_raw: list[dict] = []
    page = 1
    pages_fetched = 0
    is_final = False
    while pages_fetched < 20:
        raw_page, meta = api_fetch_page(url, auth, page=page, limit=per_page)
        pages_fetched += 1
        all_raw.extend(raw_page)
        is_final = bool(meta["is_final_page"])
        if is_final:
            break
        page += 1
    pagination = _pagination_from_pages(
        per_page=per_page,
        pages_fetched=pages_fetched,
        returned_count=len(all_raw),
        is_final_page=is_final,
    )
    return all_raw, pagination


def load_queue_snapshot(
    project_id: str | None = None,
    *,
    fetch_prs: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
    fetch_issues: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
) -> QueueSnapshot:
    """Load open PR and issue queues for a registry project (default: first entry)."""
    registry = load_registry()
    project: ProjectRecord | None = None
    if project_id:
        for entry in registry.projects:
            if entry.id == project_id:
                project = entry
                break
    else:
        project = registry.projects[0] if registry.projects else None

    if project is None:
        return QueueSnapshot(
            project_id=project_id or "",
            repo_label="",
            prs=(),
            issues=(),
            pr_pagination=None,
            issue_pagination=None,
            fetch_error="project not found in registry",
        )

    host = _host_from_url(project.remote_host)
    offline_test = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip() in {
        "1",
        "true",
        "yes",
    }

    def _empty_fetch(*_args, **_kwargs):
        return [], _pagination_from_pages(
            per_page=50,
            pages_fetched=1,
            returned_count=0,
            is_final_page=True,
        )

    pr_fetch = fetch_prs or (_empty_fetch if offline_test else _fetch_prs)
    issue_fetch = fetch_issues or (_empty_fetch if offline_test else _fetch_issues)
    using_live_fetch = not offline_test and (fetch_prs is None or fetch_issues is None)
    auth = get_auth_header(host) if using_live_fetch else "test-auth"
    if using_live_fetch and not auth:
        return QueueSnapshot(
            project_id=project.id,
            repo_label=f"{project.gitea_owner}/{project.repo_name}",
            prs=(),
            issues=(),
            pr_pagination=None,
            issue_pagination=None,
            fetch_error=(
                f"Gitea credentials unavailable for {host}; "
                "queue cannot be loaded (fail closed — not showing empty queue)"
            ),
        )

    try:
        raw_prs, pr_pagination = pr_fetch(
            host, project.gitea_owner, project.repo_name, auth
        )
        raw_issues, issue_pagination = issue_fetch(
            host, project.gitea_owner, project.repo_name, auth
        )
    except Exception as exc:  # noqa: BLE001 — surface operator-visible fetch errors
        return QueueSnapshot(
            project_id=project.id,
            repo_label=f"{project.gitea_owner}/{project.repo_name}",
            prs=(),
            issues=(),
            pr_pagination=None,
            issue_pagination=None,
            fetch_error=f"Gitea fetch failed: {exc}",
        )

    issue_to_prs: dict[int, list[int]] = {}
    for pr in raw_prs:
        linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
        if linked is not None:
            issue_to_prs.setdefault(linked, []).append(int(pr["number"]))

    issue_claimed = {
        int(i["number"])
        for i in raw_issues
        if any(lb.get("name") == "status:in-progress" for lb in i.get("labels", []))
    }

    pr_items = tuple(
        _format_pr_item(
            pr,
            _classify_pr(
                pr,
                issue_claimed=issue_claimed,
                issue_to_prs=issue_to_prs,
            ),
        )
        for pr in sorted(raw_prs, key=lambda p: int(p["number"]), reverse=True)
    )
    issue_items = tuple(
        _format_issue_item(
            issue,
            _classify_issue(
                issue,
                linked_prs=issue_to_prs.get(int(issue["number"]), []),
            ),
        )
        for issue in sorted(raw_issues, key=lambda i: int(i["number"]), reverse=True)
    )

    return QueueSnapshot(
        project_id=project.id,
        repo_label=f"{project.gitea_owner}/{project.repo_name}",
        prs=pr_items,
        issues=issue_items,
        pr_pagination=pr_pagination,
        issue_pagination=issue_pagination,
    )


def snapshot_to_dict(snapshot: QueueSnapshot) -> dict[str, Any]:
    """JSON-serializable export for /api/queue."""

    def _page(meta: PaginationMeta | None) -> dict[str, Any] | None:
        if meta is None:
            return None
        return {
            "page": meta.page,
            "per_page": meta.per_page,
            "returned_count": meta.returned_count,
            "has_more": meta.has_more,
            "is_final_page": meta.is_final_page,
            "inventory_complete": meta.inventory_complete,
            "pages_fetched": meta.pages_fetched,
        }

    def _item(item: QueueItem) -> dict[str, Any]:
        return {
            "number": item.number,
            "title": item.title,
            "badges": list(item.badges),
            **item.extra,
        }

    return {
        "project_id": snapshot.project_id,
        "repo": snapshot.repo_label,
        "fetch_error": snapshot.fetch_error,
        "prs": [_item(p) for p in snapshot.prs],
        "issues": [_item(i) for i in snapshot.issues],
        "pagination": {
            "prs": _page(snapshot.pr_pagination),
            "issues": _page(snapshot.issue_pagination),
        },
    }
