import collections
import dataclasses
import enum
import typing
from typing import Self, TYPE_CHECKING
from collections.abc import Sequence, Iterable

import debian.debian_support

from debputy.manifest_parser.tagging_types import DebputyDispatchableType
from debputy.manifest_parser.util import AttributePath
from debputy.substitution import Substitution
from debputy.util import POSTINST_DEFAULT_CONDITION

if TYPE_CHECKING:
    from debputy.plugins.debputy.binary_package_rules import (
        MaintainerProvidedMaintscriptSnippetContainer,
    )

# The standard ctrl scripts from `dpkg` (`config` is from `debconf`)
DPKG_DEB_CONTROL_SCRIPTS = frozenset(
    {
        "preinst",
        "prerm",
        "postinst",
        "postrm",
    }
)
# all control scripts for debs and udebs respectively.
ALL_DEB_CONTROL_SCRIPTS = DPKG_DEB_CONTROL_SCRIPTS | {"config"}
ALL_UDEB_CONTROL_SCRIPTS = frozenset(
    {
        "postinst",
        "menutest",
        "isinstallable",
        "config",
    }
)
ALL_CONTROL_SCRIPTS = DPKG_DEB_CONTROL_SCRIPTS | ALL_UDEB_CONTROL_SCRIPTS
# TODO: Unclear what is missing to support the remaining `udeb` scripts.
#
# Note depending on support, this might change into scripts that we do something useful
# for vs. scripts we just handle as a PPF.
SUPPORTED_UDEB_SCRIPTS = frozenset({"postinst"})


@dataclasses.dataclass(slots=True, frozen=True)
class MaintscriptForBinary:
    name: str


class SnippetAnchor(enum.StrEnum):
    CUSTOM_SCRIPT_PLACEHOLDER = "custom-script-placeholder"
    """
    Placeholder.

    Custom scripts should not have a specific place but rather a "before X/after X" logic. 
    """
    USER_CREATION = "user-creation"
    CONFIGURATION_MANAGEMENT = "configuration-management"
    # TODO: Rename before it becomes public
    _BETWEEN_CONFIGURATION_MANAGEMENT_AND_SERVICE = (
        "between-configuration-maagement-and-service"
    )
    SERVICE = "service"


class SnippetResolver:

    def _resolve_snippet(self) -> str:
        raise NotImplementedError

    def resolve(self) -> str:
        snippet = self._resolve_snippet()
        if not snippet.endswith("\n"):
            return snippet + "\n"
        return snippet

    def apply_manifest_substitution(
        self,
        definition_source: str,
        substitution: Substitution,
    ) -> "SnippetResolver":
        return SubstitutingSnippetResolver(self, definition_source, substitution)

    @classmethod
    def snippet(cls, snippet: str) -> "SnippetResolver":
        return ConstantSnippetResolver(snippet, "IGNORED", None)

    @classmethod
    def lazy_snippet_no_manifest_substitution(
        cls,
        impl: collections.abc.Callable[[], str],
    ) -> "SnippetResolver":
        return LazySnippetResolver(impl)

    @classmethod
    def lazy_snippet(
        cls,
        impl: collections.abc.Callable[[], str],
    ) -> "SnippetResolver":
        return LazySnippetResolver(impl)


class ConstantSnippetResolver(SnippetResolver):
    def __init__(
        self, snippet: str, definition_source: str, substitution: Substitution | None
    ) -> None:
        self._snippet = snippet
        self._definition_source = definition_source
        self._substitution = substitution

    @property
    def provides_substitution(self) -> bool:
        return self._substitution is not None

    def _resolve_snippet(self) -> str:
        template = self._snippet
        substitution = self._substitution
        if substitution:
            return substitution.substitute(
                template,
                self._definition_source,
            )
        return template


class CachingSnippetResolver(SnippetResolver):

    def __init__(self) -> None:
        self._resolved: str | None = None

    def _resolve_snippet_uncached(self) -> str:
        raise NotImplementedError

    def _resolve_snippet(self) -> str:
        resolved = self._resolved
        if resolved is not None:
            return resolved
        resolved = self._resolve_snippet_uncached()
        self._resolved = resolved
        return resolved


class LazySnippetResolver(CachingSnippetResolver):

    def __init__(self, resolver: collections.abc.Callable[[], str]) -> None:
        super().__init__()
        self._resolver = resolver

    def _resolve_snippet_uncached(self) -> str:
        return self._resolver()


class SubstitutingSnippetResolver(CachingSnippetResolver):

    def __init__(
        self,
        wrapped_resolver: SnippetResolver,
        definition_source: str,
        substitution: Substitution,
    ) -> None:
        super().__init__()
        self._wrapped_resolver = wrapped_resolver
        self._definition_source = definition_source
        self._substitution = substitution

    def _resolve_snippet_uncached(self) -> str:
        template = self._wrapped_resolver.resolve()
        return self._substitution.substitute(template, self._definition_source)


