#!/usr/bin/env python3
"""
Real-World Device-Type Validation
==================================

Unlike ``benchmark_phase0.py`` (which *fabricates* .sub files from the protocol
DB's own timing parameters and therefore only measures an upper bound), this
harness runs the **real identification pipeline** against **real community
Flipper Zero captures** — the UberGuidoZ Sub-GHz corpus — where the folder name
is the ground-truth device type.

It measures what actually matters for "what kind of device is this?":

  1. Category routing  — does the router put the capture in the right device
     family? Reported two ways:
        top1   : router's single best category  == ground truth
        routed : ground truth ∈ router's allowed_categories (the searched set)
  2. Coverage          — % of files the pipeline can even act on (RAW-parseable,
     timing-extractable, ≥1 device match).

Run:
    python scripts/benchmark_realworld.py                 # default sample
    python scripts/benchmark_realworld.py --per-category 80 --out /tmp/rw.json
"""

import argparse
import json
import random
import sys
import time
from collections import Counter, defaultdict
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from src.parser.sub_parser import parse_sub_file
from src.matcher.pattern_decoder import get_pattern_decoder


# ── Ground truth: UberGuidoZ folder name -> GigLez router category ──────────
# Only folders with an unambiguous mapping onto a category the router can emit
# are included. Ambiguous grab-bags (Misc, Jamming, Settings, Pocsag, ...) are
# intentionally excluded so the denominator stays honest.
FOLDER_TO_CATEGORY = {
    "Doorbells":              "Doorbell",
    "Garages":                "Garage Door Opener",
    "Gates":                  "Garage Door Opener",
    "Weather_stations":       "Weather Sensor",
    "Ceiling_Fans":           "Fan Controller",
    "Fans":                   "Fan Controller",
    "Motion_Sensors":         "Security Sensor",
    "Smoke_Alarm":            "Security Sensor",
    "Vehicles":               "Remote Control",
    "Smart_Home_Remotes":     "Remote Control",
    "Remote_Outlet_Switches": "Remote Control",
}

DEFAULT_DATASET = (
    Path(__file__).parent.parent
    / "data/rf_test_datasets/UberGuidoZ_Flipper/Sub-GHz"
)


def collect_files(dataset_root: Path, per_category: int, seed: int):
    """Return list of (path, ground_truth_category, folder) sampled per folder."""
    rng = random.Random(seed)
    out = []
    for folder, category in FOLDER_TO_CATEGORY.items():
        folder_path = dataset_root / folder
        if not folder_path.is_dir():
            continue
        subs = sorted(folder_path.rglob("*.sub"))
        rng.shuffle(subs)
        for p in subs[:per_category]:
            out.append((p, category, folder))
    return out


def evaluate(files, decoder):
    """Run the routing + decode pipeline over the sampled files."""
    ta = decoder.timing_analyzer
    pd = decoder.preamble_detector
    router = decoder.category_router

    results = []
    for path, gt_category, folder in files:
        rec = {
            "file": str(path),
            "folder": folder,
            "ground_truth": gt_category,
            "status": None,          # ok | no_raw | no_timing | parse_error
            "file_format": None,
            "protocol": None,        # Flipper Protocol: field (present on KEY files)
            "predicted_top1": None,
            "allowed_categories": [],
            "routed_hit": False,
            "top1_hit": False,
            "n_device_matches": 0,
            "top_device": None,
            "top_confidence": None,
        }
        try:
            meta = parse_sub_file(str(path))
            rec["file_format"] = getattr(meta, "file_format", None)
            rec["protocol"] = getattr(meta, "protocol", None)

            if not getattr(meta, "has_raw_data", False):
                # Decoded KEY file: no pulses to time, but a Protocol name is
                # itself identifying — route by name.
                if not getattr(meta, "protocol", None):
                    rec["status"] = "no_raw"   # nothing to go on
                    results.append(rec)
                    continue
                pred = router.route_by_protocol(meta.protocol, meta.frequency)
                rec["status"] = "ok_key"
                rec["predicted_top1"] = pred.primary_category
                rec["allowed_categories"] = list(pred.allowed_categories or [])
                rec["top1_hit"] = (pred.primary_category == gt_category)
                rec["routed_hit"] = (
                    gt_category in rec["allowed_categories"] or pred.use_full_db
                )
                matches = decoder.decode(meta)
                rec["n_device_matches"] = len(matches)
                if matches:
                    rec["top_device"] = matches[0].name
                    rec["top_confidence"] = round(matches[0].confidence, 3)
                results.append(rec)
                continue

            pulses = meta.raw_data
            timing = ta.extract_timing(pulses)
            short, long = timing.short_pulse_us, timing.long_pulse_us
            if short == 0 or long == 0:
                rec["status"] = "no_timing"
                results.append(rec)
                continue

            detected = pd.detect(pulses, short, long)
            ptype = detected.type if detected else "none"

            pred = router.predict(
                frequency=meta.frequency,
                short_pulse_us=short,
                long_pulse_us=long,
                pulse_count=len(pulses),
                preamble_type=ptype,
            )
            rec["status"] = "ok"
            rec["predicted_top1"] = pred.primary_category
            rec["allowed_categories"] = list(pred.allowed_categories or [])
            rec["top1_hit"] = (pred.primary_category == gt_category)
            rec["routed_hit"] = (
                gt_category in rec["allowed_categories"]
                or (pred.use_full_db)  # full-DB fallback searches everything
            )

            matches = decoder.decode(meta)
            rec["n_device_matches"] = len(matches)
            if matches:
                rec["top_device"] = matches[0].name
                rec["top_confidence"] = round(matches[0].confidence, 3)

        except Exception as e:      # noqa: BLE001 — want to bucket, not crash
            rec["status"] = "parse_error"
            rec["error"] = str(e)[:200]

        results.append(rec)
    return results


