from __future__ import annotations

import os
import re

from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TypeVar

from packaging.utils import canonicalize_name

from poetry.core.constraints.generic import parse_constraint as parse_generic_constraint
from poetry.core.constraints.version import parse_constraint
from poetry.core.packages.dependency_group import MAIN_GROUP
from poetry.core.packages.specification import PackageSpecification
from poetry.core.packages.utils.utils import contains_group_without_marker
from poetry.core.packages.utils.utils import create_nested_marker
from poetry.core.packages.utils.utils import normalize_python_version_markers
from poetry.core.version.markers import parse_marker


if TYPE_CHECKING:
    from collections.abc import Iterable
    from collections.abc import Sequence

    from packaging.utils import NormalizedName

    from poetry.core.constraints.version import VersionConstraint
    from poetry.core.packages.directory_dependency import DirectoryDependency
    from poetry.core.packages.file_dependency import FileDependency
    from poetry.core.version.markers import BaseMarker

    T = TypeVar("T", bound="Dependency")


class Dependency(PackageSpecification):
    def __init__(
        self,
        name: str,
        constraint: str | VersionConstraint,
        optional: bool = False,
        groups: Iterable[str] | None = None,
        allows_prereleases: bool | None = None,
        extras: Iterable[str] | None = None,
        source_type: str | None = None,
        source_url: str | None = None,
        source_reference: str | None = None,
        source_resolved_reference: str | None = None,
        source_subdirectory: str | None = None,
    ) -> None:
        from poetry.core.version.markers import AnyMarker

        super().__init__(
            name,
            source_type=source_type,
            source_url=source_url,
            source_reference=source_reference,
            source_resolved_reference=source_resolved_reference,
            source_subdirectory=source_subdirectory,
            features=extras,
        )

        # Attributes must be immutable for clone() to be safe!
        # (For performance reasons, clone only creates a copy instead of a deep copy).

        self._constraint: VersionConstraint
        self._pretty_constraint: str
        self.constraint = constraint

        self._optional = optional

        if not groups:
            groups = [MAIN_GROUP]

        self.groups = frozenset(canonicalize_name(g) for g in groups)
        self._allows_prereleases = allows_prereleases
        # "_develop" is only required for enriching [project] dependencies
        self._develop = False

        self._python_versions = "*"
        self._python_constraint = parse_constraint("*")
        self._transitive_marker: BaseMarker | None = None

        self._in_extras: Sequence[NormalizedName] = []

        self._activated = not self._optional

        self.is_root = False
        self._marker: BaseMarker = AnyMarker()
        self.source_name: str | None = None

    @property
    def name(self) -> NormalizedName:
        return self._name

    @property
    def constraint(self) -> VersionConstraint:
        return self._constraint

    @constraint.setter
    def constraint(self, constraint: str | VersionConstraint) -> None:
        if isinstance(constraint, str):
            self._constraint = parse_constraint(constraint)
        else:
            self._constraint = constraint

        self._pretty_constraint = str(constraint)

    @property
    def pretty_constraint(self) -> str:
        return self._pretty_constraint

    @property
    def pretty_name(self) -> str:
        return self._pretty_name

    @property
    def python_versions(self) -> str:
        return self._python_versions

    @python_versions.setter
    def python_versions(self, value: str) -> None:
        self._python_versions = value
        self._python_constraint = parse_constraint(value)
        if not self._python_constraint.is_any():
            self._marker = self._marker.intersect(
                parse_marker(
                    create_nested_marker("python_version", self._python_constraint)
                )
            )

    @property
    def marker(self) -> BaseMarker:
        return self._marker

    @marker.setter
    def marker(self, marker: str | BaseMarker) -> None:
        from poetry.core.constraints.version import parse_constraint
        from poetry.core.packages.utils.utils import convert_markers
        from poetry.core.version.markers import BaseMarker
        from poetry.core.version.markers import parse_marker

        if not isinstance(marker, BaseMarker):
            marker = parse_marker(marker)

        self._marker = marker

        markers = convert_markers(marker)

        if "extra" in markers:
            # If we have extras, the dependency is optional
            self.deactivate()

            new_in_extras = []
            for or_ in markers["extra"]:
                for op, extra in or_:
                    if op == "==":
                        new_in_extras.append(canonicalize_name(extra))
                    elif op == "" and ("||" in extra or "," in extra):
                        sep = "||" if "||" in extra else ","
                        extra_values = [e.strip() for e in extra.split(sep)]
                        for _extra in extra_values:
                            if not _extra.startswith("!="):
                                new_in_extras.append(canonicalize_name(_extra))
            self._in_extras = [
                *self._in_extras,
                *(e for e in new_in_extras if e not in self._in_extras),
            ]

        # Recalculate python versions.
        self._python_versions = "*"
        if not contains_group_without_marker(markers, "python_version"):
            python_version_markers = markers["python_version"]
            self._python_versions = normalize_python_version_markers(
                python_version_markers
            )

        self._python_constraint = parse_constraint(self._python_versions)

    @property
    def transitive_marker(self) -> BaseMarker:
        if self._transitive_marker is None:
            return self.marker

        return self._transitive_marker

    @transitive_marker.setter
    def transitive_marker(self, value: BaseMarker) -> None:
        self._transitive_marker = value

    @property
    def python_constraint(self) -> VersionConstraint:
        return self._python_constraint

    @property
    def extras(self) -> frozenset[NormalizedName]:
        # extras activated in a dependency is the same as features
        return self._features

    @property
    def in_extras(self) -> Sequence[NormalizedName]:
        return self._in_extras

    @property
    def base_pep_508_name(self) -> str:
        from poetry.core.constraints.version import Version
        from poetry.core.constraints.version import VersionUnion

        requirement = self.complete_pretty_name

        constraint = self.constraint
        if isinstance(constraint, VersionUnion):
            if (
                constraint.excludes_single_version
                or constraint.excludes_single_wildcard_range
            ):
                # This branch is a short-circuit logic for special cases and
                # avoids having to split and parse constraint again. This has
                # no functional difference with the logic in the else branch.
                requirement += f" ({constraint})"
            else:
                constraints = ",".join(
                    str(parse_constraint(c)) for c in self.pretty_constraint.split(",")
                )
                requirement += f" ({constraints})"
        elif isinstance(constraint, Version):
            requirement += f" (=={constraint.text})"
        elif not constraint.is_any():
            requirement += f" ({str(constraint).replace(' ', '')})"

        return requirement

    @property
    def base_pep_508_name_resolved(self) -> str:
        return self.base_pep_508_name

    def allows_prereleases(self) -> bool | None:
        """
        None (default): only use pre-release versions
                        if no stable version satisfies the constraint
        False: do not allow pre-release versions
               even if this means there is no solution
        True: do not distinguish between stable and pre-release versions
        """
        return self._allows_prereleases

    def is_optional(self) -> bool:
        return self._optional

    def is_activated(self) -> bool:
        return self._activated

    def is_vcs(self) -> bool:
        return False

    def is_file(self) -> bool:
        return False

    def is_directory(self) -> bool:
        return False

    def is_url(self) -> bool:
        return False

    def to_pep_508(self, with_extras: bool = True, *, resolved: bool = False) -> str:
        from poetry.core.packages.utils.utils import convert_markers

        if resolved:
            requirement = self.base_pep_508_name_resolved
        else:
            requirement = self.base_pep_508_name

        markers = []
        has_extras = False
        if not self.marker.is_any():
            marker = self.marker
            if not with_extras:
                marker = marker.without_extras()

            # we re-check for any marker here since the without extra marker might
            # return an any marker again
            if not (marker.is_empty() or marker.is_any()):
                markers.append(str(marker))

            has_extras = "extra" in convert_markers(marker)
        else:
            # Python marker
            if self.python_versions != "*":
                python_constraint = self.python_constraint

                markers.append(
                    create_nested_marker("python_version", python_constraint)
                )

        in_extras = " || ".join(self._in_extras)
        if in_extras and with_extras and not has_extras:
            markers.append(
                create_nested_marker("extra", parse_generic_constraint(in_extras))
            )

        if markers:
            if len(markers) > 1:
                marker_str = " and ".join(f"({m})" for m in markers)
            else:
                marker_str = markers[0]
            requirement += f" ; {marker_str}"

        return requirement

    def activate(self) -> None:
        """
        Set the dependency as mandatory.
        """
        self._activated = True

    def deactivate(self) -> None:
        """
        Set the dependency as optional.
        """
        if not self._optional:
            self._optional = True

        self._activated = False

    def with_constraint(self: T, constraint: str | VersionConstraint) -> T:
        dependency = self.clone()
        dependency.constraint = constraint
        return dependency

    def with_groups(self, groups: Iterable[str]) -> Dependency:
        dependency = self.clone()
        dependency.groups = frozenset(canonicalize_name(g) for g in groups)
        return dependency

    @classmethod
    def create_from_pep_508(
        cls,
        name: str,
        relative_to: Path | None = None,
        groups: Iterable[str] | None = None,
    ) -> Dependency:
        """
        Resolve a PEP-508 requirement string to a `Dependency` instance. If a
        `relative_to` path is specified, this is used as the base directory if the
        identified dependency is of file or directory type.
        """
        from poetry.core.packages.url_dependency import URLDependency
        from poetry.core.packages.utils.link import Link
        from poetry.core.packages.utils.utils import cached_is_dir
        from poetry.core.packages.utils.utils import is_archive_file
        from poetry.core.packages.utils.utils import is_python_project
        from poetry.core.packages.utils.utils import is_url
        from poetry.core.packages.utils.utils import path_to_url
        from poetry.core.packages.utils.utils import strip_extras
        from poetry.core.packages.utils.utils import url_to_path
        from poetry.core.packages.vcs_dependency import VCSDependency
        from poetry.core.utils.patterns import wheel_file_re
        from poetry.core.vcs.git import ParsedUrl
        from poetry.core.version.requirements import parse_requirement

        # Removing comments
        parts = name.split(" #", 1)
        name = parts[0].strip()
        if len(parts) > 1:
            rest = parts[1]
            if " ;" in rest:
                name += " ;" + rest.split(" ;", 1)[1]

        req = parse_requirement(name)

        name = req.name
        link = None

        if is_url(name):
            link = Link(name)
        elif req.url:
            link = Link(req.url)
        else:
            path_str = os.path.normpath(os.path.abspath(name))  # noqa: PTH100
            p, extras = strip_extras(path_str)
            if cached_is_dir(p) and (os.path.sep in name or name.startswith(".")):
                if not is_python_project(Path(name)):
                    raise ValueError(
                        f"Directory {name!r} is not installable. Not a Python project."
                    )
                link = Link(path_to_url(p))
            elif is_archive_file(p):
                link = Link(path_to_url(p))

        # it's a local file, dir, or url
        if link:
            is_file_uri = link.scheme == "file"
            is_relative_uri = is_file_uri and re.search(r"\.\./", link.url)

            # Handle relative file URLs
            if is_file_uri and is_relative_uri:
                path = Path(link.path)
                if relative_to:
                    path = relative_to / path
                link = Link(path_to_url(path))

            # wheel file
            version = None
            if link.is_wheel:
                m = wheel_file_re.match(link.filename)
                if not m:
                    raise ValueError(f"Invalid wheel name: {link.filename}")
                name = m.group("name")
                version = m.group("ver")

            dep: Dependency | None = None

            if link.scheme.startswith("git+"):
                url = ParsedUrl.parse(link.url)
                dep = VCSDependency(
                    name,
                    "git",
                    url.url,
                    rev=url.rev,
                    directory=url.subdirectory,
                    extras=req.extras,
                    groups=groups,
                )
            elif link.scheme == "git":
                dep = VCSDependency(
                    name,
                    "git",
                    link.url_without_fragment,
                    extras=req.extras,
                    groups=groups,
                )
            elif link.scheme in ("http", "https"):
                dep = URLDependency(
                    name,
                    link.url_without_fragment,
                    directory=link.subdirectory_fragment,
                    extras=req.extras,
                    groups=groups,
                )
            elif is_file_uri:
                # handle RFC 8089 references
                path = url_to_path(req.url)
                dep = _make_file_or_dir_dep(
                    name=name,
                    path=path,
                    base=relative_to,
                    subdirectory=link.subdirectory_fragment,
                    extras=req.extras,
                    groups=groups,
                )
            else:
                with suppress(ValueError):
                    # this is a local path not using the file URI scheme
                    dep = _make_file_or_dir_dep(
                        name=name,
                        path=Path(req.url),
                        base=relative_to,
                        extras=req.extras,
                        groups=groups,
                    )

            if dep is None:
                dep = Dependency(name, version or "*", extras=req.extras, groups=groups)

            if version:
                dep._constraint = parse_constraint(version)
        else:
            constraint: VersionConstraint | str
            constraint = req.constraint if req.pretty_constraint else "*"
            dep = Dependency(name, constraint, extras=req.extras, groups=groups)

        if req.marker:
            dep.marker = req.marker

        return dep

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Dependency):
            return NotImplemented

        # "constraint" is implicitly given for direct origin dependencies and might not
        # be set yet ("*"). Thus, it shouldn't be used to determine if two direct origin
        # dependencies are equal.
        # Calling is_direct_origin() for one dependency is sufficient because
        # super().__eq__() returns False for different origins.
        return super().__eq__(other) and (
            self._constraint == other.constraint or self.is_direct_origin()
        )

    def __hash__(self) -> int:
        # don't include _constraint in hash because it is mutable!
        return super().__hash__()

    def __str__(self) -> str:
        if self.is_root:
            return self._pretty_name
        if self.is_direct_origin():
            # adding version since this information is especially useful in debug output
            parts = [p.strip() for p in self.base_pep_508_name.split("@", 1)]
            return f"{parts[0]} ({self._pretty_constraint}) @ {parts[1]}"
        return self.base_pep_508_name

    def __repr__(self) -> str:
        return f"<{self.__class__.__name__} {self}>"


def _make_file_or_dir_dep(
    name: str,
    path: Path,
    base: Path | None = None,
    subdirectory: str | None = None,
    extras: Iterable[str] | None = None,
    groups: Iterable[str] | None = None,
) -> FileDependency | DirectoryDependency | None:
    """
    Helper function to create a file or directoru dependency with the given arguments.

    If path is not a file or directory that exists, a guess is made based on the suffix
    of the given path. This is done to prevent dependencies from being parsed as normal
    dependencies. This allows for downstream error handling.

    See also: poetry#10068
    """
    from poetry.core.packages.directory_dependency import DirectoryDependency
    from poetry.core.packages.file_dependency import FileDependency

    _path = path

    if not path.is_absolute() and base:
        # a base path was specified, so we should respect that
        _path = Path(base) / path

    # we check if it is a file (if it exists) or rely on suffix to guess
    is_file = _path.is_file() if _path.exists() else path.suffix != ""

    if is_file:
        return FileDependency(
            name, path, base=base, directory=subdirectory, extras=extras, groups=groups
        )

    if subdirectory:
        path = path / subdirectory

    return DirectoryDependency(name, path, base=base, extras=extras, groups=groups)
