"""
triggers, callables to bind to a step in a MergeEngine to affect changes
"""

__all__ = (
    "base",
    "UNINSTALLING_MODES",
    "INSTALLING_MODES",
    "BaseSystemUnmergeProtection",
    "BinaryDebug",
    "BlockFileType",
    "CommonDirectoryModes",
    "PruneFiles",
    "SavePkg",
    "detect_world_writable",
    "fix_gid_perms",
    "fix_set_bits",
    "fix_uid_perms",
    "ldconfig",
    "merge",
    "unmerge",
    "InfoRegen",
)

import os
import platform
import re
import time
from math import floor

from snakeoil import process
from snakeoil.bash import iter_read_bash
from snakeoil.compatibility import IGNORED_EXCEPTIONS
from snakeoil.fileutils import touch
from snakeoil.osutils import ensure_dirs, listdir_files, normpath, pjoin
from snakeoil.process import spawn

from .. import os_data
from ..fs import contents, fs
from ..fs.livefs import gen_obj
from ..fs.ops import merge_contents, unmerge_contents
from ..operations.observer import threadsafe_repo_observer
from ..package.mutated import MutatedPkg
from ..util import file_type, thread_pool
from . import const, errors

UNINSTALLING_MODES = (const.REPLACE_MODE, const.UNINSTALL_MODE)
INSTALLING_MODES = (const.REPLACE_MODE, const.INSTALL_MODE)


class base:
    """base trigger class

    :ivar required_csets: If None, all csets are passed in, else it must be a
        sequence, those specific csets are passed in
    :ivar _label: Either None, or a string to use for this triggers label
    :ivar _hook: sequence of hook points to register into
    :ivar priority: range of 0 to 100, order of execution for triggers per hook
    :ivar _engine_types: if None, trigger works for all engine modes, else it's
        limited to that mode, and must be a sequence
    """

    required_csets = None
    _label = None
    _hooks = None
    _engine_types = None
    priority = 50

    suppress_exceptions = True

    @property
    def label(self):
        if self._label is not None:
            return self._label
        return str(self.__class__.__name__)

    def register(self, engine):
        """
        register with a MergeEngine
        """
        if self._engine_types is not None and engine.mode not in self._engine_types:
            return

        # ok... so we care about this mode.
        try:
            iter(self._hooks)
        except TypeError:
            # bad monkey...
            raise TypeError(f"{self!r}: {self._hooks!r}: _hooks needs to be a sequence")

        csets = self.get_required_csets(engine.mode)

        for hook in self._hooks:
            try:
                engine.add_trigger(hook, self, csets)
            except KeyError:
                # unknown hook.
                continue

    def get_required_csets(self, mode):
        csets = self.required_csets
        if csets is not None:
            if not isinstance(csets, tuple):
                # has to be a dict.
                csets = csets.get(mode)
        return csets

    def localize(self, engine):
        """
        'localize' a trigger to a specific merge engine process
        mainly used if the trigger comes from configuration
        """
        return self

    @staticmethod
    def _get_csets(required_csets, csets):
        return [csets[x] for x in required_csets]

    def trigger(self, engine, csets):
        raise NotImplementedError(self, "trigger")

    def __call__(self, engine, csets):
        """execute the trigger"""

        required_csets = self.get_required_csets(engine.mode)

        if required_csets is None:
            return self.trigger(engine, csets)
        return self.trigger(engine, *self._get_csets(required_csets, csets))

    def __str__(self):
        return f"{self.label}: cset({self.required_csets}) ftrigger({self.trigger})"

    def __repr__(self):
        return "<%s cset=%r @#%x>" % (self.label, self.required_csets, id(self))


