from typing import (
    cast,
    NotRequired,
    Optional,
    Tuple,
    Union,
    Type,
    TypedDict,
    List,
    Annotated,
)

from debputy import DEBPUTY_DOC_ROOT_DIR
from debputy._manifest_constants import (
    MK_CONFFILE_MANAGEMENT_X_OWNING_PACKAGE,
    MK_CONFFILE_MANAGEMENT_X_PRIOR_TO_VERSION,
    MK_INSTALLATIONS_INSTALL_EXAMPLES,
    MK_INSTALLATIONS_INSTALL,
    MK_INSTALLATIONS_INSTALL_DOCS,
    MK_INSTALLATIONS_INSTALL_MAN,
    MK_INSTALLATIONS_DISCARD,
)
from debputy.installations import InstallRule
from debputy.maintscript_snippet import DpkgMaintscriptHelperCommand
from debputy.manifest_parser.base_types import (
    DebputyParsedContent,
    DebputyParsedContentStandardConditional,
    FileSystemMode,
    FileSystemOwner,
    FileSystemGroup,
    SymlinkTarget,
    FileSystemExactMatch,
)
from debputy.manifest_parser.declarative_parser import DebputyParseHint
from debputy.manifest_parser.exceptions import ManifestParseException
from debputy.manifest_parser.parser_data import ParserContextData
from debputy.manifest_parser.util import AttributePath
from debputy.packages import BinaryPackage
from debputy.path_matcher import ExactFileSystemPath
from debputy.plugin.api import DebputyPluginInitializer
from debputy.plugin.api.impl import DebputyPluginInitializerProvider
from debputy.transformation_rules import (
    CreateSymlinkReplacementRule,
    TransformationRule,
    CreateDirectoryTransformationRule,
    RemoveTransformationRule,
    MoveTransformationRule,
    PathMetadataTransformationRule,
    CreateSymlinkPathTransformationRule,
)
from debputy.util import (
    _normalize_path,
    PKGNAME_REGEX,
    PKGVERSION_REGEX,
    debian_policy_normalize_symlink_target,
)

# TODO: Should be configurable somehow or depend on the package
_SEARCH_DIRS = ["debian/tmp", "."]


def _manifest_format_doc(anchor: str) -> str:
    return f"{DEBPUTY_DOC_ROOT_DIR}/MANIFEST-FORMAT.md#{anchor}"


def initialize_via_private_api(public_api: DebputyPluginInitializer) -> None:
    api = cast("DebputyPluginInitializerProvider", public_api)

    register_install_rules(api)
    register_transformation_rules(api)
    register_dpkg_conffile_rules(api)


def register_install_rules(api: DebputyPluginInitializerProvider) -> None:
    api.provide_manifest_rule(
        InstallRule,
        MK_INSTALLATIONS_INSTALL,
        ParsedInstallRule,
        _install_rule_handler,
        source_format=_with_alt_form(ParsedInstallRuleSourceFormat),
        reference_documentation_url=_manifest_format_doc("generic-install-install"),
    )
    api.provide_manifest_rule(
        InstallRule,
        MK_INSTALLATIONS_INSTALL_DOCS,
        ParsedInstallDocOrExamplesRule,
        _install_docs_or_examples_rule_handler,
        rule_aliases=[
            MK_INSTALLATIONS_INSTALL_EXAMPLES,
            "install-doc",
            "install-example",
        ],
        source_format=_with_alt_form(ParsedInstallDocOrExamplesRuleSourceFormat),
        reference_documentation_url=_manifest_format_doc(
            "install-documentation-and-examples-install-docs-and-install-examples"
        ),
    )
    api.provide_manifest_rule(
        InstallRule,
        MK_INSTALLATIONS_INSTALL_MAN,
        ParsedInstallManpageRule,
        _install_man_rule_handler,
        source_format=_with_alt_form(ParsedInstallManpageRuleSourceFormat),
        reference_documentation_url=_manifest_format_doc(
            "install-manpages-install-man"
        ),
    )
    api.provide_manifest_rule(
        InstallRule,
        MK_INSTALLATIONS_DISCARD,
        ParsedInstallDiscardRule,
        _install_discard_rule_handler,
        source_format=_with_alt_form(ParsedInstallDiscardRuleSourceFormat),
        reference_documentation_url=_manifest_format_doc(
            "discard-or-exclude-upstream-provided-paths-discard"
        ),
    )


