#!/usr/bin/env python3
"""A line-for-line replica of app/lib/features/shelf_photo/shelf_identity.dart and of
ShelfReadController._merge (docs/dispatch/M-shelf-identity.md decision 0), so the merged-row
counts in the reports can be produced from saved frame outputs without a Flutter toolchain.

    python3 scripts/shelf_identity.py docs/dispatch/reports/C-probe/probe.json
    python3 scripts/shelf_identity.py docs/dispatch/reports/C-probe/replay-sonnet.json --rows

Reads the probe's or replay_frames.py's JSON (a "frames" list, each with "products"), skips
frames with no products, and prints the merged row count for the first 1, 2 and 3 frames, for
every consecutive 2- and 3-frame window, and for all frames. --rows lists the rows with the reads
that went into each. The Dart tests in app/test/shelf_identity_test.dart assert the same numbers
on the same reads, so if this script and the Dart ever disagree, one of them has drifted.

Nothing here is clever on purpose: keep it identical to the Dart, including tie-breaks.
"""
import argparse
import json
import re
import sys

JACCARD_FLOOR = 0.5
STOPWORDS = {"the", "and", "for", "with", "skin", "face", "daily"}
BRAND_NOISE = {"the", "co", "inc"}
UNKNOWN_BRANDS = {"unknown", "none", "na"}
UNIT_WORDS = {"ml", "oz", "floz", "fl", "g", "kg", "mg", "l"}

_DECIMAL_IN_NUMBER = re.compile(r"(\d)\.(\d)")
_SIZE = re.compile(r"\b\d+\s*(ml|oz|floz|fl|g|kg|mg|l)\b")
_LETTER_THEN_DIGIT = re.compile(r"([a-z])(?=\d)")
_DIGIT_THEN_LETTER = re.compile(r"(\d)(?=[a-z])")
_NON_ALPHANUMERIC = re.compile(r"[^a-z0-9]+")

# The same map as ShelfIdentity._folds, not a general normaliser.
_FOLDS = {
    "à": "a", "á": "a", "â": "a", "ã": "a", "ä": "a", "å": "a", "æ": "ae",
    "ç": "c",
    "è": "e", "é": "e", "ê": "e", "ë": "e",
    "ì": "i", "í": "i", "î": "i", "ï": "i",
    "ñ": "n",
    "ò": "o", "ó": "o", "ô": "o", "õ": "o", "ö": "o", "ø": "o", "œ": "oe",
    "ù": "u", "ú": "u", "û": "u", "ü": "u",
    "ý": "y", "ÿ": "y",
    "ß": "ss",
    "’": "", "'": "",
}


def fold(s):
    return "".join(_FOLDS.get(ch, ch) for ch in s.lower())


def raw_tokens(text):
    t = fold(text)
    t = _DECIMAL_IN_NUMBER.sub(r"\1\2", t)
    t = _SIZE.sub(" ", t)
    t = _LETTER_THEN_DIGIT.sub(r"\1 ", t)
    t = _DIGIT_THEN_LETTER.sub(r"\1 ", t)
    return [x for x in _NON_ALPHANUMERIC.split(t) if x]


def normalise_brand(brand):
    joined = "".join(t for t in raw_tokens(brand or "") if t not in BRAND_NOISE)
    return "" if joined in UNKNOWN_BRANDS else joined


def name_tokens(name, brand=""):
    tokens = set(raw_tokens(name)) - STOPWORDS - UNIT_WORDS
    if brand:
        tokens -= set(raw_tokens(brand))
        tokens.discard(normalise_brand(brand))
    return tokens


def jaccard(a, b):
    union = a | b
    return len(a & b) / len(union) if union else 0.0


def identity(read):
    """(brand, name, category, actives) as the Dart `ProductIdentity` record."""
    return (read.get("brand") or "", read["name"], read.get("category"), frozenset(read.get("actives") or []))


def overlap(a, b):
    return len(name_tokens(a[1], a[0]) & name_tokens(b[1], b[0]))


def same_product(a, b):
    brand = normalise_brand(a[0])
    if not brand or brand != normalise_brand(b[0]):
        return False
    ta, tb = name_tokens(a[1], a[0]), name_tokens(b[1], b[0])
    if not ta or not tb:
        return False
    if ta <= tb or tb <= ta:
        return True
    if a[2] is None or a[2] != b[2]:
        return False
    if a[3] != b[3]:
        return False
    return jaccard(ta, tb) >= JACCARD_FLOOR


USABLE = 0.6


def merge(rows, products, shot_id):
    """ShelfReadController._merge. `rows` is mutated and returned."""
    for d in products:
        fresh = {
            "slug": d["key"], "name": d["name"], "brand": d.get("brand") or "", "category": d.get("category"),
            "actives": set(d.get("actives") or []), "confidence": float(d.get("confidence") or 0),
            "shots": {shot_id}, "reads": [(shot_id, d["key"], d["name"])],
        }
        # An unknown category is clamped under the usable line, as ShelfCandidate.fromDetected does.
        fresh["checked"] = fresh["confidence"] >= USABLE
        best, best_overlap, best_conf = -1, -1, -1.0
        for i, c in enumerate(rows):
            ci = (c["brand"], c["name"], c["category"], frozenset(c["actives"]))
            if c["slug"] != d["key"] and not same_product(ci, identity(d)):
                continue
            ov = overlap(ci, identity(d))
            if ov > best_overlap or (ov == best_overlap and c["confidence"] > best_conf):
                best, best_overlap, best_conf = i, ov, c["confidence"]
        if best < 0:
            rows.append(fresh)
            continue
        c = rows[best]
        better = fresh["confidence"] > c["confidence"]
        if len(fresh["name"]) > len(c["name"]):
            c["name"] = fresh["name"]
        if better:
            c["brand"], c["category"], c["confidence"], c["checked"] = fresh["brand"], fresh["category"], fresh["confidence"], fresh["checked"]
        c["actives"] |= fresh["actives"]
        c["shots"].add(shot_id)
        c["reads"].extend(fresh["reads"])
    return rows


