import itertools
import os
import re
import subprocess
import textwrap
import typing
from collections.abc import Iterable, Iterator, Mapping

from debian.deb822 import PkgRelation
from debputy.manifest_parser.util import AttributePath
from debputy.packaging.alternatives import SYSTEM_DEFAULT_PATH_DIRS
from debputy.plugin.api import (
    VirtualPath,
    BinaryCtrlAccessor,
    PackageProcessingContext,
)
from debputy.plugins.debputy.paths import (
    INITRAMFS_HOOK_DIR,
    SYSTEMD_TMPFILES_DIR,
    GSETTINGS_SCHEMA_DIR,
    SYSTEMD_SYSUSERS_DIR,
)
from debputy.plugins.debputy.types import (
    DebputyCapability,
    BuiltUsing,
    StaticBuiltUsing,
)
from debputy.util import assume_not_none, _debug_log, _error, _warn

DPKG_ROOT = '"${DPKG_ROOT}"'
DPKG_ROOT_UNQUOTED = "${DPKG_ROOT}"

KERNEL_MODULE_EXTENSIONS = tuple(
    f"{ext}{comp_ext}"
    for ext, comp_ext in itertools.product(
        (".o", ".ko"),
        ("", ".gz", ".bz2", ".xz"),
    )
)


def detect_initramfs_hooks(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    _unused: PackageProcessingContext,
) -> None:
    hook_dir = fs_root.lookup(INITRAMFS_HOOK_DIR)
    if not hook_dir:
        return
    for _ in hook_dir.iterdir():
        # Only add the trigger if the directory is non-empty. It is unlikely to matter a lot,
        # but we do this to match debhelper.
        break
    else:
        return

    ctrl.dpkg_trigger("activate-noawait", "update-initramfs")


def _all_tmpfiles_conf(fs_root: VirtualPath) -> Iterable[VirtualPath]:
    seen_tmpfiles = set()
    tmpfiles_dirs = [
        SYSTEMD_TMPFILES_DIR,
        "./etc/tmpfiles.d",
    ]
    for tmpfiles_dir_path in tmpfiles_dirs:
        tmpfiles_dir = fs_root.lookup(tmpfiles_dir_path)
        if not tmpfiles_dir:
            continue
        for path in tmpfiles_dir.iterdir():
            if (
                not path.is_file
                or not path.name.endswith(".conf")
                or path.name in seen_tmpfiles
            ):
                continue
            seen_tmpfiles.add(path.name)
            yield path


def detect_systemd_tmpfiles(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    _unused: PackageProcessingContext,
) -> None:
    tmpfiles_confs = [
        x.name for x in sorted(_all_tmpfiles_conf(fs_root), key=lambda x: x.name)
    ]
    if not tmpfiles_confs:
        return

    tmpfiles_escaped = ctrl.maintscript.escape_shell_words(*tmpfiles_confs)

    snippet = textwrap.dedent(f"""\
            if [ -x "$(command -v systemd-tmpfiles)" ]; then
                systemd-tmpfiles ${{DPKG_ROOT:+--root="$DPKG_ROOT"}} --create {tmpfiles_escaped} || true
            fi
    """)

    ctrl.maintscript.on_configure(snippet)


def _all_sysusers_conf(fs_root: VirtualPath) -> Iterable[VirtualPath]:
    sysusers_dir = fs_root.lookup(SYSTEMD_SYSUSERS_DIR)
    if not sysusers_dir:
        return
    for child in sysusers_dir.iterdir():
        if not child.name.endswith(".conf"):
            continue
        yield child


def detect_systemd_sysusers(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    _unused: PackageProcessingContext,
) -> None:
    sysusers_confs = [p.name for p in _all_sysusers_conf(fs_root)]
    if not sysusers_confs:
        return

    sysusers_escaped = ctrl.maintscript.escape_shell_words(*sysusers_confs)

    snippet = textwrap.dedent(f"""\
            systemd-sysusers ${{DPKG_ROOT:+--root="$DPKG_ROOT"}} {sysusers_escaped}
    """)

    ctrl.substvars.add_dependency(
        "misc:Depends", "systemd | systemd-standalone-sysusers | systemd-sysusers"
    )
    ctrl.maintscript.on_configure(snippet)


def detect_commands(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    context: PackageProcessingContext,
) -> None:
    if context.binary_package.is_udeb:
        return
    for path_name in SYSTEM_DEFAULT_PATH_DIRS:
        path_dir = fs_root.lookup(path_name)
        if path_dir is None or not path_dir.is_dir:
            continue
        for child in path_dir.iterdir():
            if not (child.is_file or child.is_symlink):
                continue
            ctrl.substvars.add_dependency("misc:Commands", child.name)


