import dataclasses
import os.path
from typing import (
    Optional,
    Callable,
    FrozenSet,
    Dict,
    List,
    Tuple,
    Generic,
    TYPE_CHECKING,
    TypeVar,
    Iterable,
    cast,
    Any,
    Sequence,
    Union,
    Type,
)

from debputy.exceptions import DebputyRuntimeError
from debputy.filesystem_scan import DebputyFSIsROError
from debputy.installations import InstallRule
from debputy.maintscript_snippet import DpkgMaintscriptHelperCommand
from debputy.manifest_conditions import ManifestCondition
from debputy.manifest_parser.exceptions import ManifestParseException
from debputy.manifest_parser.util import AttributePath
from debputy.packages import BinaryPackage
from debputy.plugin.api import (
    VirtualPath,
    BinaryCtrlAccessor,
    MetadataDetectorPackageContext,
)
from debputy.plugin.api.spec import (
    DebputyPluginInitializer,
    MetadataAutoDetector,
    DpkgTriggerType,
    ParserDocumentation,
)
from debputy.transformation_rules import TransformationRule
from debputy.util import _normalize_path

if TYPE_CHECKING:
    from debputy.manifest_parser.base_types import DebputyParsedContent
    from debputy.manifest_parser.parser_data import ParserContextData
    from debputy.highlevel_manifest import (
        HighLevelManifest,
        PackageTransformationDefinition,
    )


_DEB_ONLY = frozenset(["deb"])


TD = TypeVar("TD", bound="DebputyParsedContent")
PF = TypeVar("PF")
SF = TypeVar("SF")
TP = TypeVar("TP")
TTP = Type[TP]

DIPKWHandler = Callable[[str, AttributePath, "ParserContextData"], TP]
DIPHandler = Callable[[str, PF, AttributePath, "ParserContextData"], TP]


@dataclasses.dataclass(slots=True)
class DebputyPluginMetadata:
    plugin_name: str
    api_compat_version: int
    plugin_loader: Optional[Callable[[], Callable[["DebputyPluginInitializer"], None]]]
    plugin_initializer: Optional[Callable[["DebputyPluginInitializer"], None]]
    plugin_path: str
    _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_plugin()
        plugin_initializer = self.plugin_initializer
        assert plugin_initializer is not None
        plugin_initializer(api)
        self._is_initialized = True

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


@dataclasses.dataclass(slots=True, frozen=True)
class PluginProvidedParser(Generic[PF, TP]):
    parser: "DeclarativeInputParser[PF]"
    handler: Callable[[str, PF, "AttributePath", "ParserContextData"], TP]
    plugin_metadata: DebputyPluginMetadata

    def parse(
        self,
        name: str,
        value: object,
        attribute_path: "AttributePath",
        *,
        parser_context: "ParserContextData",
    ) -> TP:
        parsed_value = self.parser.parse_input(
            value, attribute_path, parser_context=parser_context
        )
        return self.handler(name, parsed_value, attribute_path, parser_context)


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

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

    def compute_dest(
        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,
    ) -> Tuple[str, 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.stem}" (e.g., "debian/foo.{self.stem}"). They'
                " do not use priority at all."
            )

        path_format = self.installed_as_format
        if self.supports_priority and assigned_priority is None:
            assigned_priority = self.default_priority

        path = path_format.format(
            priority=assigned_priority,
            name=assigned_name,
            owning_package=owning_package,
        )

        dirname, basename = os.path.split(path)
        dirname = _normalize_path(dirname)

        if self.post_formatting_rewrite:
            basename = self.post_formatting_rewrite(basename)
        return dirname, basename


@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}"
            raise PluginAPIViolationError(
                f'The plugin {nv} violated the API contract for "metadata detectors"'
                " by attempting to mutate the provided file system in its metadata detector"
                f" with id {self.detector_id}.  File system mutation is *not* supported at"
                " this stage (file system layout is committed and the attempted changes"
                " would be lost)."
            ) from e
        except (ChildProcessError, RuntimeError, AttributeError) as e:
            nv = f"{self.plugin_metadata.plugin_name}"
            raise UnhandledOrUnexpectedErrorFromPluginError(
                f"The plugin {nv} threw an unhandled or unexpected exception from its metadata"
                f" detector with id {self.detector_id}."
            ) from e


