import argparse
import collections
import glob
import logging
import os
import re
import subprocess
import sys
import textwrap
import time
from itertools import zip_longest
from pathlib import Path
from typing import (
    NoReturn,
    TYPE_CHECKING,
    Union,
    Set,
    FrozenSet,
    Optional,
    TypeVar,
    IO,
    Dict,
    Iterator,
    Iterable,
    Literal,
    Tuple,
)

from debian.deb822 import Deb822

from debputy.architecture_support import DpkgArchitectureBuildProcessValuesTable


if TYPE_CHECKING:
    from debputy.packages import BinaryPackage
    from debputy.filesystem_scan import FSPath


T = TypeVar("T")


SLASH_PRUNE = re.compile("//+")
PKGNAME_REGEX = re.compile(r"[a-z0-9][-+.a-z0-9]+", re.ASCII)
PKGVERSION_REGEX = re.compile(
    r"""
                 (?: \d+ : )?                # Optional epoch
                 \d[0-9A-Za-z.+:~]*          # Upstream version (with no hyphens)
                 (?: - [0-9A-Za-z.+:~]+ )*   # Optional debian revision (+ upstreams versions with hyphens)
""",
    re.VERBOSE | re.ASCII,
)
DEFAULT_PACKAGE_TYPE = "deb"
DBGSYM_PACKAGE_TYPE = "deb"
UDEB_PACKAGE_TYPE = "udeb"

POSTINST_DEFAULT_CONDITION = (
    '[ "$1" = "configure" ]'
    ' || [ "$1" = "abort-upgrade" ]'
    ' || [ "$1" = "abort-deconfigure" ]'
    ' || [ "$1" = "abort-remove" ]'
)


_SPACE_RE = re.compile(r"\s")
_DOUBLE_ESCAPEES = re.compile(r'([\n`$"\\])')
_REGULAR_ESCAPEES = re.compile(r'([\s!"$()*+#;<>?@\[\]\\`|~])')
_PROFILE_GROUP_SPLIT = re.compile(r">\s+<")
_DEFAULT_LOGGER: Optional[logging.Logger] = None


def debputy_autoscript_tagline() -> str:
    # TODO: Handle versioning at some point.
    return "debputy/<unversioned>"


def assume_not_none(x: Optional[T]) -> T:
    if x is None:  # pragma: no cover
        raise ValueError(
            'Internal error: None was given, but the receiver assumed "not None" here'
        )
    return x


def _info(msg: str) -> None:
    global _DEFAULT_LOGGER
    logger = _DEFAULT_LOGGER
    if logger:
        logger.info(msg)
    # No fallback print for info


def _error(msg: str, *, prog=None) -> "NoReturn":
    global _DEFAULT_LOGGER
    logger = _DEFAULT_LOGGER
    if logger:
        logger.error(msg)
    else:
        me = os.path.basename(sys.argv[0]) if prog is None else prog
        print(
            f"{me}: error: {msg}",
            file=sys.stderr,
        )
    sys.exit(1)


def _warn(msg: str, *, prog=None) -> None:
    global _DEFAULT_LOGGER
    logger = _DEFAULT_LOGGER
    if logger:
        logger.warning(msg)
    else:
        me = os.path.basename(sys.argv[0]) if prog is None else prog

        print(
            f"{me}: warning: {msg}",
            file=sys.stderr,
        )


class ColorizedArgumentParser(argparse.ArgumentParser):
    def error(self, message: str) -> NoReturn:
        self.print_usage(sys.stderr)
        _error(message, prog=self.prog)


def ensure_dir(path: str) -> None:
    if not os.path.isdir(path):
        os.makedirs(path, mode=0o755, exist_ok=True)


def _clean_path(orig_p: str) -> str:
    p = SLASH_PRUNE.sub("/", orig_p)
    if "." in p:
        path_base = p
        # We permit a single leading "./" because we add that when we normalize a path, and we want normalization
        # of a normalized path to be a no-op.
        if path_base.startswith("./"):
            path_base = path_base[2:]
            assert path_base
        for segment in path_base.split("/"):
            if segment in (".", ".."):
                raise ValueError(
                    'Please provide paths that are normalized (i.e., no ".." or ".").'
                    f' Offending input "{orig_p}"'
                )
    return p


