import re
from pathlib import Path
from typing import TypeAlias

from fluidattacks_core.semver.match_versions import match_version_ranges

from labels.model.file import DependencyType, Location
from labels.model.indexables import IndexedDict, ParsedValue
from labels.model.package import Package
from labels.model.package_manager import PackageManager
from labels.model.relationship import Relationship, RelationshipType
from labels.parsers.cataloger.javascript.package_builder import new_simple_npm_package
from labels.parsers.cataloger.utils import (
    LOGGER,
    get_enriched_location,
)
from labels.parsers.collection.json import parse_json_with_tree_sitter

DependencyRequirements: TypeAlias = dict[str, str]

DependencyMap: TypeAlias = dict[str, DependencyRequirements]


X_RANGE_PATTERN = re.compile(r"^\d+(?:\.\d+)?\.x\Z")
BARE_NUMBER_PATTERN = re.compile(r"^\d+(?:\.\d+)?\Z")
HYPHEN_RANGE_PATTERN = re.compile(r"^(\S+)\s+-\s+(\S+)\Z")
OPERATOR_PATTERN = re.compile(r"^([><=!]+)(.+)\Z")


def _bare_number_to_bounds(token: str) -> tuple[str, str]:
    parts = token.split(".")
    upper_parts = list(parts)
    upper_parts[-1] = str(int(upper_parts[-1]) + 1)
    return f"{token}.0", ".".join(upper_parts) + ".0"


def _x_range_to_bounds(x_token: str) -> tuple[str, str]:
    return _bare_number_to_bounds(x_token.removesuffix(".x"))


def _range_token_to_bounds(token: str) -> tuple[str, str]:
    if X_RANGE_PATTERN.match(token):
        return _x_range_to_bounds(token)
    return _bare_number_to_bounds(token)


def _is_range_token(token: str) -> bool:
    return bool(X_RANGE_PATTERN.match(token) or BARE_NUMBER_PATTERN.match(token))


def _convert_hyphen_range(lower: str, upper: str) -> str:
    upper_parts = upper.split(".")
    if len(upper_parts) < 3 and upper_parts[-1].isdigit():
        upper_parts[-1] = str(int(upper_parts[-1]) + 1)
        return f">={lower} <{'.'.join(upper_parts)}.0"
    return f">={lower} <={upper}"


def _version_tuple(version: str) -> tuple[int, ...]:
    return tuple(int(s) for s in version.split(".") if s.isdigit())


def _bump_leftmost_nonzero(int_parts: list[int]) -> list[int]:
    for i, val in enumerate(int_parts):
        if val != 0:
            int_parts[i] = val + 1
            int_parts[i + 1 :] = [0] * (len(int_parts) - i - 1)
            return int_parts
    int_parts[-1] = 1
    return int_parts


def _implicit_upper_from_caret_tilde(token: str) -> str | None:
    if len(token) < 2 or token[0] not in ("^", "~"):
        return None

    prefix, version_str = token[0], token[1:]
    try:
        parts = [int(p) for p in version_str.split(".")]
    except ValueError:
        return None

    if prefix == "^":
        parts = _bump_leftmost_nonzero(parts)

    if prefix == "~":
        if len(parts) >= 2:
            parts[1] += 1
            parts[2:] = [0] * (len(parts) - 2)
        else:
            parts[0] += 1

    parts += [0] * max(0, 3 - len(parts))

    return ".".join(str(p) for p in parts)


def _is_tighter_upper(new_op: str, new_ver: str, cur_op: str, cur_ver: str) -> bool:
    new_key = (_version_tuple(new_ver), new_op.endswith("="))
    cur_key = (_version_tuple(cur_ver), cur_op.endswith("="))
    return new_key < cur_key


def _resolve_caret_tilde_range(caret_tilde_token: str, operators: list[str]) -> str:
    implicit_lower = caret_tilde_token[1:]
    implicit_upper = _implicit_upper_from_caret_tilde(caret_tilde_token)
    if implicit_upper is None:
        return " ".join([implicit_lower, *operators])

    lower_op = ">="
    lower = implicit_lower
    upper_op = "<"
    upper = implicit_upper

    for token in operators:
        if match := OPERATOR_PATTERN.match(token):
            op: str = match.group(1)
            ver: str = match.group(2)

            if op.startswith(">") and _version_tuple(ver) > _version_tuple(lower):
                lower_op = op
                lower = ver
            elif op.startswith("<") and _is_tighter_upper(op, ver, upper_op, upper):
                upper_op = op
                upper = ver

    return f"{lower_op}{lower} {upper_op}{upper}"


def _normalize_single_token(token: str) -> str:
    if X_RANGE_PATTERN.match(token):
        lower, upper = _x_range_to_bounds(token)
        return f">={lower} <{upper}"
    return token