def detect_icons(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    _unused: PackageProcessingContext,
) -> None:
    icons_root_dir = fs_root.lookup("./usr/share/icons")
    if not icons_root_dir:
        return
    icon_dirs = []
    for subdir in icons_root_dir.iterdir():
        if subdir.name in ("gnome", "hicolor"):
            # dh_icons skips this for some reason.
            continue
        for p in subdir.all_paths():
            if p.is_file and p.name.endswith((".png", ".svg", ".xpm", ".icon")):
                icon_dirs.append(subdir.absolute)
                break
    if not icon_dirs:
        return

    icon_dir_list_escaped = ctrl.maintscript.escape_shell_words(*icon_dirs)

    postinst_snippet = textwrap.dedent(f"""\
        if command -v update-icon-caches >/dev/null; then
            update-icon-caches {icon_dir_list_escaped}
        fi
    """)

    postrm_snippet = textwrap.dedent(f"""\
        if command -v update-icon-caches >/dev/null; then
            update-icon-caches {icon_dir_list_escaped}
        fi
    """)

    ctrl.maintscript.on_configure(postinst_snippet)
    ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet)


def detect_gsettings_dependencies(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    _unused: PackageProcessingContext,
) -> None:
    gsettings_schema_dir = fs_root.lookup(GSETTINGS_SCHEMA_DIR)
    if not gsettings_schema_dir:
        return

    for path in gsettings_schema_dir.all_paths():
        if path.is_file and path.name.endswith((".xml", ".override")):
            ctrl.substvars.add_dependency(
                "misc:Depends", "dconf-gsettings-backend | gsettings-backend"
            )
            break


def detect_kernel_modules(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    _unused: PackageProcessingContext,
) -> None:
    for prefix in [".", "./usr"]:
        module_root_dir = fs_root.lookup(f"{prefix}/lib/modules")

        if not module_root_dir:
            continue

        module_version_dirs = []

        for module_version_dir in module_root_dir.iterdir():
            if not module_version_dir.is_dir:
                continue

            for fs_path in module_version_dir.all_paths():
                if fs_path.name.endswith(KERNEL_MODULE_EXTENSIONS):
                    module_version_dirs.append(module_version_dir.name)
                    break

        for module_version in module_version_dirs:
            module_version_escaped = ctrl.maintscript.escape_shell_words(module_version)
            postinst_snippet = textwrap.dedent(f"""\
                    if [ -e /boot/System.map-{module_version_escaped} ]; then
                        depmod -a -F /boot/System.map-{module_version_escaped} {module_version_escaped} || true
                    fi
            """)

            postrm_snippet = textwrap.dedent(f"""\
                if [ -e /boot/System.map-{module_version_escaped} ]; then
                    depmod -a -F /boot/System.map-{module_version_escaped} {module_version_escaped} || true
                fi
            """)

            ctrl.maintscript.on_configure(postinst_snippet)
            # TODO: This should probably be on removal. However, this is what debhelper did and we should
            # do the same until we are sure (not that it matters a lot).
            ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet)


def detect_xfonts(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    context: PackageProcessingContext,
) -> None:
    xfonts_root_dir = fs_root.lookup("./usr/share/fonts/X11/")
    if not xfonts_root_dir:
        return

    cmds = []
    cmds_postinst = []
    cmds_postrm = []
    escape_shell_words = ctrl.maintscript.escape_shell_words
    package_name = context.binary_package.name

    for xfonts_dir in xfonts_root_dir.iterdir():
        xfonts_dirname = xfonts_dir.name
        if not xfonts_dir.is_dir or xfonts_dirname.startswith("."):
            continue
        if fs_root.lookup(f"./etc/X11/xfonts/{xfonts_dirname}/{package_name}.scale"):
            cmds.append(escape_shell_words("update-fonts-scale", xfonts_dirname))
        cmds.append(
            escape_shell_words("update-fonts-dir", "--x11r7-layout", xfonts_dirname)
        )
        alias_file = fs_root.lookup(
            f"./etc/X11/xfonts/{xfonts_dirname}/{package_name}.alias"
        )
        if alias_file:
            cmds_postinst.append(
                escape_shell_words(
                    "update-fonts-alias",
                    "--include",
                    alias_file.absolute,
                    xfonts_dirname,
                )
            )
            cmds_postrm.append(
                escape_shell_words(
                    "update-fonts-alias",
                    "--exclude",
                    alias_file.absolute,
                    xfonts_dirname,
                )
            )

    if not cmds:
        return

    postinst_snippet = textwrap.dedent(f"""\
        if command -v update-fonts-dir >/dev/null; then
            {';'.join(itertools.chain(cmds, cmds_postinst))}
        fi
    """)

    postrm_snippet = textwrap.dedent(f"""\
        if [ -x "`command -v update-fonts-dir`" ]; then
            {';'.join(itertools.chain(cmds, cmds_postrm))}
        fi
    """)

    ctrl.maintscript.unconditionally_in_script("postinst", postinst_snippet)
    ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet)
    ctrl.substvars.add_dependency("misc:Depends", "xfonts-utils")


