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

from debputy.intermediate_manifest import PathType
from debputy.manifest_parser.base_types import (
    ROOT_DEFINITION,
    FileSystemOwner,
    FileSystemGroup,
)
from debputy.plugin.api.spec import VirtualPath
from debputy.util import generated_content_dir, _error, escape_shell

BY_BASENAME = operator.attrgetter("name")


class DebputyFSError(RuntimeError):
    pass


class DebputyFSIsROError(DebputyFSError):
    pass


def _cp_a(source: str, dest: str) -> None:
    cmd = ["cp", "-a", source, dest]
    try:
        subprocess.check_call(cmd)
    except subprocess.CalledProcessError:
        full_command = escape_shell(*cmd)
        _error(
            f"The attempt to make an internal copy of {escape_shell(source)} failed. Please review the output of cp"
            f" above to understand what went wrong. The full command was: {full_command}"
        )


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",
        "_mode",
        "_owner",
        "_group",
        "_mtime",
        "_stat_cache",
        "__weakref__",
    )

    def __init__(
        self,
        basename: str,
        parent: Optional["FSPath"],
        children: Optional[Dict[str, "FSPath"]] = None,
        initial_mode: Optional[int] = None,
        mtime: Optional[float] = None,
        stat_cache: Optional[os.stat_result] = 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
        self._mode = initial_mode
        self._mtime = mtime
        self._stat_cache = stat_cache
        self._owner = ROOT_DEFINITION
        self._group = ROOT_DEFINITION

        # 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:  # doctest: +SKIP
            >>>    subprocess.check_call(['strip', fs_path])     # doctest: +SKIP

        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:
            new_path = self
            fs_path = self.fs_path
            unlink_on_error = self
        else:
            fs_path = self.fs_path
            directory = generated_content_dir()
            with tempfile.NamedTemporaryFile(
                dir=directory, suffix=f"__{self.name}", delete=False
            ) as new_path:
                new_path.close()
                _cp_a(fs_path, new_path.name)
                parent_dir = self.parent_dir
                self.parent_dir = None
                new_path = InlineReplaceableFileFSPath(
                    self.name,
                    parent_dir,
                    new_path.name,
                    replaceable_inline=True,
                )
                fs_path = new_path.fs_path
                unlink_on_error = new_path

        current_mtime = self._mtime
        if current_mtime is not None:
            os.utime(fs_path, (current_mtime, current_mtime))

        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)
        new_path._reset_caches()

    def chown(
        self,
        owner: Optional[FileSystemOwner],
        group: Optional[FileSystemGroup],
    ) -> None:
        """Change the owner/group of this path

        :param owner: The desired owner definition for this path. If None, then no change of owner is performed.
        :param group: The desired  group definition for this path. If None, then no change of group is performed.
        """
        self._rw_check()

        if owner is not None:
            self._owner = owner.ownership_definition
        if group is not None:
            self._group = group.ownership_definition

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

    def _uncached_stat(self) -> os.stat_result:
        return os.lstat(self.fs_path)

    @property
    def mode(self) -> int:
        current_mode = self._mode
        if current_mode is None:
            current_mode = stat.S_IMODE(self.stat().st_mode)
            self._mode = current_mode
        return current_mode

    @mode.setter
    def mode(self, new_mode: int) -> None:
        self._rw_check()
        min_bit = 0o500 if self.is_dir else 0o400
        if (new_mode & min_bit) != min_bit:
            omode = oct(new_mode)[2:]
            omin = oct(min_bit)[2:]
            raise ValueError(
                f'Attempt to set mode of path "{self._orphan_safe_path()}" to {omode} rejected;'
                f" Minimum requirements are {omin} (read-bit and, for dirs, exec bit for user)."
                " There are no paths that do not need these requirements met and they can cause"
                " problems during build or on the final system."
            )
        self._mode = new_mode

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

    @mtime.setter
    def mtime(self, new_mtime: float) -> None:
        self._rw_check()
        self._mtime = new_mtime

    @property
    def tar_owner_info(self) -> Tuple[str, int, str, int]:
        owner = self._owner
        group = self._group
        return (
            owner.entity_name,
            owner.entity_id,
            group.entity_name,
            group.entity_id,
        )

    @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,
        mtime: Optional[float] = 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"
            )

        if fs_basename_matters and subdir_key is None:
            raise ValueError(
                "When fs_basename_matters is True, a subdir_key must be provided"
            )

        directory = generated_content_dir(subdir_key=subdir_key)

        if fs_basename_matters:
            fs_path = os.path.join(directory, name)
            child = InlineReplaceableFileFSPath(
                name,
                self,
                fs_path,
                replaceable_inline=True,
                mtime=mtime,
            )
            yield child
        else:
            with tempfile.NamedTemporaryFile(
                dir=directory, suffix=f"__{name}", delete=False
            ) as fd:
                fs_path = fd.name
                child = InlineReplaceableFileFSPath(
                    name,
                    self,
                    fs_path,
                    replaceable_inline=True,
                    mtime=mtime,
                )
                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"
            )

        if require_copy:
            directory = generated_content_dir()
            with tempfile.NamedTemporaryFile(
                dir=directory, suffix=f"__{name}", delete=False
            ) as fd:
                fd.close()
                _cp_a(fs_path, fd.name)
                new_fs_path = fd.name

        if use_fs_path_mode:
            mode = None

        if stat.S_ISREG(st.st_mode):
            return InlineReplaceableFileFSPath(
                name,
                self,
                new_fs_path,
                initial_mode=mode,
                stat_cache=st,
                replaceable_inline=require_copy,
            )
        return PlainFSPathEntry(
            name,
            self,
            new_fs_path,
            stat_cache=st,
            initial_mode=mode,
        )

    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

    def _reset_caches(self) -> None:
        self._mtime = None
        self._stat_cache = None


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

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

    @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 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 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__ = ()

    def __init__(
        self,
        basename: str,
        parent: Optional["FSPath"],
        children: Optional[Dict[str, "FSPath"]] = None,
        initial_mode: Optional[int] = None,
        mtime: Optional[float] = None,
        stat_cache: Optional[os.stat_result] = None,
    ):
        super().__init__(
            basename,
            parent,
            children,
            initial_mode=initial_mode,
            mtime=mtime,
            stat_cache=stat_cache,
        )

    @property
    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={},
            initial_mode=0o755,
        )

    @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=parent_dir,
            initial_mode=0o755,
        )

    @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=parent_dir,
            initial_mode=_SYMLINK_MODE,
        )
        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 InlineReplaceableFileFSPath(FSPath):
    __slots__ = ("_fs_path", "_replaceable_inline")

    def __init__(
        self,
        basename: str,
        parent_dir: FSPath,
        fs_path: str,
        *,
        replaceable_inline: bool = False,
        initial_mode: Optional[int] = None,
        mtime: Optional[float] = None,
        stat_cache: Optional[os.stat_result] = None,
    ):
        super().__init__(
            basename,
            parent_dir,
            initial_mode=initial_mode,
            mtime=mtime,
            stat_cache=stat_cache,
        )
        self._fs_path = fs_path
        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

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

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


