#!/usr/bin/env python
__package__ = "abxpkg"

import fcntl
import sys
import time
from contextlib import contextmanager
from pathlib import Path

from pydantic import TypeAdapter, model_validator
from typing import Self

from .base_types import BinProviderName, PATHStr, BinName, InstallArgs
from .semver import SemVer
from .binprovider import BinProvider, EnvProvider, ShallowBinary, remap_kwargs
from .logging import format_subprocess_output

_LAST_UPDATE_CHECK = None
UPDATE_CHECK_INTERVAL = 60 * 60 * 24  # 1 day
APT_LOCK_PATH = Path("/tmp/abxpkg-apt.lock")


class AptProvider(BinProvider):
    name: BinProviderName = "apt"
    _log_emoji = "🐧"
    INSTALLER_BIN: BinName = "apt-get"

    PATH: PATHStr = ""  # Starts empty; setup_PATH() discovers package runtime bin dirs via dpkg and replaces PATH with those dirs.
    euid: int | None = (
        0  # Import-time default that forces every apt subprocess through the root/sudo execution path.
    )

    @model_validator(mode="after")
    def add_package_aliases(self) -> Self:
        self.overrides["gem"] = {
            **self.overrides.get("gem", {}),
            "install_args": ["ruby"],
        }
        return self

    @contextmanager
    def apt_lock(self):
        APT_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
        with APT_LOCK_PATH.open("w") as lock_file:
            fcntl.flock(lock_file, fcntl.LOCK_EX)
            try:
                yield
            finally:
                fcntl.flock(lock_file, fcntl.LOCK_UN)

    def setup_PATH(self, no_cache: bool = False) -> None:
        """Populate PATH on first use from dpkg-discovered package runtime bin dirs, not from apt-get itself."""
        if sys.platform != "linux":
            # Apt has no runtime PATH contribution on non-Linux hosts. Returning
            # here keeps fallback provider lists cheap: merely considering apt
            # must not ask other providers to locate or install apt-get.
            self.PATH = ""
            return
        # Rebuild PATH on first use, when the caller forces no_cache, or when
        # PATH is still empty — the last case covers the "INSTALLER_BINARY was
        # resolved out-of-band (hook preflight etc.), so _INSTALLER_BINARY is
        # non-None but self.PATH was never populated" race.
        if (
            no_cache
            or not self.PATH
            or self._INSTALLER_BINARY is None
            or self._INSTALLER_BINARY.loaded_abspath is None
        ):
            dpkg_binary = EnvProvider().load("dpkg")
            apt_binary = None
            try:
                apt_binary = self.INSTALLER_BINARY(no_cache=no_cache)
            except Exception:
                apt_binary = None
            dpkg_abspath = (
                dpkg_binary.loaded_abspath
                if dpkg_binary and dpkg_binary.loaded_abspath
                else None
            )
            apt_abspath = (
                apt_binary.loaded_abspath
                if apt_binary and apt_binary.loaded_abspath
                else None
            )
            if not dpkg_abspath or not apt_abspath:
                self.PATH = ""
            else:
                # Seed self.PATH with apt-get's bin_dir before calling
                # self.exec(dpkg -L bash). self.exec's build_exec_env
                # re-enters self.setup_PATH; without a non-empty PATH,
                # the ``not self.PATH`` guard at the top of this method
                # would fire on every recursive entry and infinitely
                # loop. The bin_dir is correct as a baseline value —
                # the dpkg-discovered runtime bin dirs get prepended
                # onto it just below.
                self.PATH = TypeAdapter(PATHStr).validate_python(
                    str(apt_abspath.parent),
                )
                PATH = self.PATH
                dpkg_install_dirs = (
                    self.exec(
                        bin_name=dpkg_abspath,
                        cmd=["-L", "bash"],
                        quiet=True,
                        should_log_command=False,
                    )
                    .stdout.strip()
                    .split("\n")
                )
                dpkg_bin_dirs = [
                    path for path in dpkg_install_dirs if path.endswith("/bin")
                ]
                for bin_dir in dpkg_bin_dirs:
                    if str(bin_dir) not in PATH:
                        PATH = ":".join([str(bin_dir), *PATH.split(":")])
                self.PATH = TypeAdapter(PATHStr).validate_python(PATH)
        super().setup_PATH(no_cache=no_cache)

    @staticmethod
    def _detect_distro_codename() -> tuple[str, str]:
        """Return (distro_id, codename) parsed from /etc/os-release with apt fallbacks."""
        os_release: dict[str, str] = {}
        try:
            with open("/etc/os-release", encoding="utf-8") as fh:
                for raw in fh:
                    line = raw.strip()
                    if not line or "=" not in line or line.startswith("#"):
                        continue
                    key, _, value = line.partition("=")
                    os_release[key] = value.strip().strip('"').strip("'")
        except OSError:
            pass
        distro_id = (os_release.get("ID") or "").lower()
        codename = (
            os_release.get("VERSION_CODENAME")
            or os_release.get("UBUNTU_CODENAME")
            or ""
        ).lower()
        if distro_id in ("ubuntu", "debian"):
            return distro_id, codename or (
                "noble" if distro_id == "ubuntu" else "stable"
            )
        # apt is debian-derived; fall back to ubuntu LTS for derivatives.
        return "ubuntu", codename or "noble"

    def default_docs_url_handler(
        self,
        bin_name: BinName,
        **context,
    ) -> str | None:
        package = self._docs_url_package_name(bin_name)
        if not package:
            return None
        distro, codename = self._detect_distro_codename()
        host = "packages.debian.org" if distro == "debian" else "packages.ubuntu.com"
        return f"https://{host}/{codename}/{package}"

    @remap_kwargs({"packages": "install_args"})
    def default_install_handler(
        self,
        bin_name: BinName,
        install_args: InstallArgs | None = None,
        postinstall_scripts: bool | None = None,
        min_release_age: float | None = None,
        min_version: SemVer | None = None,
        no_cache: bool = False,
        timeout: int | None = None,
    ) -> str:
        global _LAST_UPDATE_CHECK

        install_args = install_args or self.get_install_args(bin_name)

        installer_bin = self.INSTALLER_BINARY(no_cache=no_cache).loaded_abspath
        dpkg_binary = EnvProvider().load("dpkg")
        dpkg_abspath = (
            dpkg_binary.loaded_abspath
            if dpkg_binary and dpkg_binary.loaded_abspath
            else None
        )
        assert installer_bin
        if not dpkg_abspath:
            raise Exception(
                f"{self.__class__.__name__}.INSTALLER_BIN is not available on this host: {self.INSTALLER_BIN}",
            )

        # print(f'[*] {self.__class__.__name__}: Installing {bin_name}: {self.INSTALLER_BIN} install {install_args}')

        with self.apt_lock():
            if (
                not _LAST_UPDATE_CHECK
                or (time.time() - _LAST_UPDATE_CHECK) > UPDATE_CHECK_INTERVAL
            ):
                # only update if we haven't checked in the last day
                self.exec(
                    bin_name=installer_bin,
                    cmd=["update", "-qq"],
                    timeout=timeout,
                )
                _LAST_UPDATE_CHECK = time.time()

            proc = self.exec(
                bin_name=installer_bin,
                cmd=["install", "-y", "-qq", "--no-install-recommends", *install_args],
                timeout=timeout,
            )
        if proc.returncode != 0:
            self._raise_proc_error("install", install_args, proc)
        return (
            format_subprocess_output(proc.stdout, proc.stderr)
            or f"Installed {install_args} successfully."
        )

    @remap_kwargs({"packages": "install_args"})
    def default_update_handler(
        self,
        bin_name: BinName,
        install_args: InstallArgs | None = None,
        postinstall_scripts: bool | None = None,
        min_release_age: float | None = None,
        min_version: SemVer | None = None,
        no_cache: bool = False,
        timeout: int | None = None,
    ) -> str:
        global _LAST_UPDATE_CHECK

        install_args = install_args or self.get_install_args(bin_name)

        installer_bin = self.INSTALLER_BINARY(no_cache=no_cache).loaded_abspath
        dpkg_binary = EnvProvider().load("dpkg")
        dpkg_abspath = (
            dpkg_binary.loaded_abspath
            if dpkg_binary and dpkg_binary.loaded_abspath
            else None
        )
        assert installer_bin
        if not dpkg_abspath:
            raise Exception(
                f"{self.__class__.__name__}.INSTALLER_BIN is not available on this host: {self.INSTALLER_BIN}",
            )

        with self.apt_lock():
            if (
                not _LAST_UPDATE_CHECK
                or (time.time() - _LAST_UPDATE_CHECK) > UPDATE_CHECK_INTERVAL
            ):
                self.exec(
                    bin_name=installer_bin,
                    cmd=["update", "-qq"],
                    timeout=timeout,
                )
                _LAST_UPDATE_CHECK = time.time()

            proc = self.exec(
                bin_name=installer_bin,
                cmd=[
                    "install",
                    "--only-upgrade",
                    "-y",
                    "-qq",
                    "--no-install-recommends",
                    *install_args,
                ],
                timeout=timeout,
            )
        if proc.returncode != 0:
            self._raise_proc_error("update", install_args, proc)
        return (
            format_subprocess_output(proc.stdout, proc.stderr)
            or f"Updated {install_args} successfully."
        )

    def default_search_handler(
        self,
        bin_name: BinName,
        min_version: SemVer | None = None,
        min_release_age: float | None = None,
        timeout: int | None = None,
        **context,
    ) -> list[ShallowBinary]:
        """Search apt's package index for packages whose name matches bin_name (substring)."""
        from .binary import Binary

        # ``apt-cache search --names-only`` returns lines like ``<name> - <description>``.
        # Routing through ``self.exec`` lets apt's setup_PATH/INSTALLER_BINARY
        # auto-recover from a missing/broken apt-get on the ambient PATH
        # (e.g. CI runners where the linuxbrew copy is unusable). The
        # deadlock filter in ``BinProvider.INSTALLER_BINARY`` keeps it
        # safe under restrictive ``--binproviders`` configs.
        self.INSTALLER_BINARY(no_cache=bool(context.get("no_cache", False)))
        proc = self.exec(
            bin_name="apt-cache",
            cmd=["search", "--names-only", str(bin_name)],
            quiet=True,
            timeout=timeout,
        )
        results: list[ShallowBinary] = []
        for line in proc.stdout.splitlines():
            pkg_name, _, description = line.partition(" - ")
            pkg_name = pkg_name.strip()
            if not pkg_name or str(bin_name) not in pkg_name:
                continue
            results.append(
                Binary(
                    name=pkg_name,
                    description=description.strip(),
                    binproviders=[self],
                    overrides={self.name: {"install_args": [pkg_name]}},
                ),
            )
        return results

    @remap_kwargs({"packages": "install_args"})
    def default_uninstall_handler(
        self,
        bin_name: BinName,
        install_args: InstallArgs | None = None,
        postinstall_scripts: bool | None = None,
        min_release_age: float | None = None,
        min_version: SemVer | None = None,
        no_cache: bool = False,
        timeout: int | None = None,
    ) -> bool:
        install_args = install_args or self.get_install_args(bin_name)

        installer_bin = self.INSTALLER_BINARY(no_cache=no_cache).loaded_abspath
        dpkg_binary = EnvProvider().load("dpkg")
        dpkg_abspath = (
            dpkg_binary.loaded_abspath
            if dpkg_binary and dpkg_binary.loaded_abspath
            else None
        )
        assert installer_bin
        if not dpkg_abspath:
            raise Exception(
                f"{self.__class__.__name__}.INSTALLER_BIN is not available on this host: {self.INSTALLER_BIN}",
            )

        with self.apt_lock():
            proc = self.exec(
                bin_name=installer_bin,
                cmd=["remove", "-y", "-qq", *install_args],
                timeout=timeout,
            )
        if proc.returncode != 0:
            self._raise_proc_error("uninstall", install_args, proc)

        return True


if __name__ == "__main__":
    result = apt = AptProvider()
    func = None

    if len(sys.argv) > 1:
        result = func = getattr(apt, sys.argv[1])  # e.g. install

    if len(sys.argv) > 2 and callable(func):
        result = func(sys.argv[2])  # e.g. install ffmpeg

    print(result)