def register_transformation_rules(api: DebputyPluginInitializerProvider) -> None:
    api.provide_manifest_rule(
        TransformationRule,
        "move",
        TransformationMoveRuleSpec,
        _transformation_move_handler,
        reference_documentation_url=_manifest_format_doc(
            "move-transformation-rule-move"
        ),
    )
    api.provide_manifest_rule(
        TransformationRule,
        "remove",
        TransformationRemoveRuleSpec,
        _transformation_remove_handler,
        source_format=_with_alt_form(TransformationRemoveRuleInputFormat),
        reference_documentation_url=_manifest_format_doc(
            "remove-transformation-rule-remove"
        ),
    )
    api.provide_manifest_rule(
        TransformationRule,
        "create-symlink",
        CreateSymlinkRule,
        _transformation_create_symlink,
        reference_documentation_url=_manifest_format_doc(
            "create-symlinks-transformation-rule-create-symlink"
        ),
    )
    api.provide_manifest_rule(
        TransformationRule,
        "path-metadata",
        PathManifestRule,
        _transformation_path_metadata,
        source_format=PathManifestSourceDictFormat,
        reference_documentation_url=_manifest_format_doc(
            "change-path-ownergroup-or-mode-path-metadata"
        ),
    )
    api.provide_manifest_rule(
        TransformationRule,
        "create-directories",
        EnsureDirectoryRule,
        _transformation_mkdirs,
        source_format=_with_alt_form(EnsureDirectorySourceFormat),
        reference_documentation_url=_manifest_format_doc(
            "create-directories-transformation-rule-directories"
        ),
    )


def register_dpkg_conffile_rules(api: DebputyPluginInitializerProvider) -> None:
    api.provide_manifest_rule(
        DpkgMaintscriptHelperCommand,
        "remove",
        DpkgRemoveConffileRule,
        _dpkg_conffile_remove,
        reference_documentation_url=None,  # TODO: write and add
    )

    api.provide_manifest_rule(
        DpkgMaintscriptHelperCommand,
        "rename",
        DpkgRenameConffileRule,
        _dpkg_conffile_rename,
        reference_documentation_url=None,  # TODO: write and add
    )


class _ModeOwnerBase(DebputyParsedContentStandardConditional):
    mode: NotRequired[FileSystemMode]
    owner: NotRequired[FileSystemOwner]
    group: NotRequired[FileSystemGroup]


class PathManifestSourceDictFormat(_ModeOwnerBase):
    path: Annotated[NotRequired[str], DebputyParseHint.target_attribute("paths")]
    paths: NotRequired[List[str]]
    recursive: NotRequired[bool]


class PathManifestRule(_ModeOwnerBase):
    paths: List[str]
    recursive: NotRequired[bool]


class EnsureDirectorySourceFormat(_ModeOwnerBase):
    path: Annotated[NotRequired[str], DebputyParseHint.target_attribute("paths")]
    paths: NotRequired[List[str]]


class EnsureDirectoryRule(_ModeOwnerBase):
    paths: List[str]


class CreateSymlinkRule(DebputyParsedContentStandardConditional):
    path: str
    target: SymlinkTarget
    replacement_rule: NotRequired[CreateSymlinkReplacementRule]


class TransformationMoveRuleSpec(DebputyParsedContentStandardConditional):
    source: str
    target: str


