import dataclasses
import os
import subprocess
from functools import lru_cache
from typing import (
    Sequence,
    Optional,
    Union,
    Literal,
    Tuple,
    Mapping,
    Iterable,
    TYPE_CHECKING,
    Dict,
    MutableMapping,
    NotRequired,
    FrozenSet,
)

from debputy.manifest_conditions import ManifestCondition
from debputy.manifest_parser.exceptions import ManifestParseException
from debputy.manifest_parser.tagging_types import DebputyParsedContent
from debputy.manifest_parser.util import (
    AttributePath,
    _SymbolicModeSegment,
    parse_symbolic_mode,
)
from debputy.path_matcher import MatchRule, ExactFileSystemPath
from debputy.substitution import Substitution
from debputy.util import (
    _normalize_path,
    _error,
    _warn,
    _debug_log,
    _is_debug_log_enabled,
)

if TYPE_CHECKING:
    from debputy.manifest_parser.parser_data import ParserContextData


@dataclasses.dataclass(slots=True, frozen=True)
class OwnershipDefinition:
    entity_name: str
    entity_id: int


class DebputyParsedContentStandardConditional(DebputyParsedContent):
    when: NotRequired[ManifestCondition]


ROOT_DEFINITION = OwnershipDefinition("root", 0)


BAD_OWNER_NAMES = {
    "_apt",  # All things owned by _apt are generated by apt after installation
    "nogroup",  # It is not supposed to own anything as it is an entity used for dropping permissions
    "nobody",  # It is not supposed to own anything as it is an entity used for dropping permissions
}
BAD_OWNER_IDS = {
    65534,  # ID of nobody / nogroup
}


def _parse_ownership(
    v: Union[str, int],
    attribute_path: AttributePath,
) -> Tuple[Optional[str], Optional[int]]:
    if isinstance(v, str) and ":" in v:
        if v == ":":
            raise ManifestParseException(
                f'Invalid ownership value "{v}" at {attribute_path.path}: Ownership is redundant if it is ":"'
                f" (blank name and blank id).  Please provide non-default values or remove the definition."
            )
        entity_name: Optional[str]
        entity_id: Optional[int]
        entity_name, entity_id_str = v.split(":")
        if entity_name == "":
            entity_name = None
        if entity_id_str != "":
            entity_id = int(entity_id_str)
        else:
            entity_id = None
        return entity_name, entity_id

    if isinstance(v, int):
        return None, v
    if v.isdigit():
        raise ManifestParseException(
            f'Invalid ownership value "{v}" at {attribute_path.path}: The provided value "{v}" is a string (implying'
            " name lookup), but it contains an integer (implying id lookup).  Please use a regular int for id lookup"
            f' (removing the quotes) or add a ":" in the end ("{v}:") as a disambiguation if you are *really* looking'
            " for an entity with that name."
        )
    return v, None


@lru_cache
def _load_ownership_table_from_file(
    name: Literal["passwd.master", "group.master"],
) -> Tuple[Mapping[str, OwnershipDefinition], Mapping[int, OwnershipDefinition]]:
    filename = os.path.join("/usr/share/base-passwd", name)
    name_table = {}
    uid_table = {}
    for owner_def in _read_ownership_def_from_base_password_template(filename):
        # Could happen if base-passwd template has two users with the same ID.  We assume this will not occur.
        assert owner_def.entity_name not in name_table
        assert owner_def.entity_id not in uid_table
        name_table[owner_def.entity_name] = owner_def
        uid_table[owner_def.entity_id] = owner_def

    return name_table, uid_table


def _read_ownership_def_from_base_password_template(
    template_file: str,
) -> Iterable[OwnershipDefinition]:
    with open(template_file) as fd:
        for line in fd:
            entity_name, _star, entity_id, _remainder = line.split(":", 3)
            if entity_id == "0" and entity_name == "root":
                yield ROOT_DEFINITION
            else:
                yield OwnershipDefinition(entity_name, int(entity_id))


