import dataclasses
import importlib
import json
import os
from abc import ABC
from typing import Optional, Callable, Dict, List, Tuple, Iterable, FrozenSet, Sequence

from debian.substvars import Substvars

import debputy.version
from debputy.filesystem_scan import DebputyFSIsROError
from debputy.maintscript_snippet import (
    STD_CONTROL_SCRIPTS,
    MaintscriptSnippetContainer,
    MaintscriptSnippet,
)
from debputy.packages import BinaryPackage
from debputy.plugin.api import VirtualPath
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

_DEB_ONLY = frozenset(["deb"])


@dataclasses.dataclass(slots=True)
class DebputyPluginMetadata:
    plugin_name: str
    plugin_version: str
    api_compat_version: int
    plugin_loader: Optional[Callable[[], Callable[["DebputyPluginInitializer"], None]]]
    plugin_initializer: Optional[Callable[["DebputyPluginInitializer"], None]]
    _is_initialized: bool = False

    @property
    def is_loaded(self) -> bool:
        return self.plugin_initializer is not None

    @property
    def is_initialized(self) -> bool:
        return self._is_initialized

    def initialize_plugin(self, api: "DebputyPluginInitializer") -> None:
        if self.is_initialized:
            raise RuntimeError("Cannot load plugins twice")
        if not self.is_loaded:
            self._load()
        self.plugin_initializer(api)
        self._is_initialized = True

    def _load(self) -> None:
        self.plugin_initializer = self.plugin_loader()
        assert self.plugin_initializer is not None


@dataclasses.dataclass(slots=True, frozen=True)
class PackagerProvidedFileClassSpec:
    debputy_plugin_metadata: DebputyPluginMetadata
    name_stem: str
    install_into_directory: str
    default_mode: int
    default_priority: Optional[int]
    allow_name_segment: bool
    allow_architecture_segment: bool
    filename_format: Optional[str]
    post_formatting_rewrite: Optional[Callable[[str], str]]
    packageless_is_fallback_for_all_packages: bool

    @property
    def supports_priority(self) -> bool:
        return self.default_priority is not None

    def compute_dest_basename(
        self,
        assigned_name: str,
        # Note this method is currently used 1:1 inside plugin tests.
        *,
        owning_package: Optional[str] = None,
        assigned_priority: Optional[int] = None,
    ) -> str:
        if assigned_priority is not None and not self.supports_priority:
            raise ValueError(
                f"Cannot assign priority to packager provided files with stem"
                f' "{self.name_stem}" (e.g., "debian/foo.{self.name_stem}"). They'
                " do not use priority at all."
            )

        filename_format = self.filename_format
        if filename_format is None:
            filename_format = (
                "{priority:02}-{name}" if self.supports_priority else "{name}"
            )
        if self.supports_priority and assigned_priority is None:
            assigned_priority = self.default_priority

        filename = filename_format.format(
            priority=assigned_priority,
            name=assigned_name,
            owning_package=owning_package,
            name_stem=self.name_stem,
        )

        if self.post_formatting_rewrite:
            filename = self.post_formatting_rewrite(filename)
        return filename


@dataclasses.dataclass(slots=True)
class MetadataOrMaintscriptDetector:
    plugin_metadata: DebputyPluginMetadata
    detector_id: str
    detector: MetadataAutoDetector
    applies_to_package_types: FrozenSet[str]
    enabled: bool = True

    def applies_to(self, binary_package: BinaryPackage) -> bool:
        return binary_package.package_type in self.applies_to_package_types

    def run_detector(
        self,
        fs_root: "VirtualPath",
        ctrl: "BinaryCtrlAccessor",
        context: "MetadataDetectorPackageContext",
    ) -> None:
        try:
            self.detector(fs_root, ctrl, context)
        except DebputyFSIsROError as e:
            nv = f"{self.plugin_metadata.plugin_name}/{self.plugin_metadata.plugin_version}"
            raise PluginAPIViolationError(
                f'The plugin {nv} tried to violate the API contract for "metadata detectors"'
                " by attempting to mutate the provided file system.  File system mutation is"
                " *not* supported at this stage (file system layout is committed and the"
                " attempted changes would be lost at best)."
            ) from e


@dataclasses.dataclass
class PluginProvidedFeatureSet:
    plugin_data: Dict[str, DebputyPluginMetadata]
    packager_provided_files: Dict[str, PackagerProvidedFileClassSpec]
    metadata_maintscript_detectors: Dict[str, List[MetadataOrMaintscriptDetector]]


@dataclasses.dataclass(slots=True, frozen=True)
class MetadataDetectorPackageContextProvider(MetadataDetectorPackageContext):
    binary_package: BinaryPackage
    related_udeb_package: Optional[BinaryPackage]