class TransformationRemoveRuleSpec(DebputyParsedContentStandardConditional):
    paths: List[str]
    keep_empty_parent_dirs: NotRequired[bool]


class TransformationRemoveRuleInputFormat(DebputyParsedContentStandardConditional):
    path: Annotated[NotRequired[str], DebputyParseHint.target_attribute("paths")]
    paths: NotRequired[List[str]]
    keep_empty_parent_dirs: NotRequired[bool]


class ParsedInstallRuleSourceFormat(DebputyParsedContentStandardConditional):
    sources: NotRequired[List[str]]
    source: Annotated[NotRequired[str], DebputyParseHint.target_attribute("sources")]
    into: Annotated[
        NotRequired[Union[str, List[str]]],
        DebputyParseHint.required_when_multi_binary(),
    ]
    dest_dir: NotRequired[FileSystemExactMatch]
    as_: NotRequired[
        Annotated[
            FileSystemExactMatch,
            DebputyParseHint.conflicts_with_source_attributes("sources", "dest_dir"),
            DebputyParseHint.target_attribute("install_as"),
        ]
    ]


class ParsedInstallRule(DebputyParsedContentStandardConditional):
    sources: List[str]
    into: NotRequired[List[BinaryPackage]]
    dest_dir: NotRequired[FileSystemExactMatch]
    install_as: NotRequired[FileSystemExactMatch]


class ParsedInstallDocOrExamplesRuleSourceFormat(
    DebputyParsedContentStandardConditional
):
    sources: NotRequired[List[str]]
    source: Annotated[NotRequired[str], DebputyParseHint.target_attribute("sources")]
    into: Annotated[
        NotRequired[Union[str, List[str]]],
        DebputyParseHint.required_when_multi_binary(),
    ]


class ParsedInstallDocOrExamplesRule(DebputyParsedContentStandardConditional):
    sources: List[str]
    into: NotRequired[List[BinaryPackage]]


class ParsedInstallManpageRule(DebputyParsedContentStandardConditional):
    sources: List[str]
    language: NotRequired[str]
    section: NotRequired[int]
    into: NotRequired[List[BinaryPackage]]


class ParsedInstallManpageRuleSourceFormat(DebputyParsedContentStandardConditional):
    sources: NotRequired[List[str]]
    source: Annotated[NotRequired[str], DebputyParseHint.target_attribute("sources")]
    language: NotRequired[str]
    section: NotRequired[int]
    into: Annotated[
        NotRequired[Union[str, List[str]]],
        DebputyParseHint.required_when_multi_binary(),
    ]


class ParsedInstallDiscardRuleSourceFormat(DebputyParsedContentStandardConditional):
    paths: NotRequired[List[str]]
    path: Annotated[NotRequired[str], DebputyParseHint.target_attribute("paths")]


class ParsedInstallDiscardRule(DebputyParsedContentStandardConditional):
    paths: List[str]


class DpkgConffileManagementRuleBase(DebputyParsedContent):
    prior_to_version: NotRequired[str]
    owning_package: NotRequired[str]


class DpkgRenameConffileRule(DpkgConffileManagementRuleBase):
    source: str
    target: str


class DpkgRemoveConffileRule(DpkgConffileManagementRuleBase):
    path: str


def _parse_filename(
    filename: str,
    attribute_path: AttributePath,
    *,
    allow_directories: bool = True,
) -> str:
    try:
        normalized_path = _normalize_path(filename, with_prefix=False)
    except ValueError as e:
        raise ManifestParseException(
            f'Error parsing the path "{filename}" defined in {attribute_path.path}: {e.args[0]}'
        ) from None
    if not allow_directories and filename.endswith("/"):
        raise ManifestParseException(
            f'The path "{filename}" in {attribute_path.path} ends with "/" implying it is a directory,'
            f" but this feature can only be used for files"
        )
    if normalized_path == ".":
        raise ManifestParseException(
            f'The path "{filename}" in {attribute_path.path} looks like the root directory,'
            f" but this feature does not allow the root directory here."
        )
    return normalized_path


