#!/usr/bin/env python3
"""Update changelog/yyyy-mm.md from upstream master commits."""

from __future__ import annotations

import argparse
import datetime as dt
import os
import re
import subprocess
import sys
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path


UPSTREAM_REMOTE_URL = "git@gitcode.com:openeuler/AcTrail.git"
UPSTREAM_BRANCH = "master"
UPSTREAM_REF = "refs/remotes/actrail_changelog/master"
CHANGELOG_DIR = "changelog"
STATE_PATH = "changelog/.master-changelog-state"
AUTO_START = "<!-- actrail-master-changelog:start -->"
AUTO_END = "<!-- actrail-master-changelog:end -->"

CATEGORY_TITLES = {
    "added": "新增",
    "fixed": "修复",
    "changed": "变更",
    "performance": "性能",
    "docs": "文档",
    "tests": "测试",
    "build": "构建",
    "chore": "维护",
}

CATEGORY_ORDER = [
    "added",
    "fixed",
    "changed",
    "performance",
    "docs",
    "tests",
    "build",
    "chore",
]


@dataclass(frozen=True)
class Commit:
    sha: str
    short_sha: str
    subject: str
    date: str
    month: str
    category: str


def fail(message: str) -> None:
    print(f"error: {message}", file=sys.stderr)
    raise SystemExit(1)


def run_git(args: list[str], *, check: bool = True) -> str:
    command = ["git", *args]
    result = subprocess.run(
        command,
        check=False,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
    )
    if check and result.returncode != 0:
        stderr = result.stderr.strip()
        fail(f"{' '.join(command)} failed: {stderr}")
    return result.stdout


def repo_root() -> Path:
    root = run_git(["rev-parse", "--show-toplevel"]).strip()
    if not root:
        fail("not inside a git repository")
    return Path(root)


def ensure_upstream_ref(fetch: bool) -> None:
    if fetch:
        run_git(
            [
                "fetch",
                "--no-tags",
                UPSTREAM_REMOTE_URL,
                f"+refs/heads/{UPSTREAM_BRANCH}:{UPSTREAM_REF}",
            ]
        )
        return

    result = subprocess.run(
        ["git", "rev-parse", "--verify", UPSTREAM_REF],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False,
    )
    if result.returncode != 0:
        fail(f"{UPSTREAM_REF} is missing; rerun without --no-fetch")


def read_state(path: Path) -> dict[str, str]:
    if not path.exists():
        return {}

    state: dict[str, str] = {}
    for line in path.read_text(encoding="utf-8").splitlines():
        stripped = line.strip()
        if not stripped or stripped.startswith("#"):
            continue
        if "=" not in stripped:
            fail(f"invalid state line in {path}: {line}")
        key, value = stripped.split("=", 1)
        state[key.strip()] = value.strip()
    return state


def write_state(path: Path, last_commit: str, dry_run: bool) -> None:
    now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
    content = "\n".join(
        [
            f"source_url={UPSTREAM_REMOTE_URL}",
            f"branch={UPSTREAM_BRANCH}",
            f"source_ref={UPSTREAM_REF}",
            f"last_commit={last_commit}",
            f"updated_at={now}",
            "",
        ]
    )
    if dry_run:
        print(f"--- {path} (dry-run) ---")
        print(content, end="")
        return

    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")


def resolve_target(target: str | None) -> str:
    ref = target or UPSTREAM_REF
    return run_git(["rev-parse", "--verify", ref]).strip()


def validate_commit_exists(commit: str) -> str:
    return run_git(["rev-parse", "--verify", f"{commit}^{{commit}}"]).strip()


def is_ancestor(ancestor: str, descendant: str) -> bool:
    result = subprocess.run(
        ["git", "merge-base", "--is-ancestor", ancestor, descendant],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False,
    )
    return result.returncode == 0


def commit_range(state: dict[str, str], since: str | None, target: str) -> list[str]:
    if since:
        since_sha = validate_commit_exists(since)
        if not is_ancestor(since_sha, target):
            fail(f"--since commit {since_sha} is not an ancestor of target {target}")
        revspec = f"{since_sha}..{target}"
        return run_git(["rev-list", "--first-parent", "--reverse", revspec]).splitlines()

    last_commit = state.get("last_commit", "")
    if last_commit:
        last_sha = validate_commit_exists(last_commit)
        if not is_ancestor(last_sha, target):
            fail(
                f"state last_commit {last_sha} is not an ancestor of upstream "
                f"master {target}; inspect {STATE_PATH}"
            )
        return run_git(
            ["rev-list", "--first-parent", "--reverse", f"{last_sha}..{target}"]
        ).splitlines()

    return run_git(["rev-list", "--first-parent", "--reverse", target]).splitlines()


def first_parent_history(target: str) -> list[str]:
    return run_git(["rev-list", "--first-parent", "--reverse", target]).splitlines()


def commit_subject(sha: str) -> str:
    subject = run_git(["show", "-s", "--format=%s", sha]).strip()
    subject = re.sub(r"\s+", " ", subject)
    return subject or "(no subject)"


def commit_date(sha: str) -> str:
    return run_git(["show", "-s", "--format=%cs", sha]).strip()


