import contextlib
import dataclasses
import operator
import os
import stat
import subprocess
import tempfile
import time
from abc import ABC
from contextlib import suppress
from typing import List, TYPE_CHECKING, Iterable, Dict, Optional, Tuple, Union, Iterator
from weakref import ref, ReferenceType

from debputy.intermediate_manifest import PathType
from debputy.plugin.api.spec import VirtualPath
from debputy.util import scratch_dir

if TYPE_CHECKING:
    # Circular dependency
    from debputy.highlevel_manifest import ManifestPathRule


BY_BASENAME = operator.attrgetter("name")


class DebputyFSError(RuntimeError):
    pass


class DebputyFSIsROError(DebputyFSError):
    pass


def _split_path(path: str) -> Tuple[bool, bool, List[str]]:
    must_be_dir = True if path.endswith("/") else False
    absolute = False
    if path.startswith("/"):
        absolute = True
        path = "." + path
    path_parts = path.rstrip("/").split("/")
    if must_be_dir:
        path_parts.append(".")
    return absolute, must_be_dir, path_parts


def _root(path: VirtualPath) -> VirtualPath:
    current = path
    while True:
        parent = current.parent_dir
        if parent is None:
            return current
        current = parent


def _check_fs_path_is_file(
    fs_path: str,
    unlink_on_error: Optional["FSPath"] = None,
) -> None:
    if not os.path.isfile(fs_path):
        if unlink_on_error:
            unlink_on_error.unlink()
        with suppress(FileNotFoundError):
            os.unlink(fs_path)
        raise TypeError(
            "The provided FS backing file was deleted or replaced with a non-file entry. The entry"
            " has been disconnected."
        )


