#!/usr/bin/env python3
"""build_catalog_view — the TRIANGLE reconciler.

Joins the three corners of the catalog into one confirmation view, so we can SEE
(not hope) that our analysis of ParVagues tracks holds up:

  A — score      the track's `.tidal` (ground truth from code; tidal_score)
  C — metadata   the site's gig tracklists (bpm/style/dur + claimed ingredients;
                 www/next/content/lives/**/tracks.json) — the canonical metadata
  B — recording  the Ardour takes a track plausibly lives in (date-join via
                 take_gig_map) + whether that take has been EDA'd (audio-grounded)

For each distinct track (identity = its `.tidal` path) it computes per-EDGE
agreement so a dashboard can light cells green/amber/red/grey:

  • A present?            does the cited `.tidal` exist + parse to a score
  • A↔C sound agreement   how many of C's claimed ingredient-sounds the score
                          actually contains (precision = |C∩A|/|C|), plus the
                          symmetric jaccard and the score-only / metadata-only
                          diffs (metadata-only = suspicious / stale claim)
  • B take-link           candidate take(s) (date-join), and EDA coverage there

Everything here is corner A (local, free) ⋈ corner C (local, free); corner B is
the existing L0 metadata prior (date-join) — NOTHING in B is ear-verified yet.
EDA coverage is surfaced as a first-class RED number to drive the (freebox-gated)
catalog-wide EDA pass. Emits a validated `CatalogView` (see models.py).

    python3 build_catalog_view.py            # writes catalog_view.json + prints
"""
from __future__ import annotations
import json
import glob
import re
import sys
from datetime import date
from pathlib import Path

import tidal_score as ts
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools"))
from sample_tfidf import SPLIT, sound_vocab  # noqa: E402

HERE = Path(__file__).parent
REPO = HERE.parent.parent                       # …/Sound/Tidal
LIVES = Path("/home/pln/Work/Web/www/next/content/lives")
TAKE_MAP = HERE / "take_gig_map.md"
OUT = HERE / "catalog_view.json"
AS_OF = "2026-06-06"
FUZZY_DAYS = 3

# ingredient `type`s that are not a loaded sound source
NON_SOUND_TYPES = {"moment", "effect"}
# take `type`s that aren't a real audition candidate (no usable audio) — #56
NON_CANDIDATE_TYPES = {"empty", "sketch"}
# parse "d4 — bass" / "d10" style orbit hints from an ingredient description/code
DN_HINT = re.compile(r"\bd(\d{1,2})\b")


# ── corner C: site gig tracklists ────────────────────────────────────────────
def load_gigs():
    gigs = {}
    for f in glob.glob(str(LIVES / "**/tracks.json"), recursive=True):
        slug = str(Path(f).parent.relative_to(LIVES))
        try:
            d = json.load(open(f))
        except Exception:
            continue
        tracks = d.get("tracks") if isinstance(d, dict) else d
        dt = (d.get("date") if isinstance(d, dict) else None) or ""
        gigs[slug] = {"date": dt[:10], "tracks": tracks or []}
    return gigs


def ingredient_sounds(track, vocab):
    """Sounds the site CLAIMS this track loads, extracted from ingredient codes
    with the SAME per-block logic as the score (tidal_score `_sounds_in_block`,
    raw fallback) so A↔C compares like-for-like. Vocab-only here would drop
    non-vocab sounds the score keeps (e.g. `kick`, custom synths) and fabricate
    disagreement — the asymmetry a unit test caught."""
    claimed = []
    for ing in track.get("ingredients", []):
        if ing.get("type") in NON_SOUND_TYPES:
            continue
        code = ing.get("code", "")
        claimed += ts._sounds_in_block(code, vocab) or ts._raw_sounds(code)
    # de-dup, keep order
    seen, out = set(), []
    for t in claimed:
        if t not in seen:
            seen.add(t); out.append(t)
    return out


