# Copyright 2026 Marimo. All rights reserved.

# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = [
#     "msgspec",
# ]
#
# [tool.uv]
# exclude-newer = "2025-06-27T12:38:25.742953-04:00"
# ///
"""Generate release notes from commits on main branch."""

from __future__ import annotations

import re
import subprocess
import sys

import msgspec
import msgspec.json


class Author(msgspec.Struct):
    """GitHub author/user information."""

    login: str


class Label(msgspec.Struct):
    """GitHub label information."""

    name: str
    color: str | None = None
    description: str | None = None


class PullRequest(msgspec.Struct, rename="camel"):
    """GitHub Pull Request information."""

    number: int
    title: str
    author: Author
    labels: list[Label]
    body: str | None
    merged_at: str | None = None


class Commit(msgspec.Struct):
    """Git commit information."""

    sha: str
    message: str
    pr_number: int | None = None


class CategorizedEntry(msgspec.Struct):
    """A release note entry with its PR information."""

    commit: Commit
    pr: PullRequest | None = None


def get_commits_since_tag(since_tag: str) -> list[Commit]:
    """Get commits on main since a specific tag."""
    result = subprocess.run(  # noqa: S603
        [  # noqa: S607
            "git",
            "log",
            f"{since_tag}..HEAD",
            "--format=%H %s",
            "--first-parent",  # Only follow the first parent (main branch)
            "main",
        ],
        capture_output=True,
        text=True,
        check=True,
    )

    commits = []
    for line in result.stdout.strip().split("\n"):
        if not line:
            continue

        parts = line.split(" ", 1)
        if len(parts) != 2:
            continue

        sha, message = parts

        # Extract PR number from squash merge commit message
        # Looking for patterns like (#1234) or #1234
        pr_match = re.search(r"#(\d+)", message)
        pr_number = int(pr_match.group(1)) if pr_match else None

        commits.append(Commit(sha=sha, message=message, pr_number=pr_number))

    return commits


def get_merged_prs(limit: int = 100) -> dict[int, PullRequest]:
    """Get recently merged PRs and return as a dict keyed by PR number."""
    result = subprocess.run(  # noqa: S603
        [  # noqa: S607
            "gh",
            "pr",
            "list",
            "--base",
            "main",
            "--state",
            "merged",
            "--limit",
            str(limit),
            "--json",
            "number,title,author,labels,body,mergedAt",
        ],
        check=True,
        capture_output=True,
        text=True,
    )
    return {
        pr.number: pr
        for pr in msgspec.json.decode(result.stdout, type=list[PullRequest])
    }


def is_version_commit(commit: Commit) -> bool:
    """Check if commit is a version bump commit (e.g., 'v0.7.3')."""
    # Match patterns like "v0.7.3", "v0.7.3 (pre-release)", etc.
    return bool(re.match(r"^v\d+\.\d+\.\d+", commit.message))


def categorize_entries(
    entries: list[CategorizedEntry],
) -> dict[str, list[CategorizedEntry]]:
    """Categorize entries based on PR labels."""
    # TODO: Could add more or be more granular
    categories = {
        "breaking": [],
        "bug": [],
        "enhancement": [],
        "documentation": [],
        "preview": [],
        "other": [],
    }

    for entry in entries:
        # Skip version bump commits
        if is_version_commit(entry.commit):
            continue

        if entry.pr is None:
            categories["other"].append(entry)
            continue

        label_names = {label.name for label in entry.pr.labels}

        # Skip entries labeled as "internal"
        if "internal" in label_names:
            continue

        if "breaking" in label_names:
            categories["breaking"].append(entry)
        elif "preview" in label_names:
            categories["preview"].append(entry)
        elif "bug" in label_names:
            categories["bug"].append(entry)
        elif "enhancement" in label_names:
            categories["enhancement"].append(entry)
        elif "documentation" in label_names:
            categories["documentation"].append(entry)
        else:
            categories["other"].append(entry)

    return categories


def strip_conventional_prefix(title: str) -> str:
    """Strip conventional commit prefixes and capitalize first letter."""
    # Match patterns like "word:" or "word(scope):" at the beginning
    match = re.match(r"^(\w+)(?:\([^)]+\))?:\s*(.+)", title)
    if match:
        # Get the part after the prefix and capitalize first letter
        stripped = match.group(2)
        return stripped[0].upper() + stripped[1:] if stripped else stripped
    return title


def format_entry(entry: CategorizedEntry) -> str:
    """Format entry."""
    if entry.pr:
        title = strip_conventional_prefix(entry.pr.title)
        return f"* {title} ([#{entry.pr.number}](https://github.com/marimo-team/marimo-lsp/pull/{entry.pr.number}))"
    title = entry.commit.message
    title = strip_conventional_prefix(entry.commit.message)
    return f"* {title} ({entry.commit.sha[:7]})"


def get_contributors(entries: list[CategorizedEntry]) -> list[str]:
    """Extract unique contributors from all entries."""
    contributors = set()
    for entry in entries:
        if entry.pr and entry.pr.author:
            contributors.add(entry.pr.author.login)
    return sorted(contributors, key=str.lower)


def generate_release_notes(since_tag: str, current_tag: str) -> str:  # noqa: C901, PLR0912
    """Generate release notes since a specific tag."""
    commits = get_commits_since_tag(since_tag)
    pr_map = get_merged_prs(limit=100)

    # Match commits with PRs
    entries = []
    for commit in commits:
        pr = pr_map.get(commit.pr_number) if commit.pr_number else None
        entries.append(CategorizedEntry(commit=commit, pr=pr))

    categories = categorize_entries(entries)

    notes = ["## What's Changed\n"]

    if categories["breaking"]:
        notes.append("## 🚨 Breaking changes")
        for entry in categories["breaking"]:
            notes.append(format_entry(entry))  # noqa: PERF401
        notes.append("")

    if categories["enhancement"]:
        notes.append("## ✨ Enhancements")
        for entry in categories["enhancement"]:
            notes.append(format_entry(entry))  # noqa: PERF401
        notes.append("")

    if categories["bug"]:
        notes.append("## 🐛 Bug fixes")
        for entry in categories["bug"]:
            notes.append(format_entry(entry))  # noqa: PERF401
        notes.append("")

    if categories["documentation"]:
        notes.append("## 📚 Documentation")
        for entry in categories["documentation"]:
            notes.append(format_entry(entry))  # noqa: PERF401
        notes.append("")

    if categories["preview"]:
        notes.append("## 🔬 Preview features")
        for entry in categories["preview"]:
            notes.append(format_entry(entry))  # noqa: PERF401
        notes.append("")

    if categories["other"]:
        notes.append("## 📝 Other changes")
        for entry in categories["other"]:
            notes.append(format_entry(entry))  # noqa: PERF401
        notes.append("")

    contributors = get_contributors(entries)
    notes.append("## Contributors")
    notes.append(
        f"Thanks to all our community and contributors who made this release possible: @{', @'.join(contributors)}"
    )

    notes.append(
        f"\n**Full Changelog**: https://github.com/marimo-team/marimo-lsp/compare/{since_tag}...{current_tag}"
    )

    return "\n".join(notes)


def main() -> None:
    if len(sys.argv) < 3:
        print("Usage: generate_release_notes.py <since-tag> <current-tag>")
        print("Example: generate_release_notes.py 0.14.7 0.14.8")
        sys.exit(1)

    print(generate_release_notes(sys.argv[1], sys.argv[2]))


if __name__ == "__main__":
    main()