def _normalize_path(path: str, with_prefix=True) -> str:
    path = path.strip("/")
    if not path or path == ".":
        return "."
    if "//" in path or "." in path:
        path = _clean_path(path)
    if with_prefix ^ path.startswith("./"):
        if with_prefix:
            path = "./" + path
        else:
            path = path[2:]
    return path


def _normalize_link_target(link_target: str) -> str:
    link_target = SLASH_PRUNE.sub("/", link_target.lstrip("/"))
    result = []
    for segment in link_target.split("/"):
        if segment in (".", ""):
            # Ignore these - the empty string is generally a trailing slash
            continue
        if segment == "..":
            # We ignore "root escape attempts" like the OS would (mapping /.. -> /)
            if result:
                result.pop()
        else:
            result.append(segment)
    return "/".join(result)


def _backslash_escape(m: re.Match[str]) -> str:
    return "\\" + m.group(0)


def _escape_shell_word(w: str) -> str:
    if _SPACE_RE.match(w):
        w = _DOUBLE_ESCAPEES.sub(_backslash_escape, w)
        return f'"{w}"'
    return _REGULAR_ESCAPEES.sub(_backslash_escape, w)


def escape_shell(*args: str) -> str:
    return " ".join(_escape_shell_word(w) for w in args)


def debian_policy_normalize_symlink_target(
    link_path: str,
    link_target: str,
    normalize_link_path=False,
) -> str:
    if normalize_link_path:
        link_path = _normalize_path(link_path)
    elif not link_path.startswith("./"):
        raise ValueError("Link part was not normalized")

    link_path = link_path[2:]

    if not link_target.startswith("/"):
        link_target = "/" + os.path.dirname(link_path) + "/" + link_target

    link_path_parts = link_path.split("/")
    link_target_parts = [
        s for s in _normalize_link_target(link_target).split("/") if s != "."
    ]

    assert link_path_parts

    if link_target_parts and link_path_parts[0] == link_target_parts[0]:
        # Per Debian Policy, must be relative

        # First determine the length of the overlap
        common_segment_count = 1
        shortest_path_length = min(len(link_target_parts), len(link_path_parts))
        while (
            common_segment_count < shortest_path_length
            and link_target_parts[common_segment_count]
            == link_path_parts[common_segment_count]
        ):
            common_segment_count += 1

        if common_segment_count == shortest_path_length and len(
            link_path_parts
        ) - 1 == len(link_target_parts):
            normalized_link_target = "."
        else:
            up_dir_count = len(link_path_parts) - 1 - common_segment_count
            normalized_link_target_parts = []
            if up_dir_count:
                up_dir_part = "../" * up_dir_count
                # We overshoot with a single '/', so rstrip it away
                normalized_link_target_parts.append(up_dir_part.rstrip("/"))
            # Add the relevant down parts
            normalized_link_target_parts.extend(
                link_target_parts[common_segment_count:]
            )

            normalized_link_target = "/".join(normalized_link_target_parts)
    else:
        # Per Debian Policy, must be absolute
        normalized_link_target = "/" + "/".join(link_target_parts)

    return normalized_link_target


