#!/usr/bin/env python3
"""The declaration transition's burn-down, as one number.

The transition converts hand-written shape functions into `declare!` blocks.
`population.py` counts what is still hand-written, so as conversion proceeds its
number falls; this counts the other side and prints both, per tree and per file.

    declared 12, remaining 502 of 514  (2.3%)

That line is the progress of the whole programme. Every task in the transition
should move it, and a task that cannot say how much it moves it is not shaped
right.

    python3 scripts/progress.py           # the burn-down
    python3 scripts/progress.py --files   # per file, converted files hidden
    python3 scripts/progress.py --next    # the smallest unconverted files first

`--next` is the working queue: converting a whole small file is worth more than
converting scattered functions, because a file that is fully declared stops
needing its imports and its helpers can move with it.
"""

from __future__ import annotations

import argparse
import importlib.util
import re
from pathlib import Path

ROOT = Path.home() / "Code"
HERE = Path(__file__).resolve().parent

spec = importlib.util.spec_from_file_location("population", HERE / "population.py")
pop = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pop)

TREES = {
    "MNW/server/src/quasi": "mnw",
    "Apps/goingson/src-tauri/src/quasi": "goingson",
    "Apps/audiofiles/crates/audiofiles-browser/src/quasi": "audiofiles",
}

# `declare! {` or `quasi_declare::declare! {`, one per declared shape.
DECLARED = re.compile(r"\b(?:quasi_declare\s*::\s*)?declare\s*!\s*[{(]")

# The ratified starting point, so the percentage has a fixed denominator even
# once conversion has moved the population count. Measured 2026-09-01 and
# reproduced by population.py.
BASELINE = {"mnw": 173, "goingson": 184, "audiofiles": 157}

# COUNTED, AND NOT WORK.
#
# `population.py`'s predicate counts a function returning a description type as
# a shape to convert. Three kinds of thing match it and never will be converted,
# so they sit at the head of `--next` forever and a session not told to skip
# them converts nothing and reports a stall. Settled 2026-09-04 (GoingsOn
# `95017e31`, option (a)): the predicate does not change and the denominator
# stays the ratified 514, because the number's whole value is that it is
# comparable across waves and reproducible from the tree. They are reported
# instead.
#
# Two of the three categories cannot be detected mechanically and are listed
# here by hand, with the reason. The third -- a shape `declare!` cannot emit at
# all -- is detected in `unemittable()` below.
NOT_WORK = {
    "MNW/server/src/quasi/tip.rs": {
        "checkout": "`Action::with` is refused as a production; `doing` is the remedy",
    },
    "MNW/server/src/quasi/item_sales.rs": {"badge": "a supplier a conversion wrote"},
    "MNW/server/src/quasi/media_picker.rs": {
        "folder_choices": "a supplier a conversion wrote",
    },
    "MNW/server/src/quasi/payout_summary.rs": {
        "figures": "a supplier a conversion wrote",
    },
    "MNW/server/src/quasi/widgets/carousel.rs": {
        "region": "refused on the hard limit: a closure holding a `let mut` and an `if let`",
    },
}

# The shaped types `declare!` can return. A shape function whose return type is
# not one of these cannot be emitted at all, whatever the form grows.
SHAPED = {"Node", "Screen", "Slot", "Row", "Cells", "Field", "Act"}


def unemittable(shape):
    """Whether `declare!` could never emit this shape, and why.

    THE TEST IS WHETHER IT IS WORK, NOT WHETHER IT COMPILES TODAY. A shape no
    restructuring inside its own file can make declarable is not work; one that
    only needs restructuring is work, and most of the transition has been
    exactly that.

    So this is one rule and deliberately not three. A return type outside
    `SHAPED` is a leaf the form has to be handed -- an `Action`, a `Tag`, a
    `Choice`, a `Column` -- and no production reaches it. `feeds.rs`'s
    `Surface::address` is also a method, which is a second reason for the same
    shape: a declaration is a `fn`, never an `impl` item.

    Two rules were here on 2026-09-04 and came out the same day, because both
    answered the wrong question:

    - `-> Result<T, E>` was called unemittable. Not work-free: the read is
      hoisted out and the shape becomes infallible, which is what every
      converted MNW screen does (`user_analytics::read`, then `pane`). It
      flagged 49 ordinary conversions.
    - `-> Vec<Row>` was called unemittable because R2 has no case for it. Also
      not work-free: the rows are written at the call site inside the `list`
      that consumes them and the supplier is deleted, which is wave 4's rule.
      It flagged 19.
    """
    ret = " ".join(shape["returns"].split())
    head = pop.head_type(ret) or ""
    if head not in SHAPED:
        return f"`declare!` returns one of {'/'.join(sorted(SHAPED))}, not `{head}`"
    return None


