#!/usr/bin/python3
# -*- coding: utf-8 -*-

from __future__ import annotations

import json
import time
from typing import Any
from urllib.parse import quote, urlencode

import urllib3  # pylint: disable=import-error
from urllib3.exceptions import HTTPError  # pylint: disable=import-error

from sbo_create.__metadata__ import __version__, __website__
from sbo_create.models import PROG, AppContext, colorize

_USER_AGENT: str = f'sbo-create/{__version__} ({__website__})'
_BASE_URL: str = 'https://repology.org/api/v1/projects/'
_PAGE_SIZE: int = 200
_INTER_PAGE_SLEEP: float = 1.0   # seconds between paginated requests
_RETRY_AFTER_DEFAULT: float = 10.0  # seconds to wait when HTTP 429 has no header

# Status values returned by the repology API
_STATUS_NEWEST: str = 'newest'
_STATUS_OUTDATED: str = 'outdated'
_STATUS_PROBLEMATIC: str = 'problematic'

# repology query-param names for each status filter
_STATUS_PARAM: dict[str, str] = {
    _STATUS_NEWEST: 'newest',
    _STATUS_OUTDATED: 'outdated',
    _STATUS_PROBLEMATIC: 'problematic',
}


class RepologyHandler:  # pylint: disable=too-few-public-methods
    """Query repology.org for package version status by maintainer e-mail."""

    def __init__(self, ctx: AppContext) -> None:
        self.ctx = ctx

    # ------------------------------------------------------------------
    # Public entry point
    # ------------------------------------------------------------------

    def check_maintainer(
        self,
        email: str | None = None,
        newest: bool = False,
        outdated: bool = False,
        problematic: bool = False,
    ) -> None:
        """Fetch and display package version status from repology.org.

        Queries the repology API for the configured (or supplied) maintainer
        e-mail and prints each matching package with its upstream version and
        status.  When none of --newest / --outdated / --problematic is given,
        all three categories are shown.

        Args:
            email:       Maintainer e-mail to query. Falls back to the
                         configured maintainer e-mail when None.
            newest:      Include packages that are up-to-date.
            outdated:    Include packages that are outdated.
            problematic: Include packages with problematic status.
        """
        resolved_email: str = email or self.ctx.maintainer.email
        if not resolved_email:
            print(colorize(
                f"{PROG}: No maintainer e-mail configured. "
                f"Run '{PROG} maintainer' or pass -m EMAIL.", 'red'))
            raise SystemExit(1)

        # No filter flags → show everything
        show_all: bool = not any([newest, outdated, problematic])

        want: set[str] = set()
        if show_all or newest:
            want.add(_STATUS_NEWEST)
        if show_all or outdated:
            want.add(_STATUS_OUTDATED)
        if show_all or problematic:
            want.add(_STATUS_PROBLEMATIC)

        packages = self._fetch_all(resolved_email, want, show_all)

        if not packages:
            print(colorize("No packages found.", 'yellow'))
            raise SystemExit(0)

        self._print_table(packages, resolved_email)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _fetch_all(  # pylint: disable=too-many-locals,too-many-branches
        self,
        email: str,
        statuses: set[str],
        show_all: bool,
    ) -> list[dict[str, Any]]:
        """Collect all matching packages across paginated API responses.

        Uses /api/v1/projects/?maintainer=EMAIL[&newest=1|outdated=1|problematic=1]
        and follows pagination (?start=NAME) until exhausted.

        When show_all is True a single un-filtered query is made; otherwise
        one query per requested status is made and results are merged.

        Args:
            email:     Maintainer e-mail passed as the `maintainer` query param.
            statuses:  Set of status strings to keep from responses.
            show_all:  When True, fetch without a status filter and include any
                       status from `statuses`.

        Returns:
            Sorted list of dicts, each with keys 'name', 'version', 'status'.
        """
        http = urllib3.PoolManager(headers={'User-Agent': _USER_AGENT})

        # When show_all=True: one query with no status filter, accept any status.
        # When specific flags: one query per status with the matching filter param.
        if show_all:
            passes: list[tuple[dict[str, str], set[str]]] = [
                ({'maintainer': email}, set()),  # empty = accept all statuses
            ]
        else:
            passes = [
                ({'maintainer': email, _STATUS_PARAM[s]: '1'}, {s})
                for s in sorted(statuses)
            ]

        seen: set[str] = set()
        results: list[dict[str, Any]] = []
        page: int = 0

        print(f"Querying repology.org for {colorize(email, 'cyan')} ...", end='', flush=True)

        for base_params, accepted in passes:
            start: str | None = None
            while True:
                page += 1
                if page > 1:
                    print('.', end='', flush=True)

                path = f'{_BASE_URL}{quote(start, safe="")}/' if start else _BASE_URL
                url: str = path + '?' + urlencode(base_params)
                resp = self._get(http, url)

                try:
                    data: dict[str, Any] = json.loads(resp.data.decode())
                except (json.JSONDecodeError, UnicodeDecodeError) as exc:
                    print(colorize(f"{PROG}: invalid JSON from repology: {exc}", 'red'))
                    raise SystemExit(1) from exc

                if not data:
                    break

                for pkg_name, versions in data.items():
                    if pkg_name in seen:
                        continue
                    ver_obj = self._pick_version(versions, email, accepted)
                    if ver_obj is not None:
                        seen.add(pkg_name)
                        results.append({
                            'name': pkg_name,
                            'version': ver_obj.get('version', '?'),
                            'status': ver_obj.get('status', '?'),
                        })

                if len(data) < _PAGE_SIZE:
                    break  # last page

                # Next page starts after the last key in this page
                start = sorted(data.keys())[-1]
                time.sleep(_INTER_PAGE_SLEEP)

        print(f" {len(results)} package(s) found.")
        return sorted(results, key=lambda x: x['name'])

    @staticmethod
    def _pick_version(
        versions: list[dict[str, Any]],
        email: str,
        accepted: set[str],
    ) -> dict[str, Any] | None:
        """Choose the best version entry for a project from the API response.

        Selection priority:
        1. The entry where `email` is in the `maintainers` list AND its status
           is in `accepted` (or `accepted` is empty, meaning accept all).
        2. The first entry where `email` is in `maintainers` (status ignored).
        3. The first entry whose status is in `accepted` (fallback).

        Returns None only when `accepted` is non-empty and no entry matches.

        Args:
            versions: List of version objects for one project.
            email:    Maintainer e-mail used to prefer the right repo entry.
            accepted: Allowed status strings; empty set means accept any status.

        Returns:
            The chosen version dict, or None if nothing matches the filters.
        """
        by_maintainer: dict[str, Any] | None = None
        by_status: dict[str, Any] | None = None

        for ver in versions:
            is_mine: bool = email in ver.get('maintainers', [])
            status_ok: bool = not accepted or ver.get('status') in accepted

            if is_mine and status_ok:
                return ver  # perfect match — stop immediately

            if is_mine and by_maintainer is None:
                by_maintainer = ver

            if status_ok and by_status is None:
                by_status = ver

        # Return the entry belonging to the maintainer even if status doesn't
        # match a filter — this preserves the "unique" packages.
        if by_maintainer is not None:
            return by_maintainer
        return by_status

    def _get(self, http: urllib3.PoolManager, url: str) -> Any:
        """Perform a GET request with automatic retry on HTTP 429.

        On a 429 response the method waits for the number of seconds given in
        the ``Retry-After`` response header (or _RETRY_AFTER_DEFAULT when the
        header is absent) and retries once.  Any other non-200 status raises
        SystemExit immediately.

        Args:
            http: Shared urllib3 pool manager.
            url:  Fully-formed URL to fetch.

        Returns:
            urllib3 response object with status 200.
        """
        for attempt in range(2):
            try:
                resp = http.request('GET', url, timeout=15.0)
            except HTTPError as exc:
                print(colorize(f"{PROG}: HTTP error: {exc}", 'red'))
                raise SystemExit(1) from exc

            if resp.status == 200:
                return resp

            if resp.status == 429 and attempt == 0:
                retry_after: float = _RETRY_AFTER_DEFAULT
                header = resp.headers.get('Retry-After')
                if header:
                    try:
                        retry_after = float(header)
                    except ValueError:
                        pass
                print(colorize(
                    f"{PROG}: rate-limited by repology, waiting {retry_after:.0f}s...",
                    'yellow'))
                time.sleep(retry_after)
                continue

            print(colorize(
                f"{PROG}: repology returned HTTP {resp.status}.", 'red'))
            raise SystemExit(1)

        # Should be unreachable, but satisfy the type checker
        raise SystemExit(1)  # pragma: no cover

    @staticmethod
    def _status_color(status: str) -> str:
        """Return an ANSI-colorized status label.

        Args:
            status: One of 'newest', 'outdated', 'problematic'.

        Returns:
            Colorized string suitable for terminal output.
        """
        colors: dict[str, str] = {
            _STATUS_NEWEST: 'green',
            _STATUS_OUTDATED: 'red',
            _STATUS_PROBLEMATIC: 'yellow',
        }
        return colorize(status, colors.get(status, 'white'))

    def _print_table(
        self,
        packages: list[dict[str, Any]],
        email: str,
    ) -> None:
        """Print a formatted table of package results.

        Args:
            packages: Sorted list of package dicts (name, version, status).
            email:    Maintainer e-mail shown in the header.
        """
        name_w: int = max(len(p['name']) for p in packages)
        ver_w: int = max(len(p['version']) for p in packages)

        print(f"\nMaintainer: {colorize(email, 'cyan')}")
        print(f"{'Package':<{name_w}}  {'Version':<{ver_w}}  Status")
        print('-' * (name_w + ver_w + 12))

        for pkg in packages:
            name_col: str = f"{pkg['name']:<{name_w}}"
            ver_col: str = f"{pkg['version']:<{ver_w}}"
            status_col: str = self._status_color(pkg['status'])
            print(f"{name_col}  {ver_col}  {status_col}")

        print()
        total: int = len(packages)
        print(f"Total: {colorize(str(total), 'cyan')} package(s)")
