import re
from typing import Optional, Union

import tmt.utils
from tmt.package_managers import (
    FileSystemPath,
    Installable,
    Options,
    Package,
    PackageManager,
    PackageManagerEngine,
    PackagePath,
    escape_installables,
    provides_package_manager,
)
from tmt.utils import (
    Command,
    CommandOutput,
    GeneralError,
    RunError,
    ShellScript,
)

ReducedPackages = list[Union[Package, PackagePath]]

PACKAGE_PATH: dict[FileSystemPath, str] = {
    FileSystemPath('/usr/bin/awk'): 'gawk',
    FileSystemPath('/usr/bin/arch'): 'busybox',
    FileSystemPath('/usr/bin/flock'): 'flock',
    FileSystemPath('/usr/bin/python3'): 'python3',
    # Note: not used for anything serious, serves for unit tests as
    # an installable path.
    FileSystemPath('/usr/bin/dos2unix'): 'dos2unix',
}


class ApkEngine(PackageManagerEngine):
    install_command = Command('add')

    def prepare_command(self) -> tuple[Command, Command]:
        """
        Prepare installation command for apk
        """
        assert self.guest.facts.sudo_prefix is not None  # Narrow type
        command = Command('apk')

        if self.guest.facts.sudo_prefix:
            command = Command(self.guest.facts.sudo_prefix, 'apk')

        return (command, Command())

    def path_to_package(self, path: FileSystemPath) -> Package:
        """
        Find a package providing given filesystem path.

        This is not easily possible in Alpine. There is `apk-file` utility
        available but it seems unrealiable. Support only a fixed set
        of mappings until a better solution is available.
        """

        if path in PACKAGE_PATH:
            return Package(PACKAGE_PATH[path])

        raise GeneralError(f"Unsupported package path '{path} for Alpine Linux.")

    def _reduce_to_packages(self, *installables: Installable) -> ReducedPackages:
        packages: ReducedPackages = []

        for installable in installables:
            if isinstance(installable, (Package, PackagePath)):
                packages.append(installable)

            elif isinstance(installable, FileSystemPath):
                packages.append(self.path_to_package(installable))

            else:
                raise tmt.utils.PrepareError(
                    f"Package manager 'apk' does not support installing from a remote "
                    f"URL '{installable}'."
                )

        return packages

    def _construct_presence_script(
        self, *installables: Installable
    ) -> tuple[ReducedPackages, ShellScript]:
        reduced_packages = self._reduce_to_packages(*installables)

        shell_script = ShellScript(
            f'apk info -e {" ".join(escape_installables(*reduced_packages))}'
        )

        return reduced_packages, shell_script

    def check_presence(self, *installables: Installable) -> ShellScript:
        return self._construct_presence_script(*installables)[1]

    def refresh_metadata(self) -> ShellScript:
        return ShellScript(f'{self.command.to_script()} update')

    def enable_repo(self, *repo_ids: str) -> ShellScript:
        raise tmt.utils.PrepareError(
            "Package manager 'apk' does not support enabling repositories."
        )

    def disable_repo(self, *repo_ids: str) -> ShellScript:
        raise tmt.utils.PrepareError(
            "Package manager 'apk' does not support disabling repositories."
        )

    def install(
        self,
        *installables: Installable,
        options: Optional[Options] = None,
    ) -> ShellScript:
        options = options or Options()

        packages = self._reduce_to_packages(*installables)

        script = ShellScript(
            f'{self.command.to_script()} {self.install_command.to_script()} '
            f'{"--allow-untrusted " if options.allow_untrusted else ""}'
            f'{" ".join(escape_installables(*packages))}'
        )

        if options.check_first:
            script = self._construct_presence_script(*packages)[1] | script

        if options.skip_missing:
            script = script | ShellScript('/bin/true')

        return script

    def reinstall(
        self,
        *installables: Installable,
        options: Optional[Options] = None,
    ) -> ShellScript:
        options = options or Options()

        packages = self._reduce_to_packages(*installables)

        script = ShellScript(
            f'{self.command.to_script()} fix {" ".join(escape_installables(*packages))}'
        )

        if options.check_first:
            script = self._construct_presence_script(*packages)[1] & script

        if options.skip_missing:
            script = script | ShellScript('/bin/true')

        return script

    def install_debuginfo(
        self,
        *installables: Installable,
        options: Optional[Options] = None,
    ) -> ShellScript:
        raise tmt.utils.GeneralError("There is no support for debuginfo packages in apk.")


@provides_package_manager('apk')
class Apk(PackageManager[ApkEngine]):
    NAME = 'apk'

    _engine_class = ApkEngine
    # Compiled regex patterns for APK error messages
    _FAILED_PACKAGE_INSTALLATION_PATTERNS = [
        re.compile(r'unable to locate package\s+([^\s]+)', re.IGNORECASE),
        re.compile(r'ERROR:\s+([^\s:]+):\s+No such package', re.IGNORECASE),
        re.compile(r'([^\s]+)\s+\(no such package\):', re.IGNORECASE),
    ]

    probe_command = Command('apk', '--version')

    def check_presence(self, *installables: Installable) -> dict[Installable, bool]:
        reduced_packages, presence_script = self.engine._construct_presence_script(*installables)

        try:
            output = self.guest.execute(presence_script)
            stdout, stderr = output.stdout, output.stderr

        except RunError as exc:
            stdout, stderr = exc.stdout, exc.stderr

        if stdout is None or stderr is None:
            raise GeneralError("apk presence check output provided no output")

        results: dict[Installable, bool] = {}

        for installable, package in zip(installables, reduced_packages):
            match = re.search(rf'^{re.escape(str(package))}\s', stdout)

            if match is not None:
                results[installable] = True
                continue

            results[installable] = False

        return results

    def install_debuginfo(
        self,
        *installables: Installable,
        options: Optional[Options] = None,
    ) -> CommandOutput:
        raise tmt.utils.PrepareError(
            f'Package manager "{self.guest.facts.package_manager}" does not support '
            'installing debuginfo packages.'
        )

    def install_local(
        self,
        *installables: Installable,
        options: Optional[Options] = None,
    ) -> CommandOutput:
        options = options or Options()
        options.allow_untrusted = True
        options.check_first = False
        return self.install(*installables, options=options)