def _with_alt_form(t: Type[TypedDict]):
    return Union[
        t,
        List[str],
        str,
    ]


def _dpkg_conffile_rename(
    _name: str,
    parsed_data: DpkgRenameConffileRule,
    path: AttributePath,
    _context: ParserContextData,
) -> DpkgMaintscriptHelperCommand:
    source_file = parsed_data["source"]
    target_file = parsed_data["target"]
    normalized_source = _parse_filename(
        source_file,
        path["source"],
        allow_directories=False,
    )
    path.path_hint = source_file

    normalized_target = _parse_filename(
        target_file,
        path["target"],
        allow_directories=False,
    )
    normalized_source = "/" + normalized_source
    normalized_target = "/" + normalized_target

    if normalized_source == normalized_target:
        raise ManifestParseException(
            f"Invalid rename defined in {path.path}: The source and target path are the same!"
        )

    version, owning_package = _parse_conffile_prior_version_and_owning_package(
        parsed_data, path
    )
    return DpkgMaintscriptHelperCommand.mv_conffile(
        path,
        normalized_source,
        normalized_target,
        version,
        owning_package,
    )


def _dpkg_conffile_remove(
    _name: str,
    parsed_data: DpkgRemoveConffileRule,
    path: AttributePath,
    _context: ParserContextData,
) -> DpkgMaintscriptHelperCommand:
    source_file = parsed_data["path"]
    normalized_source = _parse_filename(
        source_file,
        path["path"],
        allow_directories=False,
    )
    path.path_hint = source_file

    normalized_source = "/" + normalized_source

    version, owning_package = _parse_conffile_prior_version_and_owning_package(
        parsed_data, path
    )
    return DpkgMaintscriptHelperCommand.rm_conffile(
        path,
        normalized_source,
        version,
        owning_package,
    )


def _parse_conffile_prior_version_and_owning_package(
    d: DpkgConffileManagementRuleBase,
    attribute_path: AttributePath,
) -> Tuple[Optional[str], Optional[str]]:
    prior_version = d.get("prior_to_version")
    owning_package = d.get("owning_package")

    if prior_version is not None and not PKGVERSION_REGEX.match(prior_version):
        p = attribute_path["prior_to_version"]
        raise ManifestParseException(
            f"The {MK_CONFFILE_MANAGEMENT_X_PRIOR_TO_VERSION} parameter in {p.path} must be a"
            r" valid package version (i.e., match (?:\d+:)?\d[0-9A-Za-z.+:~]*(?:-[0-9A-Za-z.+:~]+)*)."
        )

    if owning_package is not None and not PKGNAME_REGEX.match(owning_package):
        p = attribute_path["owning_package"]
        raise ManifestParseException(
            f"The {MK_CONFFILE_MANAGEMENT_X_OWNING_PACKAGE} parameter in {p.path} must be a valid"
            f" package name (i.e., match {PKGNAME_REGEX.pattern})."
        )

    return prior_version, owning_package


def _install_rule_handler(
    _name: str,
    parsed_data: ParsedInstallRule,
    path: AttributePath,
    context: ParserContextData,
) -> InstallRule:
    sources_path = path["sources"]
    sources = [_parse_filename(p, sources_path) for p in parsed_data["sources"]]
    install_as = parsed_data.get("install_as")
    into = parsed_data.get("into")
    dest_dir = parsed_data.get("dest_dir")
    condition = parsed_data.get("when")
    if not into:
        into = [context.single_binary_package()]
    into = frozenset(into)
    if install_as is not None:
        assert len(sources) == 1
        assert dest_dir is None
        return InstallRule.install_as(
            sources[0],
            install_as.match_rule.path,
            into,
            _SEARCH_DIRS,
            path.path,
            condition,
        )
    return InstallRule.install_dest(
        sources,
        dest_dir.match_rule.path if dest_dir is not None else None,
        into,
        _SEARCH_DIRS,
        path.path,
        condition,
    )