# ── corner B: takes ──────────────────────────────────────────────────────────
def load_takes():
    takes = []
    for line in TAKE_MAP.read_text().splitlines():
        m = re.match(r"\|\s*(\d{4}-\d{2}-\d{2})\s*\|\s*(Take\d+)\s*\|\s*([\d:]+)\s*\|"
                     r"\s*(\d+)\s*\|\s*(\w+)\s*\|\s*(.*?)\s*\|", line)
        if m:
            d, tk, dur, orb, typ, label = m.groups()
            takes.append({"date": d, "take": tk, "dur": dur, "orbits": int(orb),
                          "type": typ, "label": label})
    return takes


def parse_date(s):
    m = re.match(r"(\d{4})-(\d{2})-(\d{2})", s or "")
    return date(*map(int, m.groups())) if m else None


def takes_for_gig(gig_date, takes):
    """Takes whose date matches the gig (exact, else ±FUZZY_DAYS)."""
    gd = parse_date(gig_date)
    if not gd:
        return []
    exact = [t for t in takes if t["date"] == gig_date]
    if exact:
        return [(t, "date-exact") for t in exact]
    near = [(abs((parse_date(t["date"]) - gd).days), t) for t in takes
            if parse_date(t["date"])]
    near = [(t, f"date±{dd}d") for dd, t in near if dd <= FUZZY_DAYS]
    return near


def eda_index():
    """Which takes have audio EDA (are spectrally grounded). Detect from the
    per-take EDA artifacts on disk; today only Take89 (Montreuil) is deep-EDA'd."""
    have = {}
    for f in glob.glob(str(HERE / "**/eda_*.json"), recursive=True):
        try:
            d = json.load(open(f))
        except Exception:
            continue
        tk = d.get("take") if isinstance(d, dict) else None
        if tk:
            have[tk] = Path(f).relative_to(HERE).as_posix()
    # stemmap_take89.json is also EDA-grade grounding for Take89
    for f in glob.glob(str(HERE / "**/stemmap_take*.json"), recursive=True):
        m = re.search(r"stemmap_(take\d+)", Path(f).name.lower())
        if m:
            have.setdefault("Take" + m.group(1)[4:], Path(f).relative_to(HERE).as_posix())
    return have


def proxy_index():
    """Audition proxies present on disk, by take id → relative path. Lets the
    drawer render an <audio> ONLY when a proxy actually exists (no broken players).
    Matches `proxy_take89.mp3` / `proxy_take87.flac` style under any subdir."""
    have = {}
    for ext in ("mp3", "flac", "m4a", "ogg", "wav"):
        for f in glob.glob(str(HERE / f"**/proxy_take*.{ext}"), recursive=True):
            m = re.search(r"proxy_(take\d+)", Path(f).name.lower())
            if m:
                have.setdefault("Take" + m.group(1)[4:], Path(f).relative_to(HERE).as_posix())
    return have


# ── agreement ─────────────────────────────────────────────────────────────────
def agree(score_sounds, claimed_sounds):
    """A↔C sound agreement, with a taxonomy that never blames the metadata for a
    parser miss (the Burn-this-Book / Blue-Gold lesson):

      empty      neither side has parseable sounds
      unparsed   corner A (score) is empty — WE couldn't read it; not a conflict
      no-claim   corner C lists no parseable sound (ingredients absent/effects-only)
      agree      precision ≥ .6   (most claimed sounds are in the score)
      partial    .3 ≤ precision < .6
      conflict   0 < precision < .3  (real partial disagreement)
      divergent  precision == 0 AND both sides rich (≥2 each) → the linked `.tidal`
                 and the metadata describe DIFFERENT tracks (likely a wrong file
                 link / identity collision, e.g. Blue Gold → ccc0 ébauche), NOT a
                 claim that either source is wrong

    precision = |C∩A| / |C|  (how many claimed sounds the score actually contains).
    """
    a, c = set(score_sounds), set(claimed_sounds)
    inter = a & c
    precision = len(inter) / len(c) if c else None
    jaccard = len(inter) / len(a | c) if (a or c) else None
    if not a and not c:
        level = "empty"
    elif not a:
        level = "unparsed"          # never "conflict" — corner A is blind here
    elif not c:
        level = "no-claim"
    elif precision >= 0.6:
        level = "agree"
    elif precision >= 0.3:
        level = "partial"
    elif precision > 0:
        level = "conflict"
    elif len(a) >= 2 and len(c) >= 2:
        level = "divergent"         # wrong file link / identity collision
    else:
        level = "conflict"
    return {
        "level": level,
        "precision": round(precision, 3) if precision is not None else None,
        "jaccard": round(jaccard, 3) if jaccard is not None else None,
        "score_only": sorted(a - c),        # usually basic drums C omits — benign
        "metadata_only": sorted(c - a),     # claimed but not in score — SUSPICIOUS
        "shared": sorted(inter),
    }


