"""
Koji Artifact Provider
"""

import types
from abc import abstractmethod
from collections.abc import Iterator, Sequence
from functools import cached_property
from shlex import quote
from typing import Any, Optional, TypeVar
from urllib.parse import urljoin

import tmt.log
import tmt.utils
import tmt.utils.hints
from tmt.container import container, simple_field
from tmt.guest import Guest
from tmt.package_managers._rpm import RpmVersion
from tmt.steps.prepare.artifact.providers import (
    ArtifactInfo,
    ArtifactProvider,
    ArtifactProviderId,
    provides_artifact_provider,
)
from tmt.utils import ShellScript

koji: Optional[types.ModuleType] = None

# To silence mypy
ClientSession: Any

tmt.utils.hints.register_hint(
    'artifact-provider/koji',
    """
The ``koji`` Python package is required by tmt for Koji integration.

To quickly test Koji presence, you can try running:

    python -c 'import koji'

* Users who installed tmt from PyPI should install the ``koji`` package
  via ``pip install koji``. On Fedora/RHEL systems, ``python3-gssapi``
  must be installed first to allow ``pip`` to build and use the required
  GSSAPI bindings.
""",
)


def import_koji(logger: tmt.log.Logger) -> None:
    """Import koji module with error handling."""
    global ClientSession, koji
    try:
        import koji
        from koji import ClientSession
    except ImportError as error:
        from tmt.utils.hints import print_hints

        print_hints('artifact-provider/koji', logger=logger)

        raise tmt.utils.GeneralError("Could not import koji package.") from error


BuildT = TypeVar(
    "BuildT", bound="ArtifactProvider"
)  # Generic type for build provider classes (e.g., KojiBuild, BrewBuild)
ProviderT = TypeVar(
    "ProviderT", bound="KojiArtifactProvider"
)  # Generic type for artifact provider subclasses


@container
class KojiArtifactProvider(ArtifactProvider):
    """
    Provider for downloading artifacts from Koji builds.

    .. note::

        Only RPMs are supported currently.

    .. code-block:: python

        provider = KojiArtifactProvider("koji.build:123456", logger)
        artifacts = provider.download_artifacts(guest, Path("/tmp"), [])
    """

    _session: "ClientSession" = simple_field(init=False)

    def __post_init__(self) -> None:
        super().__post_init__()
        self._session = self._initialize_session()

    @cached_property
    def build_info(self) -> Optional[dict[str, Any]]:
        """
        Fetch and return the build metadata for the resolved build ID.

        :returns: the build metadata, or ``None`` if not found.
        """
        if self.build_id is None:
            return None
        build_info = self._call_api("getBuild", self.build_id)
        assert build_info is None or isinstance(build_info, dict)
        return build_info

    @cached_property
    @abstractmethod
    def build_id(self) -> Optional[int]:
        """
        Resolve and return the build ID.

        There are multiple possible ways of finding a build ID from the artifact provider inputs,
        individual artifact providers must chose the most fitting one.

        :returns: the build ID, or ``None`` if there is no build attached to this provider.
        :raises GeneralError: when the build should exist, but cannot be found in Koji.
        """
        raise NotImplementedError

    def _initialize_session(
        self, api_url: Optional[str] = None, top_url: Optional[str] = None
    ) -> 'ClientSession':
        """
        A koji session initialized via the koji.ClientSession function.

        Also :py:attr:`_top_url` and :py:attr:`_api_url` being the base URL for the Koji instance.
        """
        import_koji(self.logger)

        try:
            config = koji.read_config("koji")  # type: ignore[union-attr]
            self._api_url = api_url or config.get("server")
            self._top_url = top_url or config.get("topurl")
            return ClientSession(self._api_url)
        except Exception as error:
            raise tmt.utils.GeneralError(
                f"Failed to initialize API session from url '{self._api_url}'."
            ) from error

    def _call_api(self, method: str, *args: Any, **kwargs: Any) -> Any:
        """
        Generic API call method with error handling.

        :param method: API method name to call
        :param args: Positional arguments for the API call
        :param kwargs: Keyword arguments for the API call
        :return: API response
        :raises GeneralError: If API call fails
        """
        try:
            method_callable = getattr(self._session, method)
            return method_callable(*args, **kwargs)
        except Exception as error:
            raise tmt.utils.GeneralError(f"API call '{method}' failed.") from error

    def _make_build_provider(self, build_cls: type[BuildT], prefix: str) -> Optional[BuildT]:
        """Create a build provider instance if build_id is available."""
        if self.build_id is None:
            return None
        return build_cls(
            f"{prefix}:{self.build_id}",
            repository_priority=self.repository_priority,
            logger=self.logger,
        )

    @cached_property
    def build_provider(self) -> Optional['KojiBuild']:
        return self._make_build_provider(KojiBuild, "koji.build")

    @classmethod
    def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId:
        # TODO: Use a specific prefix from a ClassVar
        try:
            _, value = raw_id.split(":", maxsplit=1)
        except Exception as exc:
            raise AssertionError(
                f"Provider id '{raw_id}' is invalid, how did we get here?"
            ) from exc
        return value

    def contribute_to_shared_repo(
        self,
        guest: Guest,
        source_path: tmt.utils.Path,
        shared_repo_dir: tmt.utils.Path,
        exclude_patterns: Optional[list[tmt.utils.Pattern[str]]] = None,
    ) -> None:
        guest.execute(
            ShellScript(f"cp {quote(str(source_path))}/*.rpm {quote(str(shared_repo_dir))}")
        )
        self.logger.info(f"Contributed artifacts from '{source_path}' to '{shared_repo_dir}'.")

    def make_rpm_artifact(self, rpm_meta: dict[str, Any]) -> ArtifactInfo:
        """
        Create a normal build RPM artifact from metadata returned by listBuildRPMs.
        """
        assert self.build_info is not None
        package_name = self.build_info["package_name"]
        version_info = RpmVersion.from_rpm_meta(rpm_meta)
        path = (
            f"packages/{package_name}/"
            f"{version_info.version}/"
            f"{version_info.release}/"
            f"{version_info.arch}/"
            f"{version_info.nvra}.rpm"
        )

        return ArtifactInfo(
            version=version_info,
            location=urljoin(self._top_url, path),
            provider=self,
        )