class FSPath(VirtualPath, ABC):
    __slots__ = (
        "_basename",
        "_parent_dir",
        "_children",
        "_path_cache",
        "_parent_path_cache",
        "_last_known_parent_path",
        "__weakref__",
    )

    def __init__(
        self,
        basename: str,
        parent: Optional["FSPath"],
        children: Optional[Dict[str, "FSPath"]] = None,
    ):
        self._basename = basename
        self._path_cache: Optional[str] = None
        self._parent_path_cache: Optional[str] = None
        self._children = children
        self._last_known_parent_path: Optional[str] = None
        # The self._parent_dir = None is to create `_parent_dir` because the parent_dir setter calls
        # is_orphaned, which assumes self._parent_dir is an attribute.
        self._parent_dir: Optional[ReferenceType["FSPath"]] = None
        if parent is not None:
            self.parent_dir = parent

    def __repr__(self):
        return (
            f"{self.__class__.__name__}({self._orphan_safe_path()!r},"
            f" is_file={self.is_file},"
            f" is_dir={self.is_dir},"
            f" is_symlink={self.is_symlink},"
            f" has_fs_path={self.has_fs_path},"
            f" children_len={len(self._children) if self._children else 0})"
        )

    @property
    def name(self) -> str:
        return self._basename

    @name.setter
    def name(self, new_name: str) -> None:
        self._rw_check()
        if new_name == self._basename:
            return
        if self.is_detached:
            self._basename = new_name
            return
        parent = self.parent_dir
        parent._rw_check()
        # This little parent_dir dance ensures the parent dir detects the rename properly
        self.parent_dir = None
        self._basename = new_name
        self.parent_dir = parent

    @property
    def iterdir(self) -> Iterable["FSPath"]:
        if self._children is not None:
            yield from self._children.values()

    def lookup(self, path: str) -> Optional["FSPath"]:
        match, missing = self.attempt_lookup(path)
        if missing:
            return None
        return match

    def attempt_lookup(self, path: str) -> Tuple["FSPath", List[str]]:
        if self.is_detached:
            raise ValueError(
                f'Cannot perform lookup via "{self._orphan_safe_path()}": The path is detached'
            )
        absolute, must_be_dir, path_parts = _split_path(path)
        current = _root(self) if absolute else self
        for no, dir_part in enumerate(path_parts):
            if dir_part == ".":
                continue
            if dir_part == "..":
                current = current.parent_dir
                if current is None:
                    raise ValueError(f'The path "{path}" escapes the root dir')
                continue
            try:
                current = current[dir_part]
            except KeyError:
                missing = path_parts[no:-1] if must_be_dir else path_parts[no:]
                return current, missing
        return current, []

    def all_paths(self) -> Iterable["FSPath"]:
        yield self
        if not self.is_dir:
            return
        by_basename = BY_BASENAME
        stack = sorted(self.iterdir, key=by_basename, reverse=True)
        while stack:
            current = stack.pop()
            yield current
            if current.is_dir and not current.is_detached:
                stack.extend(sorted(current.iterdir, key=by_basename, reverse=True))

    def walk(self) -> Iterable[Tuple["FSPath", List["FSPath"]]]:
        # FIXME: can this be more "os.walk"-like without making it harder to implement?
        if not self.is_dir:
            yield self, []
            return
        by_basename = BY_BASENAME
        stack = [self]
        while stack:
            current = stack.pop()
            children = sorted(current.iterdir, key=by_basename)
            assert not children or current.is_dir
            yield current, children
            # Removing the directory counts as discarding the children.
            if not current.is_detached:
                stack.extend(reversed(children))

    def _orphan_safe_path(self) -> str:
        if not self.is_detached or self._last_known_parent_path is not None:
            return self.path
        return f"<orphaned>/{self.name}"

    @property
    def is_detached(self) -> bool:
        return self._parent_dir is None or self._parent_dir() is None

    # The __getitem__ behaves like __getitem__ from Dict but __iter__ would ideally work like a Sequence.
    # However, that does not feel compatible, so lets force people to use .children instead for the Sequence
    # behaviour to avoid surprises for now.
    # (Maybe it is a non-issue, but it is easier to add the API later than to remove it once we have committed
    # to using it)
    __iter__ = None

    def __getitem__(self, key) -> "FSPath":
        if self._children is None:
            raise KeyError(
                f"{key} (note: {self._orphan_safe_path()!r} has no children)"
            )
        if isinstance(key, FSPath):
            key = key.name
        return self._children[key]

    def __delitem__(self, key) -> None:
        self._rw_check()
        del self._children[key]

    def get(self, key: str) -> "Optional[FSPath]":
        try:
            return self[key]
        except KeyError:
            return None

    def __contains__(self, item: object) -> bool:
        if isinstance(item, VirtualPath):
            return item.parent_dir is self
        if not isinstance(item, str):
            return False
        m = self.get(item)
        return m is not None

    def _add_child(self, child: "FSPath") -> None:
        self._rw_check()
        if not self.is_dir:
            raise TypeError(f"{self._orphan_safe_path()!r} is not a directory")
        if self._children is None:
            self._children = {}

        conflict_child = self.get(child.name)
        if conflict_child is not None:
            conflict_child.unlink(recursive=True)
        self._children[child.name] = child

    @property
    def tar_path(self) -> str:
        path = self.path
        if self.is_dir:
            return path + "/"
        return path

    @property
    def path(self) -> str:
        parent_path = self.parent_dir_path
        if (
            self._parent_path_cache is not None
            and self._parent_path_cache == parent_path
        ):
            return self._path_cache
        self._parent_path_cache = parent_path
        self._path_cache = os.path.join(parent_path, self.name)
        return self._path_cache

    @property
    def parent_dir(self) -> Optional["FSPath"]:
        p = self._parent_dir()
        if p is None:
            raise ReferenceError(
                f"The path {self.name} is detached! {self.__class__.__name__}"
            )
        return p

    @parent_dir.setter
    def parent_dir(self, new_parent: Optional["FSPath"]) -> None:
        self._rw_check()
        if new_parent is not None:
            if not new_parent.is_dir:
                raise ValueError(
                    f"The parent {new_parent._orphan_safe_path()} must be a directory"
                )
            new_parent._rw_check()
        old_parent = None
        self._last_known_parent_path = None
        if not self.is_detached:
            old_parent = self.parent_dir
            del old_parent._children[self.name]
        if new_parent is not None:
            self._parent_dir = ref(new_parent)
            new_parent._add_child(self)
        else:
            if old_parent is not None and not old_parent.is_detached:
                self._last_known_parent_path = old_parent.path
            self._parent_dir = None
        self._parent_path_cache = None

    @property
    def parent_dir_path(self) -> Optional[str]:
        if self.is_detached:
            return self._last_known_parent_path
        return self.parent_dir.path

    @contextlib.contextmanager
    def replace_fs_path_content(
        self,
        *,
        use_fs_path_mode: bool = False,
    ) -> Iterator[str]:
        """Replace the contents of this file via inline manipulation

        Used as a context manager to provide the fs path for manipulation.

        Example:
            #>> with path.replace_fs_path_content() as fs_path:
            #>>    subprocess.check_call(['strip', fs_path])

        The provided file system path should be manipulated inline. The debputy framework may
        copy it first as necessary and therefore the provided fs_path may be different from
        `path.fs_path`.

        This method *may* cause the path to become detached immediately when called (because
        of implementation details).  If you need the path after the call, you must fetch it
        via an ancestor a la:

            >>> name = "..."
            >>> path = fs_root.lookup(name)                       # doctest: +SKIP
            >>> assert fs_path.has_fs_path                        # doctest: +SKIP
            >>> with path.replace_fs_path_contents() as fs_path:  # doctest: +SKIP
            ...    # Re-fetch the path
            ...    path = fs_root.lookup(name)                    # doctest: +SKIP
            ...    ...   # Do something here with fs_path/path    # doctest: +SKIP

        :param use_fs_path_mode: If True, any changes to the mode on the physical FS path will be
          recorded as the desired mode of the file when the contextmanager ends.  The provided FS path
          with start with the current mode when `use_fs_path_mode` is True. Otherwise, `debputy` will
          ignore the mode of the file system entry and re-use its own current mode
          definition.

        """
        if not self.is_file:
            raise TypeError(
                f'Cannot replace contents of "{self._orphan_safe_path()}" as it is not a file'
            )
        self._rw_check()
        if self._can_replace_inline:
            fs_path = self.fs_path
            unlink_on_error = self
        else:
            fs_path = self.fs_path
            directory = os.path.join(scratch_dir(), "generated-fs-content")
            os.makedirs(directory, exist_ok=True)
            with tempfile.NamedTemporaryFile(
                dir=directory, suffix=self.name, delete=False
            ) as new_path:
                new_path.close()
                subprocess.check_call(["cp", "-a", fs_path, new_path.name])
                parent_dir = self.parent_dir
                self.parent_dir = None
                child = FSBackedVirtualPath(
                    self.name,
                    parent_dir,
                    new_path.name,
                    replaceable_inline=True,
                )
                fs_path = child.fs_path
                unlink_on_error = child

        current_mode = self.mode()
        yield fs_path
        _check_fs_path_is_file(fs_path, unlink_on_error=unlink_on_error)
        if not use_fs_path_mode:
            os.chmod(fs_path, current_mode)

    @property
    def _can_replace_inline(self) -> bool:
        return False

    @contextlib.contextmanager
    def add_file(
        self,
        name: str,
        *,
        exist_ok: bool = True,
        use_fs_path_mode: bool = False,
        mode: int = 0o0644,
        fs_basename_matters: bool = False,
        subdir_key: Optional[str] = None,
    ) -> Iterator["FSPath"]:
        if "/" in name or name in {".", ".."}:
            raise ValueError(f'Invalid file name: "{name}"')
        if not self.is_dir:
            raise TypeError(
                f"Cannot create {self._orphan_safe_path()}/{name}:"
                f" {self._orphan_safe_path()} is not a directory"
            )
        self._rw_check()
        if name in self and not exist_ok:
            raise ValueError(
                f'The path "{self._orphan_safe_path()}" already contains a file called "{name}"'
                f" and exist_ok was False"
            )
        directory = os.path.join(scratch_dir(), "generated-fs-content")
        os.makedirs(directory, exist_ok=True)
        if fs_basename_matters and subdir_key is None:
            directory = tempfile.mkdtemp(dir=directory)
        elif fs_basename_matters:
            # FIXME: We need a stable directory for this process that is "never" reused.  The pid
            #  suffices for now, but we really should find a better solution like a cached lazily
            #  created mkdtemp.
            directory = os.path.join(directory, f"_pb-{os.getpid()}", subdir_key)
            os.makedirs(directory, exist_ok=True)

        if fs_basename_matters:
            fs_path = os.path.join(directory, name)
            child = FSBackedVirtualPath(name, self, fs_path, replaceable_inline=True)
            yield child
        else:
            with tempfile.NamedTemporaryFile(
                dir=directory, suffix=name, delete=False
            ) as fd:
                fs_path = fd.name
                child = FSBackedVirtualPath(
                    name, self, fs_path, replaceable_inline=True
                )
                fd.close()
                yield child

        if use_fs_path_mode and os.path.isfile(fs_path):
            # Ensure the caller can see the current mode
            os.chmod(fs_path, mode)
        _check_fs_path_is_file(fs_path, unlink_on_error=child)
        if not use_fs_path_mode:
            os.chmod(fs_path, mode)

    def insert_file_from_fs_path(
        self,
        name: str,
        fs_path: str,
        *,
        exist_ok: bool = True,
        use_fs_path_mode: bool = False,
        mode: int = 0o0644,
        require_copy: bool = True,
        follow_symlinks: bool = True,
    ) -> "FSPath":
        if "/" in name or name in {".", ".."}:
            raise ValueError(f'Invalid file name: "{name}"')
        if not self.is_dir:
            raise TypeError(
                f"Cannot create {self._orphan_safe_path()}/{name}:"
                f" {self._orphan_safe_path()} is not a directory"
            )
        self._rw_check()
        if name in self and not exist_ok:
            raise ValueError(
                f'The path "{self._orphan_safe_path()}" already contains a file called "{name}"'
                f" and exist_ok was False"
            )
        new_fs_path = fs_path
        if follow_symlinks:
            new_fs_path = os.path.realpath(new_fs_path, strict=True)

        st = os.lstat(new_fs_path)
        if stat.S_ISDIR(st.st_mode):
            raise ValueError(
                f'The provided path "{fs_path}" is a directory. However, this'
                " method does not support directories"
            )

        # We need a copy if the mode is wrong as we currently have a 1:1 between fs_mode mode
        # and virtual fs_mode for this kind of file.
        if not require_copy and stat.S_ISREG(st.st_mode):
            require_copy = not use_fs_path_mode and mode != stat.S_IMODE(st.st_mode)

        if require_copy:
            directory = os.path.join(scratch_dir(), "generated-fs-content")
            os.makedirs(directory, exist_ok=True)
            with tempfile.NamedTemporaryFile(
                dir=directory, suffix=name, delete=False
            ) as fd:
                fd.close()
                subprocess.check_call(["cp", "-a", fs_path, fd.name])
                new_fs_path = fd.name
                if not use_fs_path_mode:
                    os.chmod(new_fs_path, mode)

        if stat.S_ISREG(st.st_mode):
            return FSBackedVirtualPath(name, self, new_fs_path)
        return PlainFSPathEntry(name, self, new_fs_path, stat_cache=st)

    def add_symlink(self, link_name: str, link_target: str) -> "FSPath":
        if "/" in link_name or link_name in {".", ".."}:
            raise ValueError(
                f'Invalid file name: "{link_name}" (it must be a valid basename)'
            )
        if not self.is_dir:
            raise TypeError(
                f"Cannot create {self._orphan_safe_path()}/{link_name}:"
                f" {self._orphan_safe_path()} is not a directory"
            )
        self._rw_check()

        existing = self.get(link_name)
        if existing:
            # Emulate ln -sf with attempts a non-recursive unlink first.
            existing.unlink()

        return SymlinkVirtualPath(
            link_name,
            self,
            link_target,
        )

    def mkdir(self, name: str, *, fs_path: Optional[str] = None) -> "FSPath":
        if "/" in name or name in {".", ".."}:
            raise ValueError(
                f'Invalid file name: "{name}" (it must be a valid basename)'
            )
        if not self.is_dir:
            raise TypeError(
                f"Cannot create {self._orphan_safe_path()}/{name}:"
                f" {self._orphan_safe_path()} is not a directory"
            )
        self._rw_check()

        existing = self.get(name)
        if existing:
            raise ValueError(f"Path {existing.path} already exist")
        if fs_path is None:
            return VirtualDirectoryFSPath(name, self)
        st = os.lstat(fs_path)
        if not stat.S_ISDIR(st.st_mode):
            raise ValueError(
                f'The provided fs_path "{fs_path}" exist but it is not a directory!'
            )
        return PlainFSPathEntry(name, self, fs_path, stat_cache=st)

    def mkdirs(self, path: str) -> "FSPath":
        current, missing_parts = self.attempt_lookup(
            f"{path}/" if not path.endswith("/") else path
        )
        if not current.is_dir:
            raise ValueError(
                f'mkdirs of "{path}" failed: This would require {current.path} to not exist OR be'
                " a directory. However, that path exist AND is a not directory."
            )
        for missing_part in missing_parts:
            assert missing_part not in (".", "..")
            current = current.mkdir(missing_part)
        return current

    def _rw_check(self) -> None:
        if not self.is_read_write:
            raise DebputyFSIsROError(
                f'Attempt to write to "{self._orphan_safe_path()}" failed:'
                " Debputy Virtual File system is R/O."
            )

    @property
    def is_read_write(self) -> bool:
        """When true, the file system entry may be mutated

        :return: Whether file system mutations are permitted.
        """
        if self.is_detached:
            return True
        return self.parent_dir.is_read_write

    def prune_if_empty_dir(self) -> None:
        """Remove this and all (now) empty parent directories

        Same as: `rmdir --ignore-fail-on-non-empty --parents`

        This operation may cause the path (and any of its parent directories) to become "detached"
        and therefore unsafe to use in further operations.
        """
        self._rw_check()

        if not self.is_dir:
            raise TypeError(f"{self._orphan_safe_path()} is not a directory")
        if any(self.iterdir):
            return
        parent_dir = self.parent_dir

        # Recursive does not matter; we already know the directory is empty.
        self.unlink()

        # Note: The root dir must never be deleted. This works because when delegating it to the root
        # directory, its implementation of this method is a no-op. If this is later rewritten to an
        # inline loop (rather than recursion), be sure to preserve this feature.
        parent_dir.prune_if_empty_dir()

    def unlink(self, *, recursive=False) -> None:
        """Unlink a file or a directory

        This operation will detach the path from the file system (causing "is_detached" to return True).

        Note that the root directory cannot be deleted.

        :param recursive: If True, then non-empty directories will be unlinked as well removing everything inside them
          as well.  When False, an error is raised if the path is a non-empty directory
        """
        if self.is_detached:
            return
        if not recursive and any(self.iterdir):
            raise ValueError(
                f'Refusing to unlink "{self.path}": The directory was not empty and recursive was False'
            )
        # The .parent_dir setter does a _rw_check() for us.
        self.parent_dir = None


