import contextlib
import os
import stat
import tempfile
from typing import Iterable, Optional, Callable, Literal, Container, Union, Iterator

from debian.substvars import Substvars

from debputy import util
from debputy.packages import BinaryPackage

PluginInitializationEntryPoint = Callable[["DebputyPluginInitializer"], None]
MetadataAutoDetector = Callable[
    ["BinaryPackagePath", "BinaryCtrlAccessor", "MetadataDetectorPackageContext"], None
]
DpkgTriggerType = Literal[
    "activate",
    "activate-await",
    "activate-noawait",
    "interest",
    "interest-await",
    "interest-noawait",
]
Maintscript = Literal["postinst", "preinst", "prerm", "postrm"]
MetadataDetectorPackageType = Union[
    Literal["deb", "udeb"], Container[Literal["deb", "udeb"]]
]


class MetadataDetectorPackageContext:
    """Context for auto-detectors of metadata (no instantiation)

    This object holds some context related data for the metadata detector.  It may receive
    new attributes in the future.
    """

    @property
    def binary_package(self) -> BinaryPackage:
        """The binary package paragraph from `debian/control`"""
        raise NotImplementedError

    @property
    def related_udeb_package(self) -> Optional[BinaryPackage]:
        """An udeb related to this binary package (if any)"""
        raise NotImplementedError

    # """The source package paragraph from `debian/control`"""
    # source_package: SourcePackage


class DebputyPluginInitializer:
    __slots__ = ()

    def packager_provided_file(
        self,
        name_stem: str,
        install_into_directory: str,
        *,
        default_mode: int = 0o0644,
        default_priority: Optional[int] = None,
        allow_name_segment: bool = True,
        allow_architecture_segment: bool = False,
        filename_format: Optional[str] = None,
        post_formatting_rewrite: Optional[Callable[[str], str]] = None,
        packageless_is_fallback_for_all_packages: bool = False,
    ) -> None:
        """Register a packager provided file (debian/<pkg>.foo)

        Register a packager provided file that debputy should automatically detect and install for the
        packager (example `debian/foo.tmpfiles` -> `debian/foo/usr/lib/tmpfiles.d/foo.conf`).  A packager
        provided file typically identified by a package prefix and a "stem" and by convention placed
        in the `debian/` directory.

        Like debhelper, debputy also supports the `foo.bar.tmpfiles` variant where the file is to be
        installed into the `foo` package but be named after the `bar` segment rather than the package name.
        This feature can be controlled via the `allow_name_segment` parameter.

        By default, files when installed will be named after the `name` segment (defaulting to the package name if
        absent). If the file has a priority, then the priority is prefixed as two decimal digits (with leading 0 if
        necessary) and a dash is inserted between the priority and the name (example: "20-bar").

        :param name_stem: The "stem" name of the file. This would be the `tmpfiles` part of `debian/foo.tmpfiles`.
          Note that this value must be unique across all debputy plugins activated.
        :param install_into_directory: The directory that the file will be installed into. Would be
          `./usr/lib/tmpfiles.d` from the example above.
        :param default_mode: The mode the installed file should have by default. Common options are 0o0644 (the default)
          or 0o0755 (for files that must be executable).
        :param allow_architecture_segment: If True, the file may have an optional "architecture" segment at the end
           (`foo.tmpfiles.amd64`), which marks it architecture specific. When False, debputy will detect the
           "architecture" segment and report the use as an error.  Note the architecture segment is only allowed for
           arch:any packages. If a file targeting an arch:all package uses an architecture specific file it will
           always result in an error.
        :param allow_name_segment: If True, the file may have an optional "name" segment after the package name prefix.
           (`foo.<name-here>.tmpfiles`). When False, debputy will detect the "name" segment and report the use as an
           error.
        :param default_priority: Special-case option for packager files that are installed into directories that have
          "parse ordering" or "priority".  These files will generally be installed as something like `20-foo.conf`
          where the `20-` denotes their "priority".  If the plugin is registering such a file type, then it should
          provide a default priority.
        :param filename_format: Optionally a filename format if debputy's default naming is insufficient. The caller
          should provide a string with the `{name}` placeholder. The string can also include the `{priority}`
          placeholder if the `default_priority` is not None.  The format should only affect the basename.
          The following placeholders are supported:
            * `{name}` - The name in the name segment (defaulting the package name if no name segment is given)
            * `{priority}` - The priority of the file. Only provided priorities are used (that is, default_priority
               is not None)
            * `{owning_package}` - The name of the package.  Should only be used when `{name}` alone is insufficient.
              If you do not want the "name" segment in the first place, use `allow_name_segment=False` instead.
        :param post_formatting_rewrite: An optional "name correcting" callback. It receives the formatted name and can
          do any transformation required. The primary use-case for this is to replace "forbidden" characters. The most
          common case for debputy itself is to replace "." with "_" for tools that refuse to work with files containing
          "." (`lambda x: x.replace(".", "_")`).  The callback operates on the result of the `filename_format` (or
          debputy's default name if `filename_format` is omitted) and should return the basename.
        :param packageless_is_fallback_for_all_packages: If True, the packageless variant (such as, `debian/changelog`)
          is a fallback for every package.
        """
        raise NotImplementedError

    def metadata_or_maintscript_detector(
        self,
        auto_detector_id: str,
        auto_detector: MetadataAutoDetector,
        *,
        package_type: MetadataDetectorPackageType = "deb",
    ) -> None:
        """Provide a pre-assembly hook that can affect the metadata/maintscript of binary ("deb") packages

        The provided hook will be run once per binary package to be assembled, and it can see all the content
        ("data.tar") planned to be included in the deb. The hook may do any *read-only* analysis of this content
        and provide metadata, alter substvars or inject maintscript snippets.  However, the hook must *not*
        change the content ("data.tar") part of the deb.

        The hook will be run unconditionally for all binary packages built. When the hook does not apply to all
        packages, it must provide its own (internal) logic for detecting whether it is relevant and reduced itself
        to a no-op if it should not apply to the current package.

        Hooks are run in "some implementation defined order" and should not rely on being run before or after
        any other hook.

        The hooks are only applied to packages defined in `debian/control`. Notably, the metadata detector will
        not apply to auto-generated `-dbgsym` packages (as those are not listed explicitly in `debian/control`).

        :param auto_detector_id: A plugin-wide unique ID for this detector. Packagers may use this ID for disabling
          the detector and accordingly the ID is part of the plugin's API toward the packager.
        :param auto_detector: The code to be called that will be run at the metadata generation state (once for each
          binary package).
        :param package_type: Which kind of packages this metadata detector applies to.  The package type is generally
          defined by `Package-Type` field in the binary package. The default is to only run for regular `deb` packages
          and ignore `udeb` packages.
        """
        raise NotImplementedError