_SYMLINK_MODE = 0o777


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

    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,
        content: 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:
            initial_mode = mode
        else:
            initial_mode = 0o755 if is_dir else 0o644

        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,
            initial_mode=initial_mode,
            mtime=mtime,
        )
        self._content = content

    @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

    @property
    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 open(self, *, byte_io: bool = False) -> Union[typing.TextIO, typing.BinaryIO]:
        if self._content is None:
            return super().open(byte_io=byte_io)

        if byte_io:
            return io.BytesIO(self._content.encode("utf-8"))
        return io.StringIO(self._content)


class FSROOverlay(VirtualPath):
    __slots__ = (
        "_path",
        "_fs_path",
        "_parent",
        "_stat_cache",
        "_readlink_cache",
        "_children",
        "__weakref__",
    )

    def __init__(
        self,
        path: str,
        fs_path: str,
        parent: Optional["FSROOverlay"],
    ) -> None:
        self._path = path
        self._fs_path = fs_path
        self._parent = ref(parent) if parent is not None else None
        self._stat_cache = None
        self._readlink_cache = None
        self._children: Optional[Mapping[str, FSROOverlay]] = None

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

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

    def lookup(self, path: str) -> Optional["FSROOverlay"]:
        if not self.is_dir:
            return
        if self._children is None:
            self._ensure_children_are_resolved()

        absolute, _, path_parts = _split_path(path)
        current = cast("FSROOverlay", _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:
                return None
        return current

    def all_paths(self) -> Iterable["FSROOverlay"]:
        yield self
        if not self.is_dir:
            return
        stack = list(self.iterdir)
        stack.reverse()
        while stack:
            current = stack.pop()
            yield current
            if current.is_dir:
                if current._children is None:
                    current._ensure_children_are_resolved()
                stack.extend(reversed(current._children.values()))

    def _ensure_children_are_resolved(self) -> None:
        if not self.is_dir or self._children:
            return
        dir_path = self.path
        dir_fs_path = self.fs_path
        children = {}
        for name in sorted(os.listdir(dir_fs_path), key=os.path.basename):
            child_path = os.path.join(dir_path, name)
            child_fs_path = os.path.join(dir_fs_path, name)
            children[name] = FSROOverlay(
                child_path,
                child_fs_path,
                self,
            )
        self._children = children

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

    def __getitem__(self, key) -> "VirtualPath":
        if not self.is_dir:
            raise KeyError(key)
        if self._children is None:
            self._ensure_children_are_resolved()
        if isinstance(key, FSPath):
            key = key.name
        return self._children[key]

    def __delitem__(self, key) -> None:
        self._error_ro_fs()

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

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

    @property
    def parent_dir(self) -> Optional["FSROOverlay"]:
        parent = self._parent
        if parent is None:
            return None
        resolved = parent()
        if resolved is None:
            raise RuntimeError("Parent was garbage collected!")
        return resolved

    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 mode(self) -> int:
        return stat.S_IMODE(self.stat().st_mode)

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

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

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

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

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

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

    def open(self, *, byte_io: bool = False) -> Union[typing.TextIO, typing.BinaryIO]:
        # Allow symlinks for open here, because we can let the OS resolve the symlink reliably in this
        # case.
        if not self.is_file and not self.is_symlink:
            raise TypeError(
                f"Cannot open {self.path} for reading: It is not a file nor a symlink"
            )

        if byte_io:
            return open(self.fs_path, "rb")
        return open(self.fs_path, "rt", encoding="utf-8")


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
    content: 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: Optional[FSRootDir] = None
    directories = {}
    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 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 root_dir is None:
            root_fs_path = None
            if path in (".", "./", "/"):
                root_fs_path = path_def.fs_path
            root_dir = FSRootDir(fs_path=root_fs_path)
            directories["."] = root_dir

        if path not in (".", "./", "/") and not path.startswith("./"):
            path = "./" + path
        if path not in (".", "./", "/"):
            _ensure_parent_dirs(path)
        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,
            content=path_def.content,
        )
        assert not fs_path.is_detached
        if fs_path.is_dir:
            directories[fs_path.path] = fs_path
        else:
            non_directories.add(fs_path.path)

    if root_dir is None:
        root_dir = FSRootDir()

    root_dir.is_read_write = read_write_fs
    return root_dir