def apply_symbolic_mode(tar_path: "FSPath", base_mode: int, symbolic_mode: str) -> int:
    final_mode = base_mode
    special_bit_marker = 0o100_000
    sticky_bit = 0o01000
    setuid_bit = 0o04000
    setgid_bit = 0o02000
    mode_group_flag = 0o7
    bits = {
        "r": 0o4,
        "w": 0o2,
        "x": 0o1,
        "X": 0o1 if tar_path.is_dir or base_mode & 0o111 else 0o0,
        "s": special_bit_marker,
        "t": sticky_bit,
    }
    modifiers = {
        "+",
        "-",
        "=",
    }
    for orig_part in symbolic_mode.split(","):
        part_mode = 0
        part = orig_part
        subjects = set()
        while part and part[0] in ("u", "g", "o", "a"):
            subject = part[0]
            if subject == "a":
                subjects = {"u", "g", "o"}
            else:
                subjects.add(subject)
            part = part[1:]
        if not subjects:
            subjects = {"u", "g", "o"}

        if part and part[0] in modifiers:
            modifier = part[0]
        elif not part:
            raise ValueError(
                f'Invalid symbolic mode - expected [+-=] to be present (from "{orig_part}")'
            )
        else:
            raise ValueError(
                f'Invalid symbolic mode - expected "{part[0]}" to be one of [+-=]'
                f' (from "{orig_part}")'
            )
        part = part[1:]
        if part in ("u", "g", "o"):
            raise NotImplementedError(
                "Sorry, we do not support referencing an existing subject's permissions (a=u)"
                " in symbolic modes"
            )
        while part and part[0] in bits:
            part_mode |= bits[part[0]]
            part = part[1:]

        if part:
            raise ValueError(
                f'Invalid symbolic mode - Could not parse "{part[0]}" from "{orig_part}"'
            )

        mod_mode = 0
        if "u" in subjects:
            mod_mode |= (part_mode & mode_group_flag) << 6
        elif modifier == "=":
            mod_mode |= final_mode & (mode_group_flag << 6) | (final_mode & setuid_bit)
        if "g" in subjects:
            mod_mode |= (part_mode & mode_group_flag) << 3
        elif modifier == "=":
            mod_mode |= final_mode & (mode_group_flag << 3) | (final_mode & setgid_bit)
        if "o" in subjects:
            mod_mode |= part_mode & mode_group_flag
        elif modifier == "=":
            mod_mode |= final_mode & mode_group_flag
        if part_mode & special_bit_marker:
            if "u" in subjects:
                mod_mode |= setuid_bit
            if "g" in subjects:
                mod_mode |= setgid_bit
        if part_mode & sticky_bit:
            mod_mode |= sticky_bit

        if modifier == "+":
            final_mode |= mod_mode
        elif modifier == "-":
            final_mode &= ~mod_mode
        elif modifier == "=":
            # FIXME: Handle "unmentioned directory's setgid/setuid bits"
            final_mode = mod_mode
    return final_mode


def has_glob_magic(pattern: str) -> bool:
    return glob.has_magic(pattern) or "{" in pattern


def glob_escape(replacement_value: str) -> str:
    if not glob.has_magic(replacement_value) or "{" not in replacement_value:
        return replacement_value
    return (
        replacement_value.replace("[", "[[]")
        .replace("]", "[]]")
        .replace("*", "[*]")
        .replace("?", "[?]")
        .replace("{", "[{]")
        .replace("}", "[}]")
    )


# TODO: This logic should probably be moved to `python-debian`
def active_profiles_match(
    profiles_raw: str,
    active_build_profiles: Union[Set[str], FrozenSet[str]],
) -> bool:
    profiles_raw = profiles_raw.strip()
    if profiles_raw[0] != "<" or profiles_raw[-1] != ">" or profiles_raw == "<>":
        raise ValueError(
            'Invalid Build-Profiles: Must start start and end with "<" + ">" but cannot be a literal "<>"'
        )
    profile_groups = _PROFILE_GROUP_SPLIT.split(profiles_raw[1:-1])
    for profile_group_raw in profile_groups:
        should_process_package = True
        for profile_name in profile_group_raw.split():
            negation = False
            if profile_name[0] == "!":
                negation = True
                profile_name = profile_name[1:]

            matched_profile = profile_name in active_build_profiles
            if matched_profile == negation:
                should_process_package = False
                break

        if should_process_package:
            return True

    return False


def resolve_source_date_epoch(command_line_value: Optional[int]) -> int:
    mtime = command_line_value
    if mtime is None and "SOURCE_DATE_EPOCH" in os.environ:
        sde_raw = os.environ["SOURCE_DATE_EPOCH"]
        if sde_raw == "":
            _error("SOURCE_DATE_EPOCH is set but empty.")
        mtime = int(sde_raw)
    if mtime is None:
        mtime = int(time.time())
    return mtime


def compute_output_filename(package_root_dir: str, is_udeb: bool) -> str:
    with open(os.path.join(package_root_dir, "DEBIAN", "control"), "rt") as fd:
        control_file = Deb822(fd)

    package_name = control_file["Package"]
    package_version = control_file["Version"]
    package_architecture = control_file["Architecture"]
    extension = control_file.get("Package-Type") or "deb"
    if ":" in package_version:
        package_version = package_version.split(":", 1)[1]
    if is_udeb:
        extension = "udeb"

    return f"{package_name}_{package_version}_{package_architecture}.{extension}"