def report(results):
    total = len(results)
    status_counts = Counter(r["status"] for r in results)
    fmt_counts = Counter(r["file_format"] for r in results)

    routable = [r for r in results if r["status"] in ("ok", "ok_key")]
    n_routable = len(routable)
    n_raw = sum(1 for r in routable if r["status"] == "ok")
    n_key = sum(1 for r in routable if r["status"] == "ok_key")

    top1_hits = sum(r["top1_hit"] for r in routable)
    routed_hits = sum(r["routed_hit"] for r in routable)
    with_device = sum(1 for r in routable if r["n_device_matches"] > 0)

    print("=" * 74)
    print("REAL-WORLD DEVICE-TYPE VALIDATION  (UberGuidoZ Sub-GHz corpus)")
    print("=" * 74)
    print(f"Files sampled            : {total}")
    print(f"  status breakdown       : {dict(status_counts)}")
    print(f"  file formats           : {dict(fmt_counts)}")
    print(f"Routable (RAW + KEY)     : {n_routable} "
          f"({n_routable/total:.0%} of sampled)  "
          f"[RAW timing={n_raw}, KEY protocol={n_key}]")
    print()
    if n_routable:
        print("── Category accuracy (over routable files) ──")
        print(f"  top-1  (best == truth)      : {top1_hits}/{n_routable} "
              f"= {top1_hits/n_routable:.1%}")
        print(f"  routed (truth ∈ allowed set): {routed_hits}/{n_routable} "
              f"= {routed_hits/n_routable:.1%}")
        print(f"  device match coverage       : {with_device}/{n_routable} "
              f"= {with_device/n_routable:.1%}")
        print()

        # End-to-end (routable AND top-1 correct) over ALL sampled files — the
        # number a user actually experiences on an arbitrary upload.
        print("── End-to-end over ALL sampled (incl. unparseable) ──")
        print(f"  top-1   : {top1_hits}/{total} = {top1_hits/total:.1%}")
        print(f"  routed  : {routed_hits}/{total} = {routed_hits/total:.1%}")
        print()

    # Per-category breakdown
    print("── Per-category (routable only) ──")
    by_cat = defaultdict(list)
    for r in routable:
        by_cat[r["ground_truth"]].append(r)
    print(f"  {'category':22} {'n':>4} {'top1':>7} {'routed':>7}")
    for cat in sorted(by_cat):
        rs = by_cat[cat]
        n = len(rs)
        t1 = sum(x["top1_hit"] for x in rs) / n
        rt = sum(x["routed_hit"] for x in rs) / n
        print(f"  {cat:22} {n:>4} {t1:>6.0%} {rt:>6.0%}")
    print()

    # Confusion: where did top-1 send the misses?
    print("── Top-1 confusion (ground_truth -> predicted, misses only) ──")
    conf = Counter()
    for r in routable:
        if not r["top1_hit"]:
            conf[(r["ground_truth"], r["predicted_top1"])] += 1
    for (gt, pred), c in conf.most_common(15):
        print(f"  {gt:22} -> {str(pred):22} x{c}")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dataset", type=Path, default=DEFAULT_DATASET)
    ap.add_argument("--per-category", type=int, default=50,
                    help="max files sampled per folder (0 = all)")
    ap.add_argument("--seed", type=int, default=42)
    ap.add_argument("--out", type=Path, default=None,
                    help="write per-file JSON results here")
    args = ap.parse_args()

    if not args.dataset.is_dir():
        print(f"Dataset not found: {args.dataset}", file=sys.stderr)
        sys.exit(1)

    per_cat = args.per_category or 10**9
    files = collect_files(args.dataset, per_cat, args.seed)
    if not files:
        print("No .sub files found under mapped folders.", file=sys.stderr)
        sys.exit(1)

    print(f"Loading decoder + protocol DB ...")
    decoder = get_pattern_decoder()

    t0 = time.time()
    results = evaluate(files, decoder)
    dt = time.time() - t0
    print(f"Evaluated {len(files)} files in {dt:.1f}s "
          f"({dt/len(files)*1000:.0f} ms/file)\n")

    report(results)

    if args.out:
        args.out.write_text(json.dumps(results, indent=2))
        print(f"\nPer-file results -> {args.out}")


if __name__ == "__main__":
    main()
