import contextlib
import importlib
import importlib.util
import json
import os
import re
import sys
from abc import ABC
from json import JSONDecodeError
from typing import (
    Optional,
    Callable,
    Dict,
    Tuple,
    Iterable,
    Sequence,
    Type,
    List,
    Union,
    Set,
    Iterator,
    IO,
)

from debian.substvars import Substvars

from debputy.exceptions import DebputySubstitutionError
from debputy.maintscript_snippet import (
    STD_CONTROL_SCRIPTS,
    MaintscriptSnippetContainer,
    MaintscriptSnippet,
)
from debputy.manifest_parser.declarative_parser import parser_from_typed_dict
from debputy.manifest_parser.exceptions import ManifestParseException
from debputy.manifest_parser.util import AttributePath
from debputy.plugin.api.impl_types import (
    DebputyPluginMetadata,
    PluginProvidedFeatureSet,
    PluginConflictError,
    PluginError,
    PluginInitializationError,
    PluginMetadataError,
    PackagerProvidedFileClassSpec,
    _DEB_ONLY,
    MetadataOrMaintscriptDetector,
    PluginProvidedTrigger,
    PluginNotFoundError,
    PluginAPIViolationError,
    TTP,
    DIPHandler,
    PF,
    SF,
    DIPKWHandler,
    PluginProvidedManifestVariable,
)
from debputy.plugin.api.plugin_parser import (
    PLUGIN_METADATA_PARSER,
    PluginJsonMetadata,
    PLUGIN_PPF_PARSER,
    PackagerProvidedFileJsonDescription,
    PLUGIN_MANIFEST_VARS_PARSER,
)
from debputy.plugin.api.spec import (
    MaintscriptAccessor,
    Maintscript,
    DpkgTriggerType,
    BinaryCtrlAccessor,
    MetadataDetectorPackageContext,
    MetadataAutoDetector,
    PluginInitializationEntryPoint,
    DebputyPluginInitializer,
    MetadataDetectorPackageType,
    FlushableSubstvars,
    ParserDocumentation,
)
from debputy.substitution import Substitution, VariableNameState, SUBST_VAR_RE
from debputy.util import _normalize_path, POSTINST_DEFAULT_CONDITION, _info, _error

PLUGIN_TEST_SUFFIX = re.compile(r"_(?:t|test|check)(?:_([a-z0-9_]+))?[.]py$")


