import dataclasses
from abc import ABCMeta
from typing import Iterable, Mapping, Callable, Optional, Union, List, Tuple

from debian.substvars import Substvars

from debputy import filesystem_scan
from debputy.filesystem_scan import PathDef
from debputy.plugin.api import (
    VirtualPath,
    MetadataDetectorPackageContext,
    DpkgTriggerType,
    Maintscript,
)


def virtual_path(
    path_name: str,
    /,
    mode: Optional[int] = None,
    mtime: Optional[int] = None,
    fs_path: Optional[str] = None,
    link_target: Optional[str] = None,
    content: Optional[str] = None,
    materialized_content: Optional[str] = None,
) -> PathDef:
    """Define a virtual path for use with `build_virtual_file_system`

    :param path_name: The full path. Must start with "./".  If it ends with "/", the path will be interpreted
      as a directory (the `is_dir` attribute will be True).  Otherwise, it will be a symlink or file depending
      on whether a `link_target` is provided.
    :param mode: The mode to use for this path.  Defaults to 0644 for files and 0755 for directories. The mode
      should be None for symlinks.
    :param mtime: Define the last modified time for this path. If not provided, debputy will provide a default
      if the mtime attribute is accessed.
    :param fs_path: Define a file system path for this path.  This causes `has_fs_path` to return True and the
      `fs_path` attribute will return this value.  The test is required to make this path available to the extent
      required. Note that the virtual file system will *not* examine the provided path in any way nor attempt
      to resolve defaults from the path.
    :param link_target: A target for the symlink. Providing a not None value for this parameter will make the
      path a symlink.
    :param content: The content of the path (if opened).  The path must be a file.
    :param materialized_content: Same as `content` except `debputy` will put the contents into a physical file
      as needed. Cannot be used with `content` or `fs_path`.
    :return: An *opaque* object to be passed to `build_virtual_file_system`. While the exact type is provided
      to aid with typing, the type name and its behaviour is not part of the API.
    """

    is_dir = path_name.endswith("/")
    is_symlink = link_target is not None

    if is_symlink:
        if mode is not None:
            raise ValueError(
                f'Please do not provide mode for symlinks. Triggered by "{path_name}"'
            )
        if is_dir:
            raise ValueError(
                "Path name looks like a directory, but a symlink target was also provided."
                f' Please remove the trailing slash OR the symlink_target. Triggered by "{path_name}"'
            )

    if content and (is_dir or is_symlink):
        raise ValueError(
            "Content was defined however, the path appears to be a directory a or a symlink"
            f' Please remove the content, the trailing slash OR the symlink_target. Triggered by "{path_name}"'
        )

    if materialized_content is not None:
        if content is not None:
            raise ValueError(
                "The materialized_content keyword is mutually exclusive with the content keyword."
                f' Triggered by "{path_name}"'
            )
        if fs_path is not None:
            raise ValueError(
                "The materialized_content keyword is mutually exclusive with the fs_path keyword."
                f' Triggered by "{path_name}"'
            )
    return PathDef(
        path_name,
        mode=mode,
        mtime=mtime,
        has_fs_path=bool(fs_path) or materialized_content is not None,
        fs_path=fs_path,
        link_target=link_target,
        content=content,
        materialized_content=materialized_content,
    )


def build_virtual_file_system(paths: Iterable[Union[str, PathDef]]) -> VirtualPath:
    """Create a pure-virtual file system for use with metadata detectors

    This method will generate a virtual file system a list of path names or virtual path definitions.  It will
    also insert any implicit path required to make the file system connected.  As an example:

        >>> fs_root = build_virtual_file_system(['./usr/share/doc/package/copyright'])
        >>> # The file we explicitly requested is obviously there
        >>> fs_root.lookup('./usr/share/doc/package/copyright') is not None
        True
        >>> # but so is every directory up to that point
        >>> all(fs_root.lookup(d).is_dir
        ...     for d in ['./usr', './usr/share', './usr/share/doc', './usr/share/doc/package']
        ... )
        True

    Any string provided will be pased to `virtual_path` using all defaults for other parameters, making `str`
    arguments a nice easy shorthand if you just want a path to exist, but do not really care about it otherwise
    (or `virtual_path` defaults happens to work for you).

    Here is a very small example of how to create some basic file system objects to get you started:

        >>> paths = [
        ...    './usr/share/doc/',                                   # Create a directory
        ...    virtual_path("./bin/zcat", link_target="/bin/gzip"),  # Create a symlink
        ...    virtual_path("./bin/gzip", mode=0o755),               # Create a file (with a custom mode)
        ... ]
        >>> fs_root = build_virtual_file_system(paths)
        >>> fs_root.lookup('./usr/share/doc').is_dir
        True
        >>> fs_root.lookup('./bin/zcat').is_symlink
        True
        >>> fs_root.lookup('./bin/zcat').readlink() == '/bin/gzip'
        True
        >>> fs_root.lookup('./bin/gzip').is_file
        True
        >>> fs_root.lookup('./bin/gzip').mode == 0o755
        True

    :param paths: An iterable any mix of path names (str) and virtual_path definitions (results from `virtual_path`).
    :return: The root of the generated file system
    """
    return filesystem_scan.build_virtual_fs(paths, read_write_fs=False)


@dataclasses.dataclass(slots=True, frozen=True)
class RegisteredTrigger:
    dpkg_trigger_type: DpkgTriggerType
    dpkg_trigger_target: str

    def serialized_format(self) -> str:
        """The semantic contents of the DEBIAN/triggers file"""
        return f"{self.dpkg_trigger_type} {self.dpkg_trigger_target}"