# debputy does not support python2, so we do not list python / python2.
_PYTHON_PUBLIC_DIST_DIR_NAMES = re.compile(r"(?:pypy|python)3(?:[.]\d+)?")


def _public_python_dist_dirs(fs_root: VirtualPath) -> Iterator[VirtualPath]:
    usr_lib = fs_root.lookup("./usr/lib")
    root_dirs = []
    if usr_lib:
        root_dirs.append(usr_lib)

    dbg_root = fs_root.lookup("./usr/lib/debug/usr/lib")
    if dbg_root:
        root_dirs.append(dbg_root)

    for root_dir in root_dirs:
        python_dirs = (
            path
            for path in root_dir.iterdir()
            if path.is_dir and _PYTHON_PUBLIC_DIST_DIR_NAMES.match(path.name)
        )
        for python_dir in python_dirs:
            dist_packages = python_dir.get("dist-packages")
            if not dist_packages:
                continue
            yield dist_packages


def _has_py_file_in_dir(d: VirtualPath) -> bool:
    return any(f.is_file and f.name.endswith(".py") for f in d.all_paths())


def detect_pycompile_files(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    context: PackageProcessingContext,
) -> None:
    package = context.binary_package.name
    # TODO: Support configurable list of private dirs
    private_search_dirs = [
        fs_root.lookup(os.path.join(d, package))
        for d in [
            "./usr/share",
            "./usr/share/games",
            "./usr/lib",
            f"./usr/lib/{context.binary_package.deb_multiarch}",
            "./usr/lib/games",
        ]
    ]
    private_search_dirs_with_py_files = [
        p for p in private_search_dirs if p is not None and _has_py_file_in_dir(p)
    ]
    public_search_dirs_has_py_files = any(
        p is not None and _has_py_file_in_dir(p)
        for p in _public_python_dist_dirs(fs_root)
    )

    if not public_search_dirs_has_py_files and not private_search_dirs_with_py_files:
        return

    # The dh_python3 helper also supports -V and -X.  We do not use them. They can be
    # replaced by bcep support instead, which is how we will be supporting this kind
    # of configuration down the line.
    ctrl.maintscript.unconditionally_in_script(
        "prerm",
        textwrap.dedent(f"""\
        if command -v py3clean >/dev/null 2>&1; then
            py3clean -p {package}
        else
            dpkg -L {package} | sed -En -e '/^(.*)\\/(.+)\\.py$/s,,rm "\\1/__pycache__/\\2".*,e'
            find /usr/lib/python3/dist-packages/ -type d -name __pycache__ -empty -print0 | xargs --null --no-run-if-empty rmdir
        fi
        """),
    )
    if public_search_dirs_has_py_files:
        ctrl.maintscript.on_configure(textwrap.dedent(f"""\
            if command -v py3compile >/dev/null 2>&1; then
                py3compile -p {package}
            fi
            if command -v pypy3compile >/dev/null 2>&1; then
                pypy3compile -p {package} || true
            fi
            """))
    for private_dir in private_search_dirs_with_py_files:
        escaped_dir = ctrl.maintscript.escape_shell_words(private_dir.absolute)
        ctrl.maintscript.on_configure(textwrap.dedent(f"""\
            if command -v py3compile >/dev/null 2>&1; then
                py3compile -p {package} {escaped_dir}
            fi
            if command -v pypy3compile >/dev/null 2>&1; then
                pypy3compile -p {package} {escaped_dir} || true
            fi
            """))


