#!/usr/bin/env python3
"""Nudge contributors to update PR description and changelog fragments.

Always exits 0 (non-blocking). Fires when:
  - We are on a PR feature branch (detected via ``gh pr status``)
  - Substantial source changes are staged (>= MIN_SOURCE_ADDED lines)
  - The PR description appears stale or is still the default template

Silently exits when:
  - ``gh`` CLI is not installed or not authenticated
  - Not on a feature branch (e.g., on main)
  - No substantial source changes staged
  - PR description was updated recently
"""

import json
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

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

from _commit_analysis import analyze_commit  # noqa: E402

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

# Higher threshold for fragment-only nudge (big changes may need a
# changelog fragment even if the PR description is already fresh).
BIG_CHANGE_LINES = 60

# Grace period: if PR was updated within this many minutes, assume fresh.
PR_FRESHNESS_MINUTES = 60

# Default PR template body prefix (triggers staleness nudge regardless of
# timestamps).
_DEFAULT_BODY_PREFIXES = (
    "## Description\n\nFixes #",
    "## Description\r\n\r\nFixes #",
)


def _run_gh(args):
    """Run a ``gh`` command and return stdout, or None on any failure."""
    try:
        result = subprocess.run(
            ["gh"] + args,
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode != 0:
            return None
        return result.stdout
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return None


def _get_pr_context():
    """Return PR context dict or None if not on a PR feature branch.

    Uses ``gh pr status --json number,title,body,updatedAt,headRefName``.
    The ``currentBranch`` key is a single object (or null) — not an array.
    """
    output = _run_gh(
        [
            "pr",
            "status",
            "--json",
            "number,title,body,updatedAt,headRefName",
        ]
    )
    if not output:
        return None

    try:
        data = json.loads(output)
    except json.JSONDecodeError:
        return None

    current = data.get("currentBranch")
    if not current or not isinstance(current, dict):
        return None

    head = current.get("headRefName", "")
    # On main/master there may be a stale sync PR — skip.
    if head in ("main", "master", ""):
        return None

    return {
        "number": current.get("number"),
        "title": current.get("title", ""),
        "body": current.get("body", ""),
        "updated_at": current.get("updatedAt", ""),
    }


def _is_pr_description_stale(pr_context):
    """Return True if the PR description is likely stale.

    Stale conditions (any triggers):
    1. Body matches the default template (``## Description\\n\\nFixes #``)
    2. Body is empty
    3. PR updatedAt is older than the branch tip commit by > PR_FRESHNESS_MINUTES
    """
    body = pr_context["body"].strip()

    # Condition 1 & 2: empty or default template
    if not body or body.startswith(_DEFAULT_BODY_PREFIXES):
        return True

    # Condition 3: timestamp-based staleness
    updated_str = pr_context.get("updated_at", "")
    if not updated_str:
        return True  # Can't determine freshness — nudge

    try:
        pr_updated = datetime.fromisoformat(updated_str.replace("Z", "+00:00"))
    except (ValueError, TypeError):
        return True

    try:
        result = subprocess.run(
            ["git", "log", "-1", "--format=%aI"],
            capture_output=True,
            text=True,
        )
        if result.returncode != 0 or not result.stdout.strip():
            return False  # Can't determine — don't nudge
        last_commit = datetime.fromisoformat(
            result.stdout.strip().replace("Z", "+00:00")
        )
    except (ValueError, TypeError):
        return False

    # Normalise both to UTC for comparison
    if last_commit.tzinfo is None:
        last_commit = last_commit.replace(tzinfo=timezone.utc)
    if pr_updated.tzinfo is None:
        pr_updated = pr_updated.replace(tzinfo=timezone.utc)

    staleness = last_commit - pr_updated
    return staleness > timedelta(minutes=PR_FRESHNESS_MINUTES)


def _find_existing_fragments(pr_num):
    """Return list of Paths to existing changelog.d/ fragments for this PR.

    Fragments follow towncrier naming: ``<id>.<category>[.<n>].md``.
    """
    frag_dir = Path(__file__).resolve().parent.parent / "changelog.d"
    if not frag_dir.is_dir():
        return []

    prefix = f"{pr_num}."
    return sorted(
        f
        for f in frag_dir.glob("*.md")
        if f.name.startswith(prefix) and f.name != "README.md"
    )


def _fragments_staged():
    """Return True if any fragment under changelog.d/ is staged."""
    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 any(f.endswith(".md") and Path(f).name != "README.md" for f in files)


def main():
    analysis = analyze_commit()

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

    # Silent exit: trivial change
    if analysis.total_source_added < MIN_SOURCE_ADDED:
        return 0

    # Silent exit: not on a PR feature branch (or gh unavailable)
    pr_context = _get_pr_context()
    if pr_context is None:
        return 0

    pr_num = pr_context["number"]
    pr_title = pr_context["title"]
    body = pr_context["body"].strip()
    is_default = not body or body.startswith(_DEFAULT_BODY_PREFIXES)
    desc_stale = _is_pr_description_stale(pr_context)

    notes_stale = not _fragments_staged() and (
        desc_stale or analysis.total_source_added >= BIG_CHANGE_LINES
    )

    # Nothing to nudge about
    if not desc_stale and not notes_stale:
        return 0

    # Build nudge output
    print()
    print("  \033[36mPR Description Reminder\033[0m")
    print("  " + "-" * 40)
    print(
        f"  You're adding {analysis.total_source_added} lines across "
        f"{len(analysis.source_files)} source file(s)"
    )
    print(f"  on PR #{pr_num}: {pr_title}")
    print()

    # PR description nudge
    if desc_stale:
        if is_default:
            print(
                "  \033[33mPR description is still the default template.\033[0m"
            )
            print("  Update it with:")
            print(f"    gh pr edit {pr_num}")
        else:
            print("  \033[33mPR description may be stale\033[0m")
            print("  (not updated since the branch moved forward).")
            print("  Refresh it with:")
            print(f"    gh pr edit {pr_num}")

    # Fragment nudge — fires for stale descriptions OR big changes
    if notes_stale:
        existing = _find_existing_fragments(pr_num)
        if existing:
            print()
            print(
                "  \033[33mExisting changelog.d/ fragments for this PR"
                " (not staged):\033[0m"
            )
            for frag in existing:
                print(f"    - changelog.d/{frag.name}")
            print("  Consider updating them if the approach has changed.")
        else:
            print()
            print("  \033[33mNo changelog.d/ fragment for this PR.\033[0m")
            print(f"  Consider adding changelog.d/{pr_num}.<category>.md")
            print(
                "  (categories: breaking, security, feature, bugfix,"
                " removal, misc)."
            )
            print("  See changelog.d/README.md for conventions.")
        if analysis.total_source_added >= BIG_CHANGE_LINES:
            print(
                f"  This is a large change ({analysis.total_source_added}"
                " lines) — strongly consider adding a fragment."
            )

    print()
    return 0


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