_SCRATCH_DIR = None


def scratch_dir() -> str:
    global _SCRATCH_DIR
    if _SCRATCH_DIR is not None:
        return _SCRATCH_DIR
    debputy_scratch_dir = "debian/.debputy/scratch-dir"
    is_debputy_dir = True
    if os.path.isdir("debian/.debputy"):
        _SCRATCH_DIR = debputy_scratch_dir
    elif os.path.isdir("debian/.debhelper"):
        _SCRATCH_DIR = "debian/.debhelper/_debputy/scratch_dir"
        is_debputy_dir = False
    else:
        _SCRATCH_DIR = debputy_scratch_dir
    ensure_dir(_SCRATCH_DIR)
    if is_debputy_dir:
        Path("debian/.debputy/.gitignore").write_text("*\n")
    return _SCRATCH_DIR


PerlIncDir = collections.namedtuple("PerlIncDir", ["vendorlib", "vendorarch"])
PerlConfigData = collections.namedtuple("PerlConfigData", ["version", "debian_abi"])
_PERL_MODULE_DIRS: Dict[str, PerlIncDir] = {}
_PERL_CONFIG_DATA: Optional[PerlConfigData] = None


def _perl_config_data() -> PerlConfigData:
    global _PERL_CONFIG_DATA
    if _PERL_CONFIG_DATA is None:
        d = (
            subprocess.check_output(
                [
                    "perl",
                    "-MConfig",
                    "-e",
                    'print "$Config{version}\n$Config{debian_abi}\n"',
                ]
            )
            .decode("utf-8")
            .splitlines()
        )
        _PERL_CONFIG_DATA = PerlConfigData(*d)
    return _PERL_CONFIG_DATA


def _perl_version() -> str:
    return _perl_config_data().version


def perlxs_api_dependency() -> str:
    # dh_perl used the build version of perl for this, so we will too.  Most of the perl cross logic
    # assumes that the major version of build variant of Perl is the same as the host variant of Perl.
    config = _perl_config_data()
    if config.debian_abi is not None and config.debian_abi != "":
        return f"perlapi-{config.debian_abi}"
    return f"perlapi-{config.version}"


# FIXME: This need to account for cross building
def perl_module_dirs(
    dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable,
    dctrl_bin: "BinaryPackage",
) -> PerlIncDir:
    global _PERL_MODULE_DIRS
    arch = (
        dctrl_bin.resolved_architecture
        if dpkg_architecture_variables.is_cross_compiling
        else "_default_"
    )
    module_dir = _PERL_MODULE_DIRS.get(arch)
    if module_dir is None:
        cmd = ["perl"]
        if dpkg_architecture_variables.is_cross_compiling:
            version = _perl_version()
            inc_dir = f"/usr/lib/{dctrl_bin.deb_multiarch}/perl/cross-config-{version}"
            # FIXME: This should not fallback to "build-arch" but on the other hand, we use the perl module dirs
            #  for every package at the moment. So mandating correct perl dirs implies mandating perl-xs-dev in
            #  cross builds... meh.
            if os.path.exists(os.path.join(inc_dir, "Config.pm")):
                cmd.append(f"-I{inc_dir}")
        cmd.extend(
            ["-MConfig", "-e", 'print "$Config{vendorlib}\n$Config{vendorarch}\n"']
        )
        output = subprocess.check_output(cmd).decode("utf-8").splitlines(keepends=False)
        if len(output) != 2:
            raise ValueError(
                "Internal error: Unable to determine the perl include directories:"
                f" Raw output from perl snippet: {output}"
            )
        module_dir = PerlIncDir(
            vendorlib=_normalize_path(output[0]),
            vendorarch=_normalize_path(output[1]),
        )
        _PERL_MODULE_DIRS[arch] = module_dir
    return module_dir


def detect_fakeroot() -> bool:
    if "LD_PRELOAD" not in os.environ:
        return False
    env = dict(os.environ)
    del env["LD_PRELOAD"]
    try:
        return subprocess.check_output(["id", "-u"]).strip() != b"0"
    except subprocess.CalledProcessError:
        print(
            'Could not run "id -u" with LD_PRELOAD unset; assuming we are not run under fakeroot',
            file=sys.stderr,
        )
        return False