@dataclasses.dataclass(slots=True, frozen=True)
class UnboundMaintscriptSnippet:
    definition_source: str
    snippet: SnippetResolver
    snippet_anchor: SnippetAnchor = (
        SnippetAnchor._BETWEEN_CONFIGURATION_MANAGEMENT_AND_SERVICE
    )
    uses_debconf: bool = False

    def script_content(self) -> str:
        snippet = self.snippet.resolve()
        lines = [
            f"# Snippet source: {self.definition_source}\n",
            snippet,
        ]
        return "".join(lines)


@dataclasses.dataclass(slots=True, frozen=True)
class MaintscriptSnippetForNamedScript:
    definition_source: str
    maintscript_name: str
    snippet: SnippetResolver
    snippet_anchor: SnippetAnchor = (
        SnippetAnchor._BETWEEN_CONFIGURATION_MANAGEMENT_AND_SERVICE
    )
    uses_debconf: bool = False

    def apply_to_container(
        self,
        maintscript_container: "PackageMaintscriptSnippetContainer",
    ) -> None:
        maintscript_container[self.maintscript_name].append(self)

    def script_content(self) -> str:
        snippet = self.snippet.resolve()
        lines = [
            f"# Snippet source: {self.definition_source}\n",
            snippet,
        ]
        return "".join(lines)


MaintscriptSnippet: typing.TypeAlias = (
    UnboundMaintscriptSnippet | MaintscriptSnippetForNamedScript
)


class MaintscriptSnippetContainer:
    def __init__(self, script_name: str) -> None:
        self._script_name = script_name
        self._snippets_by_order: dict[SnippetAnchor, list[MaintscriptSnippet]] = {}

    def copy(self) -> "MaintscriptSnippetContainer":
        instance = self.__class__(self._script_name)
        instance._snippets_by_order = self._snippets_by_order.copy()
        return instance

    def append(self, maintscript_snippet: MaintscriptSnippet) -> None:
        assert maintscript_snippet.snippet_anchor
        if (
            isinstance(maintscript_snippet, MaintscriptSnippetForNamedScript)
            and maintscript_snippet.maintscript_name != self._script_name
        ):
            raise ValueError(
                f"Cannot apply snippet for {maintscript_snippet.maintscript_name}"
                f" to {self._script_name}"
            )
        if maintscript_snippet.snippet_anchor not in self._snippets_by_order:
            self._snippets_by_order[maintscript_snippet.snippet_anchor] = []
        self._snippets_by_order[maintscript_snippet.snippet_anchor].append(
            maintscript_snippet
        )

    def all_snippets(self) -> Iterable[MaintscriptSnippet]:
        for sa in SnippetAnchor:
            if sa not in self._snippets_by_order:
                continue
            yield from self._snippets_by_order[sa]

    def needs_debconf(self) -> bool:
        return any(
            s.uses_debconf
            for snippet_lists in self._snippets_by_order.values()
            for s in snippet_lists
        )

    def generate_snippet(
        self,
        *,
        tool_with_version: str | None = None,
        snippet_anchor: SnippetAnchor = SnippetAnchor._BETWEEN_CONFIGURATION_MANAGEMENT_AND_SERVICE,
        reverse: bool = False,
    ) -> str | None:
        snippets = self._snippets_by_order.get(snippet_anchor)
        if not snippets:
            return None
        if reverse:
            resulting_snippets: collections.abc.Iterable[MaintscriptSnippet] = reversed(
                snippets
            )
        else:
            resulting_snippets = snippets
        inner_content = "".join(s.script_content() for s in resulting_snippets)

        if not inner_content:
            return None

        if tool_with_version:
            return (
                f"# Automatically added by {tool_with_version}\n"
                + inner_content
                + "# End automatically added section"
            )
        return inner_content


class PackageMaintscriptSnippetContainer(
    collections.abc.Mapping[str, MaintscriptSnippetContainer]
):

    def __init__(self, supported_scripts: frozenset[str] | None = None) -> None:
        self._supported_scripts = (
            supported_scripts if supported_scripts is not None else ALL_CONTROL_SCRIPTS
        )
        self._script_containers: dict[str, MaintscriptSnippetContainer] = {}

    def __len__(self):
        return len(self._script_containers)

    def __iter__(self) -> collections.abc.Iterator[str]:
        yield from self._script_containers

    def __getitem__(self, key: str, /) -> MaintscriptSnippetContainer:
        if key not in self._supported_scripts:
            raise KeyError(key)
        try:
            return self._script_containers[key]
        except KeyError:
            pass
        c = MaintscriptSnippetContainer(key)
        self._script_containers[key] = c
        return c

    def apply_maintainer_provided_snippets(
        self,
        maintainer_provided_maintscript_snippets: "MaintainerProvidedMaintscriptSnippetContainer",
    ) -> None:
        for snippet in maintainer_provided_maintscript_snippets.snippets:
            snippet.apply_to_container(self)


