#!/usr/bin/env python3
"""Churn, coupling and doc-growth report over a window of history.

code-maat's analyses and this script's per-file churn go into one directory of
CSVs; improvise imports them into one model and computes the report's tables.
See context/code/tooling.md section 7.
"""

import argparse
import csv
import datetime
import json
import os
import re
import subprocess
import sys
import tempfile

# First match wins, so the order is part of the definition.
AREAS = [
    ("generated", re.compile(r"^(\.beads/|site/|Cargo\.nix$|roadmap\.org$)"
                             r"|\.(lock|cast)$|(^|/)package-lock\.json$")),
    ("repo-map", re.compile(r"^context/repo-map\.md$")),
    ("kb.pl", re.compile(r"^prolog-sessions/kb\.pl$")),
    ("context/code", re.compile(r"^context/code/")),
    ("context/test-evidence", re.compile(r"^context/test-evidence/")),
    ("other context", re.compile(r"^context/")),
    ("other docs", re.compile(r"^(docs/.*\.md|README\.md|RELEASING\.md)$")),
    ("agent docs and skills", re.compile(r"^(AGENTS\.md|CLAUDE\.md|\.agents/|\.claude/|\.codex/)")),
    ("scripts/CI", re.compile(r"^(scripts/|\.github/|nix/|flake\.nix$)")),
    ("web and harness", re.compile(r"^(web|harness)/")),
    ("rust tests", re.compile(r"(^|/)(tests|benches)/.*\.rs$|(^|/|_)tests?\.rs$")),
    ("rust src", re.compile(r"\.(rs|pest)$")),
]
OTHER = "other"
PROSE = {"context/code", "context/test-evidence", "other context", "other docs", "repo-map",
         "agent docs and skills"}
CODE = {"rust src", "rust tests", "scripts/CI", "web and harness"}
AGENT_RULES = "agent docs and skills"

# One CSV per code-maat analysis, plus churn.csv. Each stem becomes an item of
# the model's File category, which the .improvise script pages on.
ANALYSES = ["coupling", "abs-churn", "age", "soc"]
CATEGORIES = ["commit", "date", "week", "entity", "area", "kind", "coupled",
              "crosses-crate", "agent-rules"]
CHURN_HEADER = ["commit", "date", "week", "entity", "area", "kind", "added", "deleted"]
MEASURES = ["added", "deleted", "commits", "degree", "average-revs", "age-months", "soc"]
TABLES = ["by-area", "by-kind", "area-by-week", "by-day"]
HERE = os.path.dirname(os.path.abspath(__file__))


class ImproviseFailed(Exception):
    """improvise refused the pipeline's input: a product regression, not a statistic."""


def area_of(path):
    for name, rx in AREAS:
        if rx.search(path):
            return name
    return OTHER


def kind_of(area):
    return "prose" if area in PROSE else "code" if area in CODE else "other"


def crate_of(path):
    """The crate a path belongs to, or None for a path in no crate."""
    m = re.match(r"^crates/([^/]+)/", path)
    if m:
        return m.group(1)
    if path.startswith("harness/opfs-contract/"):
        return "opfs-contract"
    if re.match(r"^(src|tests|benches|examples)/", path) or path == "Cargo.toml":
        return "improvise"
    return None


def git(repo, *args):
    return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True,
                          check=True).stdout


def resolve_ref(repo, ref):
    """The ref asked for; the default gh/main falls back to HEAD where it is absent."""
    for r in ([ref] if ref else ["gh/main", "HEAD"]):
        out = subprocess.run(["git", "-C", repo, "rev-parse", "--verify", "--quiet",
                              r + "^{commit}"], capture_output=True, text=True)
        if out.returncode == 0:
            return r, out.stdout.strip()
    sys.exit(f"repo-stats: cannot resolve {ref or 'gh/main or HEAD'}")


def git2_log(repo, sha, since):
    """The log in code-maat's git2 format: the ref's history, without merges."""
    return git(repo, "log", sha, "--no-merges", f"--since={since}", "--numstat",
               "--date=short", "--pretty=format:--%h--%ad--%aN", "--no-renames")


def is_header(line):
    return line.startswith("--") and line.count("--") >= 3


def churn_rows(log):
    """One row per (commit, file). A commit is written `g<sha>`, because improvise
    reads an all-digit or exponent-shaped sha as a number (tooling.md section 7)."""
    rows, commit, date = [], None, None
    for line in log.split("\n"):
        if is_header(line):
            _, commit, date, _ = line.split("--", 3)
        elif "\t" in line:
            a, d, path = line.split("\t", 2)
            year, week, _ = datetime.date.fromisoformat(date).isocalendar()
            area = area_of(path)
            rows.append({"commit": "g" + commit, "date": date,
                         "week": f"{year}-W{week:02d}", "entity": path, "area": area,
                         "kind": kind_of(area), "added": 0 if a == "-" else int(a),
                         "deleted": 0 if d == "-" else int(d)})
    return rows


