import logging
from collections.abc import Generator
from contextlib import (
    suppress,
)
from copy import (
    deepcopy,
)

import requirements
from pydantic import (
    ValidationError,
)
from requirements.requirement import (
    Requirement,
)

from labels.model.file import (
    DependencyType,
    Location,
    LocationReadCloser,
    Scope,
)
from labels.model.package import Language, Package, PackageType
from labels.model.relationship import (
    Relationship,
)
from labels.model.resolver import (
    Resolver,
)
from labels.parsers.cataloger.generic.parser import (
    Environment,
)
from labels.parsers.cataloger.python.model import (
    PythonRequirementsEntry,
)
from labels.parsers.cataloger.python.package import (
    package_url,
)
from labels.utils.strings import format_exception

LOGGER = logging.getLogger(__name__)

OPERATOR_ORDER = {"==": 1, "===": 1, "~=": 1, ">=": 2, ">": 2, "<": 3, "<=": 3}


def get_dep_version_range(dep_specs: list[tuple[str, str]]) -> str | None:
    version_range = ""
    ordered_specs = sorted(dep_specs, key=lambda x: OPERATOR_ORDER.get(x[0], 1))
    for operator, version in ordered_specs:
        if operator not in OPERATOR_ORDER:
            return None

        if operator in {"==", "~="}:
            version_range = version
            break
        version_range += f"{operator}{version} "
    return version_range.rstrip()


def get_parsed_dependency(line: str) -> tuple[str, str, Requirement, bool] | None:
    with suppress(Exception):
        parsed_dep = next(iter(requirements.parse(line)))
        if not parsed_dep.specs or not (version := get_dep_version_range(parsed_dep.specs)):
            return None
        is_dev: bool = False
        if parsed_dep.extras and any("dev" in extra.lower() for extra in parsed_dep.extras):
            is_dev = True
        return str(parsed_dep.name), version, parsed_dep, is_dev
    return None


def split_lines_requirements(
    content: str,
) -> Generator[tuple[int, str], None, None]:
    last_line = ""
    line_number = 1
    for index, raw_line in enumerate(content.splitlines(), 1):
        if not last_line:
            line_number = index
        line = trim_requirements_txt_line(raw_line)
        if last_line != "":
            line = last_line + line
            last_line = ""
        if line.endswith("\\"):
            last_line += line.rstrip("\\")
            continue
        if not line:
            continue

        if any(
            (
                line.startswith("-e"),
                line.startswith("-r"),
                line.startswith("--requirements"),
            ),
        ):
            continue

        yield line_number, line


def parse_requirements_txt(
    _resolver: Resolver | None,
    _env: Environment | None,
    reader: LocationReadCloser,
) -> tuple[list[Package], list[Relationship]]:
    try:
        content = reader.read_closer.read()
    except UnicodeDecodeError:
        return [], []

    packages: list[Package] = []
    deps_found = False

    for line_number, line in split_lines_requirements(content):
        parsed_dep = get_parsed_dependency(line)

        if not parsed_dep:
            if not deps_found and line_number > 3:
                return [], []
            continue

        product, version, req, is_dev = parsed_dep
        if not product or not version:
            continue

        deps_found = True
        current_location = prepare_location(reader.location, line_number, is_dev=is_dev)
        p_url = package_url(product, version, None)

        package = create_package(product, version, req, current_location, p_url)
        if package:
            packages.append(package)

    return packages, []


def prepare_location(
    location: Location,
    line_number: int,
    *,
    is_dev: bool = False,
) -> Location:
    current_location = deepcopy(location)
    if current_location.coordinates:
        current_location.dependency_type = DependencyType.DIRECT
        current_location.scope = Scope.DEV if is_dev else Scope.PROD
        current_location.coordinates.line = line_number
    return current_location


def create_package(
    product: str,
    version: str,
    req: Requirement,
    location: Location,
    p_url: str,
) -> Package | None:
    try:
        return Package(
            name=product,
            version=version,
            found_by=None,
            locations=[location],
            language=Language.PYTHON,
            p_url=p_url,
            metadata=PythonRequirementsEntry(
                name=str(req.name),
                extras=sorted(req.extras),
                version_constraint=",".join(f"{s[0]} {s[1]}" for s in req.specs)
                if req.specs
                else "",
                markers=p_url,
            ),
            licenses=[],
            type=PackageType.PythonPkg,
        )
    except ValidationError as ex:
        LOGGER.warning(
            "Malformed package. Required fields are missing or data types are incorrect.",
            extra={
                "extra": {
                    "exception": format_exception(str(ex)),
                    "location": location.path(),
                },
            },
        )
        return None


def remove_trailing_comment(line: str) -> str:
    parts = line.split("#", 1)
    if len(parts) < 2:
        # there aren't any comments
        return line
    return parts[0]


def parse_url(line: str) -> str:
    parts = line.split("@")

    if len(parts) > 1:
        desired_index = -1

        for index, raw_part in enumerate(parts):
            part = "".join([char for char in raw_part if char.isalnum()])

            if part.startswith("git"):
                desired_index = index
                break

        if desired_index != -1:
            return "@".join(parts[desired_index:]).strip()

    return ""


def trim_requirements_txt_line(line: str) -> str:
    line = line.strip()

    return remove_trailing_comment(line)