@provides_artifact_provider("koji.task")
class KojiTask(KojiArtifactProvider):
    @cached_property
    def build_id(self) -> Optional[int]:
        task_id = int(self.id)
        if builds := self._call_api("listBuilds", taskID=task_id):
            if len(builds) > 1:
                self.logger.warning(
                    f"Task '{task_id}' produced {len(builds)} builds, using the first one."
                )
            build_id = builds[0]["build_id"]  # Assume the task produced a single build
            assert isinstance(build_id, int)
            return build_id
        return None

    def _get_task_children(self, task_id: int) -> Iterator[int]:
        """
        Fetch all descendant tasks using getTaskDescendents.

        :param task_id: the parent task ID
        :yield: task IDs including parent and all descendants
        """
        descendants_map = self._call_api("getTaskDescendents", task_id)
        for task_id_str in descendants_map:
            yield int(task_id_str)

    def make_rpm_artifact(self, task_id: int, filename: str) -> ArtifactInfo:  # type: ignore[override]
        """
        Create a scratch RPM artifact from a task output filename.
        """
        pathinfo = koji.PathInfo(  # type: ignore[union-attr]
            topdir=self._top_url
        )
        work_path = pathinfo.work("DEFAULT")
        task_path = pathinfo.taskrelpath(task_id)
        url = f"{work_path}/{task_path}/{filename}"

        return ArtifactInfo(
            version=RpmVersion.from_filename(filename),
            location=url,
            provider=self,
        )

    @cached_property
    def artifacts(self) -> Sequence[ArtifactInfo]:
        self.logger.debug(f"Fetching RPMs for task '{self.id}'.")
        # If task produced a build, reuse build path
        if self.build_id is not None:
            self.logger.debug(
                f"Task '{self.id}' produced build '{self.build_id}', fetching RPMs from the build."
            )
            assert self.build_provider is not None
            return list(self.build_provider.artifacts)

        # Otherwise, list the task output files for scratch builds
        self.logger.debug(f"Task '{self.id}' did not produce a build, fetching scratch RPMs.")

        artifacts: list[ArtifactInfo] = []
        seen_ids = set()  # Multiple tasks may produce the same RPM

        for child_task in self._get_task_children(int(self.id)):
            for filename in self._call_api("listTaskOutput", child_task):
                if not filename.endswith(".rpm"):
                    self.logger.warning(f"Skipping '{filename}': not an RPM")
                    continue
                rpm = self.make_rpm_artifact(child_task, filename)
                if rpm.id not in seen_ids:
                    artifacts.append(rpm)
                    seen_ids.add(rpm.id)
                else:
                    self.logger.debug(
                        f"Skipping redundant RPM '{rpm.id}' from task '{child_task}'"
                    )

        return artifacts


@provides_artifact_provider('koji.build')
class KojiBuild(KojiArtifactProvider):
    @cached_property
    def build_id(self) -> int:
        return int(self.id)

    @cached_property
    def artifacts(self) -> Sequence[ArtifactInfo]:
        self.logger.debug(f"Fetching RPMs for build '{self.build_id}'.")

        return [
            self.make_rpm_artifact(rpm_dict)
            for rpm_dict in self._call_api("listBuildRPMs", self.build_id)
        ]


@provides_artifact_provider("koji.nvr")
class KojiNvr(KojiArtifactProvider):
    @cached_property
    def build_info(self) -> Optional[dict[str, Any]]:
        """
        Fetch and return the build metadata for the nvr.

        :returns: the build metadata, or ``None`` if not found.
        """
        build_info = self._call_api("getBuild", self.id)
        assert build_info is None or isinstance(build_info, dict)
        return build_info

    @cached_property
    def build_id(self) -> int:
        if not self.build_info:
            raise tmt.utils.GeneralError(f"No build found for NVR '{self.id}'.")
        build_id = self.build_info["id"]
        assert isinstance(build_id, int)
        return build_id

    @cached_property
    def artifacts(self) -> Sequence[ArtifactInfo]:
        """
        RPM artifacts for the given NVR.
        """
        self.logger.debug(f"Fetching RPMs for NVR '{self.id}'.")
        assert self.build_provider is not None
        return list(self.build_provider.artifacts)
