#!/usr/bin/env python3
"""Fetch changelog for a Python package upgrade and summarize it with Claude.

Usage:
    python generate_changelog_summary.py <package> <current_version> <latest_version>

Prints a Markdown summary to stdout. Exits 0 with no output on any failure
(never blocks the calling workflow).

Environment variables:
    ANTHROPIC_API_KEY  - Required for LLM summarization
    GH_TOKEN           - Optional, used for GitHub Releases API (higher rate limits)
"""

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

from packaging.version import Version

PYPI_URL = "https://pypi.org/pypi/{}/json"
GITHUB_RELEASES_URL = "https://api.github.com/repos/{}/{}/releases?per_page=100"
MAX_CHANGELOG_CHARS = 30_000

GITHUB_URL_KEYS = ["Source", "Repository", "Source Code", "GitHub", "Homepage", "Code"]
CHANGELOG_KEYS = ["Changelog", "Changes", "Release Notes", "History", "What's New"]

# In-repo changelog filenames, tried at the package subpath (for monorepos)
# and then at the repo root.
CHANGELOG_FILENAMES = ["CHANGELOG.md", "CHANGELOG.rst", "HISTORY.md", "CHANGES.md"]
RAW_URL = "https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}"

# Prepended to the "changelog not found" notice so downstream steps (the
# impact analysis) can distinguish a real changelog from a not-found diagnostic.
# It's an HTML comment, so it renders invisibly in the PR body.
NOT_FOUND_MARKER = "<!-- changelog-not-found -->"