class MaintscriptAccessor:
    __slots__ = ()

    def on_configure(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
        skip_on_rollback: bool = False,
    ) -> None:
        """Provide a snippet to be run when the package is about to be "configured"

        This condition is the most common "post install" condition and covers the two
        common cases:
          * On initial install, OR
          * On upgrade

        In dpkg maintscript terms, this method roughly corresponds to postinst containing
             `if [ "$1" = configure ]; then <snippet>; fi`

        Additionally, the condition will by default also include rollback/abort scenarios such as "above-remove",
        which is normally what you want but most people forget about.

        :param run_snippet: The actual shell snippet to be run in the given condition.  The snippet must be idempotent.
          The snippet may contain newlines as necessary, which will make the result more readable.  Additionally, the
          snippet may contain '{{FOO}}' substitutions by default.
        :param skip_on_rollback: By default, this condition will also cover common rollback scenarios. This
          is normally what you want (or benign in most cases due to the idempotence requirement for maintscripts).
          However, you can disable the rollback cases, leaving only "On initial install OR On upgrade".
        :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy.
          In most cases, this is safe to do and provides more readable scripts. However, it may cause issues
          with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented.
          You are recommended to do 4 spaces of indentation when indent is False for readability.
        :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no
          substitution is provided.
        """
        raise NotImplementedError

    def on_initial_install(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        """Provide a snippet to be run when the package is about to be "configured" for the first time

        The snippet will only be run on the first time the package is installed (ever or since last purge).
        Note that "first" does not mean "exactly once" as dpkg does *not* provide such semantics. There are two
        common cases where this can snippet can be run multiple times for the same system (and why the snippet
        must still be idempotent):

          1) The package is installed (1), then purged and then installed again (2).  This can partly be mitigated
             by having an `on_purge` script to do clean up.

          2) As the package is installed, the `postinst` script terminates prematurely (Disk full, power loss, etc.).
             The user resolves the problem and runs `dpkg --configure <pkg>`, which in turn restarts the script
             from the beginning.  This is why scripts must be idempotent in general.

        In dpkg maintscript terms, this method roughly corresponds to postinst containing
             `if [ "$1" = configure ] && [ -z "$2" ]; then <snippet>; fi`

        :param run_snippet: The actual shell snippet to be run in the given condition.  The snippet must be idempotent.
          The snippet may contain newlines as necessary, which will make the result more readable.  Additionally, the
          snippet may contain '{{FOO}}' substitutions by default.
        :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy.
          In most cases, this is safe to do and provides more readable scripts. However, it may cause issues
          with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented.
          You are recommended to do 4 spaces of indentation when indent is False for readability.
        :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no
          substitution is provided.
        """
        raise NotImplementedError

    def on_upgrade(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        """Provide a snippet to be run when the package is about to be "configured" after an upgrade

        The snippet will only be run on any upgrade (that is, it will be skipped on the initial install).

        In dpkg maintscript terms, this method roughly corresponds to postinst containing
             `if [ "$1" = configure ] && [ -n "$2" ]; then <snippet>; fi`

        :param run_snippet: The actual shell snippet to be run in the given condition.  The snippet must be idempotent.
          The snippet may contain newlines as necessary, which will make the result more readable.  Additionally, the
          snippet may contain '{{FOO}}' substitutions by default.
        :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy.
          In most cases, this is safe to do and provides more readable scripts. However, it may cause issues
          with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented.
          You are recommended to do 4 spaces of indentation when indent is False for readability.
        :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no
          substitution is provided.
        """
        raise NotImplementedError

    def on_upgrade_from(
        self,
        version: str,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        """Provide a snippet to be run when the package is about to be "configured" after an upgrade from a given version

        The snippet will only be run on any upgrade (that is, it will be skipped on the initial install).

        In dpkg maintscript terms, this method roughly corresponds to postinst containing
             `if [ "$1" = configure ] && dpkg --compare-versions le-nl "$2" ; then <snippet>; fi`

        :param version: The version to upgrade from
        :param run_snippet: The actual shell snippet to be run in the given condition.  The snippet must be idempotent.
          The snippet may contain newlines as necessary, which will make the result more readable.  Additionally, the
          snippet may contain '{{FOO}}' substitutions by default.
        :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy.
          In most cases, this is safe to do and provides more readable scripts. However, it may cause issues
          with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented.
          You are recommended to do 4 spaces of indentation when indent is False for readability.
        :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no
          substitution is provided.
        """
        raise NotImplementedError

    def on_before_removal(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        """Provide a snippet to be run when the package is about to be removed

        The snippet will be run before dpkg removes any files.

        In dpkg maintscript terms, this method roughly corresponds to prerm containing
             `if [ "$1" = remove ] ; then <snippet>; fi`

        :param run_snippet: The actual shell snippet to be run in the given condition.  The snippet must be idempotent.
          The snippet may contain newlines as necessary, which will make the result more readable.  Additionally, the
          snippet may contain '{{FOO}}' substitutions by default.
        :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy.
          In most cases, this is safe to do and provides more readable scripts. However, it may cause issues
          with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented.
          You are recommended to do 4 spaces of indentation when indent is False for readability.
        :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no
          substitution is provided.
        """
        raise NotImplementedError

    def on_removed(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        """Provide a snippet to be run when the package has been removed

        The snippet will be run after dpkg removes the package content from the file system.

        **WARNING**: The snippet *cannot* rely on dependencies and must rely on `Essential: yes` packages.

        In dpkg maintscript terms, this method roughly corresponds to postrm containing
             `if [ "$1" = remove ] ; then <snippet>; fi`

        :param run_snippet: The actual shell snippet to be run in the given condition.  The snippet must be idempotent.
          The snippet may contain newlines as necessary, which will make the result more readable.  Additionally, the
          snippet may contain '{{FOO}}' substitutions by default.
        :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy.
          In most cases, this is safe to do and provides more readable scripts. However, it may cause issues
          with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented.
          You are recommended to do 4 spaces of indentation when indent is False for readability.
        :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no
          substitution is provided.
        """
        raise NotImplementedError

    def on_purge(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        """Provide a snippet to be run when the package is being purged.

        The snippet will when the package is purged from the system.

        **WARNING**: The snippet *cannot* rely on dependencies and must rely on `Essential: yes` packages.

        In dpkg maintscript terms, this method roughly corresponds to postrm containing
             `if [ "$1" = purge ] ; then <snippet>; fi`

        :param run_snippet: The actual shell snippet to be run in the given condition.  The snippet must be idempotent.
          The snippet may contain newlines as necessary, which will make the result more readable.  Additionally, the
          snippet may contain '{{FOO}}' substitutions by default.
        :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy.
          In most cases, this is safe to do and provides more readable scripts. However, it may cause issues
          with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented.
          You are recommended to do 4 spaces of indentation when indent is False for readability.
        :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no
          substitution is provided.
        """
        raise NotImplementedError

    def unconditionally_in_script(
        self,
        maintscript: Maintscript,
        run_snippet: str,
        /,
        perform_substitution: bool = True,
    ) -> None:
        """Provide a snippet to be run in a given script

        Run a given snippet unconditionally from a given script.  The snippet must contain its own conditional
        for when it should be run.

        :param maintscript: The maintscript to insert the snippet into.
        :param run_snippet: The actual shell snippet to be run.  The snippet will be run unconditionally and should
          contain its own conditions as necessary. The snippet must be idempotent. The snippet may contain newlines
          as necessary, which will make the result more readable.  Additionally, the snippet may contain '{{FOO}}'
          substitutions by default.
        :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no
          substitution is provided.
        """
        raise NotImplementedError

    def escape_shell_words(self, *args: str) -> str:
        """Provide sh-shell escape of strings

          `assert escape_shell("foo", "fu bar", "baz") == 'foo "fu bar" baz'`

        This is useful for ensuring file names and other "input" are considered one parameter even when they
        contain spaces or shell meta-characters.

        :param args: The string(s) to be escaped.
        :return: Each argument escaped such that each argument becomes a single "word" and then all these words are
          joined by a single space.
        """
        return util.escape_shell(*args)


class BinaryCtrlAccessor:
    __slots__ = ()

    def dpkg_trigger(self, trigger_type: DpkgTriggerType, trigger_target: str) -> None:
        """Register a declarative dpkg level trigger

        The provided trigger will be added to the package's metadata (the triggers file of the control.tar).

        If the trigger has already been added previously, a second call with the same trigger data will be ignored.
        """
        raise NotImplementedError

    @property
    def maintscript(self) -> MaintscriptAccessor:
        """Attribute for manipulating maintscripts"""
        raise NotImplementedError

    @property
    def substvars(self) -> "FlushableSubstvars":
        """Attribute for manipulating dpkg substvars (deb-substvars)"""
        raise NotImplementedError


class VirtualPath:
    __slots__ = ()

    @property
    def name(self) -> str:
        """Basename of the path a.k.a. last segment of the path

        In a path "usr/share/doc/pkg/changelog.gz" the basename is "changelog.gz".

        For a directory, the basename *never* ends with a `/`.
        """
        raise NotImplementedError

    @property
    def iterdir(self) -> Iterable["VirtualPath"]:
        """Returns an iterable that iterates over all children of this path

        For directories, this returns an iterable of all children. For non-directories,
        the iterable is always empty.
        """
        raise NotImplementedError

    def lookup(self, path: str) -> Optional["VirtualPath"]:
        """Perform a path lookup relative to this path

        As an example `doc_dir = fs_root.lookup('./usr/share/doc')`

        Does *not* resolve / follow symlink.  If the path `a/b` is given and `a` is a symlink,
        this method always returns None.

        If the provided path starts with `/`, then the lookup is performed relative to the
        file system root.  That is, you can assume the following to always be True:

            `fs_root.lookup("usr") == any_path_beneath_fs_root.lookup('/usr')`

        Note: This method requires the path to be attached (see `is_detached`) regardless of
        whether the lookup is relative or absolute.

        :param path: The path to look. Can contain "." and ".." segments.  If starting with `/`,
          look up is performed relative to the file system root, otherwise the lookup is relative
          to this path.
        :return: The path object for the desired path if it can be found. Otherwise, None.
        """
        raise NotImplementedError

    def all_paths(self) -> Iterable["VirtualPath"]:
        """Iterate over this path and all of its descendants (if any)

        If used on the root path, then every path in the package is returned.

        The iterable is ordered, so using the order in output will be produce
        bit-for-bit reproducible output. Additionally, a directory will always
        be seen before its descendants. Otherwise, the order is implementation
        defined.

        The iteration is lazy and as a side effect do account for some obvious
        mutation. Like if the current path is removed, then none of its children
        will be returned (provided mutation happens before the lazy iteration
        was required to resolve it). Likewise, mutation of the directory will
        also work (again, provided mutation happens before the lazy iteration order).

        :return: An ordered iterable of this path followed by its descendants.
        """
        raise NotImplementedError

    @property
    def is_detached(self) -> bool:
        """Returns True if this path is detached

        Paths that are detached from the file system will not be present in the package and
        most operations are unsafe on them. This usually only happens if the path or one of
        its parent directories are unlinked (rm'ed) from the file system tree.

        All paths are attached by default and will only become detached as a result of
        an action to mutate the virtual file system.  Note that the file system may not
        always be manipulated.

        :return: True if the entry is detached. Detached entries should be discarded, so they
        can be garbage collected.
        """
        raise NotImplementedError

    # 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: object) -> "VirtualPath":
        """Lookup a (direct) child by name

        Ignoring the possible `KeyError`, then the following are the same:
            `fs_root["usr"] == fs_root.lookup('usr')`

        Note that unlike `.lookup` this can only locate direct children.
        """
        raise NotImplementedError

    def __delitem__(self, key) -> None:
        """Remove a child from this node if it exists

        If that child is a directory, then the entire tree is removed (like `rm -fr`).
        """
        raise NotImplementedError

    def get(self, key: str) -> "Optional[VirtualPath]":
        """Lookup a (direct) child by name

        The following are the same:
            `fs_root.get("usr") == fs_root.lookup('usr')`

        Note that unlike `.lookup` this can only locate direct children.
        """
        try:
            return self[key]
        except KeyError:
            return None

    def __contains__(self, item: object) -> bool:
        """Determine if this path includes a given child (either by object or string)

        Examples:

            if 'foo' in dir: ...
        """
        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

    @property
    def path(self) -> str:
        """Returns the "full" path for this file system entry

        This is the path that debputy uses to refer to this file system entry. It is always
        normalized. Use the `absolute` attribute for how the path looks
        when the package is installed. Alternatively, there is also `fs_path`, which is the
        path to the underlying file system object (assuming there is one). That is the one
        you need if you want to read the file.

        This is attribute is mostly useful for debugging or for looking up the path relative
        to the "root" of the virtual file system that debputy maintains.

        If the path is detached (see `is_detached`), then this method returns the path as it
        was known prior to being detached.

        Note: As an implementation detail, this path always starts with "./" (except the file
        system root, which is called "." at the moment). However, this may change in a future version.
        """
        raise NotImplementedError

    @property
    def absolute(self) -> str:
        """Returns the absolute version of this path

        This is how to refer to this path when the package is installed.

        If the path is detached (see `is_detached`), then this method returns the last known location
        of installation (prior to being detached).

        :return: The absolute path of this file as it would be on the installed system.
        """
        return self.path.lstrip(".")

    @property
    def parent_dir(self) -> Optional["VirtualPath"]:
        """The parent directory of this path

        Note this operation requires the path is "attached" (see `is_detached`).  All paths are attached
        by default but unlinking paths will cause them to become detached.

        :return: The parent path or None for the root.
        """
        raise NotImplementedError

    def stat(self) -> os.stat_result:
        """Attempt to do stat of the underlying path (if it exists)

        This never follow symlinks (it behaves like `os.lstat`). It will raise an error
        if the path is not backed by a file system object (that is, `has_fs_path` is False).

        :return: The stat result or an error.
        """
        raise NotImplementedError()

    def mode(self) -> int:
        """Determine the mode bits of this path object

        This *often* work even when `has_fs_path` is False as virtual paths can have a predefined
        mode.

        Note that like with `stat` above, this never follows symlinks.

        :return: The mode bits for the path.
        """
        return stat.S_IMODE(self.stat().st_mode)

    def mtime(self) -> float:
        """Determine the mtime of this path object

        This *often* work even when `has_fs_path` is False as virtual paths can have a predefined
        mtime.

        Note that like with `stat` above, this never follows symlinks.

        :return: The mtime for the path.
        """
        return self.stat().st_mtime

    def readlink(self) -> str:
        """Determine the link target of this path assuming it is a symlink

        For paths where `is_symlink` is True, this already returns a link target even when
        `has_fs_path` is False.

        :return: The link target of the path or an error is this is not a symlink
        """
        raise NotImplementedError()

    @property
    def fs_path(self) -> str:
        """Request the underling fs_path of this path

        Only available when `has_fs_path` is True.  Generally this should only be used for files to read
        the contents of the file and do some action based on the parsed result.

        The path should only be used for read-only purposes as debputy may assume that it is safe to have
        multiple paths pointing to the same file system path.

        Note that:
          * This is often *not* available for directories and symlinks.
          * The debputy in-memory file system overrules the physical file system. Attempting to "fix" things
            by using `os.chmod` or `os.unlink`'ing files, etc. will generally not do as you expect. Best case,
            your actions are ignored and worst case it will cause the build to fail as it violates debputy's
            internal invariants.

        :return: The path to the underlying file system object on the build system or an error if no such
        file exist (see `has_fs_path`).
        """
        raise NotImplementedError()

    @property
    def is_dir(self) -> bool:
        """Determine if this path is a directory

        Never follows symlinks.

        :return: True if this path is a directory. False otherwise.
        """
        raise NotImplementedError()

    @property
    def is_file(self) -> bool:
        """Determine if this path is a directory

        Never follows symlinks.

        :return: True if this path is a regular file. False otherwise.
        """
        raise NotImplementedError()

    @property
    def is_symlink(self) -> bool:
        """Determine if this path is a symlink

        :return: True if this path is a symlink. False otherwise.
        """
        raise NotImplementedError()

    @property
    def has_fs_path(self) -> bool:
        """Determine whether this path is backed by a file system path

        :return: True if this path is backed by a file system object on the build system.
        """
        raise NotImplementedError()


class FlushableSubstvars(Substvars):
    @contextlib.contextmanager
    def flush(self) -> Iterator[str]:
        """Temporarily write the substvars to a file and then re-read it again

        >>> s = FlushableSubstvars()
        >>> 'Test:Var' in s
        False
        >>> with s.flush() as name, open(name, 'wt', encoding='utf-8') as fobj:
        ...     _ = fobj.write('Test:Var=bar\\n')  # "_ = " is to ignore the return value of write
        >>> 'Test:Var' in s
        True

        Used as a context manager to define when the file is flushed and can be
        accessed via the file system. If the context terminates successfully, the
        file is read and its content replaces the current substvars.

        This is mostly useful if the plugin needs to interface with a third-party
        tool that requires a file as interprocess communication (IPC) for sharing
        the substvars.

        The file may be truncated or completed replaced (change inode) as long as
        the provided path points to a regular file when the context manager
        terminates successfully.

        Note that any manipulation of the substvars via the `Substvars` API while
        the file is flushed will silently be discarded if the context manager completes
        successfully.
        """
        with tempfile.NamedTemporaryFile(mode="w+t", encoding="utf-8") as tmp:
            self.write_substvars(tmp)
            tmp.flush()  # Temping to use close, but then we have to manually delete the file.
            yield tmp.name
            # Re-open; seek did not work when I last tried (if I did it work, feel free to
            # convert back to seek - as long as it works!)
            with open(tmp.name, "rt", encoding="utf-8") as fd:
                self.read_substvars(fd)

    def save(self):
        # Promote the debputy extension over `save()` for the plugins.
        if self._substvars_path is None:
            raise TypeError(
                "Please use `flush()` extension to temporarily write the substvars to the file system"
            )
        super().save()