class PlainFSPathEntry(FSPath):
    __slots__ = ("_fs_path", "_stat_cache")

    def __init__(
        self,
        basename: str,
        parent: Optional["FSPath"],
        fs_path: str,
        *,
        stat_cache: Optional[os.stat_result] = None,
    ):
        super().__init__(basename, parent, None)
        self._fs_path = fs_path
        self._stat_cache = stat_cache

    @property
    def _st_mode(self) -> int:
        return self.stat().st_mode

    @property
    def is_dir(self) -> bool:
        return stat.S_ISDIR(self._st_mode)

    @property
    def is_file(self) -> bool:
        return stat.S_ISREG(self._st_mode)

    @property
    def is_symlink(self) -> bool:
        return stat.S_ISLNK(self._st_mode)

    def stat(self) -> os.stat_result:
        if self._stat_cache is None:
            self._stat_cache = os.lstat(self.fs_path)
        return self._stat_cache

    def readlink(self) -> str:
        if not self.is_symlink:
            raise TypeError(f'"{self._orphan_safe_path()!r}" is not a symlink')
        return os.readlink(self.fs_path)

    @property
    def fs_path(self) -> str:
        return self._fs_path

    @property
    def has_fs_path(self) -> bool:
        return True


class FSPathDirEntry(FSPath):
    __slots__ = ("_dir_entry",)

    def __init__(
        self,
        dir_entry: os.DirEntry,
        parent: "FSPath",
        children: Optional[Dict[str, "FSPath"]] = None,
    ):
        self._dir_entry = dir_entry
        super().__init__(
            dir_entry.name,
            parent=parent,
            children=children,
        )

    @property
    def is_dir(self) -> bool:
        return self._dir_entry.is_dir(follow_symlinks=False)

    @property
    def is_file(self) -> bool:
        return self._dir_entry.is_file(follow_symlinks=False)

    @property
    def is_symlink(self) -> bool:
        return self._dir_entry.is_symlink()

    def stat(self) -> os.stat_result:
        return self._dir_entry.stat(follow_symlinks=False)

    def readlink(self) -> str:
        if not self.is_symlink:
            raise TypeError(f'"{self._orphan_safe_path()!r}" is not a symlink')
        return os.readlink(self.fs_path)

    @property
    def fs_path(self) -> str:
        return self._dir_entry.path

    @property
    def has_fs_path(self) -> bool:
        return True