def scan():
    per_file = {}
    for rel in TREES:
        for f in sorted((ROOT / rel).rglob("*.rs")):
            parts = f.relative_to(ROOT).parts
            if f.name in ("tests.rs", "parity.rs") or "tests" in parts:
                continue
            raw = f.read_text(encoding="utf-8", errors="replace")
            blanked = pop.strip_cfg_test(pop.blank_noncode(raw))
            key = str(f.relative_to(ROOT))
            hand = pop.shapes_in(f)
            per_file[key] = {
                "tree": TREES[rel],
                "shapes": hand,
                "hand": len(hand),
                "declared": len(DECLARED.findall(blanked)),
                "lines": sum(h.get("lines", 0) for h in hand),
            }
    return per_file


def counted_but_not_work(per_file):
    """Every counted shape that no conversion will ever remove, with its reason.

    `NOT_WORK` holds the two categories that need a human to say so; the third
    is derived, so a new method returning an `Action` is reported the day it is
    written rather than the day somebody notices.
    """
    out = []
    for path, v in sorted(per_file.items()):
        for shape in v["shapes"]:
            why = NOT_WORK.get(path, {}).get(shape["fn"]) or unemittable(shape)
            if why:
                out.append((path, shape["fn"], why))
    return out


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--files", action="store_true", help="per file, fully converted files hidden")
    ap.add_argument("--next", action="store_true", help="the smallest unconverted files first")
    ap.add_argument("--parked", action="store_true",
                    help="the shapes the predicate counts that are not work, and why")
    args = ap.parse_args()

    per_file = scan()
    total_base = sum(BASELINE.values())
    declared = sum(v["declared"] for v in per_file.values())
    remaining = sum(v["hand"] for v in per_file.values())
    pct = 100.0 * declared / total_base if total_base else 0.0

    for tree, base in BASELINE.items():
        d = sum(v["declared"] for v in per_file.values() if v["tree"] == tree)
        h = sum(v["hand"] for v in per_file.values() if v["tree"] == tree)
        print(f"  {tree:11} declared {d:4}, remaining {h:4} of {base}")
    print(f"\ndeclared {declared}, remaining {remaining} of {total_base}  ({pct:.1f}%)")

    parked = counted_but_not_work(per_file)
    if parked:
        print(f"of which counted but not work: {len(parked)}")

    if args.parked:
        print("\nCounted, and not work. Never take one of these off the queue.\n")
        for path, name, why in parked:
            print(f"  {path}:{name}\n      {why}")

    if args.next:
        skip = {(path, name) for path, name, _ in parked}
        todo = []
        for k, v in per_file.items():
            left = [s for s in v["shapes"] if (k, s["fn"]) not in skip]
            if left:
                todo.append((k, len(left), sum(s.get("lines", 0) for s in left)))
        todo.sort(key=lambda row: (row[2], row[1]))
        print("\nSmallest unconverted files first. A whole file is the unit worth taking.")
        print("The counted-but-not-work shapes are already out; `--parked` lists them.\n")
        for path, hand, lines in todo[:20]:
            print(f"  {hand:3} shapes {lines:5}L  {path}")

    if args.files:
        print()
        for k, v in sorted(per_file.items()):
            if v["hand"]:
                print(f"  {v['declared']:3}/{v['declared'] + v['hand']:3}  {k}")
    return 0


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