"""Check that optional extras stay in sync with required dependencies.

When a package appears in both [project.dependencies] and
[project.optional-dependencies], we ensure their version constraints match.
This prevents silent version drift (e.g. bumping a required dep but
forgetting the corresponding extra).
"""

import sys
import tomllib
from pathlib import Path
from re import compile as re_compile

# Matches the package name at the start of a PEP 508 dependency string.
# Stops at the first non-name character; downstream code is responsible for
# stripping extras (`[...]`) and env markers (`; ...`) from the remainder.
_NAME_RE = re_compile(r"^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)")


def _normalize(name: str) -> str:
    """Normalize a package name for equality comparison.

    Lowercases and maps `-` and `.` to `_`. Looser than PEP 503
    (which uses `-` and collapses runs), but sufficient for matching the
    same package across two PEP 508 strings.

    Returns:
        Lowercased, underscore-normalized package name.
    """
    return name.lower().replace("-", "_").replace(".", "_")


def _parse_dep(dep: str) -> tuple[str, str]:
    """Return `(normalized_name, version_spec)` from a PEP 508 string.

    Strips extras (`pkg[async]`), environment markers (`; python_version ...`),
    URL specifiers (`pkg @ git+...`), and whitespace so the returned
    `version_spec` is directly comparable between a required and optional dep.

    Returns:
        Tuple of normalized package name and bare version specifier.

    Raises:
        ValueError: If the dependency string cannot be parsed.
    """
    match = _NAME_RE.match(dep)
    if not match:
        msg = f"Cannot parse dependency: {dep!r}"
        raise ValueError(msg)
    name = match.group(1)
    rest = dep[match.end() :].strip()

    if rest.startswith("["):
        close = rest.find("]")
        if close == -1:
            msg = f"Unclosed extras bracket in dependency: {dep!r}"
            raise ValueError(msg)
        rest = rest[close + 1 :].strip()

    if ";" in rest:
        rest = rest.split(";", 1)[0].strip()

    # URL specifiers have no comparable version; treat as unconstrained.
    if rest.startswith("@"):
        rest = ""

    rest = " ".join(rest.split())
    return _normalize(name), rest


def main(pyproject_path: Path) -> int:
    """Check extras sync and return `0` on pass, `1` on mismatch or parse error."""
    with pyproject_path.open("rb") as f:
        data = tomllib.load(f)

    required: dict[str, str] = {}
    for dep in data.get("project", {}).get("dependencies", []):
        try:
            name, spec = _parse_dep(dep)
        except ValueError as e:
            print(f"::error file={pyproject_path}::{e}")
            return 1
        required[name] = spec

    optional = data.get("project", {}).get("optional-dependencies", {})
    if not optional:
        return 0

    mismatches: list[str] = []
    for group, deps in optional.items():
        for dep in deps:
            try:
                name, spec = _parse_dep(dep)
            except ValueError as e:
                print(f"::error file={pyproject_path}::{e}")
                return 1
            if name in required and spec != required[name]:
                mismatches.append(
                    f"  [{group}] {name}: extra has '{spec}' "
                    f"but required dep has '{required[name]}'"
                )

    if mismatches:
        print(f"Extra / required dependency version mismatch in {pyproject_path}:")
        print("\n".join(mismatches))
        print(
            "\nUpdate the optional extras in [project.optional-dependencies] "
            "to match [project.dependencies]."
        )
        return 1

    print(f"All extras in {pyproject_path} are in sync with required dependencies.")
    return 0


def run(argv: list[str]) -> int:
    """Check each path in `argv`, returning `1` if any fails else `0`.

    Defaults to `pyproject.toml` when `argv` is empty. Every path is checked
    even after one fails, so all problems surface in a single pass rather than
    only the first.

    Returns:
        `0` if every checked file is in sync, `1` if any file mismatches or
        fails to parse.
    """
    paths = [Path(p) for p in argv] or [Path("pyproject.toml")]
    exit_code = 0
    for path in paths:
        if main(path) != 0:
            exit_code = 1
    return exit_code


if __name__ == "__main__":
    raise SystemExit(run(sys.argv[1:]))