class VirtualFSPathBase(FSPath, ABC):
    __slots__ = ("_mtime",)

    def __init__(
        self,
        basename: str,
        parent: Optional["FSPath"],
        children: Optional[Dict[str, "FSPath"]] = None,
    ):
        super().__init__(basename, parent, children)
        self._mtime = None

    def mtime(self) -> float:
        if self._mtime is None:
            self._mtime = time.time()
        return self._mtime

    @property
    def has_fs_path(self) -> bool:
        return False

    def stat(self) -> os.stat_result:
        raise TypeError(
            "stat() is only applicable to paths backed by the file system. The path"
            f" {self._orphan_safe_path()!r} is purely virtual"
        )

    @property
    def fs_path(self) -> str:
        raise TypeError(
            "fs_path is only applicable to paths backed by the file system. The path"
            f" {self._orphan_safe_path()!r} is purely virtual"
        )


class FSRootDir(FSPath):
    __slots__ = ("_fs_path", "_fs_read_write")

    def __init__(self, fs_path: Optional[str] = None) -> None:
        self._fs_path = fs_path
        self._fs_read_write = True
        super().__init__(".", None, children={})

    @property
    def is_detached(self) -> bool:
        return False

    def _orphan_safe_path(self) -> str:
        return self.name

    @property
    def path(self) -> str:
        return self.name

    @property
    def parent_dir(self) -> Optional["FSPath"]:
        return None

    @parent_dir.setter
    def parent_dir(self, new_parent) -> None:
        if new_parent is not None:
            raise ValueError("The root directory cannot become a non-root directory")

    @property
    def parent_dir_path(self) -> Optional[str]:
        return None

    @property
    def is_dir(self) -> bool:
        return True

    @property
    def is_file(self) -> bool:
        return False

    @property
    def is_symlink(self) -> bool:
        return False

    def readlink(self) -> str:
        raise TypeError(f'"{self._orphan_safe_path()!r}" is a directory; not a symlink')

    @property
    def has_fs_path(self) -> bool:
        return self._fs_path is not None

    def stat(self) -> os.stat_result:
        if not self.has_fs_path:
            raise TypeError(
                "stat() is only applicable to paths backed by the file system. The path"
                f" {self._orphan_safe_path()!r} is purely virtual"
            )
        return os.stat(self.fs_path)

    @property
    def fs_path(self) -> str:
        if not self.has_fs_path:
            raise TypeError(
                "fs_path is only applicable to paths backed by the file system. The path"
                f" {self._orphan_safe_path()!r} is purely virtual"
            )
        return self._fs_path

    @property
    def is_read_write(self) -> bool:
        return self._fs_read_write

    @is_read_write.setter
    def is_read_write(self, new_value: bool) -> None:
        self._fs_read_write = new_value

    def prune_if_empty_dir(self) -> None:
        # No-op for the root directory. There is never a case where you want to delete this directory
        # (and even if you could, debputy will need it for technical reasons, so the root dir stays)
        return

    def unlink(self, *, recursive=False) -> None:
        # No-op for the root directory. There is never a case where you want to delete this directory
        # (and even if you could, debputy will need it for technical reasons, so the root dir stays)
        return