def _install_docs_or_examples_rule_handler(
    name: str,
    parsed_data: ParsedInstallDocOrExamplesRule,
    path: AttributePath,
    context: ParserContextData,
) -> InstallRule:
    sources_path = path["sources"]
    sources = [_parse_filename(p, sources_path) for p in parsed_data["sources"]]
    into = parsed_data.get("into")
    if not into:
        into = [context.single_binary_package()]
    condition = parsed_data.get("when")
    into = frozenset(into)
    if name in (MK_INSTALLATIONS_INSTALL_EXAMPLES, "install-example"):
        install_rule = InstallRule.install_examples
    else:
        install_rule = InstallRule.install_doc
    return install_rule(
        sources,
        into,
        _SEARCH_DIRS,
        path.path,
        condition,
    )


def _install_man_rule_handler(
    _name: str,
    parsed_data: ParsedInstallManpageRule,
    attribute_path: AttributePath,
    context: ParserContextData,
) -> InstallRule:
    sources_path = attribute_path["sources"]
    sources = [_parse_filename(p, sources_path) for p in parsed_data["sources"]]
    language = parsed_data.get("language")
    section = parsed_data.get("section")

    if language is not None:
        is_lang_ok = language in (
            "C",
            "derive-from-basename",
            "derive-from-path",
        )

        if not is_lang_ok and len(language) == 2 and language.islower():
            is_lang_ok = True

        if (
            not is_lang_ok
            and len(language) == 5
            and language[2] == "_"
            and language[:2].islower()
            and language[3:].isupper()
        ):
            is_lang_ok = True

        if not is_lang_ok:
            raise ManifestParseException(
                f'The language attribute must in a 2-letter language code ("de"), a 5-letter language + dialect'
                f' code ("pt_BR"), "derive-from-basename", "derive-from-path", or omitted.  The problematic'
                f' definition is {attribute_path["language"]}'
            )

    if section is not None and (section < 1 or section > 10):
        raise ManifestParseException(
            f"The section attribute must in the range [1-9] or omitted.  The problematic definition is"
            f' {attribute_path["section"]}'
        )
    if section is None and any(s.endswith(".gz") for s in sources):
        raise ManifestParseException(
            "Sorry, compressed manpages are not supported without an explicit `section` definition at the moment."
            " This limitation may be removed in the future.  Problematic definition from"
            f' {attribute_path["sources"]}'
        )
    if any(s.endswith("/") for s in sources):
        raise ManifestParseException(
            'The install-man rule can only match non-directories.  Therefore, none of the sources can end with "/".'
            " as that implies the source is for a directory.  Problematic definition from"
            f' {attribute_path["sources"]}'
        )
    into = parsed_data.get("into")
    if not into:
        into = [context.single_binary_package()]
    condition = parsed_data.get("when")
    into = frozenset(into)
    return InstallRule.install_man(
        sources,
        into,
        section,
        language,
        _SEARCH_DIRS,
        attribute_path.path,
        condition,
    )


def _install_discard_rule_handler(
    _name: str,
    parsed_data: ParsedInstallDiscardRule,
    path: AttributePath,
    _context: ParserContextData,
) -> InstallRule:
    attr_path = path["paths"]
    paths = [_parse_filename(p, attr_path) for p in parsed_data["paths"]]
    condition = parsed_data.get("when")
    return InstallRule.discard_paths(
        paths,
        _SEARCH_DIRS,
        path.path,
        condition,
    )


