from __future__ import annotations

import os
import sys
import tarfile
from copy import deepcopy
from typing import TYPE_CHECKING, Literal, cast

from packaging.utils import canonicalize_name

from tox.tox_env.errors import Fail

if TYPE_CHECKING:
    from collections.abc import Sequence
    from pathlib import Path

    from packaging._parser import MarkerAtom, MarkerList, Op, Value, Variable
    from packaging.markers import Marker
    from packaging.requirements import Requirement


def dependencies_with_extras(
    deps: list[Requirement],
    extras: set[str],
    package_name: str,
    *,
    available_extras: set[str] | None = None,
) -> list[Requirement]:
    return dependencies_with_extras_from_markers(
        extract_extra_markers(deps), extras, package_name, available_extras=available_extras
    )


def dependencies_with_extras_from_markers(
    deps_with_markers: list[tuple[Requirement, set[str | None]]],
    extras: set[str],
    package_name: str,
    *,
    available_extras: set[str] | None = None,
) -> list[Requirement]:
    normalized_extras: set[str] = {canonicalize_name(e) for e in extras}
    if available_extras is not None and normalized_extras:
        normalized_available = {canonicalize_name(e) for e in available_extras}
        if unknown := normalized_extras - normalized_available:
            available_str = ", ".join(sorted(normalized_available)) or "none"
            unknown_str = ", ".join(sorted(unknown))
            msg = f"extras not found for package {package_name}: {unknown_str} (available: {available_str})"
            raise Fail(msg)
    result: list[Requirement] = []
    found: set[str] = set()
    todo: set[str | None] = normalized_extras | {None}
    visited: set[str | None] = set()
    while todo:
        new_extras: set[str | None] = set()
        for req, extra_markers in deps_with_markers:
            if todo & extra_markers:
                if canonicalize_name(req.name) == canonicalize_name(package_name):  # support for recursive extras
                    new_extras.update(canonicalize_name(e) for e in (req.extras or set()))
                else:
                    req_str = str(req)
                    if req_str not in found:
                        found.add(req_str)
                        result.append(req)
        visited.update(todo)
        todo = new_extras - visited
    return result


def extract_extra_markers(deps: list[Requirement]) -> list[tuple[Requirement, set[str | None]]]:
    """Extract extra markers from dependencies.

    :param deps: the dependencies

    :returns: a list of requirement, extras set

    """
    return [_extract_extra_markers(d) for d in deps]


def _extract_extra_markers(req: Requirement) -> tuple[Requirement, set[str | None]]:
    req = deepcopy(req)
    markers: MarkerList = getattr(req.marker, "_markers", []) or []
    new_markers: MarkerList = []
    extra_markers: set[str] = set()
    marker = markers.pop(0) if markers else None
    while marker:
        extra = _get_extra(marker)
        if extra is not None:
            extra_markers.add(canonicalize_name(extra))
            if new_markers and new_markers[-1] in {"and", "or"}:
                del new_markers[-1]
            marker = markers.pop(0) if markers else None
            if marker in {"and", "or"}:
                marker = markers.pop(0) if markers else None
        else:
            new_markers.append(marker)
            marker = markers.pop(0) if markers else None
    if new_markers:
        cast("Marker", req.marker)._markers = new_markers  # ruff:ignore[private-member-access]
    else:
        req.marker = None
    return req, cast("set[str | None]", extra_markers) or {None}


def _get_extra(
    _marker: MarkerList | tuple[Variable | Value, Op, Variable | Value] | Sequence[MarkerAtom] | Literal["and", "or"],
) -> str | None:
    if not isinstance(_marker, tuple) or len(_marker) != 3:  # ruff:ignore[magic-value-comparison]
        return None
    marker_tuple = cast("tuple[Variable | Value, Op, Variable | Value]", _marker)
    left, op, right = marker_tuple
    if hasattr(left, "value") and left.value == "extra" and hasattr(op, "value") and op.value == "==":
        return right.value if hasattr(right, "value") else None
    return None


def safe_extractall(tar: tarfile.TarFile, path: Path) -> None:
    if sys.version_info >= (3, 12):  # pragma: >=3.12 cover
        try:
            tar.extractall(path=str(path), filter="data")
        except tarfile.OutsideDestinationError as exc:
            msg = f"tar member {exc.tarinfo.name!r} would extract outside of {path}"
            raise Fail(msg) from exc
    else:  # pragma: <3.12 cover
        dest_resolved = path.resolve()
        safe_members: list[tarfile.TarInfo] = []
        for member in tar.getmembers():
            member_path = (path / member.name).resolve()
            if not str(member_path).startswith(f"{dest_resolved}{os.sep}") and member_path != dest_resolved:
                msg = f"tar member {member.name!r} would extract outside of {path}"
                raise Fail(msg)
            safe_members.append(member)
        tar.extractall(path=str(path), members=safe_members)  # ruff:ignore[tarfile-unsafe-members]