class ThreadedTrigger(base):
    def identify_work(self, engine, *csets):
        raise NotImplementedError(self, "identify_work")

    def _run_job(self, observer, functor, args, kwds):
        try:
            functor(*args, **kwds)
        except IGNORED_EXCEPTIONS as e:
            if isinstance(e, KeyboardInterrupt):
                return
            raise
        except Exception as e:
            observer.error(f"exception occurred in thread: {e}")

    def threading_get_args(self, engine, *csets):
        return ()

    def threading_get_kwargs(self, engine, *csets):
        return {}

    def trigger(self, engine, *csets):
        if not self.threading_setup(engine, *csets):
            return

        observer = engine.observer
        observer = threadsafe_repo_observer(observer)
        args = (observer,) + self.threading_get_args(engine, *csets)
        kwargs = self.threading_get_kwargs(engine, *csets)
        # Grab PKGCORE_TRIGGER_PARALLELISM to make development easier
        kwargs["threads"] = int(
            os.environ.get("PKGCORE_TRIGGER_PARALLELISM", engine.parallelism)
        )

        work = list(self.identify_work(engine, *csets))
        thread_pool.map_async(work, self.thread_trigger, *args, **kwargs)

        self.threading_finish(engine, *csets)

    def threading_setup(self, engine, *csets):
        return True

    def threading_finish(self, engine, *csets):
        pass


class mtime_watcher:
    """
    passed a list of locations, return a :obj:`contents.contentsSet` containing
    those that are directories.

    If the location doesn't exist, it's ignored.  If stat_func is os.stat
    and the location is a symlink pointing at a nonexistent location, it's
    ignored.

    Additionally, since this function is used for effectively 'snapshotting'
    related directories, if any mtimes are *now* (fs doesn't do subsecond
    resolution, osx for example), induces a sleep for a second to ensure
    any later re-runs do not get bit by completing within the race window.

    Finally, if any mtime is detected that is in the future, it is reset
    to 'now'.
    """

    def __init__(self):
        self.saved_mtimes = None
        self.locations = None

    def __bool__(self):
        return bool(self.saved_mtimes)

    @staticmethod
    def _scan_mtimes(locations, stat_func):
        for x in locations:
            try:
                st = stat_func(x)
            except FileNotFoundError:
                continue
            obj = gen_obj(x, stat=st)
            if fs.isdir(obj):
                yield obj

    def set_state(self, locations, stat_func=os.stat, forced_past=2):
        """
        set the initial state; will adjust ondisk mtimes as needed
        to avoid race potentials.

        :param locations: sequence, file paths to scan
        :param stat_func: stat'er to use.  defaults to os.stat
        """
        self.locations = locations
        mtimes = list(self._scan_mtimes(locations, stat_func))

        cset = contents.contentsSet(mtimes)
        now = time.time()
        pause_cutoff = floor(now)
        past = float(max(pause_cutoff - forced_past, 0))
        resets = [x for x in mtimes if x.mtime > past]
        for x in resets:
            cset.add(x.change_attributes(mtime=past))
            os.utime(x.location, (past, past))

        self.saved_mtimes = cset

    def check_state(self, locations=None, stat_func=os.stat):
        """
        set the initial state; will adjust ondisk mtimes as needed
        to avoid race potentials.

        :param locations: sequence, file paths to scan; uses the locations
          from the set_state invocation if not supplised.
        :param stat_func: stat'er to use.  defaults to os.stat
        :return: boolean, True if things have changed, False if not.
        """
        if locations is None:
            locations = self.locations

        for x in self.get_changes(locations=locations, stat_func=stat_func):
            return True
        return False

    def get_changes(self, locations=None, stat_func=os.stat):
        """
        generator yielding the fs objs for what has changed.

        :param locations: sequence, file paths to scan; uses the locations
          from the set_state invocation if not supplised.
        :param stat_func: stat'er to use.  defaults to os.stat
        """
        if locations is None:
            locations = self.locations

        for x in self._scan_mtimes(locations, stat_func):
            if x not in self.saved_mtimes or self.saved_mtimes[x].mtime != x.mtime:
                yield x


def update_elf_hints(root):
    return spawn.spawn(["/sbin/ldconfig", "-X", "-r", root], fd_pipes={1: 1, 2: 2})