def _transformation_move_handler(
    _name: str,
    parsed_data: TransformationMoveRuleSpec,
    path: AttributePath,
    context: ParserContextData,
) -> TransformationRule:
    source = parsed_data["source"]
    target = parsed_data["target"]
    condition = parsed_data.get("when")
    path.path_hint = source

    parser = cast("YAMLManifestParser", context)

    source_match = parser.parse_path_or_glob(source, path["source"])
    target_path = parser.normalize_path(target, path["target"]).path
    if (
        isinstance(source_match, ExactFileSystemPath)
        and source_match.path == target_path
    ):
        raise ManifestParseException(
            f"The transformation rule {path.path} requests a move of {source_match} to"
            f" {target_path}, which is the same path"
        )
    return MoveTransformationRule(
        source_match,
        target_path,
        target.endswith("/"),
        path,
        condition,
    )


def _transformation_remove_handler(
    _name: str,
    parsed_data: TransformationRemoveRuleSpec,
    attribute_path: AttributePath,
    context: ParserContextData,
) -> TransformationRule:
    paths = parsed_data["paths"]
    attribute_path.path_hint = paths[0]
    parser = cast("YAMLManifestParser", context)
    keep_empty_parent_dirs = parsed_data.get("keep_empty_parent_dirs", False)

    match_rules = [parser.parse_path_or_glob(p, attribute_path) for p in paths]
    return RemoveTransformationRule(
        match_rules,
        keep_empty_parent_dirs,
        attribute_path,
    )


def _transformation_create_symlink(
    _name: str,
    parsed_data: CreateSymlinkRule,
    attribute_path: AttributePath,
    context: ParserContextData,
) -> TransformationRule:
    provided_link_path = parsed_data["path"]
    attribute_path.path_hint = provided_link_path
    parser = cast("YAMLManifestParser", context)
    link_dest = parser.normalize_path(provided_link_path, attribute_path["path"]).path
    replacement_rule: CreateSymlinkReplacementRule = parsed_data.get(
        "replacement_rule",
        "abort-on-non-empty-directory",
    )
    try:
        link_target = debian_policy_normalize_symlink_target(
            link_dest,
            parsed_data["target"].symlink_target,
        )
    except ValueError as e:
        raise AssertionError(
            "Debian Policy normalization should not raise ValueError here"
        ) from e

    condition = parsed_data.get("when")

    return CreateSymlinkPathTransformationRule(
        link_target,
        link_dest,
        replacement_rule,
        attribute_path,
        condition,
    )


def _transformation_path_metadata(
    _name: str,
    parsed_data: PathManifestRule,
    attribute_path: AttributePath,
    context: ParserContextData,
) -> TransformationRule:
    provided_paths = parsed_data["paths"]
    attribute_path.path_hint = provided_paths[0]
    owner = parsed_data.get("owner")
    group = parsed_data.get("group")
    mode = parsed_data.get("mode")
    recursive = parsed_data.get("recursive", False)
    parser = cast("YAMLManifestParser", context)
    match_rules = [
        parser.parse_path_or_glob(p, attribute_path["paths"]) for p in provided_paths
    ]

    if owner is None and group is None and mode is None:
        raise ManifestParseException(
            "At least one of owner, group and mode must be provided"
            f" in {attribute_path.path}"
        )
    condition = parsed_data.get("when")

    return PathMetadataTransformationRule(
        match_rules,
        owner,
        group,
        mode,
        recursive,
        attribute_path.path,
        condition,
    )


def _transformation_mkdirs(
    _name: str,
    parsed_data: PathManifestRule,
    attribute_path: AttributePath,
    context: ParserContextData,
) -> TransformationRule:
    provided_paths = parsed_data["paths"]
    attribute_path.path_hint = provided_paths[0]
    owner = parsed_data.get("owner")
    group = parsed_data.get("group")
    mode = parsed_data.get("mode")
    parser = cast("YAMLManifestParser", context)
    directories = [
        parser.normalize_path(p, attribute_path["paths"]).path for p in provided_paths
    ]

    condition = parsed_data.get("when")

    return CreateDirectoryTransformationRule(
        directories,
        owner,
        group,
        mode,
        attribute_path.path,
        condition,
    )
