#!/usr/bin/env python3
# Copyright 2026 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Verifies vpython.toml and PEP 723 specification files against uv.lock files.

This script parses Python virtual environment specification files (vpython.toml
and inline PEP 723 script blocks) and ensures that all declared dependencies are
present and satisfied in the companion uv.lock lockfile. It expects multi-target
universal lockfiles generated by uv (e.g. via `uv lock` or `uv pip compile`).
"""

from __future__ import annotations

import os
import re
import sys

if sys.version_info < (3, 11):
    print(
        f"Error: verify_lockfile.py requires Python 3.11+ "
        f"(running under Python {sys.version.split()[0]})",
        file=sys.stderr,
    )
    sys.exit(1)

import tomllib

from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version

# Reference regex pattern from PEP 723:
# https://peps.python.org/pep-0723/#reference-implementation
# License: Public Domain / CC0-1.0-Universal
PEP723_REGEX = (
    r"(?m)^\s*# /// (?P<type>[a-zA-Z0-9-]+)\r?\n"
    r"(?P<content>(?:^\s*#.*(?:\r?\n|$))*?)\s*# ///\s*$"
)


def normalize_name(name: str) -> str:
    """Normalizes a package name per PEP 503/685 via packaging.utils."""
    return canonicalize_name(name.split("[")[0].strip())


def parse_vpython_toml(path: str) -> list[str] | None:
    """Parses a vpython.toml file and extracts its dependencies list.

    Args:
        path: Path to the vpython.toml file.

    Returns:
        List of dependency requirement strings, or None if parsing failed.
    """
    try:
        with open(path, "rb") as f:
            data = tomllib.load(f)
        if not isinstance(data, dict):
            print(
                f"Error: Expected dictionary root in TOML file {path}",
                file=sys.stderr,
            )
            return None
        deps = data.get("dependencies", [])
        if not isinstance(deps, list):
            print(
                f"Error: 'dependencies' in {path} must be a list.",
                file=sys.stderr,
            )
            return None
        return deps
    except (OSError, UnicodeError) as e:
        print(f"Error: Failed to read {path}: {e}", file=sys.stderr)
        return None
    except (tomllib.TOMLDecodeError, ValueError, TypeError) as e:
        print(f"Error: Failed to parse TOML in {path}: {e}", file=sys.stderr)
        return None


def read_pep723(script: str) -> str | None:
    """Extracts inline PEP 723 script metadata content from script string.

    Reference implementation:
    https://peps.python.org/pep-0723/#reference-implementation
    License: Public Domain / CC0-1.0-Universal
    """
    matches = [
        m
        for m in re.finditer(PEP723_REGEX, script)
        if m.group("type") in ("script", "vpython")
    ]
    if len(matches) > 1:
        raise ValueError("Multiple PEP 723 script blocks found")
    elif len(matches) == 1:
        content = matches[0].group("content")
        return "".join(
            line.lstrip()[2:]
            if line.lstrip().startswith("# ")
            else line.lstrip()[1:]
            for line in content.splitlines(keepends=True)
        )
    else:
        return None


def parse_pep723(path: str) -> list[str] | None:
    """Parses an inline PEP 723 script metadata block from a Python file.

    Args:
        path: Path to the Python script file.

    Returns:
        List of dependency requirement strings, or None if parsing failed.
    """
    try:
        with open(path, "r", encoding="utf-8") as f:
            script_text = f.read()
    except (OSError, UnicodeError) as e:
        print(f"Error: Failed to read {path}: {e}", file=sys.stderr)
        return None

    try:
        content = read_pep723(script_text)
        if content is None:
            return []
        data = tomllib.loads(content)
        if not isinstance(data, dict):
            print(
                f"Error: Expected dictionary root in PEP 723 block in {path}",
                file=sys.stderr,
            )
            return None
        deps = data.get("dependencies", [])
        if not isinstance(deps, list):
            print(
                f"Error: 'dependencies' in PEP 723 block "
                f"in {path} must be a list.",
                file=sys.stderr,
            )
            return None
        return deps
    except (ValueError, tomllib.TOMLDecodeError, TypeError) as e:
        print(
            f"Error: Failed to parse PEP 723 inline block in {path}: {e}",
            file=sys.stderr,
        )
        return None


def parse_uv_lock(path: str) -> dict[str, list[str]] | None:
    """Parses a uv.lock file (TOML or requirements.txt format).

    Args:
        path: Path to the uv.lock file.

    Returns:
        Dict mapping normalized package names to lists of locked
        version strings, or None if the lockfile is missing or invalid.
    """
    if not os.path.exists(path):
        return None

    try:
        with open(path, "rb") as f:
            content_bytes = f.read()
    except (OSError, UnicodeError) as e:
        print(f"Error: Failed to read {path}: {e}", file=sys.stderr)
        return None

    # First attempt TOML parsing (standard uv.lock format)
    try:
        data = tomllib.loads(content_bytes.decode("utf-8"))
        if isinstance(data, dict):
            deps: dict[str, list[str]] = {}
            packages = data.get("package", [])
            if isinstance(packages, list):
                for pkg in packages:
                    if isinstance(pkg, dict) and "name" in pkg:
                        name = normalize_name(str(pkg["name"]))
                        ver = str(pkg.get("version", "0.0.0"))
                        if ver not in deps.setdefault(name, []):
                            deps[name].append(ver)
            return deps
    except (tomllib.TOMLDecodeError, ValueError, TypeError):
        pass

    raw_content = content_bytes.decode("utf-8", errors="ignore")
    if re.search(
        r"^\s*\[\[package\]\]", raw_content, re.MULTILINE
    ) or re.search(r"^\s*version\s*=", raw_content, re.MULTILINE):
        print(
            f"Error: Failed to parse TOML structure in lockfile {path}",
            file=sys.stderr,
        )
        return None

    # Fallback to requirements.txt-style parsing (e.g. from `uv pip compile`)
    fallback_deps: dict[str, list[str]] = {}
    for line in raw_content.splitlines():
        line = line.split("#")[0].split(";")[0].split("--")[0].strip()
        if "==" in line or "===" in line:
            sep = "===" if "===" in line else "=="
            parts = line.split(sep, 1)
            name = normalize_name(parts[0])
            ver = parts[1].rstrip("\\ ").strip()
            if name and ver and ver not in fallback_deps.setdefault(name, []):
                fallback_deps[name].append(ver)
        elif "@" in line or "#egg=" in line:
            if "#egg=" in line:
                parts = line.split("#egg=", 1)
            else:
                parts = line.split("@", 1)
            name = normalize_name(parts[0] if "@" in line else parts[1])
            if name and "0.0.0" not in fallback_deps.setdefault(name, []):
                fallback_deps[name].append("0.0.0")

    return fallback_deps


def verify(spec_path: str, lock_path: str) -> int:
    """Verifies spec file declared dependencies in lock_path.

    Note: Environment markers (e.g., sys_platform == 'win32') in
    dependencies are intentionally NOT evaluated against local presubmit,
    because uv.lock is a multi-target universal lockfile for all target
    environments. Every declared dependency must be present in uv.lock.

    Args:
        spec_path: Path to vpython.toml or PEP 723 script file.
        lock_path: Path to companion .uv.lock file.

    Returns:
        0 on verification success, 1 on error.
    """
    if not os.path.exists(spec_path) and not os.path.exists(lock_path):
        # Both spec and lockfile were deleted in CL; nothing to verify.
        return 0

    if not os.path.exists(spec_path):
        print(f"Error: Spec file not found at {spec_path}", file=sys.stderr)
        return 1

    if spec_path.endswith((".py", ".pyw")) or not spec_path.endswith(".toml"):
        toml_deps = parse_pep723(spec_path)
    else:
        toml_deps = parse_vpython_toml(spec_path)

    if toml_deps is None:
        return 1

    if not toml_deps:
        return 0

    lock_deps = parse_uv_lock(lock_path)
    if lock_deps is None:
        if not os.path.exists(lock_path):
            print(
                f"Error: Lockfile {lock_path} is missing!",
                file=sys.stderr,
            )
        else:
            print(
                f"Error: Lockfile {lock_path} is invalid TOML or unparseable!",
                file=sys.stderr,
            )
        print(
            "Developers must run 'vpython3' (or "
            "'vpython3 -vpython-tool upgrade') locally "
            "to synchronize and commit their lockfile changes.",
            file=sys.stderr,
        )
        return 1

    out_of_sync = False
    errors: list[str] = []

    for dep in toml_deps:
        if not isinstance(dep, str):
            errors.append(
                f"Invalid dependency entry "
                f"(expected string, got {type(dep).__name__}): {dep}"
            )
            out_of_sync = True
            continue

        try:
            req = Requirement(dep)
        except (InvalidRequirement, ValueError, TypeError) as e:
            errors.append(f"Invalid requirement '{dep}' in spec: {e}")
            out_of_sync = True
            continue

        name = normalize_name(req.name)
        if name not in lock_deps:
            errors.append(
                f"Package '{name}' is in spec but missing from lockfile"
            )
            out_of_sync = True
            continue

        locked_versions = lock_deps[name]
        satisfied = False
        for ver_str in locked_versions:
            try:
                if req.specifier.contains(Version(ver_str), prereleases=True):
                    satisfied = True
                    break
            except (InvalidVersion, ValueError, TypeError):
                pass

        if not satisfied:
            errors.append(
                f"No locked version of '{name}' ({locked_versions}) "
                f"satisfies requirement '{dep}'"
            )
            out_of_sync = True

    if out_of_sync:
        print(
            f"Spec '{os.path.basename(spec_path)}' and lockfile "
            f"'{os.path.basename(lock_path)}' are OUT OF SYNC:",
            file=sys.stderr,
        )
        for err in errors:
            print(f"  - {err}", file=sys.stderr)
        print(
            "\nDevelopers must run 'vpython3' (or "
            "'vpython3 -vpython-tool upgrade') locally "
            "to synchronize and commit their lockfile changes.",
            file=sys.stderr,
        )
        return 1

    return 0


def main(argv: list[str] | None = None) -> int:
    """Main entry point for command-line execution.

    Args:
        argv: Optional list of command-line arguments.

    Returns:
        0 on verification success, 1 on error.
    """
    if argv is None:
        argv = sys.argv[1:]
    if len(argv) < 2:
        print(
            "Usage: verify_lockfile.py <spec_file> <lock_file>", file=sys.stderr
        )
        return 1
    return verify(argv[0], argv[1])


if __name__ == "__main__":
    sys.exit(main())