class VirtualDirectoryFSPath(VirtualFSPathBase):
    __slots__ = ()

    def __init__(self, basename: str, parent_dir: FSPath) -> None:
        super().__init__(
            basename,
            parent_dir,
        )

    @property
    def is_dir(self) -> bool:
        return True

    @property
    def is_file(self) -> bool:
        return False

    @property
    def is_symlink(self) -> bool:
        return False

    def readlink(self) -> str:
        raise TypeError(f'"{self._orphan_safe_path()!r}" is a directory; not a symlink')


class SymlinkVirtualPath(VirtualFSPathBase):
    __slots__ = ("_link_target",)

    def __init__(self, basename: str, parent_dir: FSPath, link_target: str):
        super().__init__(
            basename,
            parent_dir,
        )
        self._link_target = link_target

    @property
    def is_dir(self) -> bool:
        return False

    @property
    def is_file(self) -> bool:
        return False

    @property
    def is_symlink(self) -> bool:
        return True

    def readlink(self) -> str:
        return self._link_target


class FSBackedVirtualPath(VirtualFSPathBase):
    __slots__ = ("_fs_path", "_stat_cache", "_replaceable_inline")

    def __init__(
        self,
        basename: str,
        parent_dir: FSPath,
        fs_path: str,
        *,
        replaceable_inline: bool = False,
    ):
        super().__init__(
            basename,
            parent_dir,
        )
        self._fs_path = fs_path
        self._stat_cache = None
        self._replaceable_inline = replaceable_inline

    @property
    def is_dir(self) -> bool:
        return False

    @property
    def is_file(self) -> bool:
        return True

    @property
    def is_symlink(self) -> bool:
        return False

    def readlink(self) -> str:
        raise TypeError(f'"{self._orphan_safe_path()!r}" is a file; not a symlink')

    @property
    def has_fs_path(self) -> bool:
        return True

    def stat(self) -> os.stat_result:
        if self._stat_cache is None:
            self._stat_cache = os.lstat(self.fs_path)
        return self._stat_cache

    @property
    def fs_path(self) -> str:
        return self._fs_path

    @property
    def _can_replace_inline(self) -> bool:
        return self._replaceable_inline