def merge_frames(frames):
    rows = []
    for shot_id, frame in enumerate(frames, start=1):
        merge(rows, frame["products"], shot_id)
    return rows


def dart_fixture(frames):
    """app/test/shelf_frames_fixture.dart: the same reads as Dart constants, so the Dart tests
    run on the real data without retyping it. Regenerate with --dart-fixture after a new device
    run and check the diff; the tests' counts will need the numbers this script prints."""
    def camel(w):
        parts = w.split("_")
        return parts[0] + "".join(x.capitalize() for x in parts[1:])

    def q(x):
        return "'" + x.replace("\\", "\\\\").replace("'", "\\'") + "'"

    out = ["import 'package:corneo/core/models/enums.dart';",
           "import 'package:corneo/features/shelf_photo/shelf_read.dart';",
           "",
           "/// The reads `identify_products` (Sonnet 4.5) returned for the six aimed frames of the report L",
           "/// device run: `docs/dispatch/reports/C-probe/probe.json`, frames 2 to 7 (frame 1 was captured",
           "/// while the phone was still being aimed and read nothing). Written by",
           "/// `python3 scripts/shelf_identity.py docs/dispatch/reports/C-probe/probe.json --dart-fixture app/test/shelf_frames_fixture.dart`,",
           "/// not retyped; the frame each read came from is the map key. This is the data",
           "/// docs/dispatch/M-shelf-identity.md decision 0 was written against, and the counts asserted in",
           "/// `shelf_identity_test.dart` are what the same script prints for it without `--dart-fixture`.",
           "///",
           "/// Brands are as returned: `<UNKNOWN>`, empty, and absent (here empty) all occur.",
           "const Map<int, List<DetectedProduct>> shelfFrames = {"]
    for f in frames:
        index = f.get("index") or (frames.index(f) + 1)
        usable = sum(1 for p in f["products"] if (p.get("confidence") or 0) >= USABLE)
        ms = f.get("function_ms") or f.get("ms") or 0
        out.append(f"  // {label(f)}: {len(f['products'])} reads, {usable} at or above {USABLE}, {ms} ms")
        out.append(f"  {index}: [")
        for p in f["products"]:
            acts = ", ".join("Active." + camel(a) for a in (p.get("actives") or []))
            out += ["    DetectedProduct(",
                    f"      slug: {q(p['key'])},",
                    f"      name: {q(p['name'])},",
                    f"      brand: {q(p.get('brand') or '')},",
                    f"      category: Category.{camel(p['category'])},",
                    f"      actives: {{{acts}}}," if acts else "      actives: {},",
                    f"      confidence: {p['confidence']},",
                    f"      readFrom: {q(p.get('read_from') or 'front_label')},",
                    "    ),"]
        out.append("  ],")
    out += ["};", "",
            "/// Every read of the six frames, in frame order, as `ShelfReadController` would see them.",
            "List<DetectedProduct> get allShelfReads => [for (final frame in shelfFrames.values) ...frame];",
            ""]
    return "\n".join(out)


def label(frame):
    return frame.get("file") or f"frame_{frame.get('index', '?')}"


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("path", help="probe.json or replay-<label>.json")
    ap.add_argument("--rows", action="store_true", help="list the merged rows for all frames")
    ap.add_argument("--dart-fixture", metavar="PATH", help="write the frames as app/test/shelf_frames_fixture.dart")
    args = ap.parse_args()
    data = json.load(open(args.path))
    frames = [f for f in data["frames"] if f.get("products")]
    if not frames:
        sys.exit("no frames with products")
    if args.dart_fixture:
        with open(args.dart_fixture, "w") as f:
            f.write(dart_fixture(frames))
        print(f"wrote {args.dart_fixture}")
        return
    slugs = lambda fs: len({p["key"] for f in fs for p in f["products"]})
    reads = lambda fs: sum(len(f["products"]) for f in fs)
    print(f"{len(frames)} frames with products: {', '.join(label(f) for f in frames)}")
    print(f"{'shots':<7}{'frames':<26}{'reads':>6}{'slugs':>7}{'rows':>6}{'sure':>6}{'not sure':>10}")
    windows = [frames[:n] for n in (1, 2, 3) if n <= len(frames)]
    for n in (2, 3):
        windows += [frames[i:i + n] for i in range(1, len(frames) - n + 1)]
    if len(frames) > 3:
        windows.append(frames)
    for fs in windows:
        rows = merge_frames(fs)
        sure = sum(1 for r in rows if r["confidence"] >= USABLE)
        names = ", ".join(label(f).replace("frame_", "").replace(".jpg", "") for f in fs)
        print(f"{len(fs):<7}{names:<26}{reads(fs):>6}{slugs(fs):>7}{len(rows):>6}{sure:>6}{len(rows) - sure:>10}")
    if args.rows:
        print()
        for r in merge_frames(frames):
            src = "; ".join(f"{s}:{k}" for s, k, _ in r["reads"])
            print(f"[{len(r['shots'])} shot{'s' if len(r['shots']) != 1 else ''}] {r['slug']:<42} {r['name']!r:<46} {r['confidence']:.2f}  <- {src}")


if __name__ == "__main__":
    main()