class PluginError(RuntimeError):
    @property
    def message(self) -> str:
        return self.args[0]


class PluginInitializationError(PluginError):
    pass


class PluginMetadataError(PluginError):
    pass


class PluginConflictError(PluginError):
    @property
    def plugin_a(self) -> DebputyPluginMetadata:
        return self.args[1]

    @property
    def plugin_b(self) -> DebputyPluginMetadata:
        return self.args[2]


class PluginAPIViolationError(PluginError):
    pass


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}) ..."
        )
        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,
        name_stem: str,
        install_into_directory: str,
        *,
        default_mode: int = 0o0644,
        default_priority: Optional[int] = None,
        allow_name_segment: bool = True,
        allow_architecture_segment: bool = False,
        filename_format: Optional[str] = None,
        post_formatting_rewrite: Optional[Callable[[str], str]] = None,
        packageless_is_fallback_for_all_packages: bool = False,
    ) -> None:
        packager_provided_files = self._feature_set.packager_provided_files
        existing = packager_provided_files.get(name_stem)
        install_into_directory = _normalize_path(install_into_directory)

        if "/" in name_stem:
            raise ValueError(
                'The name_stem parameter cannot contain "/". Triggered by plugin'
                f" {self._plugin_name}"
            )

        if filename_format is not None and "/" in filename_format:
            raise ValueError(
                'The filename_format parameter cannot contain "/". Triggered by plugin'
                f" {self._plugin_name}"
            )

        if existing is not None:
            if existing.debputy_plugin_metadata.plugin_name != self._plugin_name:
                message = (
                    f'The name_stem "{name_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' name_stem "{name_stem}" twice for packager provided files.'
                )
            raise PluginConflictError(
                message, existing.debputy_plugin_metadata, self._plugin_metadata
            )
        packager_provided_files[name_stem] = PackagerProvidedFileClassSpec(
            self._plugin_metadata,
            name_stem,
            install_into_directory,
            default_mode=default_mode,
            default_priority=default_priority,
            allow_name_segment=allow_name_segment,
            allow_architecture_segment=allow_architecture_segment,
            filename_format=filename_format,
            post_formatting_rewrite=post_formatting_rewrite,
            packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages,
        )

    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)


@dataclasses.dataclass(slots=True, frozen=True)
class PluginProvidedTrigger:
    dpkg_trigger_type: DpkgTriggerType
    dpkg_trigger_target: str
    provider: DebputyPluginMetadata
    provider_source_id: str

    def serialized_format(self) -> str:
        return f"{self.dpkg_trigger_type} {self.dpkg_trigger_target}"


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


def load_plugin_features(
    json_plugin_search_dirs: Sequence[str],
) -> PluginProvidedFeatureSet:
    feature_set = PluginProvidedFeatureSet(
        plugin_data={},
        packager_provided_files={},
        metadata_maintscript_detectors={},
    )
    plugins = [plugin_metadata_for_debputys_own_plugin()]
    # TODO: Support loading only requested modules to ensure deterministic behaviour in unclean builds.
    plugins.extend(_find_all_json_plugins(json_plugin_search_dirs))

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


def _find_all_json_plugins(
    search_dirs: Sequence[str],
) -> Iterable[DebputyPluginMetadata]:
    seen = set()
    for search_dir in search_dirs:
        try:
            dir_fd = os.scandir(search_dir)
        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 parse_json_plugin_desc(path: str) -> DebputyPluginMetadata:
    with open(path, "rb") as fd:
        raw = json.load(fd)
    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]
    try:
        plugin_version = raw["plugin_version"]
        module_name = raw["module"]
        plugin_initializer = raw["plugin_initializer"]
        api_compat = raw["api_compat_version"]
    except KeyError as e:
        raise PluginMetadataError(
            f'The plugin defined in "{path}" was missing the required key: {e.args[0]}'
        ) from e

    if "." in plugin_initializer:
        raise PluginMetadataError(
            f'The "plugin_initializer" must not contain ".", but the one defined in "{path}" did'
        )

    def _loader() -> Callable[["DebputyPluginInitializer"], None]:
        if api_compat != 1:
            raise PluginMetadataError(
                f'The plugin defined in "{path}" requires API compat level {api_compat}, but this'
                f" version of debputy only supports API compat version of 1"
            )

        try:
            module = importlib.import_module(module_name)
        except ModuleNotFoundError as e:
            raise PluginMetadataError(
                f'The plugin defined in "{path}" wanted to load the module "{module_name}", but'
                " this module is not available in the python search path"
            ) from e
        func = getattr(module, plugin_initializer)

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

    return DebputyPluginMetadata(
        plugin_name=plugin_name,
        plugin_version=plugin_version,
        plugin_loader=_loader,
        api_compat_version=api_compat,
        plugin_initializer=None,
    )