def build():
    """The pipeline: A=score ⋈ C=metadata ⋈ B=recording → CatalogView dict.
    Pure (no IO beyond reading the corpus); returns the dict so tide.py and the
    test suite can drive it without a subprocess."""
    vocab, kind = sound_vocab()
    gigs, takes = load_gigs(), load_takes()
    eda = eda_index()
    proxies = proxy_index()

    # invert: track .tidal path → its appearances across gigs
    tracks: dict[str, dict] = {}
    for slug, g in gigs.items():
        for tr in g["tracks"]:
            fp = tr.get("file")
            if not fp:
                continue
            rec = tracks.setdefault(fp, {"appearances": [], "names": []})
            rec["appearances"].append((slug, g["date"], tr))
            if tr.get("name") and tr["name"] not in rec["names"]:
                rec["names"].append(tr["name"])

    rows = []
    for fp, rec in sorted(tracks.items()):
        path = REPO / fp
        a_present = path.exists()
        score = ts.orbit_sounds(path, vocab, kind) if a_present else {}
        score_sounds = [d["sound"] for d in score.values()]
        # embed the .tidal text so the drawer's ground-truth source view is load-once
        # (no per-file fetch, no relative-path fragility — #58)
        source = None
        if a_present:
            try:
                source = path.read_text(encoding="utf-8", errors="replace")
            except Exception:
                source = None

        # corner C: union claimed sounds + representative metadata + raw ingredients
        claimed, metas, gig_slugs, ingredients = [], [], [], []
        for slug, gdate, tr in rec["appearances"]:
            gig_slugs.append(slug)
            claimed += ingredient_sounds(tr, vocab)
            metas.append({"gig": slug, "date": gdate, "bpm": tr.get("bpm"),
                          "style": tr.get("style"), "dur": tr.get("duration")})
            for ing in tr.get("ingredients", []):       # ground-truth for the drawer
                ingredients.append({"type": ing.get("type", ""), "code": ing.get("code", ""),
                                    "description": ing.get("description", ""), "gig": slug})
        claimed = list(dict.fromkeys(claimed))          # de-dup, keep order

        # corner B: candidate takes via date-join across this track's gigs.
        # Drop empty/sketch take-types — they have no usable audio and only pollute
        # the candidate list (Take63 empty / Take64 sketch date-noise). (#56)
        cand_takes, edad = {}, 0
        for slug, gdate, _tr in rec["appearances"]:
            for tk, method in takes_for_gig(gdate, takes):
                key = tk["take"]
                if tk["type"].lower() in NON_CANDIDATE_TYPES:
                    continue
                if key not in cand_takes:
                    has_eda = key in eda
                    cand_takes[key] = {
                        "take": key, "type": tk["type"], "via": slug,
                        "method": method, "is_set": tk["type"].upper() == "SET",
                        "eda": eda.get(key),
                        "audio": proxies.get(key),
                    }
                    if has_eda:
                        edad += 1

        ac = agree(score_sounds, claimed)
        rows.append({
            "track": fp,
            "names": rec["names"],
            "name": rec["names"][0] if rec["names"] else Path(fp).stem,
            "gigs": sorted(set(gig_slugs)),
            "metas": metas,
            # corner A
            "score_present": a_present,
            "n_orbits": len(score),
            "score": {f"d{o}": score[o]["sound"] for o in sorted(score)},
            "score_sounds": sorted(set(score_sounds)),
            "source": source,
            # corner C
            "claimed_sounds": claimed,
            "ingredients": ingredients,         # raw code+description, for ground-truth view
            # A↔C
            "ac": ac,
            # corner B
            "takes": sorted(cand_takes.values(), key=lambda x: x["take"]),
            "n_takes": len(cand_takes),
            "n_takes_eda": edad,
            "recorded": bool(cand_takes),
        })

    # ── alias fragmentation: one human NAME → multiple .tidal files ───────────
    # (legit versions/arrangements OR a wrong link — divergent A↔C tells them apart)
    name_files: dict[str, set] = {}
    for r in rows:
        for nm in r["names"]:
            name_files.setdefault(nm.lower(), set()).add(r["track"])
    for r in rows:
        sib = set()
        for nm in r["names"]:
            sib |= name_files.get(nm.lower(), set())
        sib.discard(r["track"])
        r["alias_siblings"] = sorted(sib)        # other files sharing this name

    # ── coverage stats ───────────────────────────────────────────────────────
    n = len(rows)
    def cnt(pred): return sum(1 for r in rows if pred(r))
    def lvl(x): return cnt(lambda r: r["ac"]["level"] == x)
    stats = {
        "tracks_total": n,
        "score_present": cnt(lambda r: r["score_present"]),
        "score_parsed": cnt(lambda r: r["n_orbits"] > 0),
        "with_metadata": cnt(lambda r: r["claimed_sounds"]),
        "ac_agree": lvl("agree"),
        "ac_partial": lvl("partial"),
        "ac_conflict": lvl("conflict"),
        "ac_divergent": lvl("divergent"),
        "ac_unparsed": lvl("unparsed"),
        "ac_no_claim": lvl("no-claim"),
        "ac_empty": lvl("empty"),
        "alias_fragmented": cnt(lambda r: r["alias_siblings"]),
        "recorded": cnt(lambda r: r["recorded"]),
        "unrecorded": cnt(lambda r: not r["recorded"]),
        "with_eda": cnt(lambda r: r["n_takes_eda"] > 0),
        "takes_total": len(takes),
        "takes_with_eda": len(eda),
        "gigs_total": len(gigs),
    }

    return {
        "schema": "catalog-view v1 (triangle: A=score ⋈ C=metadata ⋈ B=recording; "
                  "B + EDA unverified)",
        "as_of": AS_OF,
        "stats": stats,
        "tracks": rows,
    }


