import dataclasses
import os
from abc import ABCMeta
from typing import (
    Iterable,
    Mapping,
    Callable,
    Optional,
    Union,
    List,
    Tuple,
    Set,
    Sequence,
    Any,
)

from debian.substvars import Substvars

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


DEBPUTY_TEST_AGAINST_INSTALLED_PLUGINS = (
    os.environ.get("DEBPUTY_TEST_PLUGIN_LOCATION", "uninstalled") == "installed"
)


@dataclasses.dataclass(slots=True, frozen=True)
class ADRExampleIssue:
    name: str
    example_index: int
    inconsistent_paths: Sequence[str]


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_def` 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:

        >>> from debputy.plugin.api import virtual_path_def
        >>> path_defs = [
        ...    './usr/share/doc/',                                       # Create a directory
        ...    virtual_path_def("./bin/zcat", link_target="/bin/gzip"),  # Create a symlink
        ...    virtual_path_def("./bin/gzip", mode=0o755),               # Create a file (with a custom mode)
        ... ]
        >>> fs_root = build_virtual_file_system(path_defs)
        >>> 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_def definitions
        (results from `virtual_path_def`).
    :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,
        path: Optional[VirtualPath] = 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}`).
        :param path: Special-case param, only needed for when testing a special `debputy` PPF..
        :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[PackageProcessingContext] = 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

    @property
    def declared_manifest_variables(self) -> Set[str]:
        """Extract the manifest variables declared by the plugin

        :return: All manifest variables declared by the plugin
        """
        raise NotImplementedError

    def automatic_discard_rules_examples_with_issues(self) -> Sequence[ADRExampleIssue]:
        """Validate examples of the automatic discard rules

        For any failed example, use `debputy plugin show automatic-discard-rules <name>` to see
        the failed example in full.

        :return: If any examples have issues, this will return a non-empty sequence with an
          entry with each issue.
        """
        raise NotImplementedError