class DeclarativeInputParser(Generic[TD]):
    @property
    def inline_reference_documentation(self) -> Optional[ParserDocumentation]:
        return None

    @property
    def reference_documentation_url(self) -> Optional[str]:
        doc = self.inline_reference_documentation
        return doc.documentation_reference_url if doc is not None else None

    def parse_input(
        self,
        value: object,
        path: "AttributePath",
        *,
        parser_context: Optional["ParserContextData"] = None,
    ) -> TD:
        raise NotImplementedError


class DispatchingParser(Generic[TP]):
    def __init__(self, base_type: TTP, manifest_attribute_path: str) -> None:
        self.base_type = base_type
        self.manifest_attribute_path = manifest_attribute_path
        self._parsers: Dict[str, PluginProvidedParser[Any, TP]] = {}

    def is_known_keyword(self, keyword: str) -> bool:
        return keyword in self._parsers

    def registered_keywords(self) -> Iterable[str]:
        yield from self._parsers

    def parser_for(self, keyword: str) -> PluginProvidedParser[Any, TP]:
        return self._parsers[keyword]

    def parse(
        self,
        orig_value: object,
        attribute_path: "AttributePath",
        *,
        parser_context: "ParserContextData",
    ) -> TP:
        if isinstance(orig_value, str):
            key = orig_value
            value = None
            value_path = attribute_path
        elif isinstance(orig_value, dict):
            if len(orig_value) != 1:
                valid_keys = ", ".join(self._parsers.keys())
                raise ManifestParseException(
                    f'The mapping "{attribute_path.path}" had two keys, but it should only have one top level key.'
                    " Maybe you are missing a list marker behind the second key or some indentation.  The"
                    f' possible keys are: {", ".join(valid_keys)}'
                )
            key, value = next(iter(orig_value.items()))
            value_path = attribute_path[key]
        else:
            raise ManifestParseException(
                f"The attribute {attribute_path.path} must be a string or a mapping."
            )
        provided_parser = self._parsers.get(key)
        if provided_parser is None:
            valid_keys = ", ".join(self._parsers.keys())
            raise ManifestParseException(
                f'Unknown or unsupported action "{key}" at {value_path.path}.'
                " Valid options at this location are keys:"
                f' {", ".join(sorted(valid_keys))}'
            )
        return provided_parser.parse(
            key, value, value_path, parser_context=parser_context
        )

    def register_keyword(
        self,
        keyword: Union[str, Sequence[str]],
        handler: DIPKWHandler,
        plugin_metadata: DebputyPluginMetadata,
        *,
        inline_reference_documentation: Optional[ParserDocumentation] = None,
    ) -> None:
        reference_documentation_url = None
        if inline_reference_documentation:
            if inline_reference_documentation.attribute_doc:
                raise ValueError(
                    "Cannot provide per-attribute documentation for a value-less keyword!"
                )
            if inline_reference_documentation.alt_parser_description:
                raise ValueError(
                    "Cannot provide non-mapping-format documentation for a value-less keyword!"
                )
            reference_documentation_url = (
                inline_reference_documentation.documentation_reference_url
            )
        parser = DeclarativeValuelessKeywordInputParser(
            inline_reference_documentation,
            documentation_reference=reference_documentation_url,
        )

        def _combined_handler(
            name: str,
            _ignored: Any,
            attr_path: AttributePath,
            context: "ParserContextData",
        ) -> TP:
            return handler(name, attr_path, context)

        p = PluginProvidedParser(
            parser,
            _combined_handler,
            plugin_metadata,
        )

        self._add_parser(keyword, p)

    def register_parser(
        self,
        keyword: Union[str, List[str]],
        parser: "DeclarativeInputParser[PF, TP]",
        handler: Callable[[str, PF, "AttributePath", "ParserContextData"], TP],
        plugin_metadata: DebputyPluginMetadata,
    ) -> None:
        p = PluginProvidedParser(
            parser,
            handler,
            plugin_metadata,
        )
        self._add_parser(keyword, p)

    def _add_parser(
        self,
        keyword: Union[str, List[str]],
        ppp: "PluginProvidedParser[PF, TP]",
    ) -> None:
        ks = [keyword] if isinstance(keyword, str) else keyword
        for k in ks:
            existing_parser = self._parsers.get(k)
            if existing_parser is not None:
                message = (
                    f'The rule name "{k}" for {self.base_type.__name__} is already taken by'
                    f" the plugin {existing_parser.plugin_metadata.plugin_name}. This conflict was triggered"
                    f" when plugin {ppp.plugin_metadata.plugin_name} attempted to register its parser."
                )
                raise PluginConflictError(
                    message,
                    existing_parser.plugin_metadata,
                    ppp.plugin_metadata,
                )
            self._parsers[k] = ppp