class FSPathVirtualPath(VirtualFSPathBase):
    __slots__ = ("_path_info", "_stat_cache")

    def __init__(self, path_info: "ManifestPathRule", parent_dir: FSPath):
        spec_path = path_info.member_path
        assert spec_path is not None
        if path_info.ensure_path_type is None:
            raise ValueError("path_info.ensure_path_type must be not None")
        if (not path_info.ensure_path_type.can_be_virtual) ^ (
            path_info.source_path is not None
        ):
            raise ValueError(
                "Virtual paths must either be a path that that are purely virtual or"
                f" have a backing source file.  ({path_info.ensure_path_type.can_be_virtual=}"
                f" vs. {path_info.source_path})"
            )
        super().__init__(
            os.path.basename(spec_path),
            parent_dir,
        )
        self._path_info = path_info
        self._stat_cache = None

    @property
    def is_dir(self) -> bool:
        return self._path_info.ensure_path_type == PathType.DIRECTORY

    @property
    def is_file(self) -> bool:
        return self._path_info.ensure_path_type == PathType.FILE

    @property
    def is_symlink(self) -> bool:
        return self._path_info.ensure_path_type == PathType.SYMLINK

    def readlink(self) -> str:
        if not self.is_symlink:
            raise TypeError(
                f'"{self._orphan_safe_path()!r}" is a directory; not a symlink'
            )
        return self._path_info.link_target

    @property
    def has_fs_path(self) -> bool:
        return self._path_info.source_path is not None

    def stat(self) -> os.stat_result:
        if not self.has_fs_path:
            return super().stat()
        if self._stat_cache is None:
            self._stat_cache = os.lstat(self.fs_path)
        return self._stat_cache

    @property
    def fs_path(self) -> str:
        if not self.has_fs_path:
            return super().fs_path
        return self._path_info.source_path


