#!/usr/bin/env python3
"""
scorecard.py — record and trend the twelve-category project scorecard.

    python3 scorecard.py record p01 --first-principles 3 --correctness 4 ...
    python3 scorecard.py record p01 --interactive
    python3 scorecard.py show                 # all projects, all categories
    python3 scorecard.py trend                # per-category sparkline across projects
    python3 scorecard.py weakest              # what the next project must train
    python3 scorecard.py stage 1              # stage review summary

Scores live in ../scores/scorecard.json — committed, so the trend survives the
34 months and a laptop replacement.

WHY THIS EXISTS. scorecard.md says "plot the trend across the stage's projects" and
provided nothing to record into or plot with, so the one dataset that would show you
improving was never going to be captured. Twelve categories x fifteen projects is 180
numbers; that is a spreadsheet's worth of signal and it does not fit in your head.

WHAT IT REFUSES TO DO. It will not compute an overall score. Averaging
"first-principles understanding" with "code quality" produces a number that means
nothing and invites you to offset a weakness with a strength — which is the opposite of
the point. The weakest category is the output that matters.
"""

from __future__ import annotations

import argparse
import json
import sys
from datetime import date
from pathlib import Path

SCORES = Path(__file__).resolve().parents[1] / "scores" / "scorecard.json"

# (key, cli-flag, display name, the one-line 3-anchor as a prompt reminder)
CATEGORIES = [
    ("first_principles", "first-principles", "First-principles understanding",
     "3 = can explain every major decision and derived >=1 constant yourself"),
    ("correctness", "correctness", "Correctness",
     "3 = property tests, invariants in code, and you can state what is NOT tested"),
    ("implementation_depth", "implementation-depth", "Implementation depth",
     "3 = central mechanism hand-written, library boundary explicit and defensible"),
    ("code_quality", "code-quality", "Code quality",
     "3 = sensible modules, why-comments, a stranger can run it"),
    ("systems_reasoning", "systems-reasoning", "Systems reasoning",
     "3 = profiled, found the bottleneck, can classify it into one of four kinds"),
    ("experimental_rigor", "experimental-rigor", "Experimental rigor",
     "3 = fixed seeds, repeated trials, one variable, stated baseline, uncertainty"),
    ("benchmark_quality", "benchmark-quality", "Benchmark quality",
     "3 = p50/p95/p99, warmup separated, environment recorded, raw samples saved"),
    ("failure_analysis", "failure-analysis", "Failure analysis",
     "3 = deliberate fault injection with predicted vs observed behaviour"),
    ("originality", "originality", "Originality of hypotheses",
     "3 = a falsifiable hypothesis with a stated falsifier, tested"),
    ("communication", "communication", "Communication",
     "3 = complete report; figures with units; a peer could follow it"),
    ("reproducibility", "reproducibility", "Reproducibility",
     "3 = one command each to build, test, reproduce the headline benchmark"),
    ("completion", "completion", "Completion discipline",
     "3 = exit criteria met inside the class ceiling; cuts documented"),
]

PROJECT_ORDER = ["p01", "p02", "p11a", "p13a", "p03", "p04", "p11b", "p13b",
                 "p05", "p06", "p07", "p08", "p09", "p10", "p12", "p14", "p15"]
STAGES = {1: ["p01", "p02", "p11a", "p13a"], 2: ["p03", "p04", "p11b", "p13b"],
          3: ["p05", "p06", "p07"], 4: ["p08", "p09", "p10"],
          5: ["p12", "p14"], 6: ["p15"]}

BLOCKS = " ▁▂▃▄▅▆▇"   # index 0 unused; 1-5 map onto ▁..▅


def load() -> dict:
    if not SCORES.exists():
        return {}
    return json.loads(SCORES.read_text())


def save(d: dict) -> None:
    SCORES.parent.mkdir(parents=True, exist_ok=True)
    SCORES.write_text(json.dumps(d, indent=2, sort_keys=True) + "\n")


def spark(vals: list[int | None]) -> str:
    return "".join("·" if v is None else BLOCKS[max(1, min(5, v))] for v in vals)


def cmd_record(a) -> int:
    data = load()
    entry = data.get(a.project, {})
    if a.interactive:
        print(f"Scoring {a.project}. 1-5, blank to skip. Score DOWN when unsure.\n")
        for key, _flag, name, anchor in CATEGORIES:
            cur = entry.get(key)
            prompt = f"  {name}\n    {anchor}\n    score{f' [{cur}]' if cur else ''}: "
            raw = input(prompt).strip()
            if raw:
                v = int(raw)
                if not 1 <= v <= 5:
                    print("    must be 1-5, skipped")
                    continue
                entry[key] = v
            ev = input("    evidence (what artifact justifies it): ").strip()
            if ev:
                entry.setdefault("evidence", {})[key] = ev
            print()
    else:
        for key, flag, _name, _anchor in CATEGORIES:
            v = getattr(a, flag.replace("-", "_"), None)
            if v is not None:
                if not 1 <= v <= 5:
                    print(f"{flag} must be 1-5", file=sys.stderr)
                    return 1
                entry[key] = v
    entry["date"] = a.date or date.today().isoformat()
    if a.hours is not None:
        entry["hours_actual"] = a.hours
    data[a.project] = entry
    save(data)

    scored = [k for k, *_ in CATEGORIES if k in entry]
    print(f"recorded {a.project}: {len(scored)}/12 categories -> {SCORES}")
    missing = [n for k, _f, n, _a in CATEGORIES if k not in entry]
    if missing:
        print("  still unscored: " + ", ".join(missing))
    else:
        lo = min((entry[k], n) for k, _f, n, _a in CATEGORIES)
        print(f"  weakest: {lo[1]} ({lo[0]}). That is what the next project must train.")
    return 0