def classify(subject: str) -> str:
    text = subject.lower()
    normalized = re.sub(r"^[!#]\d+\s+", "", text).strip()

    if re.match(r"^(feat|feature)(\(.+\))?!?:", normalized) or normalized.startswith(
        ("add ", "added ", "新增")
    ):
        return "added"
    if re.match(r"^fix(\(.+\))?!?:", normalized) or normalized.startswith(
        ("fix ", "fixed ", "修复")
    ):
        return "fixed"
    if re.match(r"^perf(\(.+\))?!?:", normalized) or "performance" in normalized:
        return "performance"
    if re.match(r"^docs?(\(.+\))?!?:", normalized) or normalized.startswith(
        ("doc ", "docs ", "readme", "文档")
    ):
        return "docs"
    if re.match(r"^test(s)?(\(.+\))?!?:", normalized) or normalized.startswith(
        ("test ", "tests ", "e2e ")
    ):
        return "tests"
    if re.match(r"^(build|ci|release)(\(.+\))?!?:", normalized) or normalized.startswith(
        ("build ", "ci ", "package ")
    ):
        return "build"
    if re.match(r"^refactor(\(.+\))?!?:", normalized) or normalized.startswith(
        (
            "update ",
            "change ",
            "convert ",
            "rework ",
            "integrate ",
            "refactor ",
            "improve ",
            "support ",
            "prepare ",
        )
    ):
        return "changed"
    return "chore"


def load_commits(shas: list[str]) -> list[Commit]:
    commits: list[Commit] = []
    for sha in shas:
        subject = commit_subject(sha)
        date = commit_date(sha)
        commits.append(
            Commit(
                sha=sha,
                short_sha=sha[:7],
                subject=subject,
                date=date,
                month=date[:7],
                category=classify(subject),
            )
        )
    return commits


def strip_existing_auto_block(content: str) -> tuple[str, str]:
    start = content.find(AUTO_START)
    end = content.find(AUTO_END)
    if start == -1 and end == -1:
        return content.rstrip(), ""
    if start == -1 or end == -1 or end < start:
        fail("invalid changelog auto block markers")
    before = content[:start].rstrip()
    after = content[end + len(AUTO_END) :].strip()
    return before, after


def render_month(month: str, commits: list[Commit], existing: str) -> str:
    before, after = strip_existing_auto_block(existing)
    if not before:
        before = f"# {month} 变更记录"

    by_day: dict[str, list[Commit]] = defaultdict(list)
    for commit in commits:
        by_day[commit.date].append(commit)

    lines: list[str] = [before, "", AUTO_START]
    for day in sorted(by_day, reverse=True):
        lines.extend(["", f"## {day}"])
        day_commits = by_day[day]
        for category in CATEGORY_ORDER:
            entries = [c for c in day_commits if c.category == category]
            if not entries:
                continue
            lines.extend(["", f"### {CATEGORY_TITLES[category]}"])
            for entry in entries:
                lines.append(f"- {entry.subject} (`{entry.short_sha}`)")
    lines.extend(["", AUTO_END])
    if after:
        lines.extend(["", after])
    return "\n".join(lines).rstrip() + "\n"


def update_changelog_files(root: Path, commits: list[Commit], dry_run: bool) -> None:
    by_month: dict[str, list[Commit]] = defaultdict(list)
    for commit in commits:
        by_month[commit.month].append(commit)

    changelog_dir = root / CHANGELOG_DIR
    for month in sorted(by_month):
        path = changelog_dir / f"{month}.md"
        existing = path.read_text(encoding="utf-8") if path.exists() else ""
        content = render_month(month, by_month[month], existing)
        if dry_run:
            print(f"--- {path} (dry-run) ---")
            print(content, end="")
            continue
        changelog_dir.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Fetch gitcode.com/openEuler/AcTrail.git master and update "
            "changelog/yyyy-mm.md from commits not yet recorded in state."
        )
    )
    parser.add_argument(
        "--since",
        help=(
            "Start after this commit instead of changelog state. The commit itself "
            "is excluded."
        ),
    )
    parser.add_argument(
        "--to",
        help=(
            f"Target commit/ref. Defaults to {UPSTREAM_REF} after fetching upstream master."
        ),
    )
    parser.add_argument(
        "--no-fetch",
        action="store_true",
        help=f"Do not fetch upstream master; use existing {UPSTREAM_REF}.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Print generated files and state without writing them.",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    root = repo_root()
    os.chdir(root)

    ensure_upstream_ref(fetch=not args.no_fetch)
    state_file = root / STATE_PATH
    state = read_state(state_file)
    target = resolve_target(args.to)
    new_shas = commit_range(state, args.since, target)
    if not new_shas:
        print(f"changelog is already up to date for upstream {UPSTREAM_BRANCH}")
        return 0

    shas = new_shas if args.since else first_parent_history(target)
    commits = load_commits(shas)
    update_changelog_files(root, commits, dry_run=args.dry_run)
    write_state(state_file, last_commit=target, dry_run=args.dry_run)
    print(
        f"processed {len(new_shas)} new commit(s) from upstream "
        f"{UPSTREAM_BRANCH} through {target[:12]}"
    )
    return 0


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