from typing import Mapping, Dict, Optional, Tuple, List, cast

from debian.deb822 import Deb822
from debian.substvars import Substvars

from debputy.architecture_support import faked_arch_table
from debputy.packages import BinaryPackage
from debputy.plugin.api import (
    PluginInitializationEntryPoint,
    VirtualPath,
    MetadataDetectorPackageContext,
    DpkgTriggerType,
    Maintscript,
)
from debputy.plugin.api.impl import (
    plugin_metadata_for_debputys_own_plugin,
    DebputyPluginInitializerProvider,
    parse_json_plugin_desc,
    MaintscriptAccessorProviderBase,
    BinaryCtrlAccessorProviderBase,
)
from debputy.plugin.api.impl_types import (
    PackagerProvidedFileClassSpec,
    DebputyPluginMetadata,
    PluginProvidedFeatureSet,
    MetadataDetectorPackageContextProvider,
    PluginProvidedTrigger,
)
from debputy.plugin.api.spec import MaintscriptAccessor, FlushableSubstvars
from debputy.plugin.api.test_api.test_spec import (
    InitializedPluginUnderTest,
    RegisteredPackagerProvidedFile,
    RegisteredTrigger,
    RegisteredMaintscript,
)
from debputy.plugin.debputy.debputy_plugin import initialize_debputy_features

RegisteredPackagerProvidedFile.register(PackagerProvidedFileClassSpec)


def _initialize_plugin_under_test(
    plugin_metadata: DebputyPluginMetadata,
    load_debputy_plugin: bool = True,
) -> "InitializedPluginUnderTest":
    feature_set = PluginProvidedFeatureSet(
        plugin_data={},
        packager_provided_files={},
        metadata_maintscript_detectors={},
    )
    if load_debputy_plugin:
        debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin(
            initialize_debputy_features
        )
        # Load debputy's own plugin first, so conflicts with debputy's plugin are detected early
        debputy_provider = DebputyPluginInitializerProvider(
            debputy_plugin_metadata, feature_set
        )
        debputy_provider.load_plugin()

    plugin_under_test_provider = DebputyPluginInitializerProvider(
        plugin_metadata, feature_set
    )
    plugin_under_test_provider.load_plugin()

    return InitializedPluginUnderTestImpl(
        plugin_metadata.plugin_name,
        feature_set,
    )


def initialize_plugin_under_test_from_desc(
    desc_file: str,
) -> "InitializedPluginUnderTest":
    """Load and initialize a plugin for testing it

    This method will load the plugin via plugin description, which is the method that `debputy` does at
     run-time (in contrast to `initialize_plugin_under_test_preloaded`, which bypasses this concrete part
     of the flow).

    :param desc_file: The plugin description file (`.json`) that describes how to load the plugin.  Note that
      the description file is *not* required to a valid version at this stage (e.g., "N/A" or "@PLACEHOLDER@")
      is fine. So you still use this method if you substitute in the version during build after running the
      tests. To support this flow, the file name can also end with `.json.in` (instead of `.json`).
    :return: The loaded plugin for testing
    """

    if not desc_file.endswith((".json", ".json.in")):
        raise ValueError("The plugin file must end with .json or .json.in")

    plugin_metadata = parse_json_plugin_desc(desc_file)

    return _initialize_plugin_under_test(plugin_metadata, load_debputy_plugin=True)