class FileSystemMode:
    @classmethod
    def parse_filesystem_mode(
        cls,
        mode_raw: str,
        attribute_path: AttributePath,
    ) -> "FileSystemMode":
        if mode_raw and mode_raw[0].isdigit():
            return OctalMode.parse_filesystem_mode(mode_raw, attribute_path)
        return SymbolicMode.parse_filesystem_mode(mode_raw, attribute_path)

    def compute_mode(self, current_mode: int, is_dir: bool) -> int:
        raise NotImplementedError


@dataclasses.dataclass(slots=True, frozen=True)
class SymbolicMode(FileSystemMode):
    provided_mode: str
    segments: Sequence[_SymbolicModeSegment]

    @classmethod
    def parse_filesystem_mode(
        cls,
        mode_raw: str,
        attribute_path: AttributePath,
    ) -> "SymbolicMode":
        segments = list(parse_symbolic_mode(mode_raw, attribute_path))
        return SymbolicMode(mode_raw, segments)

    def __str__(self) -> str:
        return self.symbolic_mode()

    @property
    def is_symbolic_mode(self) -> bool:
        return False

    def symbolic_mode(self) -> str:
        return self.provided_mode

    def compute_mode(self, current_mode: int, is_dir: bool) -> int:
        final_mode = current_mode
        for segment in self.segments:
            final_mode = segment.apply(final_mode, is_dir)
        return final_mode


@dataclasses.dataclass(slots=True, frozen=True)
class OctalMode(FileSystemMode):
    octal_mode: int

    @classmethod
    def parse_filesystem_mode(
        cls,
        mode_raw: str,
        attribute_path: AttributePath,
    ) -> "FileSystemMode":
        try:
            mode = int(mode_raw, base=8)
        except ValueError as e:
            error_msg = 'An octal mode must be all digits between 0-7 (such as "644")'
            raise ManifestParseException(
                f"Cannot parse {attribute_path.path} as an octal mode: {error_msg}"
            ) from e
        return OctalMode(mode)

    @property
    def is_octal_mode(self) -> bool:
        return True

    def compute_mode(self, _current_mode: int, _is_dir: bool) -> int:
        return self.octal_mode

    def __str__(self) -> str:
        return f"0{oct(self.octal_mode)[2:]}"


@dataclasses.dataclass(slots=True, frozen=True)
class _StaticFileSystemOwnerGroup:
    ownership_definition: OwnershipDefinition

    @property
    def entity_name(self) -> str:
        return self.ownership_definition.entity_name

    @property
    def entity_id(self) -> int:
        return self.ownership_definition.entity_id

    @classmethod
    def from_manifest_value(
        cls,
        raw_input: Union[str, int],
        attribute_path: AttributePath,
    ) -> "_StaticFileSystemOwnerGroup":
        provided_name, provided_id = _parse_ownership(raw_input, attribute_path)
        owner_def = cls._resolve(raw_input, provided_name, provided_id, attribute_path)
        if (
            owner_def.entity_name in BAD_OWNER_NAMES
            or owner_def.entity_id in BAD_OWNER_IDS
        ):
            raise ManifestParseException(
                f'Refusing to use "{raw_input}" as {cls._owner_type()} (defined at {attribute_path.path})'
                f' as it resolves to "{owner_def.entity_name}:{owner_def.entity_id}" and no path should have this'
                f" entity as {cls._owner_type()} as it is unsafe."
            )
        return cls(owner_def)

    @classmethod
    def _resolve(
        cls,
        raw_input: Union[str, int],
        provided_name: Optional[str],
        provided_id: Optional[int],
        attribute_path: AttributePath,
    ) -> OwnershipDefinition:
        table_name = cls._ownership_table_name()
        name_table, id_table = _load_ownership_table_from_file(table_name)
        name_match = (
            name_table.get(provided_name) if provided_name is not None else None
        )
        id_match = id_table.get(provided_id) if provided_id is not None else None
        if id_match is None and name_match is None:
            name_part = provided_name if provided_name is not None else "N/A"
            id_part = provided_id if provided_id is not None else "N/A"
            raise ManifestParseException(
                f'Cannot resolve "{raw_input}" as {cls._owner_type()} (from {attribute_path.path}):'
                f" It is not known to be a static {cls._owner_type()} from base-passwd."
                f' The value was interpreted as name: "{name_part}" and id: {id_part}'
            )
        if id_match is None:
            assert name_match is not None
            return name_match
        if name_match is None:
            assert id_match is not None
            return id_match
        if provided_name != id_match.entity_name:
            raise ManifestParseException(
                f"Bad {cls._owner_type()} declaration: The id {provided_id} resolves to {id_match.entity_name}"
                f" according to base-passwd, but the packager declared to should have been {provided_name}"
                f" at {attribute_path.path}"
            )
        if provided_id != name_match.entity_id:
            raise ManifestParseException(
                f"Bad {cls._owner_type} declaration: The name {provided_name} resolves to {name_match.entity_id}"
                f" according to base-passwd, but the packager declared to should have been {provided_id}"
                f" at {attribute_path.path}"
            )
        return id_match

    @classmethod
    def _owner_type(cls) -> Literal["owner", "group"]:
        raise NotImplementedError

    @classmethod
    def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]:
        raise NotImplementedError


