import contextlib
import dataclasses
import errno
import os.path
import re
import shutil
from re import Match
from typing import (
    Optional,
    Callable,
    Union,
    Iterable,
    TextIO,
    Tuple,
    Generator,
    Any,
    Sequence,
)

from debputy.packages import BinaryPackage
from debputy.substitution import Substitution
from debputy.util import _escape_shell_word, ensure_dir

SnippetReplacement = Union[str, Callable[[str], str]]
MAINTSCRIPT_TOKEN_NAME_PATTERN = r"[A-Za-z0-9_.+]+"
MAINTSCRIPT_TOKEN_NAME_REGEX = re.compile(MAINTSCRIPT_TOKEN_NAME_PATTERN)
MAINTSCRIPT_TOKEN_REGEX = re.compile(f"#({MAINTSCRIPT_TOKEN_NAME_PATTERN})#")
_ARCH_FILTER_START = re.compile(r"^(\[([^]]*)])[ \t]+")
_ARCH_FILTER_END = re.compile(r"\s+(\[([^]]*)])$")
_BUILD_PROFILE_FILTER = re.compile(r"(<([^>]*)>(?:\s+<([^>]*)>)*)")


class CannotEmulateExecutableDHConfigFile(Exception):
    def message(self) -> str:
        return self.args[0]

    def config_file(self) -> str:
        return self.args[1]


@dataclasses.dataclass(slots=True, frozen=True)
class DHConfigFileLine:
    config_file: str
    line_no: int
    executable_config: bool
    original_line: str
    tokens: Sequence[str]
    arch_filter: Optional[str]
    build_profile_filter: Optional[str]


def dhe_dbgsym_root_dir(binary_package: BinaryPackage) -> str:
    return os.path.join("debian", ".debhelper", binary_package.name, "dbgsym-root")


@contextlib.contextmanager
def _lines_from(config_file: str) -> Generator[Tuple[TextIO, bool], Any, Any]:
    with open(config_file, "rt") as fd:
        yield fd, os.access(config_file, os.X_OK)


def _prune_match(
    line: str,
    match: Optional[Match[str]],
    match_mapper: Optional[Callable[[Match[str]], str]] = None,
) -> Tuple[str, Optional[str]]:
    if match is None:
        return line, None
    s, e = match.span()
    if match_mapper:
        matched_part = match_mapper(match)
    else:
        matched_part = line[s:e]
    # We prune exactly the matched part and assume the regexes leaves behind spaces if they were important.
    line = line[:s] + line[e:]
    # One special-case, if the match is at the beginning or end, then we can safely discard left
    # over whitespace.
    return line.strip(), matched_part


def dhe_filedoublearray(
    config_file: str,
    substitution: Optional[Substitution],
    *,
    allow_dh_exec_rename: bool = False,
) -> Iterable[DHConfigFileLine]:
    with _lines_from(config_file) as (fd, is_executable):
        for line_no, orig_line in enumerate(fd, start=1):
            arch_filter = None
            build_profile_filter = None
            if (
                line_no == 1
                and is_executable
                and not orig_line.startswith(
                    ("#!/usr/bin/dh-exec", "#! /usr/bin/dh-exec")
                )
            ):
                raise CannotEmulateExecutableDHConfigFile(
                    "Only #!/usr/bin/dh-exec based executables can be emulated",
                    config_file,
                )
            orig_line = orig_line.rstrip("\n")
            line = orig_line.strip()
            if not line or line.startswith("#"):
                continue
            if is_executable:
                if "=>" in line and not allow_dh_exec_rename:
                    raise CannotEmulateExecutableDHConfigFile(
                        'Cannot emulate dh-exec\'s "=>" feature to rename files for the concrete file',
                        config_file,
                    )
                line, arch_filter = _prune_match(
                    line,
                    _ARCH_FILTER_START.match(line) or _ARCH_FILTER_END.match(line),
                    # Remove the enclosing []
                    lambda m: m.group(1)[1:-1].strip(),
                )
                line, build_profile_filter = _prune_match(
                    line,
                    _BUILD_PROFILE_FILTER.match(line),
                )
            parts = tuple(
                substitution.substitute(w, f'{config_file} line {line_no} token "{w}"')
                for w in line.split()
            )
            yield DHConfigFileLine(
                config_file,
                line_no,
                is_executable,
                orig_line,
                parts,
                arch_filter,
                build_profile_filter,
            )


def dhe_pkgfile(
    binary_package: BinaryPackage,
    basename: str,
    always_fallback_to_packageless_variant: bool = False,
    mandatory: bool = False,
    bug_950723_prefix_matching: bool = False,
) -> Optional[str]:
    # TODO: Architecture specific files
    maybe_at_suffix = "@" if bug_950723_prefix_matching else ""
    possible_names = [f"{binary_package.name}{maybe_at_suffix}.{basename}"]
    if binary_package.is_main_package or always_fallback_to_packageless_variant:
        possible_names.append(
            f"{basename}@" if bug_950723_prefix_matching else basename
        )

    for name in possible_names:
        path = os.path.join("debian", name)
        if os.path.exists(path):
            return path
    if mandatory:
        raise FileNotFoundError(
            errno.ENOENT,
            os.strerror(errno.ENOENT),
            os.path.join("debian", possible_names[0]),
        )
    return None


def dhe_install_pkg_file_as_ctrl_file_if_present(
    binary_package: BinaryPackage,
    basename: str,
    root_dir: str,
    mode: int,
    **kwargs,
) -> None:
    source = dhe_pkgfile(binary_package, basename, **kwargs)
    if source is None:
        return
    ctrl_dir = os.path.join(root_dir, "DEBIAN")
    ensure_dir(ctrl_dir)
    dhe_install_path(source, os.path.join(ctrl_dir, basename), mode)


def dhe_install_path(source: str, dest: str, mode: int) -> None:
    # TODO: "install -p -mXXXX foo bar" silently discards broken
    # symlinks to install the file in place.  (#868204)
    print(
        f"    install -p -m{oct(mode)[2:]} {_escape_shell_word(source)} {_escape_shell_word(dest)}"
    )
    shutil.copyfile(source, dest)
    os.chmod(dest, mode)