def cmd_show(a) -> int:
    data = load()
    projs = [p for p in PROJECT_ORDER if p in data]
    if not projs:
        print("no scores recorded yet. `scorecard.py record p01 --interactive`")
        return 0
    w = max(len(n) for _k, _f, n, _a in CATEGORIES)
    print(f"{'category':<{w}} " + " ".join(f"{p:>4}" for p in projs) + "   trend")
    print("-" * (w + 5 * len(projs) + 10))
    for key, _flag, name, _anchor in CATEGORIES:
        vals = [data[p].get(key) for p in projs]
        cells = " ".join(f"{v:>4}" if v is not None else "   ·" for v in vals)
        print(f"{name:<{w}} {cells}   {spark(vals)}")
    return 0


def cmd_trend(a) -> int:
    data = load()
    projs = [p for p in PROJECT_ORDER if p in data]
    if len(projs) < 2:
        print("need at least two scored projects for a trend")
        return 0
    print("Per-category movement, first scored project -> latest.\n")
    rising, flat, falling = [], [], []
    for key, _flag, name, _anchor in CATEGORIES:
        vals = [(p, data[p].get(key)) for p in projs if data[p].get(key) is not None]
        if len(vals) < 2:
            continue
        d = vals[-1][1] - vals[0][1]
        line = (f"  {name:<34} {vals[0][1]} -> {vals[-1][1]}  "
                f"{spark([v for _p, v in vals])}  {d:+d}")
        (rising if d > 0 else falling if d < 0 else flat).append(line)
    for title, group, note in (
        ("RISING", rising, ""),
        ("FLAT", flat, "A category that never moves is one you are avoiding. "
                       "Failure analysis and originality are the usual suspects, "
                       "because both require sitting with being wrong."),
        ("FALLING", falling, "Usually experimental rigor or communication — the things "
                             "cut under time pressure. That is a scheduling problem, "
                             "not a skill problem; fix it in the allocation."),
    ):
        if group:
            print(f"{title}")
            print("\n".join(group))
            if note:
                print(f"    -> {note}")
            print()
    if not falling and not flat and len(rising) == len(CATEGORIES):
        print("Everything rising smoothly is suspicious. Re-read the 1/3/5 anchors —")
        print("you have probably started scoring against your own past work rather")
        print("than against the definitions.")
    return 0


def cmd_weakest(a) -> int:
    data = load()
    if not data:
        print("no scores recorded yet")
        return 0
    latest = None
    for p in PROJECT_ORDER:
        if p in data:
            latest = p
    entry = data[latest]
    scored = [(entry[k], n) for k, _f, n, _a in CATEGORIES if k in entry]
    if not scored:
        print(f"{latest} has no category scores")
        return 0
    scored.sort()
    print(f"Latest scored project: {latest}\n")
    print("Weakest three — pick ONE for the next project to train:\n")
    for v, n in scored[:3]:
        print(f"  {v}  {n}")
    print("\nOne category at a time. Trying to raise all twelve raises none.")
    print("Write the chosen one into the next project's weekly objectives.")
    return 0


def cmd_stage(a) -> int:
    data = load()
    projs = [p for p in STAGES.get(a.n, []) if p in data]
    if not projs:
        print(f"no scored projects in stage {a.n} yet")
        return 0
    print(f"Stage {a.n} review — {', '.join(projs)}\n")
    for key, _flag, name, _anchor in CATEGORIES:
        vals = [data[p].get(key) for p in projs if data[p].get(key) is not None]
        if not vals:
            continue
        print(f"  {name:<34} mean {sum(vals)/len(vals):.1f}   {spark(vals)}")
    print("\nNow answer the four stage-review questions in writing:")
    print("  1. What can I build that I could not at the start of this stage?")
    print("  2. Which predictions were wrong, and was there a pattern in HOW?")
    print("  3. What did I avoid because it was hard?")
    print("  4. Weakest category above -> which mechanic next stage trains it?")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)

    r = sub.add_parser("record")
    r.add_argument("project")
    r.add_argument("--interactive", action="store_true")
    r.add_argument("--date")
    r.add_argument("--hours", type=float, help="actual hours spent, for calibration")
    for _key, flag, _name, anchor in CATEGORIES:
        r.add_argument(f"--{flag}", type=int, help=anchor)
    r.set_defaults(fn=cmd_record)

    sub.add_parser("show").set_defaults(fn=cmd_show)
    sub.add_parser("trend").set_defaults(fn=cmd_trend)
    sub.add_parser("weakest").set_defaults(fn=cmd_weakest)
    s = sub.add_parser("stage"); s.add_argument("n", type=int); s.set_defaults(fn=cmd_stage)

    a = ap.parse_args()
    return a.fn(a)


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