class StaticFileSystemOwner(_StaticFileSystemOwnerGroup):
    @classmethod
    def _owner_type(cls) -> Literal["owner", "group"]:
        return "owner"

    @classmethod
    def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]:
        return "passwd.master"


class StaticFileSystemGroup(_StaticFileSystemOwnerGroup):
    @classmethod
    def _owner_type(cls) -> Literal["owner", "group"]:
        return "group"

    @classmethod
    def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]:
        return "group.master"


@dataclasses.dataclass(slots=True, frozen=True)
class SymlinkTarget:
    raw_symlink_target: str
    attribute_path: AttributePath
    symlink_target: str

    @classmethod
    def parse_symlink_target(
        cls,
        raw_symlink_target: str,
        attribute_path: AttributePath,
        substitution: Substitution,
    ) -> "SymlinkTarget":
        return SymlinkTarget(
            raw_symlink_target,
            attribute_path,
            substitution.substitute(raw_symlink_target, attribute_path.path),
        )


class FileSystemMatchRule:
    @property
    def raw_match_rule(self) -> str:
        raise NotImplementedError

    @property
    def attribute_path(self) -> AttributePath:
        raise NotImplementedError

    @property
    def match_rule(self) -> MatchRule:
        raise NotImplementedError

    @classmethod
    def parse_path_match(
        cls,
        raw_match_rule: str,
        attribute_path: AttributePath,
        parser_context: "ParserContextData",
    ) -> "FileSystemMatchRule":
        return cls.from_path_match(
            raw_match_rule, attribute_path, parser_context.substitution
        )

    @classmethod
    def from_path_match(
        cls,
        raw_match_rule: str,
        attribute_path: AttributePath,
        substitution: "Substitution",
    ) -> "FileSystemMatchRule":
        try:
            mr = MatchRule.from_path_or_glob(
                raw_match_rule,
                attribute_path.path,
                substitution=substitution,
            )
        except ValueError as e:
            raise ManifestParseException(
                f'Could not parse "{raw_match_rule}" (defined at {attribute_path.path})'
                f" as a path or a glob: {e.args[0]}"
            )

        if isinstance(mr, ExactFileSystemPath):
            return FileSystemExactMatchRule(
                raw_match_rule,
                attribute_path,
                mr,
            )
        return FileSystemGenericMatch(
            raw_match_rule,
            attribute_path,
            mr,
        )


@dataclasses.dataclass(slots=True, frozen=True)
class FileSystemGenericMatch(FileSystemMatchRule):
    raw_match_rule: str
    attribute_path: AttributePath
    match_rule: MatchRule