class DebputyPluginInitializerProvider(DebputyPluginInitializer):
    __slots__ = (
        "_plugin_metadata",
        "_feature_set",
        "_plugin_detector_ids",
        "_substitution",
    )

    def __init__(
        self,
        plugin_metadata: DebputyPluginMetadata,
        feature_set: PluginProvidedFeatureSet,
        substitution: Substitution,
    ) -> None:
        self._plugin_metadata: DebputyPluginMetadata = plugin_metadata
        self._feature_set = feature_set
        self._plugin_detector_ids = set()
        self._substitution = substitution

    def load_plugin(self) -> None:
        metadata = self._plugin_metadata
        if metadata.plugin_name in self._feature_set.plugin_data:
            raise PluginConflictError(
                f'The plugin "{metadata.plugin_name}" has already been loaded!?'
            )
        assert (
            metadata.api_compat_version == 1
        ), f"Unsupported plugin API compat version {metadata.api_compat_version}"
        self._feature_set.plugin_data[metadata.plugin_name] = metadata
        _info(f"Loading plugin {metadata.plugin_name} ...")
        assert not metadata.is_initialized
        try:
            metadata.initialize_plugin(self)
        except Exception as e:
            if isinstance(e, TypeError) and not callable(metadata.plugin_initializer):
                raise PluginMetadataError(
                    f"The specified entry point for plugin {metadata.plugin_name} does not appear to be a"
                    f" callable (callable returns False). The specified entry point identifies"
                    f' itself as "{metadata.plugin_initializer.__qualname__}".'
                ) from e
            elif isinstance(e, PluginError):
                raise
            raise PluginInitializationError(
                f"Exception while attempting to load plugin {metadata.plugin_name}"
            ) from e

    def packager_provided_file(
        self,
        stem: str,
        installed_path: str,
        *,
        default_mode: int = 0o0644,
        default_priority: Optional[int] = None,
        allow_name_segment: bool = True,
        allow_architecture_segment: bool = False,
        post_formatting_rewrite: Optional[Callable[[str], str]] = None,
        packageless_is_fallback_for_all_packages: bool = False,
        reservation_only: bool = False,
    ) -> None:
        packager_provided_files = self._feature_set.packager_provided_files
        existing = packager_provided_files.get(stem)

        if installed_path.endswith("/"):
            raise ValueError(
                f'The installed_path ends with "/" indicating it is a directory, but it must be a file.'
                f" Triggered by plugin {self._plugin_name}."
            )

        installed_path = _normalize_path(installed_path)

        has_name_var = "{name}" in installed_path

        if installed_path.startswith("./DEBIAN") or reservation_only:
            # Special-case, used for control files.
            if self._plugin_name != "debputy":
                raise ValueError(
                    "Sorry; Using DEBIAN as install path or/and reservation_only is a debputy-internal"
                    f" API. Triggered by plugin {self._plugin_name}"
                )
        elif not has_name_var and "{owning_package}" not in installed_path:
            raise ValueError(
                'The installed_path must contain a "{name}" (preferred) or a "{owning_package}"'
                " substitution (or have installed_path end with a slash).  Otherwise, the installed"
                f" path would caused file-conflicts. Triggered by plugin {self._plugin_name}"
            )

        if allow_name_segment and not has_name_var:
            raise ValueError(
                'When allow_name_segment is True, the installed_path must have a "{name}" substitution'
                " variable. Otherwise, the name segment will not work properly. Triggered by"
                f" plugin {self._plugin_name}"
            )

        if (
            default_priority is not None
            and "{priority}" not in installed_path
            and "{priority:02}" not in installed_path
        ):
            raise ValueError(
                'When default_priority is not None, the installed_path should have a "{priority}"'
                ' or a "{priority:02}" substitution variable. Otherwise, the priority would be lost.'
                f" Triggered by plugin {self._plugin_name}"
            )

        if existing is not None:
            if existing.debputy_plugin_metadata.plugin_name != self._plugin_name:
                message = (
                    f'The stem "{stem}" is registered twice for packager provided files.'
                    f" Once by {existing.debputy_plugin_metadata.plugin_name} and once"
                    f" by {self._plugin_name}"
                )
            else:
                message = (
                    f"Bug in the plugin {self._plugin_name}: It tried to register the"
                    f' stem "{stem}" twice for packager provided files.'
                )
            raise PluginConflictError(
                message, existing.debputy_plugin_metadata, self._plugin_metadata
            )
        packager_provided_files[stem] = PackagerProvidedFileClassSpec(
            self._plugin_metadata,
            stem,
            installed_path,
            default_mode=default_mode,
            default_priority=default_priority,
            allow_name_segment=allow_name_segment,
            allow_architecture_segment=allow_architecture_segment,
            post_formatting_rewrite=post_formatting_rewrite,
            packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages,
            reservation_only=reservation_only,
        )

    def metadata_or_maintscript_detector(
        self,
        auto_detector_id: str,
        auto_detector: MetadataAutoDetector,
        *,
        package_type: MetadataDetectorPackageType = "deb",
    ) -> None:
        if auto_detector_id in self._plugin_detector_ids:
            raise ValueError(
                f"The plugin {self._plugin_name} tried to register"
                f' "{auto_detector_id}" twice'
            )
        self._plugin_detector_ids.add(auto_detector_id)
        all_detectors = self._feature_set.metadata_maintscript_detectors
        if self._plugin_name not in all_detectors:
            all_detectors[self._plugin_name] = []
        if isinstance(package_type, str):
            package_types = (
                _DEB_ONLY if package_type == "deb" else frozenset([package_type])
            )
        else:
            package_types = frozenset(package_type)
        all_detectors[self._plugin_name].append(
            MetadataOrMaintscriptDetector(
                detector_id=auto_detector_id,
                detector=auto_detector,
                plugin_metadata=self._plugin_metadata,
                applies_to_package_types=package_types,
                enabled=True,
            )
        )

    def document_builtin_variable(
        self,
        variable_name: str,
        variable_reference_documentation: str,
        *,
        is_context_specific: bool = False,
        is_for_special_case: bool = False,
    ) -> None:
        manifest_variables = self._feature_set.manifest_variables
        self._restricted_api()
        state = self._substitution.variable_state(variable_name)
        if state == VariableNameState.UNDEFINED:
            raise ValueError(
                f"The plugin {self._plugin_name} attempted to document built-in {variable_name},"
                f" but it is not known to be a variable"
            )

        assert variable_name not in manifest_variables

        manifest_variables[variable_name] = PluginProvidedManifestVariable(
            self._plugin_metadata,
            variable_name,
            None,
            is_context_specific_variable=is_context_specific,
            variable_reference_documentation=variable_reference_documentation,
            is_documentation_placeholder=True,
            is_for_special_case=is_for_special_case,
        )

    def manifest_variable(
        self,
        variable_name: str,
        value: str,
        variable_reference_documentation: Optional[str] = None,
    ) -> None:
        manifest_variables = self._feature_set.manifest_variables
        existing = manifest_variables.get(variable_name)

        if existing is not None:
            if existing.plugin_metadata.plugin_name == self._plugin_name:
                message = (
                    f"Bug in the plugin {self._plugin_name}: It tried to register the"
                    f' manifest variable "{variable_name}" twice.'
                )
            else:
                message = (
                    f"The plugins {existing.plugin_metadata.plugin_name} and {self._plugin_name}"
                    f" both tried to provide the manifest variable {variable_name}"
                )
            raise PluginConflictError(
                message, existing.plugin_metadata, self._plugin_metadata
            )
        if not SUBST_VAR_RE.match("{{" + variable_name + "}}"):
            raise ValueError(
                f"The plugin {self._plugin_name} attempted to declare {variable_name},"
                f" which is not a valid variable name"
            )

        namespace = ""
        variable_basename = variable_name
        if ":" in variable_name:
            namespace, variable_basename = variable_name.rsplit(":", 1)
            assert namespace != ""
            assert variable_name != ""

        if namespace != "" and namespace not in ("token", "path"):
            raise ValueError(
                f"The plugin {self._plugin_name} attempted to declare {variable_name},"
                f" which is in the reserved namespace {namespace}"
            )

        state = self._substitution.variable_state(variable_name)
        if state != VariableNameState.UNDEFINED:
            raise ValueError(
                f"The plugin {self._plugin_name} attempted to declare {variable_name},"
                f" which would shadow a built-in variable"
            )

        variable_name_upper = variable_name.upper()
        if variable_name_upper.startswith(("DEB_", "DPKG_")):
            raise ValueError(
                f"The plugin {self._plugin_name} attempted to declare {variable_name},"
                f" which is a reserved variable name"
            )
        elif (
            variable_name_upper.startswith(("DEBPUTY", "_DEBPUTY"))
            or variable_basename.startswith("_")
            or variable_basename.upper().startswith("DEBPUTY")
        ) and self._plugin_name != "debputy":
            raise ValueError(
                f"The plugin {self._plugin_name} attempted to declare {variable_name},"
                f" which is a variable name reserved by debputy"
            )
        try:
            resolved_value = self._substitution.substitute(
                value, "Plugin initialization"
            )
            depends_on_variable = resolved_value != value
        except DebputySubstitutionError:
            depends_on_variable = True
        if depends_on_variable:
            raise ValueError(
                f"The plugin {self._plugin_name} attempted to declare {variable_name} with value {value!r}."
                f" This value depends on another variable, which is not supported. This restriction may be"
                f" lifted in the future."
            )

        manifest_variables[variable_name] = PluginProvidedManifestVariable(
            self._plugin_metadata,
            variable_name,
            value,
            is_context_specific_variable=False,
            variable_reference_documentation=variable_reference_documentation,
        )

    @property
    def _plugin_name(self) -> str:
        return self._plugin_metadata.plugin_name

    def provide_manifest_keyword(
        self,
        rule_type: TTP,
        rule_name: Union[str, List[str]],
        handler: DIPKWHandler,
        *,
        inline_reference_documentation: Optional[ParserDocumentation] = None,
    ) -> None:
        self._restricted_api()
        if rule_type not in self._feature_set.dispatchable_parsers:
            types = ", ".join(
                sorted(x.__name__ for x in self._feature_set.dispatchable_parsers)
            )
            raise ValueError(
                f"The rule_type was not a supported type. It must be one of {types}"
            )
        dispatching_parser = self._feature_set.dispatchable_parsers[rule_type]
        dispatching_parser.register_keyword(
            rule_name,
            handler,
            self._plugin_metadata,
            inline_reference_documentation=inline_reference_documentation,
        )

    def provide_parse_manifest_rule(
        self,
        rule_type: TTP,
        rule_name: Union[str, List[str]],
        parsed_format: Type[PF],
        handler: DIPHandler,
        *,
        source_format: Optional[SF] = None,
        inline_reference_documentation: Optional[ParserDocumentation] = None,
    ) -> None:
        self._restricted_api()
        if rule_type not in self._feature_set.dispatchable_parsers:
            types = ", ".join(
                sorted(x.__name__ for x in self._feature_set.dispatchable_parsers)
            )
            raise ValueError(
                f"The rule_type was not a supported type. It must be one of {types}"
            )
        parser = parser_from_typed_dict(
            parsed_format,
            source_content=source_format,
            inline_reference_documentation=inline_reference_documentation,
        )
        dispatching_parser = self._feature_set.dispatchable_parsers[rule_type]
        dispatching_parser.register_parser(
            rule_name,
            parser,
            handler,
            self._plugin_metadata,
        )

    def _restricted_api(self) -> None:
        if self._plugin_name != "debputy":
            raise PluginAPIViolationError(
                f"Plugin {self._plugin_name} attempted to access a debputy-only API."
                " If you are the maintainer of this plugin and want access to this"
                " API, please file a feature request to make this public."
                " (The API is currently private as it is unstable.)"
            )


