import logging
import os
import shutil
import subprocess
from abc import ABC, abstractmethod
from typing import override, ClassVar

from .board_manager import get_dmi_dependend_packages
from .config_parser import ConfigEntryType
from .state_manager import mark_installed

logger = logging.getLogger(__name__)
packages_logger = logging.getLogger(f"{__name__.split('.', 1)[0]}.packages")


class PackageManager(ABC):
    BIN: str  # Name des Binaries, muss in Subklassen angegeben werden

    @staticmethod
    def _run(args: list[str]) -> tuple[int, str, str]:
        """Executes a system command with full logging and returns (rc, stdout, stderr)."""
        logger.debug(f"Executing command: {' '.join(args)}")

        proc = subprocess.run(
            args,
            check=False,
            capture_output=True,
            text=True,
            env={
                **os.environ,
                "DEBIAN_FRONTEND": "noninteractive",
                "LANG": "C",
                "LC_ALL": "C",
            },
        )

        logger.debug(f"Return code: {proc.returncode}")
        if proc.stdout:
            logger.debug(f"Stdout:\n{proc.stdout.rstrip()}")
        if proc.stderr:
            logger.debug(f"Stderr:\n{proc.stderr.rstrip()}")

        return proc.returncode, proc.stdout, proc.stderr

    @staticmethod
    def _run_checked(
        args: list[str],
        success_codes: list[int] | None = None,
    ) -> tuple[bool, str]:
        """Runs a command and compares the return code to a given list of return codes treated as success."""
        if success_codes is None:
            success_codes = [0]

        rc, out, _err = PackageManager._run(args)
        success = True
        if rc not in success_codes:
            success = False
        return success, out

    @staticmethod
    def _normalize_packages(packages: list[str] | str) -> list[str]:
        if isinstance(packages, str):
            logger.debug(
                "Received a single string instead of list[str]. Autocorrecting..."
            )
            return packages.split()
        return packages

    def manage_packages(self, hardware_packages: set[str] | list[str]) -> None:
        logger.info(f"Packages required by hardware: {sorted(hardware_packages)}")

        dmi_pkgs = get_dmi_dependend_packages()

        pkg_list: list[str] = []
        for pkg in set(list(hardware_packages) + list(dmi_pkgs)):
            if self.package_exists(pkg):
                pkg_list.append(pkg)
            elif pkg in dmi_pkgs:
                logger.info(
                    f"No installation candidate for {pkg} in. Maybe this package isn't needed."
                )
            else:
                logger.warning(f"No installation candidate for {pkg} in repositories")

        if not pkg_list:
            logger.info("No package to be installed.")
        else:
            logger.info(f"Installing {len(pkg_list)} packages in one call:")
            logger.info("  " + ", ".join(pkg_list))

            ok = self.install(pkg_list)

            if ok:
                for pkg in hardware_packages:
                    if mark_installed(pkg, ConfigEntryType.PKG):
                        logger.info(f"[OK] Installed {pkg}")
                    else:
                        logger.error(f"[FAILED] to mark {pkg} as installed")
            else:
                logger.error(
                    "[FAILED] Package manager reported failure during batch install"
                )

    # ---- Abstract API ----

    @abstractmethod
    def install(self, packages: list[str] | str) -> bool: ...
    @abstractmethod
    def update(self) -> bool: ...
    @abstractmethod
    def package_exists(self, pkgname: str) -> bool: ...


class AptManager(PackageManager):
    BIN: str = "apt-get"
    DPKG_CONFIGURE_COMMAND: ClassVar[tuple[str, ...]] = ("dpkg", "--configure", "-a")

    @override
    def install(self, packages: list[str] | str) -> bool:
        packages = self._normalize_packages(packages)
        packages_logger.info(f"APT installing: {packages}")
        logger.info(f"Installing via APT (with recommends): {packages}")

        install_command = [self.BIN, "install", "-y", "--install-recommends", *packages]
        rc, _out, err = self._run(install_command)
        if rc == 0:
            return True

        if not self.requires_dpkg_configure(err):
            logger.error(f"Command failed: {' '.join(install_command)}")
            return False

        logger.warning(
            "APT reported an interrupted dpkg state. Running dpkg --configure -a once."
        )
        if not self.configure_pending_dpkg_packages():
            return False

        retry_rc, _retry_out, _retry_err = self._run(install_command)
        if retry_rc == 0:
            return True

        logger.error(
            f"Command failed after dpkg configure retry: {' '.join(install_command)}"
        )
        return False

    @classmethod
    def configure_pending_dpkg_packages(cls) -> bool:
        return cls._run_checked(list(cls.DPKG_CONFIGURE_COMMAND))[0]

    @staticmethod
    def requires_dpkg_configure(stderr: str) -> bool:
        normalized_stderr = stderr.lower()
        return (
            "dpkg was interrupted" in normalized_stderr
            and "dpkg --configure -a" in normalized_stderr
        )

    @override
    def update(self) -> bool:
        logger.info("Updating APT package lists.")
        return self._run_checked([self.BIN, "update"])[0]

    @override
    def package_exists(self, pkgname: str) -> bool:
        logger.debug(f"Checking if APT package exists: {pkgname}")
        _success, out = self._run_checked(["apt-cache", "policy", pkgname])

        for line in out.splitlines():
            if line.strip().startswith("Candidate:") and "(none)" not in line:
                return True

        return False