def main():
    out = build()
    from models import CatalogView          # DRY contract: validate before writing
    CatalogView.model_validate(out)
    OUT.write_text(json.dumps(out, ensure_ascii=False, indent=1))
    rows = out["tracks"]
    print(f"✓ {OUT}")
    s = out["stats"]
    print(f"  tracks: {s['tracks_total']}  | score parsed {s['score_parsed']}  "
          f"| metadata {s['with_metadata']}")
    print(f"  A↔C: {s['ac_agree']} agree · {s['ac_partial']} partial · "
          f"{s['ac_conflict']} conflict · {s['ac_divergent']} DIVERGENT · "
          f"{s['ac_no_claim']} no-claim · {s['ac_unparsed']} unparsed")
    print(f"  alias-fragmented (name→multiple files): {s['alias_fragmented']}")
    print(f"  recording: {s['recorded']} have a candidate take · "
          f"{s['unrecorded']} unrecorded · {s['with_eda']} EDA-grounded")
    print(f"  EDA coverage: {s['takes_with_eda']}/{s['takes_total']} takes")
    div = [r for r in rows if r["ac"]["level"] == "divergent"]
    if div:
        print(f"\n  ⚠ DIVERGENT ({len(div)}) — linked .tidal ≠ metadata (wrong link?):")
        for r in div[:15]:
            sib = f"  ↔ {len(r['alias_siblings'])} other file(s) share the name" \
                if r["alias_siblings"] else ""
            print(f"    {r['name']:<26} {r['track']}{sib}")
    conflicts = [r for r in rows if r["ac"]["level"] == "conflict"]
    if conflicts:
        print(f"\n  ⚠ conflicts ({len(conflicts)}) — partial disagreement:")
        for r in conflicts[:15]:
            print(f"    {r['name']:<26} claimed∉score: "
                  f"{', '.join(r['ac']['metadata_only']) or '—'}")


if __name__ == "__main__":
    main()