class ldconfig(base):
    required_csets = ()
    priority = 10
    _engine_types = None
    _hooks = ("pre_merge", "post_merge", "pre_unmerge", "post_unmerge")

    default_ld_path = ["usr/lib", "usr/lib64", "usr/lib32", "lib", "lib64", "lib32"]

    def __init__(self, ld_so_conf_path="etc/ld.so.conf"):
        self.ld_so_conf_path = ld_so_conf_path.lstrip(os.path.sep)
        self.saved_mtimes = mtime_watcher()

    def ld_so_path(self, offset):
        return pjoin(offset, self.ld_so_conf_path)

    def read_ld_so_conf(self, offset):
        fp = self.ld_so_path(offset)

        try:
            l = [x.lstrip(os.path.sep) for x in iter_read_bash(fp)]
        except FileNotFoundError:
            self._mk_ld_so_conf(fp)
            # fall back to an educated guess.
            l = self.default_ld_path
        return [pjoin(offset, x) for x in l]

    def _mk_ld_so_conf(self, fp):
        if not ensure_dirs(os.path.dirname(fp), mode=0o755, minimal=True):
            raise errors.BlockModification(
                self, f"failed creating/setting {fp} to 0755, root/root for uid/gid"
            )
        try:
            touch(fp)
        except EnvironmentError as e:
            raise errors.BlockModification(self, e) from e

    def trigger(self, engine):
        # ldconfig is only meaningful in GNU/Linux
        if platform.system() != "Linux":
            return

        locations = self.read_ld_so_conf(engine.offset)
        if engine.phase.startswith("pre_"):
            self.saved_mtimes.set_state(locations)
            return

        # always invoke regen; ld.so.conf can have source/include statements,
        # and modern ldconfig maintains a cache that renders this very, very fast.
        self.regen(engine)

    def regen(self, engine):
        ret = update_elf_hints(engine.offset)
        if ret != 0:
            engine.observer.warn("ldconfig returned %i from execution", ret)


class InfoRegen(base):
    required_csets = ()

    # could implement this to look at csets, and do incremental removal and
    # addition; doesn't seem worth while though for the additional complexity

    _hooks = ("pre_merge", "post_merge", "pre_unmerge", "post_unmerge")
    _engine_types = None
    _label = "gnu info regen"

    locations = ("/usr/share/info",)

    def __init__(self):
        self.saved_mtimes = mtime_watcher()

    def get_binary_path(self):
        try:
            return process.find_binary("install-info")
        except process.CommandNotFound:
            # swallow it.
            return None

    def trigger(self, engine):
        locations = [
            pjoin(engine.offset, x.lstrip(os.path.sep)) for x in self.locations
        ]

        if engine.phase.startswith("pre_"):
            self.saved_mtimes.set_state(locations)
            return
        elif engine.phase == "post_merge" and engine.mode == const.REPLACE_MODE:
            # skip post_merge for replace.
            # we catch it on unmerge...
            return

        bin_path = self.get_binary_path()
        if bin_path is None:
            return

        regens = set(x.location for x in self.saved_mtimes.get_changes(locations))
        # force regeneration of any directory lacking the info index.
        regens.update(x for x in locations if not os.path.isfile(pjoin(x, "dir")))

        bad = []
        for x in regens:
            bad.extend(self.regen(bin_path, x))

        if bad and engine.observer is not None:
            bad_info = ", ".join(map(repr, sorted(bad)))
            engine.observer.warn(f"bad info files: {bad_info}")

    def should_skip_directory(self, basepath, files):
        return False

    def regen(self, binary, basepath):
        ignores = ("dir", "dir.old")
        try:
            files = listdir_files(basepath)
        except FileNotFoundError:
            return

        if self.should_skip_directory(basepath, files):
            return

        # wipe old indexes.
        for x in set(ignores).intersection(files):
            os.remove(pjoin(basepath, x))

        index = pjoin(basepath, "dir")
        for x in files:
            if x in ignores or x.startswith("."):
                continue

            ret, data = spawn.spawn_get_output(
                [binary, "--quiet", pjoin(basepath, x), "--dir-file", index],
                collect_fds=(1, 2),
                split_lines=False,
            )

            if (
                not data
                or "already exists" in data
                or "warning: no info dir entry" in data
            ):
                continue
            yield pjoin(basepath, x)