def translate_capabilities(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    _context: PackageProcessingContext,
) -> None:
    caps = []
    maintscript = ctrl.maintscript
    for p in fs_root.all_paths():
        if not p.is_file:
            continue
        metadata_ref = p.metadata(DebputyCapability)
        capability = metadata_ref.value
        if capability is None:
            continue

        abs_path = maintscript.escape_shell_words(p.absolute)

        cap_script = "".join(
            [
                "    # Triggered by: {DEFINITION_SOURCE}\n"
                "    _TPATH=$(dpkg-divert --truename {ABS_PATH})\n",
                '    if setcap {CAP} "{DPKG_ROOT_UNQUOTED}${{_TPATH}}"; then\n',
                '        chmod {MODE} "{DPKG_ROOT_UNQUOTED}${{_TPATH}}"\n',
                '        echo "Successfully applied capabilities {CAP} on ${{_TPATH}}"\n',
                "    else\n",
                # We do not reset the mode here; generally a re-install or upgrade would re-store both mode,
                # and remove the capabilities.
                '        echo "The setcap failed to processes {CAP} on ${{_TPATH}}; falling back to no capability support" >&2\n',
                "    fi\n",
            ]
        ).format(
            CAP=maintscript.escape_shell_words(capability.capabilities).replace(
                "\\+", "+"
            ),
            DPKG_ROOT_UNQUOTED=DPKG_ROOT_UNQUOTED,
            ABS_PATH=abs_path,
            MODE=maintscript.escape_shell_words(str(capability.capability_mode)),
            DEFINITION_SOURCE=capability.definition_source.replace("\n", "\\n"),
        )
        assert cap_script.endswith("\n")
        caps.append(cap_script)

    if not caps:
        return

    maintscript.on_configure(
        textwrap.dedent("""\
        if command -v setcap > /dev/null; then
        {SET_CAP_COMMANDS}
            unset _TPATH
        else
            echo "The setcap utility is not installed available; falling back to no capability support" >&2
        fi
        """).format(
            SET_CAP_COMMANDS="".join(caps).rstrip("\n"),
        )
    )


def pam_auth_update(
    fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    _context: PackageProcessingContext,
) -> None:
    pam_configs_dir = fs_root.lookup("/usr/share/pam-configs")
    if not pam_configs_dir:
        return
    pam_configs = [f.name for f in pam_configs_dir.iterdir() if f.is_file]
    if not pam_configs:
        return
    maintscript = ctrl.maintscript
    maintscript.on_configure("pam-auth-update --package\n")
    maintscript.on_before_removal(
        textwrap.dedent(
            f"""\
            if [ "${{DPKG_MAINTSCRIPT_PACKAGE_REFCOUNT:-1}}" = 1 ]; then
                pam-auth-update --package --remove {maintscript.escape_shell_words(*pam_configs)}
            fi
            """,
        )
    )


def auto_depends_arch_any_solink(
    fs_foot: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    context: PackageProcessingContext,
) -> None:
    package = context.binary_package
    if package.is_arch_all:
        return
    libbasedir = fs_foot.lookup("usr/lib")
    if not libbasedir:
        return
    libmadir = libbasedir.get(package.deb_multiarch)
    if libmadir:
        libdirs = [libmadir, libbasedir]
    else:
        libdirs = [libbasedir]
    targets = []
    for libdir in libdirs:
        for path in libdir.iterdir():
            if not path.is_symlink or not path.name.endswith(".so"):
                continue
            target = path.readlink()
            resolved = assume_not_none(path.parent_dir).lookup(target)
            if resolved is not None:
                continue
            targets.append((libdir.path, target))

    roots = list(context.accessible_package_roots())
    if not roots:
        return

    for libdir_path, target in targets:
        final_path = os.path.join(libdir_path, target)
        matches = []
        for opkg, ofs_root in roots:
            m = ofs_root.lookup(final_path)
            if not m:
                continue
            matches.append(opkg)
        if not matches or len(matches) > 1:
            if matches:
                all_matches = ", ".join(p.name for p in matches)
                _warn(
                    f"auto-depends-solink: The {final_path} was found in multiple packages ({all_matches}):"
                    f" Not generating a dependency."
                )
            else:
                _warn(
                    f"auto-depends-solink: The {final_path} was NOT found in any accessible package:"
                    " Not generating a dependency. This detection only works when both packages are arch:any"
                    " and they have the same build-profiles."
                )
            continue
        pkg_dep = matches[0]
        # The debputy API should not allow this constraint to fail
        assert pkg_dep.is_arch_all == package.is_arch_all
        # If both packages are arch:all or both are arch:any, we can generate a tight dependency
        relation = f"{pkg_dep.name} (= ${{binary:Version}})"
        ctrl.substvars.add_dependency("misc:Depends", relation)