# itertools recipe
def grouper(
    iterable: Iterable[T],
    n: int,
    *,
    incomplete: Literal["fill", "strict", "ignore"] = "fill",
    fillvalue: Optional[T] = None,
) -> Iterator[Tuple[T, ...]]:
    """Collect data into non-overlapping fixed-length chunks or blocks"""
    # grouper('ABCDEFG', 3, fillvalue='x') --> ABC DEF Gxx
    # grouper('ABCDEFG', 3, incomplete='strict') --> ABC DEF ValueError
    # grouper('ABCDEFG', 3, incomplete='ignore') --> ABC DEF
    args = [iter(iterable)] * n
    if incomplete == "fill":
        return zip_longest(*args, fillvalue=fillvalue)
    if incomplete == "strict":
        return zip(*args, strict=True)
    if incomplete == "ignore":
        return zip(*args)
    else:
        raise ValueError("Expected fill, strict, or ignore")


_LOGGING_SET_UP = False


def _check_color() -> Tuple[bool, bool, Optional[str]]:
    dpkg_or_default = os.environ.get(
        "DPKG_COLORS", "never" if "NO_COLOR" in os.environ else "auto"
    )
    requested_color = os.environ.get("DEBPUTY_COLORS", dpkg_or_default)
    bad_request = None
    if requested_color not in {"auto", "always", "never"}:
        bad_request = requested_color
        requested_color = "auto"

    if requested_color == "auto":
        stdout_color = sys.stdout.isatty()
        stderr_color = sys.stdout.isatty()
    else:
        enable = requested_color == "always"
        stdout_color = enable
        stderr_color = enable
    return stdout_color, stderr_color, bad_request


def setup_logging() -> None:
    global _LOGGING_SET_UP, _DEFAULT_LOGGER
    if _LOGGING_SET_UP:
        raise RuntimeError("Logging has already been configured.")
    stdout_color, stderr_color, bad_request = _check_color()

    if stdout_color or stderr_color:
        try:
            import colorlog
        except ImportError:
            stdout_color = False
            stderr_color = False

    class LogLevelFilter(logging.Filter):
        def __init__(self, threshold, above: bool):
            super().__init__()
            self.threshold = threshold
            self.above = above

        def filter(self, record):
            if self.above:
                return record.levelno >= self.threshold
            else:
                return record.levelno < self.threshold

    color_format = (
        "{bold}{name}{reset}: {bold}{log_color}{levelnamelower}{reset}: {message}"
    )
    colorless_format = "{name}: {levelnamelower}: {message}"

    if stdout_color:
        stdout_handler = colorlog.StreamHandler(sys.stdout)
        stdout_handler.setFormatter(
            colorlog.ColoredFormatter(color_format, style="{", force_color=True)
        )
        colorlog.getLogger().addHandler(stdout_handler)
    else:
        stdout_handler = logging.StreamHandler(sys.stdout)
        stdout_handler.setFormatter(logging.Formatter(colorless_format, style="{"))

    if stderr_color:
        stderr_handler = colorlog.StreamHandler(sys.stderr)
        stderr_handler.setFormatter(
            colorlog.ColoredFormatter(color_format, style="{", force_color=True)
        )
        colorlog.getLogger().addHandler(stderr_handler)
    else:
        stderr_handler = logging.StreamHandler(sys.stderr)
        stderr_handler.setFormatter(logging.Formatter(colorless_format, style="{"))

    stdout_handler.addFilter(LogLevelFilter(logging.WARN, False))
    stderr_handler.addFilter(LogLevelFilter(logging.WARN, True))

    name = os.path.basename(sys.argv[0])

    old_factory = logging.getLogRecordFactory()

    def record_factory(*args, **kwargs):  # pragma: no cover
        record = old_factory(*args, **kwargs)
        record.levelnamelower = record.levelname.lower()
        return record

    logging.setLogRecordFactory(record_factory)

    logging.getLogger().setLevel(logging.INFO)
    _DEFAULT_LOGGER = logging.getLogger(name)

    if bad_request:
        _DEFAULT_LOGGER.warning(
            f'Invalid color request for "{bad_request}" in either DEBPUTY_COLORS or DPKG_COLORS.'
            ' Resetting to "auto".'
        )

    _LOGGING_SET_UP = True