class merge(base):
    required_csets = ("install",)
    _engine_types = INSTALLING_MODES
    _hooks = ("merge",)

    suppress_exceptions = False

    def trigger(self, engine, merging_cset):
        return merge_contents(merging_cset, callback=engine.observer.installing_fs_obj)


class unmerge(base):
    required_csets = ("uninstall",)
    _engine_types = UNINSTALLING_MODES
    _hooks = ("unmerge",)

    suppress_exceptions = False

    def trigger(self, engine, unmerging_cset):
        return unmerge_contents(
            unmerging_cset, callback=engine.observer.removing_fs_obj
        )


class BaseSystemUnmergeProtection(base):
    required_csets = ("uninstall",)
    priority = -100
    _engine_types = UNINSTALLING_MODES
    _hooks = ("unmerge",)

    suppress_exceptions = False

    _preserve_sequence = (
        "/usr",
        "/usr/lib",
        "/usr/lib64",
        "/usr/lib32",
        "/usr/bin",
        "/usr/sbin",
        "/bin",
        "/sbin",
        "/lib",
        "/lib32",
        "/lib64",
        "/etc",
        "/var",
        "/home",
        "/root",
    )

    def __init__(self, preserve_sequence=None):
        if preserve_sequence is None:
            preserve_sequence = self._preserve_sequence
        self._block = tuple(x.lstrip("/") for x in preserve_sequence)

    def trigger(self, engine, uninstall):
        uninstall.difference_update(pjoin(engine.offset, x) for x in self._block)
        return True


class fix_uid_perms(base):
    required_csets = ("new_cset",)
    _hooks = ("pre_merge",)
    _engine_types = INSTALLING_MODES

    def __init__(self, uid=os_data.portage_uid, replacement=os_data.root_uid):
        super().__init__()
        self.bad_uid = uid
        self.good_uid = replacement

    def trigger(self, engine, cset):
        good = self.good_uid
        bad = self.bad_uid

        cset.update(x.change_attributes(uid=good) for x in cset if x.uid == bad)


class fix_gid_perms(base):
    required_csets = ("new_cset",)
    _hooks = ("pre_merge",)
    _engine_types = INSTALLING_MODES

    def __init__(self, gid=os_data.portage_gid, replacement=os_data.root_gid):
        super().__init__()
        self.bad_gid = gid
        self.good_gid = replacement

    def trigger(self, engine, cset):
        good = self.good_gid
        bad = self.bad_gid

        cset.update(x.change_attributes(gid=good) for x in cset if x.gid == bad)


class fix_set_bits(base):
    required_csets = ("new_cset",)
    _hooks = ("pre_merge",)
    _engine_types = INSTALLING_MODES

    def trigger(self, engine, cset):
        reporter = engine.observer
        # if s(uid|gid) *and* world writable...
        l = [x for x in cset.iterlinks(True) if (x.mode & 0o6000) and (x.mode & 0o002)]

        if reporter is not None:
            for x in l:
                if x.mode & 0o4000:
                    reporter.warn(
                        f"correcting unsafe world writable SetGID: {x.location}"
                    )
                else:
                    reporter.warn(
                        f"correcting unsafe world writable SetUID: {x.location}"
                    )
        if l:
            # wipe setgid/setuid
            cset.update(x.change_attributes(mode=x.mode & ~0o6002) for x in l)


class detect_world_writable(base):
    required_csets = ("new_cset",)
    _hooks = ("pre_merge",)
    _engine_types = INSTALLING_MODES

    def __init__(self, fix_perms=False):
        super().__init__()
        self.fix_perms = fix_perms

    def trigger(self, engine, cset):
        if not engine.observer and not self.fix_perms:
            return

        reporter = engine.observer

        l = [x for x in cset.iterlinks(True) if x.mode & 0o002]
        if reporter is not None:
            for x in l:
                reporter.warn(f"world writable file: {x.location}")
        if self.fix_perms:
            cset.update(x.change_attributes(mode=x.mode & ~0o002) for x in l)