@dataclasses.dataclass(slots=True, frozen=True)
class FileSystemExactMatchRule(FileSystemMatchRule):
    raw_match_rule: str
    attribute_path: AttributePath
    match_rule: ExactFileSystemPath

    @classmethod
    def from_path_match(
        cls,
        raw_match_rule: str,
        attribute_path: AttributePath,
        substitution: "Substitution",
    ) -> "FileSystemExactMatchRule":
        try:
            normalized = _normalize_path(raw_match_rule)
        except ValueError as e:
            raise ManifestParseException(
                f'The path "{raw_match_rule}" provided in {attribute_path.path} should be relative to the'
                ' root of the package and not use any ".." or "." segments.'
            ) from e
        if normalized == ".":
            raise ManifestParseException(
                f'The path "{raw_match_rule}" matches a file system root and that is not a valid match'
                f' at "{attribute_path.path}". Please narrow the provided path.'
            )
        mr = ExactFileSystemPath(
            substitution.substitute(normalized, attribute_path.path)
        )
        if mr.path.endswith("/") and issubclass(cls, FileSystemExactNonDirMatchRule):
            raise ManifestParseException(
                f'The path "{raw_match_rule}" at {attribute_path.path} resolved to'
                f' "{mr.path}". Since the resolved path ends with a slash ("/"), this'
                " means only a directory can match. However, this attribute should"
                " match a *non*-directory"
            )
        return cls(
            raw_match_rule,
            attribute_path,
            mr,
        )


class FileSystemExactNonDirMatchRule(FileSystemExactMatchRule):
    pass


class DpkgBuildflagsCache:

    __slots__ = ("_cache_keys",)

    def __init__(self) -> None:
        self._cache_keys: Dict[FrozenSet[Tuple[str, str]], Mapping[str, str]] = {}

    def run_dpkg_buildflags(
        self,
        env: Mapping[str, str],
        definition_source: Optional[str],
    ) -> Mapping[str, str]:
        cache_key = frozenset((k, v) for k, v in env.items() if k.startswith("DEB_"))
        dpkg_env = self._cache_keys.get(cache_key)
        if dpkg_env is not None:
            return dpkg_env
        dpkg_env = {}
        try:
            bf_output = subprocess.check_output(["dpkg-buildflags"], env=env)
        except FileNotFoundError:
            if definition_source is None:
                _error(
                    "The dpkg-buildflags command was not available and is necessary to set the relevant"
                    "env variables by default."
                )
            _error(
                "The dpkg-buildflags command was not available and is necessary to set the relevant"
                f"env variables for the environment defined at {definition_source}."
            )
        except subprocess.CalledProcessError as e:
            if definition_source is None:
                _error(
                    f"The dpkg-buildflags command failed with exit code {e.returncode}. Please review the output from"
                    f" dpkg-buildflags above to resolve the issue."
                )
            _error(
                f"The dpkg-buildflags command failed with exit code {e.returncode}. Please review the output from"
                f" dpkg-buildflags above to resolve the issue. The environment definition that triggered this call"
                f" was {definition_source}"
            )
        else:
            warned = False
            for line in bf_output.decode("utf-8").splitlines(keepends=False):
                if "=" not in line or line.startswith("="):
                    if not warned:
                        _warn(
                            f"Unexpected output from dpkg-buildflags (not a K=V line): {line}"
                        )
                    continue
                k, v = line.split("=", 1)
                if k.strip() != k:
                    if not warned:
                        _warn(
                            f'Unexpected output from dpkg-buildflags (Key had spaces): "{line}"'
                        )
                    continue
                dpkg_env[k] = v
        self._cache_keys[cache_key] = dpkg_env
        return dpkg_env


_DPKG_BUILDFLAGS_CACHE = DpkgBuildflagsCache()
del DpkgBuildflagsCache


class BuildEnvironmentDefinition:

    def dpkg_buildflags_env(
        self,
        env: Mapping[str, str],
        definition_source: Optional[str],
    ) -> Mapping[str, str]:
        return _DPKG_BUILDFLAGS_CACHE.run_dpkg_buildflags(env, definition_source)

    def log_computed_env(self, source: str, computed_env: Mapping[str, str]) -> None:
        _debug_log(f"Computed environment variables from {source}")
        for k, v in computed_env.items():
            _debug_log(f"  {k}={v}")

    def update_env(self, env: MutableMapping[str, str]) -> None:
        dpkg_env = self.dpkg_buildflags_env(env, None)
        if _is_debug_log_enabled():
            self.log_computed_env("dpkg-buildflags", dpkg_env)
        env.update(dpkg_env)


class BuildEnvironments:

    def __init__(
        self,
        environments: Dict[str, BuildEnvironmentDefinition],
        default_environment: Optional[BuildEnvironmentDefinition],
    ) -> None:
        self.environments = environments
        self.default_environment = default_environment
