#!/usr/bin/env python3
"""Remind contributors about news fragments.

Always exits 0 (non-blocking). Two messages:
- When any file under changelog.d/ is staged: confirm that the fragment
  will be rolled into the next release's notes by towncrier and validate
  the filename matches the expected pattern.
- When source changes are substantial but no fragment is staged: nudge
  the contributor to add one.

Replaces the old shared docs/release_notes/<version>.md model — see
changelog.d/README.md for the rationale and conventions.
"""

import re
import subprocess
import sys
import tomllib
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from _commit_analysis import analyze_commit

# Minimum added source lines before the nudge fires
MIN_SOURCE_ADDED = 20

REPO_ROOT = Path(__file__).resolve().parent.parent
PYPROJECT = REPO_ROOT / "pyproject.toml"


def _load_categories():
    """Read [[tool.towncrier.type]].directory entries from pyproject.toml so
    the hook stays in sync with the canonical category list. Falls back to
    a sensible default if the file or section is missing — the hook is
    non-blocking so a degraded mode is preferable to a hard failure."""
    try:
        with PYPROJECT.open("rb") as fh:
            cfg = tomllib.load(fh)
    except (OSError, tomllib.TOMLDecodeError):
        return ("breaking", "security", "feature", "bugfix", "removal", "misc")
    types = cfg.get("tool", {}).get("towncrier", {}).get("type", [])
    cats = tuple(t["directory"] for t in types if "directory" in t)
    return cats or (
        "breaking",
        "security",
        "feature",
        "bugfix",
        "removal",
        "misc",
    )


CATEGORIES = _load_categories()

# changelog.d/<id>.<category>[.<n>].md or changelog.d/+<slug>.<category>[.<n>].md
# - <id>: integer PR/issue number
# - +<slug>: orphan fragment with no PR/issue, slug is [A-Za-z0-9_-]+
# - .<n>: optional integer counter suffix for multiple fragments of the
#   same (id, category) — towncrier renders each as a separate bullet,
#   all linked back to the same PR/issue.
FRAGMENT_RE = re.compile(
    r"^(?:\d+|\+[A-Za-z0-9_-]+)\.(?P<category>[a-z]+)(?:\.\d+)?\.md$"
)

# Color helpers: only emit ANSI when stdout is a TTY. CI logs and Windows
# terminals without VT processing render the raw escape sequences as
# visible garbage.
_USE_COLOR = sys.stdout.isatty()
_CYAN = "\033[36m" if _USE_COLOR else ""
_YELLOW = "\033[33m" if _USE_COLOR else ""
_RESET = "\033[0m" if _USE_COLOR else ""


def _fragments_staged():
    """Return the list of staged files under changelog.d/, excluding
    README.md and other non-fragment files."""
    result = subprocess.run(
        ["git", "diff", "--cached", "--name-only", "--", "changelog.d/"],
        capture_output=True,
        text=True,
    )
    files = [line for line in result.stdout.strip().splitlines() if line]
    return [
        f for f in files if f.endswith(".md") and Path(f).name != "README.md"
    ]


def _classify_fragment(path):
    """Return ("ok", category) for a valid fragment, ("bad-category", cat)
    for a fragment whose category isn't in CATEGORIES, or ("bad-name", None)
    for a filename that doesn't match the expected pattern at all."""
    name = Path(path).name
    m = FRAGMENT_RE.match(name)
    if not m:
        return "bad-name", None
    category = m.group("category")
    if category not in CATEGORIES:
        return "bad-category", category
    return "ok", category


def _print_staged_notice(staged):
    """Inform the committer that a news fragment was staged."""
    print()
    print(f"  {_CYAN}News Fragment Staged{_RESET}")
    print("  " + "-" * 40)
    for f in staged:
        print(f"    - {f}")
    print()
    print("  Files under changelog.d/ are rendered into")
    print("  docs/release_notes/<version>.md at release prep time by")
    print("  `pdm run towncrier build --version <X.Y.Z> --yes`, then")
    print("  surfaced in the GitHub release body by")
    print("  .github/workflows/release.yml.")

    # Validate filenames — non-blocking, but a typo'd category silently
    # falls through towncrier's "no fragments matched" branch and the
    # contributor's note vanishes from the release.
    issues = []
    for f in staged:
        kind, value = _classify_fragment(f)
        if kind != "ok":
            issues.append((f, kind, value))
    if issues:
        print()
        print(f"  {_YELLOW}⚠ Fragment filename problems:{_RESET}")
        for f, kind, value in issues:
            if kind == "bad-name":
                print(
                    f"    {f} — does not match `<id>.<category>.md` or "
                    f"`+<slug>.<category>.md`"
                )
            else:
                print(
                    f"    {f} — unknown category `{value}`. Use one of: "
                    f"{', '.join(CATEGORIES)}"
                )
        print()
        print("  See changelog.d/README.md for the convention.")
    print()
    print("  Format tips:")
    print("    - One sentence is usually enough; longer prose is fine for")
    print("      breaking changes that need a 'what to do' line.")
    print("    - Markdown is supported. The PR/issue link is auto-appended")
    print("      based on the fragment id (no need to add `(#NNNN)`).")
    print("    - Skip dependency bumps, internal CI tweaks, and refactors")
    print("      with no user-visible behavior — the auto-PR-list catches")
    print("      those without a fragment.")
    print()


def _print_missing_notice(analysis):
    """Nudge the committer to add a news fragment for a substantial change."""
    print()
    print(f"  {_CYAN}News Fragment Reminder{_RESET}")
    print("  " + "-" * 40)
    print(
        f"  You're adding {analysis.total_source_added} lines across "
        f"{len(analysis.source_files)} source file(s)"
    )
    print("  but no changelog.d/ fragment is staged.")
    print()
    print("  Changed source files:")
    for f in analysis.source_files:
        print(f"    - {f.path} (+{f.added})")
    print()
    print("  If this change is user-facing, drop a fragment under")
    print("  changelog.d/ named `<PR-number>.<category>.md` (categories:")
    print(f"  {', '.join(CATEGORIES)}). See changelog.d/README.md.")
    print()


def main():
    staged = _fragments_staged()

    # Always inform when a fragment is staged — contributors should know
    # the file gets rendered into the release, and any naming mistakes
    # need to surface before the fragment silently goes ignored.
    if staged:
        _print_staged_notice(staged)
        return 0

    analysis = analyze_commit()

    # Silent exit: no source files staged
    if not analysis.source_files:
        return 0

    # Silent exit: trivial change (less than MIN_SOURCE_ADDED added lines)
    if analysis.total_source_added < MIN_SOURCE_ADDED:
        return 0

    _print_missing_notice(analysis)
    return 0


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