_SYMLINK_MODE = 0o777


class VirtualTestPath(FSPath):
    __slots__ = (
        "_path_type",
        "_mode",
        "_mtime",
        "_has_fs_path",
        "_fs_path",
        "_link_target",
    )

    def __init__(
        self,
        basename,
        parent_dir: Optional[FSPath],
        mode: Optional[int] = None,
        mtime: Optional[float] = None,
        is_dir=False,
        has_fs_path: Optional[bool] = False,
        fs_path: Optional[str] = None,
        link_target: Optional[str] = None,
    ) -> None:
        if is_dir:
            self._path_type = PathType.DIRECTORY
        elif link_target is not None:
            self._path_type = PathType.SYMLINK
            if mode is not None and mode != _SYMLINK_MODE:
                raise ValueError(
                    f'Please do not assign a mode to symlinks. Triggered for "{basename}".'
                )
            assert mode is None or mode == _SYMLINK_MODE
        else:
            self._path_type = PathType.FILE
        if mode is not None:
            self._mode = mode
        else:
            self._mode = 0o755 if is_dir else 0o644

        self._mtime = mtime
        self._link_target = link_target
        if has_fs_path is None:
            has_fs_path = bool(fs_path)
        self._has_fs_path = has_fs_path
        self._fs_path = fs_path
        super().__init__(
            basename,
            parent=parent_dir,
        )

    @property
    def is_dir(self) -> bool:
        return self._path_type == PathType.DIRECTORY

    @property
    def is_file(self) -> bool:
        return self._path_type == PathType.FILE

    @property
    def is_symlink(self) -> bool:
        return self._path_type == PathType.SYMLINK

    def readlink(self) -> str:
        if not self.is_symlink:
            raise TypeError(f"readlink is only valid for symlinks ({self.path!r})")
        link_target = self._link_target
        assert link_target is not None
        return link_target

    def mode(self) -> int:
        return self._mode

    def mtime(self) -> float:
        if self._mtime is None:
            self._mtime = time.time()
        return self._mtime

    @property
    def has_fs_path(self) -> bool:
        return self._has_fs_path

    def stat(self) -> os.stat_result:
        if self.has_fs_path:
            path = self._fs_path
            if path is None:
                raise TypeError(
                    f"The test wants a real stat of {self._orphan_safe_path()!r}, which this mock path"
                    " cannot provide!"
                )
            return os.stat(path)

        raise TypeError(
            "stat() is only applicable to paths backed by the file system. The path"
            f" {self._orphan_safe_path()!r} is purely virtual"
        )

    @property
    def fs_path(self) -> str:
        if self.has_fs_path:
            path = self._fs_path
            if path is None:
                raise TypeError(
                    f"The test wants a real file system entry of {self._orphan_safe_path()!r}, which this "
                    " mock path cannot provide!"
                )
            return path
        raise TypeError(
            "fs_path is only applicable to paths backed by the file system. The path"
            f" {self._orphan_safe_path()!r} is purely virtual"
        )