class PruneFiles(base):
    required_csets = ("new_cset",)
    _hooks = ("pre_merge",)
    _engine_types = INSTALLING_MODES

    def __init__(self, sentinel_func):
        """
        :param sentinel_func: callable accepting a fsBase entry, returns
        True if the entry should be removed, False otherwise
        """
        super().__init__()
        self.sentinel = sentinel_func

    def trigger(self, engine, cset):
        removal = list(filter(self.sentinel, cset))
        if engine.observer:
            for x in removal:
                engine.observer.info(f"pruning: {x.location}")
        cset.difference_update(removal)


class CommonDirectoryModes(base):
    required_csets = ("new_cset",)
    _hooks = ("pre_merge",)
    _engine_types = INSTALLING_MODES

    directories = [
        pjoin("/usr", x) for x in (".", "lib", "lib64", "lib32", "bin", "sbin", "local")
    ]
    directories.extend(pjoin("/usr/share", x) for x in (".", "man", "info"))
    directories.extend(f"/usr/share/man/man{x}" for x in range(1, 10))
    directories.extend(["/lib", "/lib32", "/lib64", "/etc", "/bin", "/sbin", "/var"])
    directories = frozenset(map(normpath, directories))

    def trigger(self, engine, cset):
        r = engine.observer
        if not r:
            return
        for x in cset.iterdirs():
            if x.location not in self.directories:
                continue
            if x.mode != 0o755:
                r.warn(f"{x.location} path has mode {oct(x.mode)}, should be 0755")


class BlockFileType(base):
    required_csets = ("new_cset",)
    _hooks = ("pre_merge",)
    _engine_types = INSTALLING_MODES

    def __init__(self, bad_regex, regex_to_check=None, fatal=True):
        self.bad_regex, self.filter_regex = bad_regex, regex_to_check
        self.fatal = fatal

    def trigger(self, engine, cset):
        file_typer = file_type.file_identifier()

        if self.filter_regex is None:
            filter_re = lambda x: True
        else:
            filter_re = re.compile(self.filter_regex).match
        bad_pat = re.compile(self.bad_regex).match

        bad_files = []
        # this won't play perfectly w/ binpkgs
        for x in (x for x in cset.iterfiles() if filter_re(x.location)):
            if bad_pat(file_typer(x.data)):
                engine.observer.warn(f"disallowed file type: {x!r}")
                bad_files.append(x)
        if self.fatal and bad_files:
            raise errors.BlockModification(
                self,
                (
                    "blacklisted filetypes were encountered- "
                    f"pattern {self.bad_regex!r} matched files: {sorted(bad_files)}"
                ),
            )


class SavePkg(base):
    required_csets = ("raw_new_cset",)
    priority = 90
    _hooks = ("sanity_check",)
    _engine_types = INSTALLING_MODES

    _copy_source = "new"

    def __init__(self, target_repo, pristine=True, skip_if_source=True):
        if not pristine:
            self._hooks = ("pre_merge",)
            self.required_csets = ("install",)
        self.skip_if_source = skip_if_source
        self.target_repo = target_repo

    def trigger(self, engine, cset):
        pkg = getattr(engine, self._copy_source)
        # don't build binpkgs of target repo binpkgs
        if (
            self.skip_if_source
            and str(getattr(pkg, "repo")) == self.target_repo.repo_id
        ):
            return

        old_pkg = self.target_repo.match(pkg.versioned_atom)
        wrapped_pkg = MutatedPkg(pkg, {"contents": cset})
        if old_pkg:
            txt = "replacing"
            op = self.target_repo.operations.replace(*(old_pkg + [wrapped_pkg]))
        else:
            txt = "installing"
            op = self.target_repo.operations.install(wrapped_pkg)
        engine.observer.info(f"{txt} {pkg} to {self.target_repo.location}")
        op.finish()


class SavePkgIfInPkgset(SavePkg):
    def __init__(self, target_repo, pkgset, pristine=True, skip_if_source=True):
        super().__init__(target_repo, pristine=pristine, skip_if_source=skip_if_source)
        self.pkgset = pkgset

    def trigger(self, engine, cset):
        pkg = getattr(engine, self._copy_source)
        if any(x.match(pkg) for x in self.pkgset):
            return SavePkg.trigger(self, engine, cset)