def _built_using_enabled_matches(
    context: PackageProcessingContext,
) -> Iterator["_BuiltUsingTodo"]:
    """Helper implementing pass 1 of detect_built_using."""
    pkg = context.binary_package
    host = pkg.resolved_architecture
    table = context.dpkg_arch_query_table
    profiles = context.deb_options_and_profiles.deb_build_profiles
    condition_context = context.condition_context(pkg)
    for cls, field in (
        (BuiltUsing, "Built-Using"),
        (StaticBuiltUsing, "Static-Built-Using"),
    ):
        for deps, cond, attr in context.manifest_configuration(pkg, cls) or ():
            for is_first, relation in deps:
                name = relation["name"]
                if not PkgRelation.holds_on_arch(relation, host, table):
                    _debug_log(
                        f"{attr}: {name} in Build-Depends is disabled by host architecture {host}."
                    )
                elif not PkgRelation.holds_with_profiles(relation, profiles):
                    _debug_log(
                        f"{attr}: {name} in Build-Depends is disabled by profiles {' '.join(profiles)}."
                    )
                elif cond is not None and not cond.evaluate(condition_context):
                    _debug_log(f"{attr}: {name} is disabled by its manifest condition.")
                else:
                    yield _BuiltUsingTodo(field, name, is_first, attr)


class _BuiltUsingTodo(typing.NamedTuple):
    """Data transmitted between the two passes of detect_built_using."""

    field: str
    dep: str
    first_option: bool  # This relation was the first in its alternative.
    attribute_path: AttributePath


def _split_dpkg_output(dpkg_output: str) -> Iterable[tuple[str, str]]:
    for line in dpkg_output.splitlines(keepends=False):
        status, package_name, source_and_version = line.split("\x1f")
        if status[1] == "i":
            yield package_name, source_and_version


def _sources_for(
    deps: Iterable[str],
) -> Mapping[str, str]:
    """Map installed packages among deps to a "source (= version)"
    relation, excluding unknown or not installed packages.

    >>> r = _sources_for(("dpkg", "dpkg", "dpkg-dev", "gcc", "dummy"))
    >>> r["dpkg"]  # doctest: +ELLIPSIS
    'dpkg (= ...)'
    >>> r["dpkg-dev"]  # doctest: +ELLIPSIS
    'dpkg (= ...)'
    >>> r["gcc"]  # doctest: +ELLIPSIS
    'gcc-defaults (= ...)'
    >>> "dummy" in r
    False
    """
    cp = subprocess.run(
        args=(
            "dpkg-query",
            "-Wf${db:Status-Abbrev}\x1f${Package}\x1f${source:Package} (= ${source:Version})\n",
            *deps,
        ),
        capture_output=True,
        check=False,
        text=True,
    )
    # 0: OK    1: unknown package    2: other
    if cp.returncode not in (0, 1):
        _error(f"dpkg-query -W failed (code: {cp.returncode}): {cp.stderr.rstrip()}")
    # For the example above, stdout is:
    # "ii \x1fdpkg\x1fdpkg (= 1.22.21)\n"
    # "ii \x1fdpkg-dev\x1fdpkg (= 1.22.21)\n"
    # "ii \x1fgcc\x1fgcc-defaults (= 1.220)\n"
    #   ^: the package is (i)nstalled
    return dict(_split_dpkg_output(cp.stdout))


def detect_built_using(
    _fs_root: VirtualPath,
    ctrl: BinaryCtrlAccessor,
    context: PackageProcessingContext,
) -> None:
    """For efficiency on patterns like librust-*-dev, a first pass
    constructs a todo list, then at most one dpkg-query subprocess is
    spawn per binary package and the process actually happens.
    """

    all_todos = tuple(_built_using_enabled_matches(context))
    if all_todos:
        _built_using_process_matches(ctrl, all_todos)


def _built_using_process_matches(
    ctrl: BinaryCtrlAccessor,
    all_todos: Iterable[_BuiltUsingTodo],
) -> None:
    """Helper implementing pass 2 of detect_built_using."""
    relevant_sources = _sources_for(sorted(t.dep for t in all_todos))
    already_warned = set()
    for field, dep, first_option, attribute_path in all_todos:
        if dep in relevant_sources:
            ctrl.substvars.add_dependency(
                f"debputy:{field}",
                relevant_sources[dep],
            )
        # With `Build-Depends: a | b`, in usual configurations,
        # `a` is installed but `b` might not be.
        elif first_option and dep not in already_warned:
            _warn(
                f"{attribute_path.path}: {dep} is not installed despite being the first or only option in a Build-Depends clause. It will be omitted from the built-using fields."
            )
            already_warned.add(dep)
        else:
            _debug_log(
                f"{attribute_path.path}: {dep} is not installed and therefore not included in the built-using fields."
            )