def initialize_plugin_under_test_preloaded(  # Direct API (but kept in Impl for import dependency reasons)
    api_compat_version: int,
    plugin_initializer: PluginInitializationEntryPoint,
    /,
    plugin_name: str = "plugin-under-test",
    plugin_version: str = "0.0.1-under-test",
    load_debputy_plugin: bool = True,
) -> "InitializedPluginUnderTest":
    """Initialize a plugin for testing without loading it from a file

    This method by-passes the standard loading mechanism, meaning you will not test that your plugin
    description file is correct. However, on the flip side, you can test your plugin without having
    a description file, enabling you to generate the description file from scratch after the test.

    :param api_compat_version: The API version the plugin was written for. Use the same version as the
      version from the entry point (The `v1` part of `debputy.plugins.v1.initialize` translate into `1`).
    :param plugin_initializer: The entry point of the plugin
    :param plugin_name: Normally, debputy would derive this from the entry point. In the test, it will
      use a test name and version. However, you can explicitly set if you want the real name/version.
    :param plugin_version: Normally, debputy would derive this from the entry point. In the test, it will
      use a test name and version. However, you can explicitly set if you want the real name/version.
    :param load_debputy_plugin: Whether to load debputy's own plugin first. Doing so provides a more
      realistic test and enables the test to detect conflicts with debputy's own plugins (de facto making
      the plugin unloadable in practice if such a conflict is present).  This option is mostly provided
      to enable debputy to use this method for self testing.
    :return: The loaded plugin for testing
    """

    plugin_metadata = DebputyPluginMetadata(
        plugin_name=plugin_name,
        plugin_version=plugin_version,
        api_compat_version=api_compat_version,
        plugin_initializer=plugin_initializer,
        plugin_loader=None,
        plugin_path="<loaded-via-test>",
    )

    return _initialize_plugin_under_test(
        plugin_metadata, load_debputy_plugin=load_debputy_plugin
    )


class _MockArchTable:
    @staticmethod
    def matches_architecture(_a, _b) -> bool:
        return True


FAKE_DPKG_QUERY_TABLE = cast("DpkgArchTable", _MockArchTable())
del _MockArchTable


def package_metadata_context(
    *,
    host_arch: str = "amd64",
    package_fields: Optional[Dict[str, str]] = None,
    related_udeb_package_fields: Optional[Dict[str, str]] = None,
) -> MetadataDetectorPackageContext:
    process_table = faked_arch_table(host_arch)
    f = {
        "Package": "foo",
        "Architecture": "any",
    }
    if package_fields is not None:
        f.update(package_fields)

    bin_package = BinaryPackage(
        Deb822(f),
        process_table,
        FAKE_DPKG_QUERY_TABLE,
        is_main_package=True,
        should_be_acted_on=True,
    )
    udeb_package = None
    if related_udeb_package_fields is not None:
        uf = dict(related_udeb_package_fields)
        uf.setdefault("Package", f'{f["Package"]}-udeb')
        uf.setdefault("Architecture", f["Architecture"])
        uf.setdefault("Package-Type", "udeb")
        udeb_package = BinaryPackage(
            Deb822(uf),
            process_table,
            FAKE_DPKG_QUERY_TABLE,
            is_main_package=False,
            should_be_acted_on=False,
        )
    return MetadataDetectorPackageContextProvider(
        binary_package=bin_package,
        related_udeb_package=udeb_package,
    )


class MaintscriptAccessorTestProvider(MaintscriptAccessorProviderBase):
    __slots__ = ("_plugin_metadata", "_plugin_source_id", "_maintscript_container")

    def __init__(
        self,
        plugin_metadata: DebputyPluginMetadata,
        plugin_source_id: str,
        maintscript_container: Dict[str, List[RegisteredMaintscript]],
    ):
        self._plugin_metadata = plugin_metadata
        self._plugin_source_id = plugin_source_id
        self._maintscript_container = maintscript_container

    @classmethod
    def _apply_condition_to_script(
        cls, condition: str, run_snippet: str, /, indent: Optional[bool] = None
    ) -> str:
        return run_snippet

    def _append_script(
        self,
        caller_name: str,
        maintscript: Maintscript,
        full_script: str,
        /,
        perform_substitution: bool = True,
    ) -> None:
        if self._plugin_source_id not in self._maintscript_container:
            self._maintscript_container[self._plugin_source_id] = []
        self._maintscript_container[self._plugin_source_id].append(
            RegisteredMaintscript(
                maintscript,
                caller_name,
                full_script,
                perform_substitution,
            )
        )