class DpkgMaintscriptHelperCommand(DebputyDispatchableType):
    __slots__ = ("cmdline", "definition_source")

    def __init__(self, cmdline: Sequence[str], definition_source: str) -> None:
        super().__init__()
        self.cmdline = cmdline
        self.definition_source = definition_source

    @classmethod
    def _finish_cmd(
        cls,
        definition_source: str,
        cmdline: list[str],
        prior_version: str | None,
        owning_package: str | None,
    ) -> Self:
        if prior_version is not None:
            cmdline.append(prior_version)
        if owning_package is not None:
            if prior_version is None:
                # Empty is allowed according to `man dpkg-maintscript-helper`
                cmdline.append("")
            cmdline.append(owning_package)
        return cls(
            tuple(cmdline),
            definition_source,
        )

    @classmethod
    def rm_conffile(
        cls,
        definition_source: AttributePath,
        conffile: str,
        prior_version: str | None = None,
        owning_package: str | None = None,
    ) -> Self:
        cmdline = ["rm_conffile", conffile]
        return cls._finish_cmd(
            definition_source.path, cmdline, prior_version, owning_package
        )

    @classmethod
    def mv_conffile(
        cls,
        definition_source: AttributePath,
        old_conffile: str,
        new_confile: str,
        prior_version: str | None = None,
        owning_package: str | None = None,
    ) -> Self:
        cmdline = ["mv_conffile", old_conffile, new_confile]
        return cls._finish_cmd(
            definition_source.path, cmdline, prior_version, owning_package
        )

    @classmethod
    def symlink_to_dir(
        cls,
        definition_source: AttributePath,
        pathname: str,
        old_target: str,
        prior_version: str | None = None,
        owning_package: str | None = None,
    ) -> Self:
        cmdline = ["symlink_to_dir", pathname, old_target]
        return cls._finish_cmd(
            definition_source.path, cmdline, prior_version, owning_package
        )

    @classmethod
    def dir_to_symlink(
        cls,
        definition_source: AttributePath,
        pathname: str,
        new_target: str,
        prior_version: str | None = None,
        owning_package: str | None = None,
    ) -> Self:
        cmdline = ["dir_to_symlink", pathname, new_target]
        return cls._finish_cmd(
            definition_source.path, cmdline, prior_version, owning_package
        )


class MaintscriptCondition(DebputyDispatchableType):
    __slots__ = ("_maintscript_name", "_condition")

    def __init__(self, maintscript_name: str, condition: str) -> None:
        super().__init__()
        if maintscript_name not in ALL_CONTROL_SCRIPTS:
            raise ValueError(f"Invalid control script name: {maintscript_name}")
        self._maintscript_name = maintscript_name
        self._condition = condition

    @property
    def maintscript_name(self) -> str:
        return self._maintscript_name

    @classmethod
    def on_purge(cls) -> typing.Self:
        condition = '[ "$1" = "purge" ]'
        return cls("postrm", condition)

    @classmethod
    def on_configure(cls) -> typing.Self:
        condition = POSTINST_DEFAULT_CONDITION
        return cls("postinst", condition)

    @classmethod
    def on_initial_install(cls) -> typing.Self:
        condition = '[ "$1" = "configure" -a -z "$2" ]'
        return cls("postinst", condition)

    @classmethod
    def on_upgrade(cls) -> typing.Self:
        condition = '[ "$1" = "configure" -a -n "$2" ]'
        return cls("postinst", condition)

    @classmethod
    def on_upgrade_from(
        cls,
        from_version: debian.debian_support.Version,
    ) -> typing.Self:
        condition = (
            f'[ "$1" = "configure" ] && dpkg --compare-versions le-nl "{from_version}"'
        )
        return cls("postinst", condition)

    @classmethod
    def on_before_upgrade(cls) -> typing.Self:
        condition = '[ "$1" = "upgrade" ]'
        return cls("preinst", condition)

    @classmethod
    def on_before_removal(cls) -> typing.Self:
        condition = '[ "$1" = "remove" ]'
        return cls("prerm", condition)

    @classmethod
    def on_after_removal(cls) -> typing.Self:
        condition = '[ "$1" = "remove" ]'
        return cls("postrm", condition)

    @classmethod
    def on_unconditionally_in_script(cls, script_name: str) -> typing.Self:
        return cls(script_name, "true")

    def _apply_condition_to_script(
        self,
        condition: str,
        run_snippet: str,
        /,
        indent: bool | None = 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 _wrap_resolver(
        self,
        snippet_resolver: SnippetResolver,
        *,
        indent: bool | None = None,
    ) -> SnippetResolver:
        def _wrap() -> str:
            unconditional_snippet = snippet_resolver.resolve()
            return self._apply_condition_to_script(
                self._condition,
                unconditional_snippet,
                indent=indent,
            )

        return SnippetResolver.lazy_snippet(_wrap)

    def as_maintscript_snippet(
        self,
        definition_source: str,
        snippet_resolver,
        *,
        snippet_anchor: SnippetAnchor = SnippetAnchor._BETWEEN_CONFIGURATION_MANAGEMENT_AND_SERVICE,
        uses_debconf: bool = False,
        indent: bool | None = None,
    ) -> MaintscriptSnippetForNamedScript:
        return MaintscriptSnippetForNamedScript(
            definition_source,
            self.maintscript_name,
            self._wrap_resolver(snippet_resolver, indent=indent),
            snippet_anchor=snippet_anchor,
            uses_debconf=uses_debconf,
        )
