import importlib
import json
import os
from abc import ABC
from json import JSONDecodeError
from typing import Optional, Callable, Dict, Tuple, Iterable, Sequence

from debian.substvars import Substvars

import debputy.version
from debputy.maintscript_snippet import (
    STD_CONTROL_SCRIPTS,
    MaintscriptSnippetContainer,
    MaintscriptSnippet,
)
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,
)
from debputy.plugin.api.plugin_parser import (
    PLUGIN_METADATA_PARSER,
    PluginJsonMetadata,
    PLUGIN_PPF_PARSER,
    PackagerProvidedFileJsonDescription,
)
from debputy.plugin.api.spec import (
    MaintscriptAccessor,
    Maintscript,
    DpkgTriggerType,
    BinaryCtrlAccessor,
    MetadataDetectorPackageContext,
    MetadataAutoDetector,
    PluginInitializationEntryPoint,
    DebputyPluginInitializer,
    MetadataDetectorPackageType,
    FlushableSubstvars,
)
from debputy.substitution import Substitution
from debputy.util import _normalize_path, POSTINST_DEFAULT_CONDITION, _info


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

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

    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} (version: {metadata.plugin_version}) ..."
        )
        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} (version: {metadata.plugin_version})"
                    " does not appear to be a 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} (version: {metadata.plugin_version})"
            ) 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_type = (
                _DEB_ONLY if package_type == "deb" else 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_type,
                enabled=True,
            )
        )

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


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_metadata.plugin_version} ({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:
        if self._maintscript is None:
            self._maintscript = self._create_maintscript_accessor()
        return self._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",
        plugin_version=debputy.version.__version__,
        api_compat_version=1,
        plugin_initializer=loader,
        plugin_loader=None,
        plugin_path="<bundled>",
    )


def load_plugin_features(
    plugin_search_dirs: Sequence[str],
    requested_plugins_only: Optional[Sequence[str]] = None,
) -> PluginProvidedFeatureSet:
    feature_set = PluginProvidedFeatureSet(
        plugin_data={},
        packager_provided_files={},
        metadata_maintscript_detectors={},
    )
    plugins = [plugin_metadata_for_debputys_own_plugin()]
    if requested_plugins_only is not None:
        plugins.extend(
            _find_requested_json_plugins(
                plugin_search_dirs,
                requested_plugins_only,
            )
        )
    else:
        plugins.extend(_find_all_json_plugins(plugin_search_dirs))

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


def _find_requested_json_plugins(
    search_dirs: Sequence[str],
    requested_plugins_only: Sequence[str],
) -> Iterable[DebputyPluginMetadata]:
    for plugin in requested_plugins_only:
        found = False
        for search_dir in search_dirs:
            path = os.path.join(search_dir, "debputy", "plugins", f"{plugin}.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}: Could not find {plugin}.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],
) -> Iterable[DebputyPluginMetadata]:
    seen = set()
    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 _json_plugin_loader(
    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 = plugin_json_metadata.get("plugin_initializer")
    packager_provided_files_raw = plugin_json_metadata.get(
        "packager_provided_files", []
    )
    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 (module_name is None) ^ (plugin_initializer is None):
        pip = attribute_path["plugin_initializer"]
        mp = attribute_path["module"]
        raise PluginMetadataError(
            f'Either "{pip}" and "{mp}" must be provided or neither can be provided.'
            f' Problematic file is "{json_file_path}"'
        )
    if plugin_initializer is not None and "." in plugin_initializer:
        p = attribute_path["plugin_initializer"]
        raise PluginMetadataError(
            f'The "{p}" must not contain ".". Problematic file is "{json_file_path}".'
        )

    plugin_initializers = []

    if module_name is not None and plugin_initializer is not None:
        try:
            module = importlib.import_module(module_name)
        except ModuleNotFoundError as e:
            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
        plugin_initializer = getattr(module, plugin_initializer)

        if plugin_initializer is None:
            raise PluginMetadataError(
                f'The plugin defined in {json_file_path} claimed that module "{module}" would have an'
                f" attribute called {plugin_initializer}. However, it does not."
            )
        plugin_initializers.append(plugin_initializer)

    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)
                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


def parse_json_plugin_desc(path: str) -> DebputyPluginMetadata:
    with open(path, "rb") as fd:
        try:
            raw = json.load(fd)
        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
    plugin_version = plugin_json_metadata["plugin_version"]
    api_compat = plugin_json_metadata["api_compat_version"]

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