@dataclasses.dataclass(slots=True)
class DeclarativeValuelessKeywordInputParser(DeclarativeInputParser[None]):
    inline_reference_documentation: Optional[ParserDocumentation] = None
    documentation_reference: Optional[str] = None

    def parse_input(
        self,
        value: object,
        path: "AttributePath",
        *,
        parser_context: Optional["ParserContextData"] = None,
    ) -> TD:
        if value is None:
            return cast("TD", value)
        if self.documentation_reference is not None:
            doc_ref = f" (Documentation: {self.documentation_reference})"
        else:
            doc_ref = ""
        raise ManifestParseException(
            f"Expected attribute {path.path} to be a string.{doc_ref}"
        )


SUPPORTED_DISPATCHABLE_PARSERS = {
    InstallRule: "installations",
    TransformationRule: "packages.<PKGNAME>.transformations",
    DpkgMaintscriptHelperCommand: "packages.<PKGNAME>.conffile-management",
    ManifestCondition: "*.when",
}


@dataclasses.dataclass(slots=True)
class PluginProvidedManifestVariable:
    plugin_metadata: DebputyPluginMetadata
    variable_name: str
    variable_value: Optional[str]
    is_context_specific_variable: bool
    variable_reference_documentation: Optional[str] = None
    is_documentation_placeholder: bool = False
    is_for_special_case: bool = False

    _variable_value_cache: Optional[str] = None

    @property
    def is_internal(self) -> bool:
        return self.variable_name.startswith("_") or ":_" in self.variable_name

    @property
    def is_token(self) -> bool:
        return self.variable_name.startswith("token:")

    def resolve(self) -> str:
        res = self._variable_value_cache
        if res is None:
            value_resolver = self.variable_value
            if isinstance(value_resolver, Callable):
                res = value_resolver()
            else:
                res = value_resolver
            self.variable_value = res
        return res


@dataclasses.dataclass
class PluginProvidedFeatureSet:
    plugin_data: Dict[str, DebputyPluginMetadata] = dataclasses.field(
        default_factory=dict
    )
    packager_provided_files: Dict[
        str, PackagerProvidedFileClassSpec
    ] = dataclasses.field(default_factory=dict)
    metadata_maintscript_detectors: Dict[
        str, List[MetadataOrMaintscriptDetector]
    ] = dataclasses.field(default_factory=dict)
    dispatchable_parsers: Dict[TTP, "DispatchingParser[TP]"] = dataclasses.field(
        default_factory=lambda: {
            rt: DispatchingParser(rt, path)
            for rt, path in SUPPORTED_DISPATCHABLE_PARSERS.items()
        }
    )
    manifest_variables: Dict[str, PluginProvidedManifestVariable] = dataclasses.field(
        default_factory=dict
    )


class MetadataDetectorPackageContextProvider(MetadataDetectorPackageContext):
    __slots__ = ("_manifest", "_package", "_related_udeb_package")

    def __init__(
        self,
        manifest: "HighLevelManifest",
        binary_package: BinaryPackage,
        related_udeb_package: Optional[BinaryPackage],
    ) -> None:
        self._manifest = manifest
        self._binary_package = binary_package
        self._related_udeb_package = related_udeb_package

    def _package_state_for(
        self,
        package: BinaryPackage,
    ) -> "PackageTransformationDefinition":
        return self._manifest.package_state_for(package.name)

    def _package_version_for(
        self,
        package: BinaryPackage,
    ) -> str:
        package_state = self._package_state_for(package)
        version = package_state.binary_version
        if version is not None:
            return version
        return self._manifest.source_version(
            include_binnmu_version=not package.is_arch_all
        )

    @property
    def binary_package(self) -> BinaryPackage:
        return self._binary_package

    @property
    def related_udeb_package(self) -> Optional[BinaryPackage]:
        return self._related_udeb_package

    @property
    def binary_package_version(self) -> str:
        return self._package_version_for(self._binary_package)

    @property
    def related_udeb_package_version(self) -> Optional[str]:
        udeb = self._related_udeb_package
        if udeb is None:
            return None
        return self._package_version_for(udeb)


class PluginError(DebputyRuntimeError):
    pass


class PluginNotFoundError(PluginError):
    pass


class PluginInitializationError(PluginError):
    pass


class PluginMetadataError(PluginError):
    pass


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

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


class PluginAPIViolationError(PluginError):
    pass


class UnhandledOrUnexpectedErrorFromPluginError(PluginError):
    pass


@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}"