def _resolve_range_with_operators(range_token: str, operators: list[str]) -> str:
    lower, upper = _range_token_to_bounds(range_token)
    lower_ops = [t for t in operators if t.startswith(">")]
    upper_ops = [t for t in operators if t.startswith("<")]
    result: list[str] = []
    result.extend(lower_ops if lower_ops else [f">={lower}"])
    result.extend(upper_ops if upper_ops else [f"<{upper}"])
    return " ".join(result)


def _normalize_npm_range_part(part: str) -> str:
    hyphen_match = HYPHEN_RANGE_PATTERN.match(part)
    if hyphen_match:
        return _convert_hyphen_range(hyphen_match.group(1), hyphen_match.group(2))

    normalized = re.sub(r"([><=!~^]+)\s+", r"\1", part)
    tokens = normalized.split()

    if len(tokens) <= 1:
        return _normalize_single_token(tokens[0]) if tokens else normalized

    range_tokens = [t for t in tokens if _is_range_token(t)]
    caret_tilde = [t for t in tokens if t[0] in "~^"]
    operators = [t for t in tokens if t[0] in "><=!"]

    if caret_tilde and operators:
        return _resolve_caret_tilde_range(caret_tilde[0], operators)

    if caret_tilde or any(t.startswith("=") for t in operators):
        return " ".join(caret_tilde) if caret_tilde else " ".join(operators)

    if range_tokens and operators:
        return _resolve_range_with_operators(range_tokens[0], operators)

    non_range = [t for t in tokens if not _is_range_token(t)]
    return " ".join(non_range) if non_range else normalized


def _normalize_version_or_list(version_str: str) -> str:
    parts = [p.strip() for p in version_str.split("||")]
    normalized: list[str] = []
    for p in parts:
        stripped = _normalize_npm_range_part(p)
        if re.fullmatch(r"\d+(?:\.\d+)?", stripped):
            normalized.append(f"{stripped}.*")
        else:
            normalized.append(stripped)
    return " || ".join(normalized)


def normalize_npm_alias(version_or_alias: str) -> tuple[str | None, str]:
    if version_or_alias.startswith("npm:"):
        parts = version_or_alias.removeprefix("npm:").rsplit("@", 1)
        if len(parts) == 2:
            return parts[0], parts[1]
    return None, version_or_alias


def read_package_json(location: Location) -> IndexedDict[str, ParsedValue] | None:
    if not (location.coordinates and location.coordinates.real_path):
        return None

    lockfile_path = Path(location.coordinates.real_path)
    package_json_path = lockfile_path.parent / "package.json"

    if not package_json_path.exists():
        return None

    try:
        with package_json_path.open("r", encoding="utf-8") as f:
            content = parse_json_with_tree_sitter(f.read())
            if isinstance(content, IndexedDict):
                return content
            return None
    except Exception as exc:  # noqa: BLE001
        LOGGER.exception("Error reading package.json", extra={"extra": {"error": exc}})
        return None


def get_direct_dependencies_from_package_json(location: Location) -> set[str]:
    pkg_json = read_package_json(location)
    if not pkg_json:
        return set()

    direct: set[str] = set()
    deps = pkg_json.get("dependencies")
    dev_deps = pkg_json.get("devDependencies")
    opt_deps = pkg_json.get("optionalDependencies")

    if isinstance(deps, IndexedDict):
        direct.update(deps.keys())
    if isinstance(dev_deps, IndexedDict):
        direct.update(dev_deps.keys())
    if isinstance(opt_deps, IndexedDict):
        direct.update(opt_deps.keys())

    return direct


def get_dev_dependencies_from_package_json(location: Location) -> set[str]:
    pkg_json = read_package_json(location)
    if not pkg_json:
        return set()

    dev_deps = pkg_json.get("devDependencies")
    if isinstance(dev_deps, IndexedDict):
        return set(dev_deps.keys())

    return set()


def get_direct_dependencies_with_fallback(location: Location, fallback: list[str]) -> list[str]:
    pkg_json = read_package_json(location)
    if not pkg_json:
        return fallback

    direct: set[str] = set()
    deps = pkg_json.get("dependencies")
    dev_deps = pkg_json.get("devDependencies")
    opt_deps = pkg_json.get("optionalDependencies")

    if isinstance(deps, IndexedDict):
        direct.update(deps.keys())
    if isinstance(dev_deps, IndexedDict):
        direct.update(dev_deps.keys())
    if isinstance(opt_deps, IndexedDict):
        direct.update(opt_deps.keys())

    return list(direct)


def create_root_package_from_package_json(
    location: Location,
    package_manager: PackageManager = PackageManager.NPM,
) -> Package | None:
    pkg_json = read_package_json(location)
    if not pkg_json:
        return None

    root_name = pkg_json.get("name")
    root_version = pkg_json.get("version")

    if not isinstance(root_name, str) or not isinstance(root_version, str):
        return None

    root_location = get_enriched_location(
        location,
        line=1,
        is_transitive=False,
        is_dev=False,
        package_manager=package_manager,
    )
    update_root: dict[str, DependencyType] = {"dependency_type": DependencyType.ROOT}
    root_location = root_location.model_copy(deep=True, update=update_root)

    return new_simple_npm_package(root_location, root_name, root_version)