class MaintscriptAccessorProviderBase(MaintscriptAccessor, ABC):
    __slots__ = ()

    def _append_script(
        self,
        caller_name: str,
        maintscript: Maintscript,
        full_script: str,
        /,
        perform_substitution: bool = True,
    ) -> None:
        raise NotImplementedError

    @classmethod
    def _apply_condition_to_script(
        cls,
        condition: str,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
    ) -> str:
        if indent is None:
            # We auto-determine this based on heredocs currently
            indent = "<<" not in run_snippet

        if indent:
            run_snippet = "".join("  " + x for x in run_snippet.splitlines(True))
        if not run_snippet.endswith("\n"):
            run_snippet += "\n"
        condition_line = f"if {condition}; then\n"
        end_line = "fi\n"
        return "".join((condition_line, run_snippet, end_line))

    def on_configure(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
        skip_on_rollback: bool = False,
    ) -> None:
        condition = POSTINST_DEFAULT_CONDITION
        if skip_on_rollback:
            condition = '[ "$1" = "configure" ]'
        return self._append_script(
            "on_configure",
            "postinst",
            self._apply_condition_to_script(condition, run_snippet, indent=indent),
            perform_substitution=perform_substitution,
        )

    def on_initial_install(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        condition = '[ "$1" = "configure" -a -z "$2" ]'
        return self._append_script(
            "on_initial_install",
            "postinst",
            self._apply_condition_to_script(condition, run_snippet, indent=indent),
            perform_substitution=perform_substitution,
        )

    def on_upgrade(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        condition = '[ "$1" = "configure" -a -n "$2" ]'
        return self._append_script(
            "on_upgrade",
            "postinst",
            self._apply_condition_to_script(condition, run_snippet, indent=indent),
            perform_substitution=perform_substitution,
        )

    def on_upgrade_from(
        self,
        version: str,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        condition = '[ "$1" = "configure" ] && dpkg --compare-versions le-nl "$2"'
        return self._append_script(
            "on_upgrade_from",
            "postinst",
            self._apply_condition_to_script(condition, run_snippet, indent=indent),
            perform_substitution=perform_substitution,
        )

    def on_before_removal(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        condition = '[ "$1" = "remove" ]'
        return self._append_script(
            "on_before_removal",
            "prerm",
            self._apply_condition_to_script(condition, run_snippet, indent=indent),
            perform_substitution=perform_substitution,
        )

    def on_removed(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        condition = '[ "$1" = "remove" ]'
        return self._append_script(
            "on_removed",
            "postrm",
            self._apply_condition_to_script(condition, run_snippet, indent=indent),
            perform_substitution=perform_substitution,
        )

    def on_purge(
        self,
        run_snippet: str,
        /,
        indent: Optional[bool] = None,
        perform_substitution: bool = True,
    ) -> None:
        condition = '[ "$1" = "purge" ]'
        return self._append_script(
            "on_purge",
            "postrm",
            self._apply_condition_to_script(condition, run_snippet, indent=indent),
            perform_substitution=perform_substitution,
        )

    def unconditionally_in_script(
        self,
        maintscript: Maintscript,
        run_snippet: str,
        /,
        perform_substitution: bool = True,
    ) -> None:
        if maintscript not in STD_CONTROL_SCRIPTS:
            raise ValueError(
                f'Unknown script "{maintscript}". Should have been one of:'
                f' {", ".join(sorted(STD_CONTROL_SCRIPTS))}'
            )
        return self._append_script(
            "unconditionally_in_script",
            maintscript,
            run_snippet,
            perform_substitution=perform_substitution,
        )


class MaintscriptAccessorProvider(MaintscriptAccessorProviderBase):
    __slots__ = (
        "_plugin_metadata",
        "_maintscript_snippets",
        "_plugin_source_id",
        "_package_substitution",
    )

    def __init__(
        self,
        plugin_metadata: DebputyPluginMetadata,
        plugin_source_id: str,
        maintscript_snippets: Dict[str, MaintscriptSnippetContainer],
        package_substitution: Substitution,
    ):
        self._plugin_metadata = plugin_metadata
        self._plugin_source_id = plugin_source_id
        self._maintscript_snippets = maintscript_snippets
        self._package_substitution = package_substitution

    def _append_script(
        self,
        caller_name: str,
        maintscript: Maintscript,
        full_script: str,
        /,
        perform_substitution: bool = True,
    ) -> None:
        def_source = f"{self._plugin_metadata.plugin_name} ({self._plugin_source_id})"
        if perform_substitution:
            full_script = self._package_substitution.substitute(full_script, def_source)

        snippet = MaintscriptSnippet(snippet=full_script, definition_source=def_source)
        self._maintscript_snippets[maintscript].append(snippet)


class BinaryCtrlAccessorProviderBase(BinaryCtrlAccessor):
    __slots__ = (
        "_plugin_metadata",
        "_plugin_source_id",
        "_triggers",
        "_substvars",
        "_maintscript",
    )

    def __init__(
        self,
        plugin_metadata: DebputyPluginMetadata,
        plugin_source_id: str,
        triggers: Dict[Tuple[DpkgTriggerType, str], PluginProvidedTrigger],
        substvars: FlushableSubstvars,
    ) -> None:
        self._plugin_metadata = plugin_metadata
        self._plugin_source_id = plugin_source_id
        self._triggers = triggers
        self._substvars = substvars
        self._maintscript = None

    def _create_maintscript_accessor(self) -> MaintscriptAccessor:
        raise NotImplementedError

    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.
        """
        key = (trigger_type, trigger_target)
        if key in self._triggers:
            return
        self._triggers[key] = PluginProvidedTrigger(
            dpkg_trigger_type=trigger_type,
            dpkg_trigger_target=trigger_target,
            provider=self._plugin_metadata,
            provider_source_id=self._plugin_source_id,
        )

    @property
    def maintscript(self) -> MaintscriptAccessor:
        maintscript = self._maintscript
        if self._maintscript is None:
            maintscript = self._create_maintscript_accessor()
            self._maintscript = maintscript
        return maintscript

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


class BinaryCtrlAccessorProvider(BinaryCtrlAccessorProviderBase):
    __slots__ = (
        "_package_metadata_context",
        "_maintscript",
        "_maintscript_snippets",
        "_package_substitution",
    )

    def __init__(
        self,
        plugin_metadata: DebputyPluginMetadata,
        package_metadata_context: MetadataDetectorPackageContext,
        plugin_source_id: str,
        triggers: Dict[Tuple[DpkgTriggerType, str], PluginProvidedTrigger],
        substvars: Substvars,
        maintscript_snippets: Dict[str, MaintscriptSnippetContainer],
        package_substitution: Substitution,
    ) -> None:
        super().__init__(plugin_metadata, plugin_source_id, triggers, substvars)
        self._package_metadata_context = package_metadata_context
        self._maintscript_snippets = maintscript_snippets
        self._package_substitution = package_substitution
        self._maintscript = MaintscriptAccessorProvider(
            plugin_metadata,
            plugin_source_id,
            maintscript_snippets,
            package_substitution,
        )

    def _create_maintscript_accessor(self) -> MaintscriptAccessor:
        return MaintscriptAccessorProvider(
            self._plugin_metadata,
            self._plugin_source_id,
            self._maintscript_snippets,
            self._package_substitution,
        )


def plugin_metadata_for_debputys_own_plugin(
    loader: Optional[PluginInitializationEntryPoint] = None,
):
    if loader is None:
        from debputy.plugin.debputy.debputy_plugin import initialize_debputy_features

        loader = initialize_debputy_features
    return DebputyPluginMetadata(
        plugin_name="debputy",
        api_compat_version=1,
        plugin_initializer=loader,
        plugin_loader=None,
        plugin_path="<bundled>",
    )


def load_plugin_features(
    plugin_search_dirs: Sequence[str],
    substitution: Substitution,
    requested_plugins_only: Optional[Sequence[str]] = None,
    required_plugins: Optional[Set[str]] = None,
    plugin_feature_set: Optional[PluginProvidedFeatureSet] = None,
) -> PluginProvidedFeatureSet:
    if plugin_feature_set is None:
        plugin_feature_set = PluginProvidedFeatureSet()
    plugins = [plugin_metadata_for_debputys_own_plugin()]
    if required_plugins:
        plugins.extend(
            find_json_plugins(
                plugin_search_dirs,
                required_plugins,
            )
        )
    if requested_plugins_only is not None:
        plugins.extend(
            find_json_plugins(
                plugin_search_dirs,
                requested_plugins_only,
            )
        )
    else:
        plugins.extend(_find_all_json_plugins(plugin_search_dirs, required_plugins))

    for plugin_metadata in plugins:
        api = DebputyPluginInitializerProvider(
            plugin_metadata, plugin_feature_set, substitution
        )
        api.load_plugin()
    return plugin_feature_set


def find_json_plugin(
    search_dirs: Sequence[str],
    requested_plugin: str,
) -> DebputyPluginMetadata:
    r = list(find_json_plugins(search_dirs, [requested_plugin]))
    assert len(r) == 1
    return r[0]


def find_related_implementation_files_for_plugin(
    plugin_metadata: DebputyPluginMetadata,
) -> List[str]:
    plugin_path = plugin_metadata.plugin_path
    if not os.path.isfile(plugin_path):
        plugin_name = plugin_metadata.plugin_name
        _error(
            f"Cannot run find related files for {plugin_name}: The plugin seems to be bundled"
            " or loaded via a mechanism that does not support detecting its tests."
        )
    files = []
    module_name, module_file = _find_plugin_implementation_file(
        plugin_metadata.plugin_name,
        plugin_metadata.plugin_path,
    )
    if os.path.isfile(module_file):
        files.append(module_file)
    else:
        if not plugin_metadata.is_loaded:
            plugin_metadata.load_plugin()
        if module_name in sys.modules:
            _error(
                f'The plugin {plugin_metadata.plugin_name} uses the "module"" key in its'
                f" JSON metadata file ({plugin_metadata.plugin_path}) and cannot be "
                f" installed via this method. The related Python would not be installed"
                f" (which would result in a plugin that would fail to load)"
            )

    return files


def find_tests_for_plugin(
    plugin_metadata: DebputyPluginMetadata,
) -> List[str]:
    plugin_name = plugin_metadata.plugin_name
    plugin_path = plugin_metadata.plugin_path

    if not os.path.isfile(plugin_path):
        _error(
            f"Cannot run tests for {plugin_name}: The plugin seems to be bundled or loaded via a"
            " mechanism that does not support detecting its tests."
        )

    plugin_dir = os.path.dirname(plugin_path)
    test_basename_prefix = plugin_metadata.plugin_name.replace("-", "_")
    tests = []
    with os.scandir(plugin_dir) as dir_iter:
        for p in dir_iter:
            if (
                p.is_file()
                and p.name.startswith(test_basename_prefix)
                and PLUGIN_TEST_SUFFIX.search(p.name)
            ):
                tests.append(p.path)
    return tests


def find_json_plugins(
    search_dirs: Sequence[str],
    requested_plugins: Iterable[str],
) -> Iterable[DebputyPluginMetadata]:
    for plugin_name_or_path in requested_plugins:
        found = False
        if "/" in plugin_name_or_path:
            if not os.path.isfile(plugin_name_or_path):
                raise PluginNotFoundError(
                    f"Unable to load the plugin {plugin_name_or_path}: The path is not a file."
                    ' (Because the plugin name contains "/", it is assumed to be a path and search path'
                    " is not used."
                )
            yield parse_json_plugin_desc(plugin_name_or_path)
            return
        for search_dir in search_dirs:
            path = os.path.join(
                search_dir, "debputy", "plugins", f"{plugin_name_or_path}.json"
            )
            if not os.path.isfile(path):
                continue
            found = True
            yield parse_json_plugin_desc(path)
        if not found:
            search_dir_str = ":".join(search_dirs)
            raise PluginNotFoundError(
                f"Unable to load the plugin {plugin_name_or_path}: Could not find {plugin_name_or_path}.json in the"
                f" debputy/plugins subdir of any of the search dirs ({search_dir_str})"
            )


def _find_all_json_plugins(
    search_dirs: Sequence[str],
    required_plugins: Set[str],
) -> Iterable[DebputyPluginMetadata]:
    seen = set(required_plugins)
    for search_dir in search_dirs:
        try:
            dir_fd = os.scandir(os.path.join(search_dir, "debputy", "plugins"))
        except FileNotFoundError:
            continue
        with dir_fd:
            for entry in dir_fd:
                if (
                    not entry.is_file(follow_symlinks=True)
                    or not entry.name.endswith(".json")
                    or entry.name in seen
                ):
                    continue
                yield parse_json_plugin_desc(entry.path)


def _find_plugin_implementation_file(
    plugin_name: str,
    json_file_path: str,
) -> Tuple[str, str]:
    guessed_module_basename = plugin_name.replace("-", "_")
    module_name = f"debputy.plugin.{guessed_module_basename}"
    module_fs_path = os.path.join(
        os.path.dirname(json_file_path), f"{guessed_module_basename}.py"
    )
    return module_name, module_fs_path


def _resolve_module_initializer(
    plugin_name: str,
    plugin_initializer_name: str,
    module_name: Optional[str],
    json_file_path: str,
):
    module = None
    module_fs_path = None
    if module_name is None:
        module_name, module_fs_path = _find_plugin_implementation_file(
            plugin_name, json_file_path
        )
        if os.path.isfile(module_fs_path):
            spec = importlib.util.spec_from_file_location(module_name, module_fs_path)
            if spec is None:
                raise PluginInitializationError(
                    f"Failed to load {plugin_name} (path: {module_fs_path})."
                    " The spec_from_file_location function returned None."
                )
            mod = importlib.util.module_from_spec(spec)
            sys.modules[module_name] = mod
            try:
                spec.loader.exec_module(mod)
            except (Exception, GeneratorExit) as e:
                raise PluginInitializationError(
                    f"Failed to load {plugin_name} (path: {module_fs_path})."
                    " The module threw an exception while being loaded."
                ) from e
            module = mod

    if module is None:
        try:
            module = importlib.import_module(module_name)
        except ModuleNotFoundError as e:
            if module_fs_path is None:
                raise PluginMetadataError(
                    f'The plugin defined in "{json_file_path}" wanted to load the module "{module_name}", but'
                    " this module is not available in the python search path"
                ) from e
            raise PluginInitializationError(
                f"Failed to load {plugin_name}. Tried loading it from"
                f' "{module_fs_path}" (which did not exist) and PYTHONPATH as'
                f" {module_name} (where it was not found either). Please ensure"
                " the module code is installed in the correct spot or provide an"
                f' explicit "module" definition in {json_file_path}.'
            ) from e

    plugin_initializer = getattr(module, plugin_initializer_name)

    if plugin_initializer is None:
        raise PluginMetadataError(
            f'The plugin defined in {json_file_path} claimed that module "{module_name}" would have an'
            f" attribute called {plugin_initializer}. However, it does not. Please correct the plugin"
            f" metadata or initializer name in the Python module."
        )
    return plugin_initializer


def _json_plugin_loader(
    plugin_name: str,
    plugin_json_metadata: PluginJsonMetadata,
    json_file_path: str,
    attribute_path: AttributePath,
) -> Callable[["DebputyPluginInitializer"], None]:
    api_compat = plugin_json_metadata["api_compat_version"]
    module_name = plugin_json_metadata.get("module")
    plugin_initializer_name = plugin_json_metadata.get("plugin_initializer")
    packager_provided_files_raw = plugin_json_metadata.get(
        "packager_provided_files", []
    )
    manifest_variables_raw = plugin_json_metadata.get("manifest_variables")
    if api_compat != 1:
        raise PluginMetadataError(
            f'The plugin defined in "{json_file_path}" requires API compat level {api_compat}, but this'
            f" version of debputy only supports API compat version of 1"
        )
    if plugin_initializer_name is not None and "." in plugin_initializer_name:
        p = attribute_path["plugin_initializer"]
        raise PluginMetadataError(
            f'The "{p}" must not contain ".". Problematic file is "{json_file_path}".'
        )

    plugin_initializers = []

    if plugin_initializer_name is not None:
        plugin_initializer = _resolve_module_initializer(
            plugin_name,
            plugin_initializer_name,
            module_name,
            json_file_path,
        )
        plugin_initializers.append(plugin_initializer)

    if manifest_variables_raw:
        manifest_var_path = attribute_path["manifest_variables"]
        manifest_variables = [
            PLUGIN_MANIFEST_VARS_PARSER.parse_input(p, manifest_var_path[i])
            for i, p in enumerate(manifest_variables_raw)
        ]

        def _initialize_json_provided_manifest_vars(
            api: DebputyPluginInitializer,
        ) -> None:
            for idx, manifest_variable in enumerate(manifest_variables):
                name = manifest_variable["name"]
                value = manifest_variable["value"]
                doc = manifest_variable.get("reference_documentation")
                try:
                    api.manifest_variable(
                        name, value, variable_reference_documentation=doc
                    )
                except ValueError as ex:
                    var_path = manifest_var_path[idx]
                    raise PluginMetadataError(
                        f"Error while processing {var_path.path} defined in {json_file_path}: {ex.args[0]}"
                    )

        plugin_initializers.append(_initialize_json_provided_manifest_vars)

    if packager_provided_files_raw:
        ppf_path = attribute_path["packager_provided_files"]
        ppfs = [
            PLUGIN_PPF_PARSER.parse_input(p, ppf_path[i])
            for i, p in enumerate(packager_provided_files_raw)
        ]

        def _initialize_json_provided_ppfs(api: DebputyPluginInitializer) -> None:
            ppf: PackagerProvidedFileJsonDescription
            for idx, ppf in enumerate(ppfs):
                c = dict(ppf)
                stem = ppf["stem"]
                installed_path = ppf["installed_path"]
                default_mode = ppf.get("default_mode")

                if default_mode is not None:
                    c["default_mode"] = default_mode.octal_mode

                del c["stem"]
                del c["installed_path"]

                try:
                    api.packager_provided_file(stem, installed_path, **c)  # type: ignore
                except ValueError as ex:
                    p_path = ppf_path[idx]
                    raise PluginMetadataError(
                        f"Error while processing {p_path.path} defined in {json_file_path}: {ex.args[0]}"
                    )

        plugin_initializers.append(_initialize_json_provided_ppfs)

    if not plugin_initializers:
        raise PluginMetadataError(
            f"The plugin defined in {json_file_path} does not seem to provide features, "
            f" such as module + plugin-initializer or packager-provided-files."
        )

    if len(plugin_initializers) == 1:
        return plugin_initializers[0]

    def _chain_loader(api: DebputyPluginInitializer) -> None:
        for initializer in plugin_initializers:
            initializer(api)

    return _chain_loader


@contextlib.contextmanager
def _open(path: str, fd: Optional[IO[bytes]] = None) -> Iterator[IO[bytes]]:
    if fd is not None:
        yield fd
    else:
        with open(path, "rb") as fd:
            yield fd


def parse_json_plugin_desc(
    path: str, *, fd: Optional[IO[bytes]] = None
) -> DebputyPluginMetadata:
    with _open(path, fd=fd) as rfd:
        try:
            raw = json.load(rfd)
        except JSONDecodeError as e:
            raise PluginMetadataError(
                f'The plugin defined in "{path}" could not be parsed as valid JSON: {e.args[0]}'
            ) from e
    plugin_name = os.path.basename(path)
    if plugin_name.endswith(".json"):
        plugin_name = plugin_name[:-5]
    elif plugin_name.endswith(".json.in"):
        plugin_name = plugin_name[:-8]

    attribute_path = AttributePath.root_path()

    try:
        plugin_json_metadata = PLUGIN_METADATA_PARSER.parse_input(
            raw,
            attribute_path,
        )
    except ManifestParseException as e:
        raise PluginMetadataError(
            f'The plugin defined in "{path}" was valid JSON but could not be parsed: {e.message}'
        ) from e
    api_compat = plugin_json_metadata["api_compat_version"]

    return DebputyPluginMetadata(
        plugin_name=plugin_name,
        plugin_loader=lambda: _json_plugin_loader(
            plugin_name,
            plugin_json_metadata,
            path,
            attribute_path,
        ),
        api_compat_version=api_compat,
        plugin_initializer=None,
        plugin_path=path,
    )