def fetch_pypi_urls(package: str) -> dict[str, str]:
    """Fetch project_urls from PyPI."""
    url = PYPI_URL.format(package)
    req = urllib.request.Request(url, headers={"Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=15) as resp:
        data = json.loads(resp.read())
    return data.get("info", {}).get("project_urls") or {}


def _find_url(project_urls: dict[str, str], candidates: list[str]) -> str | None:
    """Case-insensitive, substring-based lookup in project_urls."""
    lower_urls = {k.lower(): v for k, v in project_urls.items()}
    for candidate in candidates:
        c = candidate.lower()
        # Exact match first
        if c in lower_urls:
            return lower_urls[c]
        # Substring match (e.g. "Docs: Changelog" contains "changelog")
        for key, url in lower_urls.items():
            if c in key:
                return url
    return None


def extract_github_repo(project_urls: dict[str, str]) -> tuple[str, str] | None:
    """Extract (owner, repo) from project_urls, or None."""
    # Try known keys first (case-insensitive)
    for candidate in GITHUB_URL_KEYS:
        url = _find_url(project_urls, [candidate]) or ""
        m = re.search(r"github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/|$)", url)
        if m:
            return m.group(1), m.group(2)
    # Try all URLs as fallback
    for url in project_urls.values():
        m = re.search(r"github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/|$)", url)
        if m:
            return m.group(1), m.group(2)
    return None


def extract_changelog_url(project_urls: dict[str, str]) -> str | None:
    """Extract a changelog URL from project_urls."""
    return _find_url(project_urls, CHANGELOG_KEYS)


def parse_github_location(url: str) -> dict | None:
    """Parse a GitHub URL into ``{owner, repo, branch, subpath}``.

    Recognizes plain repo URLs and monorepo *subtree* URLs of the form
    ``https://github.com/<owner>/<repo>/tree/<branch>/<subpath>``. ``branch``
    and ``subpath`` are ``None`` for a plain repo URL. Returns ``None`` if the
    URL is not a github.com URL.
    """
    m = re.search(r"github\.com/([^/]+)/([^/#?]+)", url or "")
    if not m:
        return None
    owner, repo = m.group(1), m.group(2)
    if repo.endswith(".git"):
        repo = repo[:-4]
    branch = subpath = None
    tree = re.search(r"github\.com/[^/]+/[^/]+/tree/([^/]+)/(.+?)/?(?:[#?].*)?$", url)
    if tree:
        branch = tree.group(1)
        subpath = tree.group(2).rstrip("/")
    return {"owner": owner, "repo": repo, "branch": branch, "subpath": subpath}


def find_subtree(
    project_urls: dict[str, str], owner: str, repo: str
) -> tuple[str | None, str | None]:
    """Find a monorepo subtree ``(branch, subpath)`` for owner/repo in project_urls.

    Many monorepos (e.g. googleapis/google-cloud-python) publish a package's
    metadata pointing at a ``/tree/<branch>/packages/<pkg>`` subtree that holds
    a per-package ``CHANGELOG.md``. Returns ``(None, None)`` if none is found.
    """
    for url in project_urls.values():
        loc = parse_github_location(url)
        if loc and loc["owner"] == owner and loc["repo"] == repo and loc["subpath"]:
            return loc["branch"], loc["subpath"]
    return None, None


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


def _strip_version_prefix(s: str) -> str:
    """Strip a leading version-decoration prefix (release-/release/ or v/V)."""
    for prefix in ("release-", "release/"):
        if s.startswith(prefix):
            return s[len(prefix):]
    # Strip a leading v/V only when it directly precedes a digit, so package
    # names that happen to start with "v" (e.g. "vcrpy") aren't mangled.
    if len(s) >= 2 and s[0] in "vV" and s[1].isdigit():
        return s[1:]
    return s


def parse_tag_version(
    tag: str, package: str | None = None, subpath: str | None = None
) -> Version | None:
    """Parse a version from a git release tag.

    Handles plain tags (``1.6.0``, ``v1.6.0``, ``release/1.6.0``) as well as
    monorepo tags that embed a package label (``langchain-aws==1.6.0``,
    ``langchain-aws@1.6.0``, ``langchain-aws/1.6.0``, ``langchain-aws-1.6.0``,
    and libs-path tags like ``libs/genai/v4.2.5``).

    When ``package`` is given, a monorepo tag whose label identifies a
    *different* package is rejected (returns ``None``), keeping sibling
    releases that share a repo out of the wrong package's changelog. The label
    is accepted if it matches the package name **or** the repo ``subpath``
    (or its last path segment) — some monorepos label tags by package name
    (``langchain-aws==1.6.0``) and others by subpath (``libs/genai/v4.2.5``).
    Plain tags carry no label and are always accepted, so single-package repos
    are unaffected.
    """
    tag = tag.strip()
    name_part: str | None = None
    version_str = tag

    # 1. Unambiguous package/version separators: pkg==1.6.0, pkg@1.6.0
    m = re.match(r"^(?P<name>[A-Za-z][\w.\-]*?)\s*(?:==|@)\s*(?P<ver>.+)$", tag)
    if m:
        name_part = m.group("name")
        version_str = _strip_version_prefix(m.group("ver").strip())
    else:
        stripped = _strip_version_prefix(tag)
        if stripped != tag:
            # Had a v/release- style prefix; no embedded package name.
            version_str = stripped
        else:
            # 2. Ambiguous '-'/'/' separator (pkg-1.6.0, pkg/1.6.0). Split at the
            #    rightmost separator whose suffix parses as a version, so the
            #    hyphens inside a package name don't confuse the boundary.
            for sep in re.finditer(r"[-/]", tag):
                suffix = _strip_version_prefix(tag[sep.end():])
                try:
                    Version(suffix)
                except Exception:
                    continue
                name_part = tag[:sep.start()]
                version_str = suffix
                # keep scanning — a later (rightmost) valid split wins

    if package and name_part:
        accepted = {normalize_name(package)}
        if subpath:
            accepted.add(normalize_name(subpath))
            accepted.add(normalize_name(subpath.rstrip("/").split("/")[-1]))
        if normalize_name(name_part) not in accepted:
            return None

    try:
        return Version(version_str)
    except Exception:
        return None


def fetch_github_releases(
    owner: str,
    repo: str,
    package: str,
    from_ver: Version,
    to_ver: Version,
    subpath: str | None = None,
) -> str:
    """Fetch GitHub release notes for versions in (from_ver, to_ver].

    ``package`` (and the repo ``subpath``, when the package lives in a monorepo
    subtree) scope the search to a single package's tags, so monorepos that
    publish many packages from one repo (e.g. langchain-ai/langchain-google,
    which tags ``libs/genai/*`` and ``libs/vertexai/*``) don't mix sibling
    release notes into the changelog.
    """
    url = GITHUB_RELEASES_URL.format(owner, repo)
    headers = {"Accept": "application/vnd.github+json"}
    gh_token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
    if gh_token:
        headers["Authorization"] = f"Bearer {gh_token}"

    req = urllib.request.Request(url, headers=headers)
    with urllib.request.urlopen(req, timeout=30) as resp:
        releases = json.loads(resp.read())

    relevant = []
    for rel in releases:
        tag = rel.get("tag_name", "")
        ver = parse_tag_version(tag, package, subpath)
        if ver is None:
            continue
        if from_ver < ver <= to_ver:
            body = rel.get("body") or ""
            name = rel.get("name") or tag
            relevant.append((ver, name, body))

    if not relevant:
        return ""

    relevant.sort(key=lambda x: x[0])
    parts = []
    for ver, name, body in relevant:
        if not body.strip():
            # Header-only release (tag with no notes). Skip it so a run of
            # bodyless releases doesn't return non-empty text and suppress the
            # in-repo CHANGELOG.md fallback in main().
            continue
        parts.append(f"## {name}\n\n{body}")

    if not parts:
        # Matching releases existed but none carried notes — report nothing so
        # the caller falls through to the per-package CHANGELOG.md.
        return ""

    text = "\n\n---\n\n".join(parts)
    if len(text) > MAX_CHANGELOG_CHARS:
        text = text[:MAX_CHANGELOG_CHARS] + "\n\n[Changelog truncated — showing most recent entries]"
    return text


def fetch_raw_changelog(url: str) -> str:
    """Fetch raw changelog content from a URL."""
    req = urllib.request.Request(url, headers={"User-Agent": "earningscall-upgrade-bot"})
    with urllib.request.urlopen(req, timeout=15) as resp:
        content_type = resp.headers.get("Content-Type", "")
        text = resp.read().decode("utf-8", errors="replace")
    # If it's HTML, we still send it — Claude can parse it
    if len(text) > MAX_CHANGELOG_CHARS:
        text = text[:MAX_CHANGELOG_CHARS] + "\n\n[Changelog truncated]"
    return text


def fetch_raw_changelog_files(
    owner: str,
    repo: str,
    branch: str | None,
    subpath: str | None,
    attempted: list[str],
) -> tuple[str, str | None]:
    """Fetch an in-repo changelog file (CHANGELOG.md, HISTORY.md, ...).

    Checks the package subpath first (for monorepos whose metadata points at a
    ``/tree/<branch>/<subpath>`` location, e.g. google-auth inside
    googleapis/google-cloud-python), then the repo root. Each URL tried is
    appended to ``attempted`` for the not-found diagnostic. Returns
    ``(changelog_text, source_url)`` or ``("", None)``.
    """
    branches = [branch] if branch else ["main", "master"]
    rel_paths: list[str] = []
    if subpath:
        rel_paths += [f"{subpath}/{name}" for name in CHANGELOG_FILENAMES]
    rel_paths += list(CHANGELOG_FILENAMES)  # repo root

    for b in branches:
        for path in rel_paths:
            raw_url = RAW_URL.format(owner=owner, repo=repo, branch=b, path=path)
            attempted.append(raw_url)
            try:
                text = fetch_raw_changelog(raw_url)
            except Exception:
                continue
            if text and text.strip():
                return text, raw_url
    return "", None


def build_not_found_notice(
    package: str,
    from_ver: str,
    to_ver: str,
    repo_info: dict | None,
    attempted: list[str],
) -> str:
    """Build a Markdown notice explaining why no changelog could be fetched.

    Lists every location that was checked so a reviewer can find the changelog
    manually. Prefixed with ``NOT_FOUND_MARKER`` so the impact-analysis step
    can tell this apart from a real changelog summary.
    """
    lines = [
        NOT_FOUND_MARKER,
        f"**Changelog not found.** Could not locate release notes for "
        f"`{package}` `{from_ver}` → `{to_ver}`.",
        "",
        "Locations checked:",
    ]
    if repo_info is None:
        lines.append("- No GitHub repository was found in the PyPI project metadata")
    # `attempted` records each source actually tried, with its outcome (set by
    # the caller), so we describe what happened rather than assuming why.
    for item in attempted:
        lines.append(f"- {item}")
    lines.append("")
    if repo_info:
        lines.append(
            f"Review manually: https://github.com/"
            f"{repo_info['owner']}/{repo_info['repo']}/releases"
        )
    lines.append(f"PyPI release history: https://pypi.org/project/{package}/#history")
    return "\n".join(lines)


def build_summary_failed_notice(
    package: str,
    from_ver: str,
    to_ver: str,
    source: str | None,
    repo_info: dict | None,
) -> str:
    """Notice for when the changelog *was* fetched but summarization failed.

    Distinct from ``build_not_found_notice``: here the changelog was located,
    so we say so and point the reviewer at it rather than claiming it wasn't
    found. It still carries ``NOT_FOUND_MARKER`` so the impact-analysis step
    skips — there is no usable summary to assess impact from.
    """
    lines = [
        NOT_FOUND_MARKER,
        f"**Changelog found but not summarized.** Release notes for "
        f"`{package}` `{from_ver}` → `{to_ver}` were fetched, but automated "
        f"summarization failed. Review them manually:",
        "",
    ]
    if source:
        lines.append(f"- Source: {source}")
    if repo_info:
        lines.append(
            f"- Releases: https://github.com/{repo_info['owner']}/{repo_info['repo']}/releases"
        )
    lines.append(f"- PyPI release history: https://pypi.org/project/{package}/#history")
    return "\n".join(lines)


def summarize_with_claude(
    package: str,
    from_ver: str,
    to_ver: str,
    changelog_text: str,
) -> str:
    """Summarize changelog using Claude API."""
    import anthropic

    client = anthropic.Anthropic()
    message = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=(
            "You are a dependency upgrade analyst. Given raw changelog entries for a "
            "Python package upgrade, produce a concise summary for a PR reviewer. "
            "Use Markdown formatting."
        ),
        messages=[
            {
                "role": "user",
                "content": (
                    f"Package: **{package}**\n"
                    f"Upgrading from `{from_ver}` to `{to_ver}`\n\n"
                    f"Raw changelog:\n\n{changelog_text}\n\n"
                    "Instructions:\n"
                    "- Summarize the notable changes in 5-15 bullet points\n"
                    "- Highlight any BREAKING CHANGES or deprecations prominently at the top\n"
                    "- Note any security fixes\n"
                    "- Ignore trivial changes (CI, docs typos, internal refactors)\n"
                    "- Focus on changes that could impact downstream consumers\n"
                    "- Be concise — each bullet should be one sentence\n"
                ),
            }
        ],
    )
    return message.content[0].text


def main() -> None:
    if len(sys.argv) != 4:
        print(
            "Usage: generate_changelog_summary.py <package> <current_version> <latest_version>",
            file=sys.stderr,
        )
        sys.exit(1)

    package = sys.argv[1]
    current_version = sys.argv[2]
    latest_version = sys.argv[3]

    api_key = os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        print("ANTHROPIC_API_KEY not set, skipping changelog summary", file=sys.stderr)
        return

    from_ver = Version(current_version)
    to_ver = Version(latest_version)

    # Step 1: Get project URLs from PyPI
    try:
        project_urls = fetch_pypi_urls(package)
    except Exception as e:
        print(f"Failed to fetch PyPI metadata: {e}", file=sys.stderr)
        project_urls = {}

    # Step 2: Locate changelog text, trying sources in order of quality. Every
    # location tried is recorded so a not-found notice can show where we looked.
    attempted: list[str] = []
    repo_info: dict | None = None
    changelog_text = ""
    source = None

    github_repo = extract_github_repo(project_urls)
    if github_repo:
        owner, repo = github_repo
        branch, subpath = find_subtree(project_urls, owner, repo)
        repo_info = {"owner": owner, "repo": repo, "branch": branch, "subpath": subpath}

        # 2a. GitHub Releases API — best formatted, per-release notes.
        releases_url = f"https://github.com/{owner}/{repo}/releases"
        try:
            changelog_text = fetch_github_releases(owner, repo, package, from_ver, to_ver, subpath)
            if changelog_text:
                source = f"GitHub releases for {owner}/{repo}"
            else:
                attempted.append(
                    f"GitHub Releases API of `{owner}/{repo}` ({releases_url}) — "
                    f"no release notes for `{package}` in `({current_version}, {latest_version}]`"
                )
        except Exception as e:
            print(f"Failed to fetch GitHub releases: {e}", file=sys.stderr)
            attempted.append(
                f"GitHub Releases API of `{owner}/{repo}` ({releases_url}) — request failed: {e}"
            )

        # 2b. In-repo CHANGELOG file — package subpath (monorepos) then root.
        if not changelog_text:
            changelog_text, raw_url = fetch_raw_changelog_files(
                owner, repo, branch, subpath, attempted
            )
            if changelog_text:
                source = raw_url

    # 2c. Explicit changelog URL declared in PyPI metadata.
    if not changelog_text:
        changelog_url = extract_changelog_url(project_urls)
        if changelog_url:
            attempted.append(changelog_url)
            try:
                changelog_text = fetch_raw_changelog(changelog_url)
                if changelog_text:
                    source = changelog_url
            except Exception as e:
                print(f"Failed to fetch changelog: {e}", file=sys.stderr)

    # Nothing found: emit a notice (instead of staying silent) so the PR says
    # what we looked for and where a reviewer can check manually.
    if not changelog_text:
        print("No changelog information found; emitting not-found notice", file=sys.stderr)
        print(build_not_found_notice(package, current_version, latest_version, repo_info, attempted))
        return

    print(f"Using changelog source: {source}", file=sys.stderr)

    # Step 3: Summarize with Claude. The changelog was fetched, so on failure
    # we emit a "found but not summarized" notice (not "not found") pointing the
    # reviewer at the source, rather than leaving the PR blank or lying.
    try:
        summary = summarize_with_claude(package, current_version, latest_version, changelog_text)
        print(summary)
    except Exception as e:
        print(f"Failed to summarize with Claude: {e}", file=sys.stderr)
        print(build_summary_failed_notice(package, current_version, latest_version, source, repo_info))


if __name__ == "__main__":
    main()