def find_root_location(root_package: Package, lockfile_path: str) -> Location | None:
    for loc in root_package.locations:
        if loc.coordinates and loc.coordinates.real_path == lockfile_path:
            return loc
    return None


def group_packages_by_name(
    packages: list[Package], root_package_id: str
) -> dict[str, list[Package]]:
    packages_by_name: dict[str, list[Package]] = {}
    for pkg in packages:
        if pkg.id_ != root_package_id:
            packages_by_name.setdefault(pkg.name, []).append(pkg)
    return packages_by_name


def add_dependency_to_root_relationship(
    dep_pkg: Package,
    root_package: Package,
    root_location: Location,
    lockfile_path: str,
    relationships: list[Relationship],
) -> None:
    for dep_loc in dep_pkg.locations:
        if dep_loc.coordinates and dep_loc.coordinates.real_path == lockfile_path:
            from_id = f"{dep_pkg.id_}@{dep_loc.location_id()}"
            to_id = f"{root_package.id_}@{root_location.location_id()}"
            relationships.append(
                Relationship(
                    from_=from_id,
                    to_=to_id,
                    type=RelationshipType.DEPENDENCY_OF_RELATIONSHIP,
                )
            )
            break


def is_version_compatible(package_version: str, required_version: str) -> bool:
    version_str = required_version.strip()
    if version_str in ("*", "*.*", "*.*.*"):
        return True

    version_str = _normalize_version_or_list(version_str)

    return match_version_ranges(package_version, version_str)


def _is_exact_version(version_str: str) -> bool:
    if X_RANGE_PATTERN.match(version_str):
        return False
    return not any(char in version_str for char in ("^", "~", "*", ">", "<", "=", " "))


def build_relationships(
    packages: list[Package],
    dependency_map: DependencyMap,
    location: Location,
    key_map: dict[tuple[str, int], str] | None = None,
) -> list[Relationship]:
    """Build relationships between packages.

    Args:
        packages: List of packages to create relationships for
        dependency_map: Map of package names to their dependencies
        location: Location of the lockfile
        key_map: Optional map for yarn lockfiles

    Returns:
        List of relationships

    """
    lockfile_path = location.path()
    relationships: list[Relationship] = []

    packages_by_name: dict[str, list[Package]] = {}
    for pkg in packages:
        packages_by_name.setdefault(pkg.name, []).append(pkg)

    for package in packages:
        package_lock_locations = [loc for loc in package.locations if loc.path() == lockfile_path]
        if not package_lock_locations:
            continue

        for package_location in package_lock_locations:
            if key_map is not None:
                # Use key_map to get dependencies (for yarn and pnpm)
                relationships.extend(
                    _create_relationships_with_key_map(
                        package,
                        package_location,
                        key_map,
                        dependency_map,
                        packages_by_name,
                        lockfile_path,
                    )
                )
            else:
                # Fallback for simple name lookup (package-lock v1/v2)
                dependencies = dependency_map.get(package.name)
                if not dependencies:
                    continue

                relationships.extend(
                    _create_simple_relationships(
                        package, package_location, dependencies, packages_by_name, lockfile_path
                    )
                )

    return relationships


def _create_relationships_with_key_map(  # noqa: PLR0913
    package: Package,
    package_location: Location,
    key_map: dict[tuple[str, int], str],
    dependency_map: DependencyMap,
    packages_by_name: dict[str, list[Package]],
    lockfile_path: str,
) -> list[Relationship]:
    line = package_location.coordinates.line if package_location.coordinates else None
    if line is None:
        return []

    dep_key = key_map.get((package.name, line))
    if not dep_key:
        return []

    dependencies = dependency_map.get(dep_key)
    if not dependencies:
        return []

    return _create_simple_relationships(
        package, package_location, dependencies, packages_by_name, lockfile_path
    )


def _create_simple_relationships(
    package: Package,
    package_location: Location,
    dependencies: DependencyRequirements,
    packages_by_name: dict[str, list[Package]],
    lockfile_path: str,
) -> list[Relationship]:
    relationships: list[Relationship] = []

    for dependency_name, required_version in dependencies.items():
        use_exact = _is_exact_version(required_version)

        for dep_pkg in packages_by_name.get(dependency_name, []):
            if use_exact:
                if dep_pkg.version != required_version:
                    continue
            elif not is_version_compatible(dep_pkg.version, required_version):
                continue

            dep_locations = [loc for loc in dep_pkg.locations if loc.path() == lockfile_path]

            for dep_location in dep_locations:
                from_id = f"{dep_pkg.id_}@{dep_location.location_id()}"
                to_id = f"{package.id_}@{package_location.location_id()}"
                relationships.append(
                    Relationship(
                        from_=from_id,
                        to_=to_id,
                        type=RelationshipType.DEPENDENCY_OF_RELATIONSHIP,
                    )
                )

    return relationships