class BinaryCtrlAccessorTestProvider(BinaryCtrlAccessorProviderBase):
    __slots__ = ("_maintscript_container",)

    def __init__(
        self,
        plugin_metadata: DebputyPluginMetadata,
        plugin_source_id: str,
        triggers: Dict[Tuple[DpkgTriggerType, str], PluginProvidedTrigger],
        substvars: FlushableSubstvars,
        maintscript_container: Dict[str, List[RegisteredMaintscript]],
    ) -> None:
        super().__init__(plugin_metadata, plugin_source_id, triggers, substvars)
        self._maintscript_container = maintscript_container

    def _create_maintscript_accessor(self) -> MaintscriptAccessor:
        return MaintscriptAccessorTestProvider(
            self._plugin_metadata,
            self._plugin_source_id,
            self._maintscript_container,
        )


class InitializedPluginUnderTestImpl(InitializedPluginUnderTest):
    def __init__(
        self,
        plugin_name: str,
        feature_set: PluginProvidedFeatureSet,
    ) -> None:
        self._feature_set = feature_set
        self._plugin_name = plugin_name
        self._packager_provided_files = None
        self._substvars = FlushableSubstvars()
        self._triggers: Dict[Tuple[DpkgTriggerType, str], PluginProvidedTrigger] = {}
        self._maintscript_container: Dict[str, List[RegisteredMaintscript]] = {}
        assert plugin_name in self._feature_set.plugin_data

    @property
    def _plugin_metadata(self) -> DebputyPluginMetadata:
        return self._feature_set.plugin_data[self._plugin_name]

    def packager_provided_files_by_stem(
        self,
    ) -> Mapping[str, RegisteredPackagerProvidedFile]:
        if self._packager_provided_files is None:
            result: Dict[str, RegisteredPackagerProvidedFile] = {}
            for spec in self._feature_set.packager_provided_files.values():
                if spec.debputy_plugin_metadata.plugin_name != self._plugin_name:
                    continue
                # Registered as a virtual subclass, so this should always be True
                assert isinstance(spec, RegisteredPackagerProvidedFile)
                result[spec.stem] = spec
            self._packager_provided_files = result
        return self._packager_provided_files

    @property
    def substvars(self) -> Substvars:
        return self._substvars

    def trigger_provided_by(self, metadata_detector_id: str) -> List[RegisteredTrigger]:
        return [
            RegisteredTrigger(t.dpkg_trigger_type, t.dpkg_trigger_target)
            for t in self._triggers.values()
            if t.provider_source_id == metadata_detector_id
            and t.provider.plugin_name == self._plugin_name
        ]

    def maintscript_registered_by(
        self,
        metadata_detector_id: str,
        *,
        maintscript: Optional[Maintscript] = None,
    ) -> List[RegisteredMaintscript]:
        all_snippets = self._maintscript_container.get(metadata_detector_id)
        if all_snippets is None:
            return []
        if maintscript is None:
            return all_snippets
        return [s for s in all_snippets if s.maintscript == maintscript]

    def run_metadata_detector(
        self,
        metadata_detector_id: str,
        fs_root: VirtualPath,
        context: Optional[MetadataDetectorPackageContext] = None,
    ) -> None:
        if fs_root.parent_dir is not None:
            raise ValueError(f"Provided path must be the file system root.")
        detectors = self._feature_set.metadata_maintscript_detectors[self._plugin_name]
        matching_detectors = [
            d for d in detectors if d.detector_id == metadata_detector_id
        ]
        if len(matching_detectors) != 1:
            assert not matching_detectors
            raise ValueError(
                f"The plugin {self._plugin_name} did not provide a metadata detector with ID"
                f' "{metadata_detector_id}"'
            )
        if context is None:
            context = package_metadata_context()
        detector = matching_detectors[0]
        if not detector.applies_to(context.binary_package):
            raise ValueError(
                f'The detector "{metadata_detector_id}" from {self._plugin_name} does not apply to the'
                " given package. Consider using `package_metadata_context()` to emulate a binary package"
                " with the correct specification. As an example: "
                '`package_metadata_context(package_fields={"Package-Type": "udeb"})` would emulate a udeb'
                " package."
            )
        ctrl = BinaryCtrlAccessorTestProvider(
            self._plugin_metadata,
            metadata_detector_id,
            self._triggers,
            self._substvars,
            self._maintscript_container,
        )
        detector.run_detector(
            fs_root,
            ctrl,
            context,
        )
