#!/usr/bin/env python3
"""Check for outdated dependencies in pyproject.toml and optionally apply upgrades.

Usage:
    python check_outdated_deps.py check                          # Output JSON list of outdated deps
    python check_outdated_deps.py apply <pkg> <old_ver> <new_ver>  # Update a single dep in-place
"""

import json
import re
import sys
import tomllib
import urllib.request
from pathlib import Path

from packaging.specifiers import SpecifierSet
from packaging.version import Version

PYPROJECT_PATH = Path(__file__).resolve().parents[2] / "pyproject.toml"
PYPI_URL = "https://pypi.org/pypi/{}/json"


def normalize_name(name: str) -> str:
    """Normalize package name per PEP 503."""
    return re.sub(r"[-_.]+", "-", name).lower()


def parse_compatible_release(dep_string: str) -> tuple[str, str, str | None] | None:
    """Parse a dependency string with a ~= specifier.

    Returns (package_name, pinned_version, extras) or None if not a compatible release pin.
    Extras like [hf_xet] or [openai] are separated out.
    """
    # Match: name[extras]~=version
    m = re.match(r"^([A-Za-z0-9_.-]+)(\[[^\]]+\])?\s*~=\s*([^\s,;]+)$", dep_string.strip())
    if not m:
        return None
    name, extras, version = m.group(1), m.group(2), m.group(3)
    return name, version, extras


def get_latest_stable_version(package_name: str) -> str | None:
    """Query PyPI for the latest stable (non-prerelease, non-yanked) version."""
    url = PYPI_URL.format(package_name)
    try:
        req = urllib.request.Request(url, headers={"Accept": "application/json"})
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read())
    except Exception as e:
        print(f"  Warning: failed to query PyPI for {package_name}: {e}", file=sys.stderr)
        return None

    releases = data.get("releases", {})
    stable_versions = []
    for ver_str, files in releases.items():
        try:
            v = Version(ver_str)
        except Exception:
            continue
        if v.is_prerelease or v.is_devrelease:
            continue
        # Skip yanked releases (all files yanked = yanked version)
        if files and all(f.get("yanked", False) for f in files):
            continue
        # Skip versions with no files at all
        if not files:
            continue
        stable_versions.append(v)

    if not stable_versions:
        return None
    return str(max(stable_versions))


def check_outdated() -> list[dict]:
    """Return list of outdated compatible-release deps."""
    with open(PYPROJECT_PATH, "rb") as f:
        pyproject = tomllib.load(f)

    deps = pyproject.get("project", {}).get("dependencies", [])
    outdated = []
    seen = set()

    for dep in deps:
        parsed = parse_compatible_release(dep)
        if parsed is None:
            continue
        name, current_version, extras = parsed
        # Skip duplicates (e.g. boto3 and boto3[crt] share the same base package and version)
        key = (normalize_name(name), current_version)
        if key in seen:
            continue
        seen.add(key)
        print(f"Checking {name} (current: ~={current_version})...", file=sys.stderr)
        latest = get_latest_stable_version(name)
        if latest is None:
            continue
        latest_ver = Version(latest)
        # Only flag as outdated if the latest version is OUTSIDE the ~= range.
        # e.g. ~=0.7.14 allows 0.7.16, so that's not outdated.
        # But ~=0.7.14 does NOT allow 0.8.0, so that IS outdated.
        specifier = SpecifierSet(f"~={current_version}")
        if latest_ver > Version(current_version) and latest_ver not in specifier:
            outdated.append({
                "package": name,
                "current_version": current_version,
                "latest_version": latest,
                "extras": extras,
            })
            print(f"  -> outdated: {current_version} -> {latest} (outside ~={current_version})", file=sys.stderr)
        else:
            print(f"  -> up to date (latest {latest} within ~={current_version})", file=sys.stderr)

    return outdated


def apply_upgrade(package: str, old_version: str, new_version: str) -> None:
    """Update a single dependency version in pyproject.toml, preserving formatting."""
    content = PYPROJECT_PATH.read_text()

    # Build a pattern that matches the dep line for this package (with optional extras)
    # We need to match the original casing in the file, so use case-insensitive match
    # Allow flexible name matching (hyphens, underscores, dots are interchangeable per PEP 503).
    # Normalize first, then escape, so re.escape doesn't break the character class.
    flexible_name = re.sub(r"[-_.]+", "[-_.]", package)
    escaped_old = re.escape(old_version)

    pattern = re.compile(
        rf"^(\s*\"{flexible_name}(?:\[[^\]]*\])?)\s*~=\s*{escaped_old}(\")",
        re.MULTILINE | re.IGNORECASE,
    )

    new_content, count = pattern.subn(rf"\g<1>~={new_version}\2", content)
    if count == 0:
        print(f"Error: could not find {package}~={old_version} in {PYPROJECT_PATH}", file=sys.stderr)
        sys.exit(1)

    PYPROJECT_PATH.write_text(new_content)
    print(f"Updated {package} from ~={old_version} to ~={new_version}", file=sys.stderr)


def bump_patch_version() -> str:
    """Increment the patch version in pyproject.toml. Returns the new version string."""
    content = PYPROJECT_PATH.read_text()
    pattern = re.compile(r'^(version\s*=\s*")(\d+\.\d+\.)(\d+)(")', re.MULTILINE)
    match = pattern.search(content)
    if not match:
        print("Error: could not find version in pyproject.toml", file=sys.stderr)
        sys.exit(1)
    old_patch = int(match.group(3))
    new_patch = old_patch + 1
    new_version = f"{match.group(2)}{new_patch}"
    new_content = pattern.sub(rf"\g<1>{new_version}\4", content)
    PYPROJECT_PATH.write_text(new_content)
    print(f"Bumped version to {new_version}", file=sys.stderr)
    return new_version


def main() -> None:
    if len(sys.argv) < 2:
        print(__doc__, file=sys.stderr)
        sys.exit(1)

    command = sys.argv[1]

    if command == "check":
        outdated = check_outdated()
        print(json.dumps(outdated))

    elif command == "table":
        outdated = check_outdated()
        if not outdated:
            print("\nAll dependencies are up to date.")
        else:
            # Calculate column widths
            pkg_w = max(len("Package"), max(len(d["package"]) for d in outdated))
            cur_w = max(len("Current"), max(len(d["current_version"]) for d in outdated))
            lat_w = max(len("Latest"), max(len(d["latest_version"]) for d in outdated))

            header = f"  {'Package':<{pkg_w}}   {'Current':>{cur_w}}   {'Latest':>{lat_w}}"
            sep = f"  {'─' * pkg_w}   {'─' * cur_w}   {'─' * lat_w}"

            print(f"\n  {len(outdated)} outdated {'dependency' if len(outdated) == 1 else 'dependencies'}:\n")
            print(header)
            print(sep)
            for d in outdated:
                print(f"  {d['package']:<{pkg_w}}   {d['current_version']:>{cur_w}}   {d['latest_version']:>{lat_w}}")
            print()

    elif command == "apply":
        if len(sys.argv) != 5:
            print("Usage: check_outdated_deps.py apply <package> <old_version> <new_version>", file=sys.stderr)
            sys.exit(1)
        apply_upgrade(sys.argv[2], sys.argv[3], sys.argv[4])

    elif command == "bump-version":
        new_version = bump_patch_version()
        print(new_version)

    else:
        print(f"Unknown command: {command}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