class DnfManager(PackageManager):
    BIN: str = "dnf"

    @override
    def install(self, packages: list[str] | str) -> bool:
        packages = self._normalize_packages(packages)
        packages_logger.info(f"DNF installing: {packages}")
        logger.info(f"Installing via DNF: {packages}")
        return self._run_checked([self.BIN, "-y", "install", *packages])[0]

    @override
    def update(self) -> bool:
        logger.info("Checking for DNF updates.")

        # DNF returns:
        #   0   = no updates available (success)
        #   100 = updates available (still success)
        #   else = error
        return self._run_checked([self.BIN, "check-update"], success_codes=[0, 100])[0]

    @override
    def package_exists(self, pkgname: str) -> bool:
        """
        Reliable DNF package existence check.

        1. Check if package is installed (rpm -q)
        2. Check if package is available (dnf repoquery)
        3. Fallback: dnf list available
        """
        logger.debug(f"Checking if DNF package exists: {pkgname}")
        # Step 1: Installed?
        if self._run_checked(["rpm", "-q", pkgname])[0]:
            return True

        # Step 2: Available? (DNF's official way)
        # repoquery returns 0 only if package exists
        success, out = self._run_checked(["dnf", "-q", "repoquery", pkgname])
        if success and pkgname in out:
            return True

        # Step 3: Fallback for minimal DNF builds
        success, out = self._run_checked(["dnf", "-q", "list", "available", pkgname])
        return "Available Packages" in out and pkgname in out


class PacmanManager(PackageManager):
    BIN: str = "pacman"

    @override
    def install(self, packages: list[str] | str) -> bool:
        packages = self._normalize_packages(packages)
        packages_logger.info(f"pacman installing: {packages}")
        logger.info(f"Installing via Pacman: {packages}")
        return self._run_checked(
            [self.BIN, "-S", "--needed", "--noconfirm", *packages]
        )[0]

    @override
    def update(self) -> bool:
        logger.info("Refreshing Pacman repositories.")
        return self._run_checked([self.BIN, "-Sy"])[0]

    @override
    def package_exists(self, pkgname: str) -> bool:
        logger.debug(f"Checking if pacman package exists: {pkgname}")
        (
            _success,
            out,
        ) = self._run_checked(["pacman", "-Si", pkgname])
        return "Repository" in out


class ZypperManager(PackageManager):
    BIN: str = "zypper"

    @override
    def install(self, packages: list[str] | str) -> bool:
        packages = self._normalize_packages(packages)
        packages_logger.info(f"zyper installing: {packages}")
        logger.info(f"Installing via Zypper (with recommends): {packages}")
        return self._run_checked(
            [self.BIN, "--non-interactive", "install", "--recommends", *packages]
        )[0]

    @override
    def update(self) -> bool:
        logger.info("Refreshing Zypper repositories.")
        return self._run_checked([self.BIN, "--non-interactive", "refresh"])[0]

    @override
    def package_exists(self, pkgname: str) -> bool:
        logger.debug(f"Checking if Zyper package exists: {pkgname}")
        (
            _success,
            out,
        ) = self._run_checked(["zypper", "search", "--match-exact", pkgname])
        return pkgname in out


def detect_package_manager() -> PackageManager | None:
    """Detect the active Linux package manager.

    E2E tests and controlled environments may force a backend with
    TUXEDO_TOMTE_PACKAGE_MANAGER. In normal operation the first supported
    package manager found in PATH is used.
    """

    subclasses = PackageManager.__subclasses__()
    by_binary = {cls.BIN: cls for cls in subclasses if getattr(cls, "BIN", None)}

    forced_binary = os.environ.get("TUXEDO_TOMTE_PACKAGE_MANAGER")
    if forced_binary:
        cls = by_binary.get(forced_binary)
        if cls is None:
            logger.error(f"Unsupported package manager forced: {forced_binary}")
            return None
        if not shutil.which(forced_binary):
            logger.error(f"Forced package manager not found in PATH: {forced_binary}")
            return None
        logger.info(
            f"Using forced package manager: {cls.__name__} (BIN={forced_binary})"
        )
        return cls()

    priority = ["apt-get", "dnf", "pacman", "zypper"]

    subclasses.sort(
        key=lambda class_name: (
            priority.index(class_name.BIN)
            if getattr(class_name, "BIN", None) in priority
            else 999
        )
    )

    for cls in subclasses:
        binary = getattr(cls, "BIN", None)
        if binary and shutil.which(binary):
            pm = cls()
            logger.info(f"Detected package manager: {cls.__name__} (BIN={binary})")
            return pm

    logger.error("Kein unterstützter Paketmanager gefunden.")
    return None