@dataclasses.dataclass(slots=True, frozen=True)
class RegisteredMaintscript:
    """Details about a maintscript registered by a plugin"""

    """Which maintscript is applies to (e.g., "postinst")"""
    maintscript: Maintscript
    """Which method was used to trigger the script (e.g., "on_configure")"""
    registration_method: str
    """The snippet provided by the plugin as it was provided

    That is, no indentation/conditions/substitutions have been applied to this text
    """
    detector_provided_script: str
    """Whether substitutions would have been applied in a production run"""
    requested_substitution: bool


class RegisteredPackagerProvidedFile(metaclass=ABCMeta):
    """Record of a registered packager provided file - No instantiation

    New "mandatory" attributes may be added in minor versions, which means instantiation will break tests.
    Plugin providers should therefore not create instances of this dataclass.  It is visible only to aid
    test writing by providing type-safety / auto-completion.
    """

    """The name stem used for generating the file"""
    stem: str
    """The recorded directory these file should be installed into"""
    installed_path: str
    """The mode that debputy will give these files when installed (unless overridden)"""
    default_mode: int
    """The default priority assigned to files unless overriden (if priories are assigned at all)"""
    default_priority: Optional[int]
    """The filename format to be used"""
    filename_format: Optional[str]
    """The formatting correcting callback"""
    post_formatting_rewrite: Optional[Callable[[str], str]]

    def compute_dest(
        self,
        assigned_name: str,
        *,
        assigned_priority: Optional[int] = None,
        owning_package: Optional[str] = None,
    ) -> Tuple[str, str]:
        """Determine the basename of this packager provided file

        This method is useful for verifying that the `installed_path` and `post_formatting_rewrite` works
        as intended. As example, some programs do not support "." in their configuration files, so you might
        have a post_formatting_rewrite à la `lambda x: x.replace(".", "_")`.  Then you can test it by
        calling `assert rppf.compute_dest("python3.11")[1] == "python3_11"` to verify that if a package like
        `python3.11` were to use this packager provided file, it would still generate a supported file name.

        For the `assigned_name` parameter, then this is normally derived from the filename. Examples for
        how to derive it:

          * `debian/my-pkg.stem` => `my-pkg`
          * `debian/my-pkg.my-custom-name.stem` => `my-custom-name`

        Note that all parts (`my-pkg`, `my-custom-name` and `stem`) can contain periods (".") despite
        also being a delimiter. Additionally, `my-custom-name` is not restricted to being a valid package
        name, so it can have any file-system valid character in it.

        For the 0.01% case, where the plugin is using *both* `{name}` *and* `{owning_package}` in the
        installed_path, then you can separately *also* set the `owning_package` attribute.  However, by
        default the `assigned_named` is used for both when `owning_package` is not provided.

        :param assigned_name: The name assigned.  Usually this is the name of the package containing the file.
        :param assigned_priority: Optionally a priority override for the file (if priority is supported). Must be
          omitted/None if priorities are not supported.
        :param owning_package: Optionally the name of the owning package.  It is only needed for those exceedingly
          rare cases where the `installed_path` contains both `{owning_package}` (usually in addition to `{name}`).
        :return: A tuple of the directory name and the basename (in that order) that combined makes up that path
          that debputy would use.
        """
        raise NotImplementedError


class InitializedPluginUnderTest:
    def packager_provided_files(self) -> Iterable[RegisteredPackagerProvidedFile]:
        """An iterable of all packager provided files registered by the plugin under test

        If you want a particular order, please sort the result.
        """
        return self.packager_provided_files_by_stem().values()

    def packager_provided_files_by_stem(
        self,
    ) -> Mapping[str, RegisteredPackagerProvidedFile]:
        """All packager provided files registered by the plugin under test grouped by name stem"""
        raise NotImplementedError

    @property
    def substvars(self) -> Substvars:
        """Returns the Substvars

        This may be mutated by metadata detectors. The test can also mutate it prior to running
        the metadata detector if it wants to verify how it handles existing substvars.

        :return: The substvars in their current state.
        """
        raise NotImplementedError

    def run_metadata_detector(
        self,
        metadata_detector_id: str,
        fs_root: VirtualPath,
        context: Optional[MetadataDetectorPackageContext] = None,
    ) -> None:
        """Run a metadata detector (by its ID) against a given file system

        :param metadata_detector_id: The ID of the metadata detector to run
        :param fs_root: The file system the metadata detector should see (must be the root of the file system)
        :param context: The context the metadata detector should see. If not provided, one will be mock will be
          provided to the extent possible.
        """
        raise NotImplementedError

    def trigger_provided_by(self, metadata_detector_id: str) -> List[RegisteredTrigger]:
        """Extract all the declarative dpkg triggers provided by the given metadata detector

        :param metadata_detector_id: The ID of the metadata detector. The detector must be run separately
          (via `run_metadata_detector`) for this method to return anything useful.
        :return: A list of all matching dpkg triggers registered by the metadata detector. If the detector has
          not been run, then the list will be empty.  If the metadata detector has been run multiple times,
          then this is the aggregation of all the runs.
        """
        raise NotImplementedError

    def maintscript_registered_by(
        self,
        metadata_detector_id: str,
        *,
        maintscript: Optional[Maintscript] = None,
    ) -> List[RegisteredMaintscript]:
        """Extract the maintscript provided by the given metadata detector

        :param metadata_detector_id: The ID of the metadata detector. The detector must be run separately
          (via `run_metadata_detector`) for this method to return anything useful.
        :param maintscript: If provided, only snippet registered for the given maintscript is returned. Can be
          used to say "Give me all the 'postinst' snippets by this metadata detector", which can simplify
          verification in some cases.
        :return: A list of all matching maintscript registered by the metadata detector. If the detector has
          not been run, then the list will be empty.  If the metadata detector has been run multiple times,
          then this is the aggregation of all the runs.
        """
        raise NotImplementedError