def write_csv(path, header, rows):
    with open(path, "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=header)
        w.writeheader()
        w.writerows(rows)


def read_csv(path):
    with open(path, newline="") as f:
        return list(csv.DictReader(f))


def code_maat(logfile, analysis, out, *extra):
    subprocess.run(["code-maat", "-l", logfile, "-c", "git2", "-a", analysis,
                    "-o", out, *extra], capture_output=True, text=True, check=True)


def flag_coupling(path):
    """code-maat's coupling.csv, plus the two flags code-maat has no notion of."""
    rows = read_csv(path)
    for r in rows:
        a, b = crate_of(r["entity"]), crate_of(r["coupled"])
        r["crosses-crate"] = "yes" if a and b and a != b else "no"
        both = area_of(r["entity"]) == area_of(r["coupled"]) == AGENT_RULES
        r["agent-rules"] = "yes" if both else "no"
    write_csv(path, ["entity", "coupled", "degree", "average-revs", "crosses-crate",
                     "agent-rules"], rows)


def build_dir(out, log, rows, min_revs, today):
    """The directory improvise imports: churn.csv and one CSV per analysis."""
    with tempfile.NamedTemporaryFile("w", suffix=".log", delete=False) as f:
        f.write(log + "\n")
    try:
        revs = ["--min-revs", str(min_revs), "--min-shared-revs", str(min_revs)]
        extra = {"coupling": revs, "age": ["--age-time-now", today]}
        for a in ANALYSES:
            code_maat(f.name, a, os.path.join(out, f"{a}.csv"), *extra.get(a, []))
    finally:
        os.unlink(f.name)
    flag_coupling(os.path.join(out, "coupling.csv"))


def improvise(out, *args):
    exe = os.environ.get("IMPROVISE", "improvise")
    p = subprocess.run([exe, *args], cwd=out, capture_output=True, text=True)
    if p.returncode != 0 or "warning:" in p.stderr:
        raise ImproviseFailed(f"improvise {' '.join(args)} exited {p.returncode}:\n"
                              f"{p.stderr}")


def run_improvise(out):
    """Import the directory into one model, a view per file included, then build
    the report's views and export them."""
    flags = [f"--category={c}" for c in CATEGORIES] + [f"--measure={m}" for m in MEASURES]
    improvise(out, "import", ".", "--no-wizard", *flags, "--name=Repo stats",
              "--formula=DeletionRatio = deleted / added FORMAT .3",
              "-o", "repo-stats.improv")
    os.makedirs(os.path.join(out, "report"), exist_ok=True)
    improvise(out, "script", os.path.join(HERE, "repo-stats.improvise"),
              "-f", "repo-stats.improv")
    return {t: read_rows(os.path.join(out, "report", f"{t}.csv")) for t in TABLES}


def read_rows(path):
    with open(path, newline="") as f:
        return list(csv.reader(f))


def gotchas(text):
    """(number, lines, chars) for each numbered item of context/gotchas.md."""
    out, cur, inside = [], None, True
    for line in text.split("\n"):
        if inside and line.startswith("---"):
            inside = False
        if not inside:
            continue
        m = re.match(r"^(\d+)\. ", line)
        if m:
            cur = [int(m.group(1)), 0, 0]
            out.append(cur)
        if cur is not None and line.strip():
            cur[1] += 1
            cur[2] += len(line)
    return [tuple(g) for g in out]


def doc_sizes(repo, sha):
    files = [p for p in git(repo, "ls-tree", "-r", "--name-only", sha, "--",
                            "context").split("\n") if p.endswith(".md")]
    show = lambda p: git(repo, "show", f"{sha}:{p}")
    lines = {p: len(show(p).splitlines()) for p in sorted(files)}
    gs = gotchas(show("context/gotchas.md")) if "context/gotchas.md" in files else []
    longest = max(gs, key=lambda g: g[2], default=None)
    evidence = {p: sum(1 for l in show(p).split("\n") if re.match(r"^#{3,4} ", l))
                for p in sorted(files) if p.startswith("context/test-evidence/")}
    return {
        "context_lines": lines,
        "gotchas": {"count": len(gs), "longest": longest and
                     {"number": longest[0], "lines": longest[1], "chars": longest[2]}},
        "test_evidence": {"entries": sum(evidence.values()), "per_file": evidence},
    }


def report(repo, ref, since, min_revs, out):
    name, sha = resolve_ref(repo, ref)
    log = git2_log(repo, sha, since)
    commits = sum(1 for line in log.split("\n") if is_header(line))
    today = git(repo, "show", "-s", "--date=short", "--format=%cd", sha).strip()
    r = {"ref": name, "sha": sha, "since": since, "commits": commits, "out": out,
         "docs": doc_sizes(repo, sha), "tables": {}, "coupling": [], "soc": [], "age": []}
    rows = churn_rows(log)
    write_csv(os.path.join(out, "churn.csv"), CHURN_HEADER, rows)
    # Empty commits alone change no file: nothing to analyse, so no model is built.
    if rows:
        build_dir(out, log, rows, min_revs, today)
        r["tables"] = run_improvise(out)
        for a in ("coupling", "soc", "age"):
            r[a] = read_csv(os.path.join(out, f"{a}.csv"))
    return r


def table(rows):
    """A CSV improvise exported, as aligned text; the first column left-aligned."""
    n = max(len(r) for r in rows)
    widths = [max(len(r[i]) for r in rows if i < len(r)) for i in range(n)]
    return [("  " + "  ".join((c.ljust if i == 0 else c.rjust)(widths[i])
                              for i, c in enumerate(r))).rstrip() for r in rows]


TITLES = {
    "by-area": "Churn by area (improvise)",
    "by-kind": "Prose, code and other; ProseToCode = prose / code (improvise)",
    "area-by-week": "Lines added by area and ISO week (improvise)",
    "by-day": "Lines added per day, churn.csv beside code-maat's abs-churn (improvise)",
}


def text(r, top):
    out = [f"repo-stats: {r['ref']} ({r['sha'][:10]}), since {r['since']}, "
           f"{r['commits']} non-merge commits"]
    if not r["tables"]:
        out += ["", "No file changed in the window, so no model was built."]
    for t in TABLES:
        if r["tables"].get(t):
            out += ["", TITLES[t]] + table(r["tables"][t])
    out += ["", "Doc sizes (lines)"]
    out += [f"  {n:>6}  {path}" for path, n in r["docs"]["context_lines"].items()]
    rm, te = r["docs"]["gotchas"], r["docs"]["test_evidence"]
    lg = rm["longest"]
    out.append(f"  gotchas: {rm['count']}" + (
        f"; longest is {lg['number']} ({lg['lines']} lines, {lg['chars']} chars)"
        if lg else ""))
    out.append(f"  test-evidence entries: {te['entries']}")
    out += ["", f"Change coupling (code-maat; top {top})"]
    for c in r["coupling"][:top]:
        flags = [f for f, k in (("CROSSES-CRATE", "crosses-crate"),
                                ("AGENT-RULES", "agent-rules")) if c[k] == "yes"]
        out.append(f"  {c['degree']:>4}% {c['average-revs']:>4} revs  {c['entity']}"
                   f" <-> {c['coupled']}" + (f"  [{', '.join(flags)}]" if flags else ""))
    out += ["", f"Sum of coupling (code-maat; top {top})"]
    out += [f"  {s['soc']:>6}  {s['entity']}" for s in r["soc"][:top]]
    out += ["", f"Code age in months (code-maat; first {top})"]
    out += [f"  {a['age-months']:>4}  {a['entity']}" for a in r["age"][:top]]
    return "\n".join(out)


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--ref", help="the ref to report on (default gh/main, else HEAD)")
    ap.add_argument("--since", default="7 days ago", help="git --since (default 7 days ago)")
    ap.add_argument("--min-revs", type=int, default=5,
                    help="code-maat --min-revs and --min-shared-revs (default 5)")
    ap.add_argument("--top", type=int, default=15, help="rows per ranked table")
    ap.add_argument("--out", default="repo-stats",
                    help="the directory for the CSVs, the model and its exports")
    ap.add_argument("--json", action="store_true", help="emit JSON instead of text")
    ap.add_argument("-C", dest="repo", default=".", help="the repository (default .)")
    a = ap.parse_args()
    os.makedirs(a.out, exist_ok=True)
    ours = {f"{n}.csv" for n in ["churn"] + ANALYSES}
    foreign = sorted(f for f in os.listdir(a.out) if f.lower().endswith(".csv") and f not in ours)
    if foreign:
        print(f"repo-stats: {a.out} holds CSVs this script did not write, and "
              f"`improvise import` would merge them: {', '.join(foreign)}", file=sys.stderr)
        return 2
    try:
        r = report(a.repo, a.ref, a.since, a.min_revs, os.path.abspath(a.out))
    except ImproviseFailed as e:
        print(f"repo-stats: {e}", file=sys.stderr)
        return 3
    print(json.dumps(r, indent=2) if a.json else text(r, a.top))
    return 0


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