def fs_scan_dir(parent_path: str) -> Iterable[os.DirEntry[str]]:
    children = (
        dir_entry
        for dir_entry in os.scandir(parent_path)
        if dir_entry.name not in (".", "..")
    )
    return sorted(children, key=BY_BASENAME, reverse=True)


def build_fs_from_root_dir(root_dir) -> FSPath:
    # We cannot use os.walk as it does not produce the right order. We wanted "sorted DFS (dir/files interleaved,
    # but symlinks last)" and os.walk can give use "unsorted DFS (child first)" or "sorted BFS (files first,
    # then dirs)" - neither of which is what we need.
    #
    # Test with:
    #
    #  etc/
    #  etc/apache2/
    #  etc/apache2/...
    #  etc/cgitrc  (<-- non-directory)
    #  etc/... (<-- any non-symlink file type here as long as it sort after cgitrc)
    #  ...
    #  <... followed by all symlinks ordered as above ...>
    #
    # The listed order is the correct order for emulating dpkg-deb but os.walk does not enable us to generate
    # that order.
    #
    fs = FSRootDir(fs_path=root_dir)
    stack: List[FSPath] = [fs]
    while stack:
        current = stack.pop()
        if not current.is_dir:
            continue
        children = fs_scan_dir(current.fs_path)
        if current.path == ".":
            children = (t for t in children if t.name != "DEBIAN")
            if "DEBIAN" in current:
                del current["DEBIAN"]

        stack.extend(FSPathDirEntry(child, current) for child in children)
    return fs


@dataclasses.dataclass(slots=True)
class PathDef:
    path_name: str
    mode: Optional[int] = None
    mtime: Optional[int] = None
    has_fs_path: Optional[bool] = None
    fs_path: Optional[str] = None
    link_target: Optional[str] = None


def _as_path_defs(paths: Iterable[Union[str, PathDef]]) -> Iterable[PathDef]:
    for p in paths:
        yield PathDef(p) if isinstance(p, str) else p


def build_virtual_fs(
    paths: Iterable[Union[str, PathDef]], read_write_fs: bool = False
) -> "FSPath":
    root_dir = FSRootDir()
    directories = {".": root_dir}
    non_directories = set()

    def _ensure_parent_dirs(p: str):
        current = p.rstrip("/")
        missing_dirs = []
        while True:
            current = os.path.dirname(current)
            if current in directories:
                break
            if current in non_directories:
                raise ValueError(
                    f'Conflicting definition for "{current}".  The path "{p}" wants it as a directory,'
                    ' but it is defined as a non-directory.  (Ensure dirs end with "/")'
                )
            missing_dirs.append(current)
        for dir_path in reversed(missing_dirs):
            parent_dir = directories[os.path.dirname(dir_path)]
            d = VirtualTestPath(os.path.basename(dir_path), parent_dir, is_dir=True)
            directories[dir_path] = d

    for path_def in _as_path_defs(paths):
        path = path_def.path_name
        if path not in (".", "./", "/") and not path.startswith("./"):
            path = "./" + path
        if path not in (".", "./", "/"):
            _ensure_parent_dirs(path)
        if path in directories or path in non_directories:
            raise ValueError(
                f'Duplicate definition of "{path}".  Can be false positive if input is not in'
                ' "correct order" (ensure directories occur before their children)'
            )
        if path in (".", "./"):
            assert "." in directories
            continue
        is_dir = False
        if path.endswith("/"):
            path = path[:-1]
            is_dir = True
        directory = directories[os.path.dirname(path)]
        assert not is_dir or not bool(
            path_def.link_target
        ), f"is_dir={is_dir} vs. link_target={path_def.link_target}"
        fs_path = VirtualTestPath(
            os.path.basename(path),
            directory,
            is_dir=is_dir,
            mode=path_def.mode,
            mtime=path_def.mtime,
            has_fs_path=path_def.has_fs_path,
            fs_path=path_def.fs_path,
            link_target=path_def.link_target,
        )
        assert not fs_path.is_detached
        if fs_path.is_dir:
            directories[fs_path.path] = fs_path
        else:
            non_directories.add(fs_path.path)

    root_dir.is_read_write = read_write_fs
    return root_dir
