#!/usr/bin/env python3
"""Generate deterministic release notes for the GitHub release workflow."""

from __future__ import annotations

import argparse
import dataclasses
import hashlib
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import urlparse


GIT_LOG_FORMAT = "%H%x1f%s%x1f%b%x1e"
SEMVER_TAG = re.compile(
    r"^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
    r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
    r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
MERGE_REQUEST_SUBJECT = re.compile(r"^!(\d+)\s+merge\b", re.IGNORECASE)
CLOSING_CLAUSE = re.compile(
    r"\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?)\s+"
    r"(#\d+(?:(?:\s*[,;]\s*|\s+and\s+)#\d+)*)",
    re.IGNORECASE,
)
ISSUE_REFERENCE = re.compile(r"#(\d+)")
BREAKING_MARKER = re.compile(
    r"^\s*(?:breaking(?:\s+change)?|breaks compatibility)\s*:",
    re.IGNORECASE | re.MULTILINE,
)


class ReleaseNotesError(RuntimeError):
    """A public-safe release note generation failure."""


@dataclasses.dataclass(frozen=True)
class Asset:
    name: str
    url: str


@dataclasses.dataclass(frozen=True)
class Entry:
    sha: str
    title: str
    merge_request: int | None
    issues: tuple[int, ...]
    labels: tuple[str, ...]
    category: str
    breaking: bool


@dataclasses.dataclass(frozen=True)
class GenerationResult:
    tag: str
    previous_tag: str
    target: str
    preview: bool
    entries: tuple[Entry, ...]
    content: str
    fingerprint: str


class GitRepository:
    def __init__(self, path: Path) -> None:
        self.path = path

    def output(self, *args: str, check: bool = True) -> str:
        completed = subprocess.run(
            ["git", "-C", str(self.path), *args],
            check=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
        )
        if completed.returncode != 0 and check:
            operation = args[0] if args else "command"
            detail = completed.stderr.strip()
            if detail:
                raise ReleaseNotesError(
                    f"release notes: git {operation} failed: {detail}"
                )
            raise ReleaseNotesError(
                f"release notes: git {operation} failed with exit code "
                f"{completed.returncode}"
            )
        if completed.returncode != 0:
            return ""
        return completed.stdout


def validate_tag(tag: str, field: str = "tag") -> str:
    value = tag.strip()
    if not SEMVER_TAG.fullmatch(value):
        raise ReleaseNotesError(
            f"release notes: {field} {value!r} must be a v-prefixed SemVer tag"
        )
    return value


def verify_commit(git: GitRepository, ref: str, field: str) -> None:
    if not git.output(
        "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", check=False
    ).strip():
        raise ReleaseNotesError(
            f"release notes: {field} {ref!r} does not resolve to a commit"
        )


def resolve_previous_tag(
    git: GitRepository, target: str, explicit: str, preview: bool
) -> str:
    if explicit.strip():
        previous = validate_tag(explicit, "previous tag")
        verify_commit(git, previous, "previous tag")
        previous_sha = git.output("rev-parse", f"{previous}^{{commit}}").strip()
        first_parent_shas = set(
            git.output("rev-list", "--first-parent", target).splitlines()
        )
        if previous_sha not in first_parent_shas:
            raise ReleaseNotesError(
                f"release notes: previous tag {previous!r} is not on the "
                f"first-parent history of {target!r}"
            )
        return previous

    describe_target = target if preview else f"{target}^"
    previous = git.output(
        "describe",
        "--tags",
        "--match=v*",
        "--abbrev=0",
        "--first-parent",
        describe_target,
        check=False,
    ).strip()
    if previous:
        validate_tag(previous, "previous tag")
    return previous


def one_line(value: str) -> str:
    return " ".join(value.strip().split())


def merge_request_number(subject: str) -> int | None:
    match = MERGE_REQUEST_SUBJECT.match(subject)
    if not match:
        return None
    number = int(match.group(1))
    return number if number > 0 else None


def first_body_title(body: str) -> str:
    metadata_prefixes = (
        "created-by:",
        "commit-by:",
        "merged-by:",
        "description:",
        "see merge request:",
        "label:",
        "labels:",
    )
    for raw_line in body.splitlines():
        line = one_line(raw_line)
        if not line:
            continue
        lower = line.lower()
        if lower.startswith(metadata_prefixes):
            continue
        if line.startswith(("#", "-", "*")):
            continue
        return line
    return ""


def extract_issues(body: str) -> tuple[int, ...]:
    issues: set[int] = set()
    for clause in CLOSING_CLAUSE.finditer(body):
        for raw_number in ISSUE_REFERENCE.findall(clause.group(1)):
            number = int(raw_number)
            if number > 0:
                issues.add(number)
    return tuple(sorted(issues))


def extract_labels(body: str) -> tuple[str, ...]:
    labels: set[str] = set()
    for raw_line in body.splitlines():
        line = raw_line.strip()
        if not re.match(r"^labels?\s*:", line, re.IGNORECASE):
            continue
        _, raw_values = line.split(":", 1)
        for raw_label in re.split(r"[,;]", raw_values):
            label = raw_label.strip().lower()
            if label:
                labels.add(label)
    return tuple(sorted(labels))


def contains_word(value: str, *words: str) -> bool:
    return any(
        re.search(rf"\b{re.escape(word)}\b", value, re.IGNORECASE) is not None
        for word in words
    )


def classify(title: str, labels: tuple[str, ...]) -> str:
    label_set = set(labels)
    if label_set.intersection({"ci", "release"}):
        return "ci"
    if label_set.intersection({"bug", "bugfix", "fix"}):
        return "fixed"
    if label_set.intersection({"enhancement", "feature"}):
        return "added"
    if contains_word(title, "release", "workflow", "artifact", "checksum", "ci"):
        return "ci"
    if contains_word(
        title, "fix", "fixed", "bug", "stabilize", "correct", "failure", "error",
        "regression",
    ):
        return "fixed"
    if contains_word(
        title, "add", "support", "introduce", "create", "implement", "expose",
    ):
        return "added"
    return "changed"


def parse_entries(raw: str) -> tuple[Entry, ...]:
    entries: list[Entry] = []
    for raw_record in raw.split("\x1e"):
        record = raw_record.strip(" \t\r\n")
        if not record:
            continue
        parts = record.split("\x1f", 2)
        if len(parts) != 3:
            raise ReleaseNotesError(
                "release notes: malformed git log record in selected range"
            )
        sha = parts[0].strip()
        subject = one_line(parts[1])
        body = parts[2].strip()
        merge_request = merge_request_number(subject)
        title = first_body_title(body) if merge_request is not None else subject
        if not title:
            title = subject
        labels = extract_labels(body)
        entries.append(
            Entry(
                sha=sha,
                title=title,
                merge_request=merge_request,
                issues=extract_issues(body),
                labels=labels,
                category=classify(title, labels),
                breaking=BREAKING_MARKER.search(f"{title}\n{body}") is not None,
            )
        )
    return tuple(entries)


def markdown_text(value: str) -> str:
    return (
        one_line(value)
        .replace("\\", "\\\\")
        .replace("[", "\\[")
        .replace("]", "\\]")
    )


def render_entry(entry: Entry, web_base_url: str) -> str:
    references: list[str] = []
    if entry.merge_request is not None:
        references.append(
            f"[!{entry.merge_request}]"
            f"({web_base_url}/merge_requests/{entry.merge_request})"
        )
    else:
        short_sha = entry.sha[:8]
        references.append(f"[{short_sha}]({web_base_url}/commit/{entry.sha})")
    for issue in entry.issues:
        references.append(f"[#{issue}]({web_base_url}/issues/{issue})")

    line = f"- {markdown_text(entry.title)}"
    if references:
        line += f" ({', '.join(references)})"
    if entry.labels:
        rendered_labels = ", ".join(
            f"`{label.replace('`', '')}`" for label in entry.labels
        )
        line += f" — {rendered_labels}"
    return line


def render_notes(
    *,
    tag: str,
    previous_tag: str,
    preview: bool,
    entries: tuple[Entry, ...],
    web_base_url: str,
    supplement: str,
    assets: tuple[Asset, ...],
    highlight_count: int,
) -> str:
    lines = [f"# gitcode-mcp {tag}", ""]
    if previous_tag:
        lines.append(
            f"Changes since `{previous_tag}`; generated from the first-parent "
            f"Git history for `{tag}`."
        )
    else:
        lines.append(
            f"Generated from the first-parent Git history through `{tag}`."
        )
    if preview:
        lines.append("This is a pre-tag preview generated from `HEAD`.")

    if supplement.strip():
        lines.extend(["", "## Maintainer notes", "", supplement.strip()])

    lines.extend(["", "## Highlights", ""])
    for entry in entries[:highlight_count]:
        lines.append(render_entry(entry, web_base_url))

    section_categories = (
        ("Added", "added"),
        ("Changed", "changed"),
        ("Fixed", "fixed"),
        ("CI / release infrastructure", "ci"),
    )
    for title, category in section_categories:
        category_entries = [entry for entry in entries if entry.category == category]
        if not category_entries:
            continue
        lines.extend(["", f"## {title}", ""])
        lines.extend(
            render_entry(entry, web_base_url) for entry in category_entries
        )

    lines.extend(["", "## Breaking changes / migration notes", ""])
    breaking_entries = [entry for entry in entries if entry.breaking]
    if breaking_entries:
        lines.extend(
            render_entry(entry, web_base_url) for entry in breaking_entries
        )
    else:
        lines.append("- None identified.")

    lines.extend(["", "## Assets", ""])
    if assets:
        for asset in sorted(assets, key=lambda item: (item.name, item.url)):
            lines.append(f"- [{markdown_text(asset.name)}]({asset.url})")
    else:
        lines.append("- No binary assets supplied to this preview.")

    return "\n".join(lines).rstrip() + "\n"


def parse_asset(raw: str) -> Asset:
    name, separator, url = raw.partition("=")
    name = name.strip()
    url = url.strip()
    if not separator or not name or not url:
        raise ReleaseNotesError(
            "release notes: asset must use the form name=https://example/path"
        )
    parsed = urlparse(url)
    if parsed.scheme != "https" or not parsed.netloc:
        raise ReleaseNotesError(
            f"release notes: asset URL for {name!r} must be an absolute HTTPS URL"
        )
    return Asset(name=name, url=url)


def generate(
    *,
    git: GitRepository,
    tag: str,
    previous_tag: str,
    preview: bool,
    web_base_url: str,
    supplement: str,
    assets: tuple[Asset, ...],
    highlight_count: int,
) -> GenerationResult:
    tag = validate_tag(tag)
    if highlight_count < 1:
        raise ReleaseNotesError("release notes: highlights must be at least 1")

    target = "HEAD" if preview else tag
    if not preview:
        verify_commit(git, tag, "tag")

    previous = resolve_previous_tag(git, target, previous_tag, preview)
    log_range = f"{previous}..{target}" if previous else target
    raw = git.output("log", "--first-parent", f"--format={GIT_LOG_FORMAT}", log_range)
    entries = parse_entries(raw)
    if not entries:
        raise ReleaseNotesError(
            f"release notes: no delivered changes found in {log_range!r}"
        )

    base_url = web_base_url.strip().rstrip("/")
    parsed_base = urlparse(base_url)
    if parsed_base.scheme != "https" or not parsed_base.netloc:
        raise ReleaseNotesError(
            "release notes: --gitcode-web-base-url must be an absolute HTTPS URL"
        )

    content = render_notes(
        tag=tag,
        previous_tag=previous,
        preview=preview,
        entries=entries,
        web_base_url=base_url,
        supplement=supplement,
        assets=assets,
        highlight_count=highlight_count,
    )
    fingerprint = hashlib.sha256(content.encode("utf-8")).hexdigest()
    return GenerationResult(
        tag=tag,
        previous_tag=previous,
        target=target,
        preview=preview,
        entries=entries,
        content=content,
        fingerprint=fingerprint,
    )


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=(
            "Generate deterministic release notes from local first-parent Git "
            "history. This is a CI/development tool, not a gitcode-mcp command."
        )
    )
    parser.add_argument("--tag", required=True, help="release or planned v* SemVer tag")
    parser.add_argument("--previous-tag", default="", help="explicit comparison tag")
    parser.add_argument(
        "--preview",
        action="store_true",
        help="generate a planned release preview from HEAD",
    )
    parser.add_argument(
        "--gitcode-web-base-url",
        required=True,
        help="GitCode repository browser URL used for MR, issue, and commit links",
    )
    parser.add_argument(
        "--repo-dir", type=Path, default=Path.cwd(), help="local Git worktree"
    )
    parser.add_argument(
        "--supplement", type=Path, help="optional maintainer Markdown"
    )
    parser.add_argument(
        "--asset",
        action="append",
        default=[],
        metavar="NAME=URL",
        help="release asset link; may be repeated",
    )
    parser.add_argument(
        "--highlights", type=int, default=3, help="number of highlight entries"
    )
    parser.add_argument(
        "--output", type=Path, help="output Markdown path; defaults to stdout"
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        supplement = ""
        if args.supplement is not None:
            supplement = args.supplement.read_text(encoding="utf-8")
        assets = tuple(parse_asset(raw) for raw in args.asset)
        result = generate(
            git=GitRepository(args.repo_dir),
            tag=args.tag,
            previous_tag=args.previous_tag,
            preview=args.preview,
            web_base_url=args.gitcode_web_base_url,
            supplement=supplement,
            assets=assets,
            highlight_count=args.highlights,
        )
        if args.output is None:
            sys.stdout.write(result.content)
        else:
            args.output.parent.mkdir(parents=True, exist_ok=True)
            args.output.write_text(result.content, encoding="utf-8")
            print(
                "release_notes:"
                f" tag={result.tag}"
                f" previous_tag={result.previous_tag or 'none'}"
                f" target={result.target}"
                f" preview={str(result.preview).lower()}"
                f" entries={len(result.entries)}"
                f" output={args.output}"
                f" fingerprint={result.fingerprint}"
            )
        return 0
    except (OSError, ReleaseNotesError) as error:
        print(str(error), file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())