class SavePkgUnmerging(SavePkg):
    required_csets = ("old_cset",)
    _engine_types = UNINSTALLING_MODES
    _copy_source = "old"

    def __init__(self, target_repo):
        self.target_repo = target_repo


class SavePkgUnmergingIfInPkgset(SavePkgUnmerging):
    def __init__(self, target_repo, pkgset, pristine=True):
        super().__init__(target_repo, pristine=pristine)
        self.pkgset = pkgset

    def trigger(self, engine, cset):
        pkg = getattr(engine, self._copy_source)
        if any(x.match(pkg) for x in self.pkgset):
            return SavePkgUnmerging.trigger(self, engine, cset)


class BinaryDebug(ThreadedTrigger):
    required_csets = ("install",)
    _engine_types = INSTALLING_MODES

    _hooks = ("pre_merge",)

    default_strip_flags = ("--strip-unneeded", "-R", ".comment")
    elf_regex = r"(^| )ELF +(\d+-bit )"

    def __init__(
        self,
        mode="split",
        strip_binary=None,
        objcopy_binary=None,
        extra_strip_flags=(),
        debug_storage="/usr/lib/debug/",
        compress=False,
    ):
        self.mode = mode = mode.lower()
        if mode not in ("split", "strip"):
            raise TypeError(f"mode {mode!r} is unknown; must be either split or strip")
        self.thread_trigger = getattr(self, f"_{mode}")
        self.threading_setup = getattr(self, f"_{mode}_setup")
        self.threading_finish = getattr(self, f"_{mode}_finish")

        self._strip_binary = strip_binary
        self._objcopy_binary = objcopy_binary
        self._strip_flags = list(self.default_strip_flags)
        self._extra_strip_flags = list(extra_strip_flags)
        self._debug_storage = debug_storage
        self._compress = compress

    def _initialize_paths(self, pkg, progs):
        for x in progs:
            obj = getattr(self, f"_{x}_binary")
            if obj is None:
                try:
                    obj = process.find_binary(f"{pkg.chost}-{x}")
                except process.CommandNotFound:
                    obj = process.find_binary(x)
            setattr(self, f"{x}_binary", obj)

    def _strip_fsobj(self, fs_obj, ftype, reporter, quiet=False):
        args = self._strip_flags
        if "executable" in ftype or "shared object" in ftype:
            args += self._extra_strip_flags
        elif "current ar archive" in ftype:
            args = ["-g"]
        if not quiet:
            reporter.info(f"stripping: {fs_obj} {' '.join(args)}")
        ret = spawn.spawn([self.strip_binary] + args + [fs_obj.data.path])
        if ret != 0:
            reporter.warn(f"stripping {fs_obj}, type {ftype} failed")
        # need to update chksums here...
        return fs_obj

    def identify_work(self, engine, cset):
        file_typer = file_type.file_identifier()
        regex_f = re.compile(self.elf_regex).match
        engine.observer.debug("starting binarydebug filetype scan")
        for fs_objs in cset.inode_map().values():
            fs_obj = fs_objs[0]
            ftype = file_typer(fs_obj.data)
            if regex_f(ftype):
                yield fs_objs, ftype
        engine.observer.debug("completed binarydebug scan")

    def threading_get_args(self, engine, cset):
        return (engine, cset)

    def _strip_setup(self, engine, cset):
        if "strip" in getattr(engine.new, "restrict", ()):
            engine.observer.info(f"stripping disabled for {engine.new}")
            return False
        self._initialize_paths(engine.new, ("strip",))
        self._modified = set()
        return True

    def _strip(self, iterable, observer, engine, cset):
        for fs_objs, ftype in iterable:
            # strip the first hardlink; then update the rest with the
            # new objects data.
            stripped = self._strip_fsobj(fs_objs[0], ftype, observer)
            self._modified.add(stripped)
            if len(fs_objs) > 1:
                self._modified.update(
                    stripped.change_attributes(location=fs_obj.location)
                    for fs_obj in fs_objs[1:]
                )

    def _strip_finish(self, engine, cset):
        if hasattr(self, "_modified"):
            cset.update(self._modified)
            del self._modified

    def _split_setup(self, engine, cset):
        skip = frozenset(["strip", "splitdebug"]).intersection(
            getattr(engine.new, "restrict", ())
        )
        skip = bool(skip)
        if not skip:
            for fs_obj in cset:
                if fs_obj.basename.endswith(".debug"):
                    skip = True
                    break
        if skip:
            engine.observer.info(
                f"splitdebug disabled for {engine.new}, skipping splitdebug"
            )
            return False

        self._initialize_paths(engine.new, ("strip", "objcopy"))
        self._modified = contents.contentsSet()
        return True

    def _split(self, iterable, observer, engine, cset):
        debug_store = pjoin(engine.offset, self._debug_storage.lstrip("/"))

        objcopy_args = [self.objcopy_binary, "--only-keep-debug"]
        if self._compress:
            objcopy_args.append("--compress-debug-sections")

        for fs_objs, ftype in iterable:
            if "ar archive" in ftype:
                continue
            if "relocatable" in ftype:
                if not any(x.basename.endswith(".ko") for x in fs_objs):
                    continue
            fs_obj = fs_objs[0]
            debug_loc = pjoin(debug_store, fs_obj.location.lstrip("/") + ".debug")
            if debug_loc in cset:
                continue
            fpath = fs_obj.data.path
            debug_ondisk = pjoin(
                os.path.dirname(fpath), os.path.basename(fpath) + ".debug"
            )

            # note that we tell the UI the final pathway- not the intermediate one.
            observer.info(f"splitdebug'ing {fs_obj.location} into {debug_loc}")

            ret = spawn.spawn(objcopy_args + [fpath, debug_ondisk])
            if ret != 0:
                observer.warn(
                    f"splitdebug'ing {fs_obj.location} failed w/ exitcode {ret}"
                )
                continue

            # note that the given pathway to the debug file /must/ be relative to ${D};
            # it must exist at the time of invocation.
            ret = spawn.spawn(
                [self.objcopy_binary, "--add-gnu-debuglink", debug_ondisk, fpath]
            )
            if ret != 0:
                observer.warn(
                    f"splitdebug created debug file {debug_ondisk!r}, but "
                    f"failed adding links to {fpath!r} ({ret!r})"
                )
                observer.debug(
                    "failed splitdebug command was %r",
                    (self.objcopy_binary, "--add-gnu-debuglink", debug_ondisk, fpath),
                )
                continue

            debug_obj = gen_obj(
                debug_loc,
                real_location=debug_ondisk,
                uid=os_data.root_uid,
                gid=os_data.root_gid,
            )

            stripped_fsobj = self._strip_fsobj(fs_obj, ftype, observer, quiet=True)

            self._modified.add(stripped_fsobj)
            self._modified.add(debug_obj)

            for fs_obj in fs_objs[1:]:
                debug_loc = pjoin(debug_store, fs_obj.location.lstrip("/") + ".debug")
                linked_debug_obj = debug_obj.change_attributes(location=debug_loc)
                observer.info(
                    f"splitdebug hardlinking {debug_obj.location} to {debug_loc}"
                )
                self._modified.add(linked_debug_obj)
                self._modified.add(
                    stripped_fsobj.change_attributes(location=fs_obj.location)
                )

    def _split_finish(self, engine, cset):
        if not hasattr(self, "_modified"):
            return
        self._modified.add_missing_directories(mode=0o775)
        # add the non directories first.
        cset.update(self._modified.iterdirs(invert=True))
        # punt any intersections, leaving just the new directories.
        self._modified.difference_update(cset)
        cset.update(self._modified)
        del self._modified


def default_plugins_triggers() -> tuple[type[base]]:
    triggers = (
        ldconfig,
        merge,
        unmerge,
        fix_uid_perms,
        fix_gid_perms,
        fix_set_bits,
        detect_world_writable,
        InfoRegen,
        CommonDirectoryModes,
        BaseSystemUnmergeProtection,
    )
    return tuple(sorted(triggers, reverse=True, key=lambda x: (x.priority, x.__name__)))
