"""Shared pydantic models for the TidalCycles → tracks tooling (L'Armada).

One typed, JSON-serializable vocabulary across the fleet: provenance, the
locate-matrix cell, the master edit-decision list (EDL), and the sample TF-IDF
report. Every produced artifact is a model → validated on read, dumped on write.

Design rules encoded here:
  - **Metadata carries provenance** (value + source TYPE + locator + as_of), so any
    fact is traceable and corrections propagate. (feedback_metadata_provenance)
  - **The ear is the oracle**: a match isn't `verified` until source=user/ear.
  - **Never invent gig metadata**: gig is a site slug (a pointer), not a fact.

JSON: `m.model_dump_json(indent=1)` to write, `Model.model_validate_json(txt)` to
read. Enums serialize as their string value.
"""
from __future__ import annotations

import re
from datetime import date
from enum import Enum
from typing import Optional

from pydantic import BaseModel, ConfigDict, Field


# ── provenance ────────────────────────────────────────────────────────────────
class Source(str, Enum):
    user = "user"        # PLN said so (typed by hand)
    ear = "ear"          # PLN's ear on audio — the oracle for identity/mastering
    file = "file"        # read from a canonical file (site tracklist, .tidal…)
    web = "web"          # scraped/fetched online
    derived = "derived"  # computed (locate xcorr, tfidf, date-join…)


class Provenance(BaseModel):
    """Where a value came from, so it can be trusted and corrected."""
    source: Source
    locator: str = ""                 # path, URL, "xcorr z=29", "datejoin±3d"…
    as_of: date
    note: str = ""


# ── locate-matrix ─────────────────────────────────────────────────────────────
class MatchState(str, Enum):
    absent = "absent"                          # looked, track not in this take
    candidate = "candidate"                    # metadata says it should be here
    candidate_ambiguous = "candidate-ambiguous"  # single-track take, which track?
    suspect = "suspect"                        # audio hit but low-z / conflicting
    verified = "verified"                      # ear-gated truth


class LocateSignals(BaseModel):
    metadata: bool = False
    join: Optional[str] = None         # "date-exact" | "date±3d" | "label"
    z: Optional[float] = None          # xcorr confidence (z ≥ 20 floor, ear still gates)
    tempo_ok: Optional[bool] = None    # setcps BPM matches
    fingerprint: Optional[float] = None  # IDF-weighted sample/orbit overlap (L3)


class LocateCell(BaseModel):
    """One (track × recording) cell of the fleet ledger."""
    track: str                          # canonical id = .tidal path (merges aliases)
    name: Optional[str] = None          # display name in this gig ("Pitbul Punk")
    tidal: Optional[str] = None
    take: str                           # Ardour take or .aup id
    gig: Optional[str] = None           # site slug — a pointer, not an audio fact
    take_type: Optional[str] = None     # track | SET | sketch
    bpm: Optional[float] = None
    offset_s: Optional[float] = None
    dur_s: Optional[float] = None
    signals: LocateSignals = Field(default_factory=LocateSignals)
    state: MatchState = MatchState.candidate
    provenance: Optional[Provenance] = None


# ── master edit-decision list (EDL) — ear-feedback → typed fixes ──────────────
class EditOp(str, Enum):
    trim_in = "trim_in"            # cut N seconds off the head
    trim_out = "trim_out"          # cut the tail at `at`
    fade_in = "fade_in"
    fade_out = "fade_out"
    crossfade = "crossfade"        # `at`→`to` overlap into next track
    gain = "gain"                  # level nudge (value = dB), e.g. kick match
    eq_highshelf = "eq_highshelf"  # tame highs (value = dB @ ~freq in note)
    investigate = "investigate"    # flagged anomaly, no op yet


class EditKind(str, Enum):
    bad_cut = "bad_cut"            # boundary bleed (also = ground-truth label!)
    laborious = "laborious"        # drags / outro too long
    harsh = "harsh"                # high-end piercing (esp. mobile)
    level = "level"                # cross-track loudness mismatch
    anomaly = "anomaly"            # dropout, weird silence
    transition = "transition"      # abrupt / needs crossfade


class EditStatus(str, Enum):
    open = "open"
    applied = "applied"
    rejected = "rejected"


class MasterEdit(BaseModel):
    """A single mastering decision, sourced from the ear, replayable + labelable.

    Each `bad_cut` is simultaneously a fix instruction AND a ground-truth label
    for the boundary/edge detectors (#25/#31): tune them until they catch these.
    """
    take: str = "Take89"
    track_no: Optional[int] = None
    name: str
    tidal: Optional[str] = None
    op: EditOp
    at: str                         # timecode "m:ss" or seconds, within the track
    to: Optional[str] = None        # end timecode for crossfade/range ops
    value: Optional[float] = None   # dB for gain/eq, seconds for fades
    kind: EditKind
    reason: str
    device: str = ""                # "WH-1000XM5+phone" — translation context
    status: EditStatus = EditStatus.open
    provenance: Provenance


class MasterEDL(BaseModel):
    take: str
    gig: Optional[str] = None
    edits: list[MasterEdit] = Field(default_factory=list)


# ── sample TF-IDF ─────────────────────────────────────────────────────────────
class SampleHit(BaseModel):
    sample: str
    score: float          # tf-idf
    df: int               # document frequency across corpus
    kind: str = "?"       # 'sample' | 'synth' — same class for fingerprinting


class TrackSignature(BaseModel):
    n_samples: int        # distinct sound sources (samples ∪ synths)
    tf: dict[str, int]
    top_tfidf: list[SampleHit] = Field(default_factory=list)


class TfidfReport(BaseModel):
    corpus: str
    n_docs: int
    vocab_size: int
    n_samples: int = 0    # distinct samples used across corpus
    n_synths: int = 0     # distinct synths used across corpus
    df: dict[str, int]
    idf: dict[str, float]
    kinds: dict[str, str] = Field(default_factory=dict)  # sound -> sample|synth
    tracks: dict[str, TrackSignature]


# ── Judge UI player data (armada/ui) — derived from the score + measured ──────
# Single source of truth: ui/src/types.gen.ts is generated from these via
# `tools/gen_types.sh` (model_json_schema → json-schema-to-typescript). Don't
# hand-edit the TS shapes; edit here and regenerate. (#42, DRY)
class Variant(str, Enum):
    stream = "stream"   # -14 LUFS streaming master
    club = "club"       # louder club master


class RoleGroup(BaseModel):
    key: str            # percs | bass | melodic | tops | atmos
    label: str
    color: str          # hex; role-family color (DESIGN.md), never hue-alone
    glyph: str


class OrbitActivity(BaseModel):
    """One orbit's lane in a take: label DERIVED from the .tidal sound, family
    VALIDATED by audio (centroid), activity = RMS dB per bin. (#40)"""
    orbit: str                      # "03" == d3 == stemmap orbit-03
    label: str                      # the ACTUAL sound on this orbit ("dr", not "hats")
    group: str                      # role-family key (RoleGroup.key)
    sound: str = ""                 # base sound name parsed from the score
    centroid: Optional[float] = None  # measured spectral centroid (Hz) — family evidence
    activity: list[float]           # RMS dB per binS bin, aligned to take t0


class MasterInfo(BaseModel):
    file: str
    I: Optional[float] = None       # integrated LUFS
    LRA: Optional[float] = None
    TP: Optional[float] = None      # true peak dBFS


class LoudTrace(BaseModel):
    trace: list[float]              # momentary LUFS per stepS
    stepS: float


class Take(BaseModel):
    id: str
    label: str
    gig: str
    date: str
    tidal: Optional[str] = None     # the score this take's labels were derived from
    dur: float
    binS: float
    masters: dict[Variant, MasterInfo]
    loud: dict[Variant, LoudTrace]
    orbits: list[OrbitActivity]


class Note(BaseModel):
    id: str
    takeId: str
    t: float                        # seconds
    text: str


class PlayerData(BaseModel):
    track: str
    calibration: str
    activeDb: float                 # "core/driving" threshold (lane is strongly on)
    litFloorDb: float = -52.0       # audible floor: orbit shows (dim) above this, so a
                                    # quiet-but-playing orbit never vanishes (#40 activation)
    roleGroups: list[RoleGroup]
    note: str = ""
    takes: list[Take]


# ── set-judge: go/no-go audition of a whole set, one pass ────────────────────
# PlayerData above answers "which of these two takes is better?". This answers
# the other question we keep asking by hand before every release: "of the N
# tracks this set split into, which ones ship?" Same evidence (orbit lanes from
# the score, activity measured from the stems), different unit of judgement —
# a LIST of tracks, each carrying a verdict and a comment.
#
# The output (DecisionSet) is the artifact: PLN's ear, captured once, in a form
# the release tooling can read. Ear-feedback is the scarce input here; losing it
# to a chat scrollback is how the same track gets re-auditioned three times.
class Verdict(str, Enum):
    pending = "pending"     # not yet heard — the honest default, never "go"
    go = "go"
    nogo = "nogo"
    maybe = "maybe"         # ships only if something changes; the comment says what


class JudgeTrack(BaseModel):
    """One track up for judgement, with everything needed to decide in one screen."""
    n: int                          # 1-indexed position in the set (the running order)
    id: str                         # stable slug — the key decisions join on
    title: str
    audio: str                      # clip URL under /audio (the head, usually)
    fullAudio: Optional[str] = None  # the whole track, when 30 s isn't enough to call it
    startS: float                   # position within the set master
    endS: float
    durS: float                     # duration of the CLIP, not of the track
    trackDurS: float                # duration of the full track
    bpm: Optional[int] = None
    section: str = ""               # the movement it belongs to ("SUNSET", "NUIT"…)
    style: str = ""
    tidal: Optional[str] = None     # the score its orbit labels were derived from
    binS: float
    clipStartS: float = 0.0         # where the clip sits inside the track (activity offset)
    # Waveform overview for the FULL track, 0..1 peak per bucket. Precomputed
    # because wavesurfer otherwise downloads and decodes the whole file before it
    # draws a single pixel — 102 MB per track here, which is a blank screen on
    # the desk and unusable over wifi on a phone. With peaks it paints at once
    # and the audio streams behind it by Range.
    peaks: list[float] = []
    orbits: list[OrbitActivity] = []
    masters: dict[Variant, MasterInfo] = {}
    samples: list[str] = []         # sample banks the score names, whole-track


class JudgeSet(BaseModel):
    gig: str                        # slug — matches content/lives/{year}/{slug}
    title: str
    date: str
    calibration: str                # how clip time maps to stem time; state it or don't ship
    activeDb: float
    litFloorDb: float = -52.0
    roleGroups: list[RoleGroup]
    note: str = ""
    variant: Variant = Variant.stream
    tracks: list[JudgeTrack]


class Decision(BaseModel):
    trackId: str
    n: int
    title: str
    verdict: Verdict = Verdict.pending
    comment: str = ""
    tS: Optional[float] = None      # playhead when the call was made, if it was made playing


class DecisionSet(BaseModel):
    """What comes back out of the SPA. Feeds the release step directly."""
    gig: str
    variant: Variant = Variant.stream
    decidedAt: str = ""             # ISO8601
    decisions: list[Decision]


# ── catalog view — the TRIANGLE: A=score ⋈ C=metadata ⋈ B=recording (#46) ─────
# Generated downstream artifact (build_catalog_view.build), validated on emit.
# A confirmation map: does the .tidal score, the site metadata, and the recordings
# agree? Lit-cell dashboard consumes this. (parsers-over-copy; tested mechanically)
class AgreeLevel(str, Enum):
    empty = "empty"            # neither side has parseable sounds
    unparsed = "unparsed"      # corner A blind — WE couldn't read it (NOT a conflict)
    no_claim = "no-claim"      # corner C lists no parseable sound
    agree = "agree"            # ≥60% of claimed sounds are in the score
    partial = "partial"        # 30–60%
    conflict = "conflict"      # 0–30% — real partial disagreement
    divergent = "divergent"    # zero overlap, both rich → wrong file link / collision


class AgreeResult(BaseModel):
    """A↔C: how well the site's claimed ingredients match the actual score."""
    level: AgreeLevel
    precision: Optional[float] = None   # |C∩A|/|C| — how true the claims are
    jaccard: Optional[float] = None
    score_only: list[str] = Field(default_factory=list)     # in score, not claimed
    metadata_only: list[str] = Field(default_factory=list)  # claimed, not in score
    shared: list[str] = Field(default_factory=list)


class TrackMeta(BaseModel):
    """Corner C facts for one gig appearance (site tracklist, a pointer)."""
    gig: str
    date: str = ""
    bpm: Optional[float] = None
    style: Optional[str] = None
    dur: Optional[float] = None


class Ingredient(BaseModel):
    """A raw corner-C ingredient (the site's claim) — ground truth for the drawer."""
    type: str = ""
    code: str = ""
    description: str = ""
    gig: str = ""


class TakeRef(BaseModel):
    """Corner B candidate: a take this track plausibly lives in (date-join, L0)."""
    take: str
    type: str                        # track | SET | sketch | empty
    via: str                         # the gig slug that joined them
    method: str                      # date-exact | date±Nd
    is_set: bool
    eda: Optional[str] = None        # path to the take's EDA artifact, if grounded
    audio: Optional[str] = None      # relative path to an audition proxy, if one exists on disk


class TrackRow(BaseModel):
    """One track (identity = its .tidal path), reconciled across the three corners."""
    track: str                       # canonical id = .tidal path
    names: list[str] = Field(default_factory=list)
    name: str
    gigs: list[str] = Field(default_factory=list)
    metas: list[TrackMeta] = Field(default_factory=list)
    # corner A — score
    score_present: bool
    n_orbits: int
    score: dict[str, str] = Field(default_factory=dict)     # {"d1":"kick",…}
    score_sounds: list[str] = Field(default_factory=list)
    source: Optional[str] = None     # embedded .tidal text → ground-truth view works load-once (no per-file fetch)
    # corner C — metadata
    claimed_sounds: list[str] = Field(default_factory=list)
    ingredients: list[Ingredient] = Field(default_factory=list)
    # A↔C
    ac: AgreeResult
    # corner B — recording
    takes: list[TakeRef] = Field(default_factory=list)
    n_takes: int
    n_takes_eda: int
    recorded: bool
    alias_siblings: list[str] = Field(default_factory=list)  # other files sharing the name


class CatalogStats(BaseModel):
    tracks_total: int
    score_present: int
    score_parsed: int
    with_metadata: int
    ac_agree: int
    ac_partial: int
    ac_conflict: int
    ac_divergent: int
    ac_unparsed: int
    ac_no_claim: int
    ac_empty: int
    alias_fragmented: int
    recorded: int
    unrecorded: int
    with_eda: int
    takes_total: int
    takes_with_eda: int
    gigs_total: int


class CatalogView(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    schema_: str = Field(alias="schema")   # 'schema' key; alias dodges BaseModel.schema
    as_of: str
    stats: CatalogStats
    tracks: list[TrackRow] = Field(default_factory=list)


# ── catalog — GENERATED rollup: derived facts ⊕ authored overlay (#51) ────────
# catalog.yaml was hand-state with no "valid as of". The catalog is now generated
# (catalog.generated.json) from catalog_view (derived) ⊕ catalog.authored.yaml
# (the only hand-maintained facts), every entry stamped. parsers-over-copy.
class CatalogEntry(BaseModel):
    id: str                                 # canonical = .tidal path
    title: str
    aka: list[str] = Field(default_factory=list)
    # derived (from catalog_view — do not hand-edit)
    sounds: list[str] = Field(default_factory=list)
    orbits: int = 0
    gigs: list[str] = Field(default_factory=list)
    takes: list[str] = Field(default_factory=list)
    recorded: bool = False
    eda_grounded: bool = False
    ac_level: AgreeLevel
    # authored (from catalog.authored.yaml overlay — may be absent)
    license: Optional[str] = None
    inspiration: list[str] = Field(default_factory=list)
    collab: list[str] = Field(default_factory=list)
    status: Optional[str] = None
    notes: Optional[str] = None
    authored_prov: Optional[Provenance] = None


class Catalog(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    schema_: str = Field(alias="schema")
    as_of: str
    n_tracks: int
    n_authored: int
    tracks: list[CatalogEntry] = Field(default_factory=list)


# ── pattern registry — phrases as things + n-gram clustering (#59) ─────────────
# Lifts the catalog from flat sound-token SETS to mini-notation PHRASES: each
# normalized phrase is "a thing" with a stable id; tracks cluster by shared
# sound-token n-grams. Generated by pattern_ngrams.build, validated on emit.
class PatternScope(str, Enum):
    unique = "unique"        # one occurrence, one track
    repeated = "repeated"    # ≥2× in ONE track → a duplicated section
    shared = "shared"        # in ≥2 tracks → a shared idiom


class PatternOccurrence(BaseModel):
    track: str                       # canonical .tidal path
    name: str = ""
    orbit: Optional[int] = None      # dN it sits on, if inside a block
    count: int = 1                   # times the phrase appears in that track
    raw: str = ""                    # a representative verbatim form (pre-normalize)


class PatternEntry(BaseModel):
    """One normalized mini-notation phrase — 'a thing' we can name and track."""
    id: str                          # stable: 'p'+sha1(norm)[:6]
    norm: str                        # normalized phrase (indices/whitespace canonical)
    n_tokens: int
    n_tracks: int
    n_total: int
    scope: PatternScope
    occurrences: list[PatternOccurrence] = Field(default_factory=list)


class TrackNeighbor(BaseModel):
    track: str
    name: str = ""
    similarity: float                # Jaccard of significant sound-token n-gram sets
    shared_patterns: list[str] = Field(default_factory=list)  # shared PatternEntry ids


class TrackPatternSig(BaseModel):
    track: str
    name: str = ""
    n_phrases: int                   # distinct phrases (≥MIN_PHRASE_TOKENS) in this track
    n_shared: int                    # how many of them are shared with another track
    neighbors: list[TrackNeighbor] = Field(default_factory=list)


class PatternRegistry(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    schema_: str = Field(alias="schema")
    as_of: str
    n_tracks: int
    n_patterns: int
    n_shared: int
    n_repeated: int
    patterns: list[PatternEntry] = Field(default_factory=list)
    tracks: list[TrackPatternSig] = Field(default_factory=list)


# ── THE ONTOLOGY: the fleet's shared visual vocabulary (concept → color + glyph) ─
# Principle 3 (DESIGN.md): "One language across the fleet." Every L'Armada surface
# (triangle.html, armada/ui, future tools) must paint the SAME concept the SAME way.
# So the vocabulary lives HERE, beside the data models — one machine-readable source
# of truth. tools/gen_tokens.py derives tokens.json + tokens.css from it; the design
# RATIONALE stays human-readable in armada/DESIGN.md (the two must agree).
#
# Never-Hue-Alone Rule: every term carries a glyph + label, so meaning survives in
# greyscale and is colour-blind safe. Colours are the DESIGN.md fleet palette.
class Lifecycle(str, Enum):
    idea = "idea"
    wip = "wip"
    ready = "ready"
    released = "released"
    blocked = "blocked"
    archived = "archived"


class OntologyTerm(BaseModel):
    """One named concept in the fleet vocabulary, with its canonical paint."""
    key: str
    label: str
    color: str               # hex — DESIGN.md fleet palette
    glyph: str               # greyscale-safe reinforcement (never hue alone)
    description: str = ""


# Role families — an orbit's measured register group (DESIGN.md §2 Secondary).
# The MAP is per-track and derived from analysis (Measured-Register Rule); these are
# only the family identities + paint, shared by every tool that shows an orbit.
ROLE_FAMILIES = [
    OntologyTerm(key="percs",   label="Percs",   color="#ff8c00", glyph="▰", description="kicks, snares, hats, percussion — transient, warm"),
    OntologyTerm(key="bass",    label="Bass",    color="#7c5cff", glyph="▂", description="sub, acid, reese/wobble, bass-spine — low, deep"),
    OntologyTerm(key="melodic", label="Melodic", color="#36c5f0", glyph="♪", description="leads, keys, riffs, high plucks — bright"),
    OntologyTerm(key="tops",    label="Tops",    color="#2dd4bf", glyph="≈", description="breaks, chops, texture loops — busy, mid-high"),
    OntologyTerm(key="atmos",   label="Atmos",   color="#8a93a6", glyph="◌", description="pads, drones, ambient, fx — diffuse, background"),
    OntologyTerm(key="vox",     label="Vox",     color="#ff3d7b", glyph="◍", description="vocal samples and chops — human, forward"),
]

# Lifecycle status — catalog/triage/release state (DESIGN.md §2 Tertiary).
LIFECYCLE_TERMS = [
    OntologyTerm(key="idea",     label="Idea",     color="#737373", glyph="·", description="raw / sketch"),
    OntologyTerm(key="wip",      label="WIP",      color="#e0a82e", glyph="◐", description="in progress"),
    OntologyTerm(key="ready",    label="Ready",    color="#5bc091", glyph="●", description="mastered, ship-ready"),
    OntologyTerm(key="released", label="Released", color="#d900ff", glyph="★", description="out in the world"),
    OntologyTerm(key="blocked",  label="Blocked",  color="#ff5252", glyph="✕", description="needs a fix before it moves"),
    OntologyTerm(key="archived", label="Archived", color="#4a4a4a", glyph="▢", description="parked / superseded"),
]

# A↔C agreement (the triangle) — REUSES the lifecycle paint so a green/amber/red means
# the same thing fleet-wide. unparsed is FAINT GREY, never red: a parser miss is not a
# conflict (the cardinal rule). divergent gets a bespoke violet (wrong file link).
AGREE_TERMS = [
    OntologyTerm(key="agree",     label="Agree",     color="#5bc091", glyph="●", description="≥60% of claimed sounds are in the score"),
    OntologyTerm(key="partial",   label="Partial",   color="#e0a82e", glyph="◐", description="30–60% — some claims unmatched"),
    OntologyTerm(key="conflict",  label="Conflict",  color="#ff5252", glyph="✕", description="0–30% — real partial disagreement"),
    OntologyTerm(key="divergent", label="Divergent", color="#b06cff", glyph="⤬", description="zero overlap, both rich → wrong file link"),
    OntologyTerm(key="no-claim",  label="No-claim",  color="#737373", glyph="·", description="metadata lists no parseable sound"),
    OntologyTerm(key="unparsed",  label="Unparsed",  color="#6a6a70", glyph="?", description="WE couldn't read the score — not a conflict"),
    OntologyTerm(key="empty",     label="Empty",     color="#4a4a4a", glyph="∅", description="neither side has parseable sounds"),
]

# Neutral surfaces + brand (DESIGN.md §2 Neutral / Primary) — the rest of the palette.
NEUTRALS = {
    "surface": "#0a0a0a", "raised": "#111111", "overlay": "#171717",
    "hairline": "#ffffff1f", "ink": "#e8e8ea", "mute": "#9a9aa0", "faint": "#6a6a70",
}
BRAND = {"magenta": "#d900ff", "magenta-low": "#a700d1", "magenta-deep": "#8900b3"}

# ── the scalable color LANGUAGE: families (hue) × variations (shades) ──────────
# To paint 50-100 concepts coherently we need families with systematic variations,
# not 100 hand-picked hexes. A family anchors an OKLCH hue; gen_tokens derives N
# distinguishable shades by walking lightness DOWN while fanning the hue ±SHADE_FAN
# degrees (PLN's choice) — so members read as one family yet stay separable, and the
# lightness ladder keeps them greyscale-safe. Each concept = (family, shade index)
# + its glyph + label (never hue alone). This is the SAMPLE-WORLD axis: a coherent
# AUTHORED model of sample kinds — deliberately SEPARATE from the analysis/measured
# register axis (ROLE_FAMILIES), which carries its own labels and colors.
SHADE_COUNT = 8            # shades generated per family (≥ members we expect in one)
SHADE_L = (0.80, 0.52)     # OKLCH lightness ladder: lightest → darkest
SHADE_CHROMA = 0.15        # base chroma (slightly eased at the ends by gen_tokens)
SHADE_FAN = 8.0            # ± degrees of hue fan across the ladder


class ColorFamily(BaseModel):
    """A hue-anchored family; gen_tokens expands it into SHADE_COUNT shades."""
    key: str
    label: str
    glyph: str
    hue: float                       # OKLCH hue anchor (degrees)
    chroma: float = SHADE_CHROMA
    match: list[str] = Field(default_factory=list)  # lexical cues → classify a sample name
    description: str = ""


# Sample-world families (authored coherent model). `match` keywords let any surface
# map a sample NAME to its family lexically — legitimate here: this is the sample's
# conceptual identity, NOT a measured register claim (that's ROLE_FAMILIES, by audio).
SAMPLE_FAMILIES = [
    ColorFamily(key="kick",  label="Kick",   glyph="●", hue=25,  match=["kick", "kik", "bd", "808bd", "909", "bassdrum"]),
    ColorFamily(key="snare", label="Snare",  glyph="◆", hue=50,  match=["snare", "sn", "sd", "clap", "claps", "cp", "rim", "rs"]),
    ColorFamily(key="perc",  label="Perc",   glyph="▴", hue=80,  match=["perc", "conga", "bongo", "tom", "clave", "shaker", "tabla", "cowbell"]),
    ColorFamily(key="hat",   label="Hat",    glyph="✦", hue=110, match=["hat", "hh", "ho", "oh", "ch", "hihat", "cymbal", "cym", "ride", "crash"]),
    ColorFamily(key="break", label="Break",  glyph="≈", hue=150, match=["break", "amen", "breaks165", "fbreak"]),
    ColorFamily(key="pad",   label="Pad",    glyph="◌", hue=180, match=["pad", "drone", "choir", "string", "ambient", "atmos", "atm", "airport", "trance"]),
    ColorFamily(key="keys",  label="Keys",   glyph="♬", hue=205, match=["key", "keys", "piano", "rhodes", "epiano", "organ", "fpiano", "qstab", "cbow", "cpluck", "clav", "marimba", "mandolin", "guitar", "organ", "forgan"]),
    ColorFamily(key="lead",  label="Lead",   glyph="♪", hue=230, match=["lead", "arp", "pluck", "stab", "blip", "saw", "square", "brass", "sax", "horn", "trump", "tromb"]),
    ColorFamily(key="synth", label="Synth",  glyph="◈", hue=260, match=["synth", "syn", "commodore", "chip", "fm", "moog", "reese", "giorgio", "electro", "cs80", "vec"]),
    ColorFamily(key="bass",  label="Bass",   glyph="▂", hue=290, match=["bass", "sub", "808", "acid", "wobble", "meth_bass", "ramplem", "bassline", "fbass"]),
    ColorFamily(key="fx",    label="FX",     glyph="✺", hue=320, match=["fx", "riser", "risers", "sweep", "noise", "impact", "downlifter", "uplifter", "glitch", "vinyl", "foley"]),
    ColorFamily(key="vox",   label="Vox",    glyph="◍", hue=355, match=["vox", "vocal", "voc", "acap", "speech", "voice", "movie", "dialog", "dialogs"]),
]

# Drum-machine name SUFFIXES (h2ogmhh, …) and strong embedded tokens, for names that
# don't lead with their family cue. Order matters: these run after the cue loop.
_DM_SUFFIX = {"hh": "hat", "oh": "hat", "ch": "hat", "sn": "snare", "sd": "snare",
              "cp": "snare", "bd": "kick", "cy": "hat"}
_STRONG_CONTAINS = [("kick", "kick"), ("snare", "snare"), ("clap", "snare"),
                    ("bass", "bass"), ("hat", "hat"), ("guitar", "keys"),
                    ("piano", "keys"), ("rhodes", "keys"), ("vocal", "vox"),
                    ("voice", "vox"), ("dialog", "vox")]

def classify_sample_family(name: str):
    """Canonical sample-name → family key (or None). The SSOT classifier every surface
    should use (DRY). It classifies ONLY by instrument tokens the NAME actually encodes
    (`vec1_snare`→snare, `moogBass`→bass) — a deliberately conservative sample-world
    claim, NOT a measured-register one. A bare folder/kit/source name (`jazz`, `gretsch`,
    `house`) carries no reliable instrument signal — a folder may be one family, a
    heterogeneous kit indexed by `:n`, or a demucs grab from some track — so it returns
    None. Resolving those is an analysis job (per-sample/index), never a name guess."""
    s = name.lower()
    toks = [t for t in re.split(r"[_\-0-9]+", s) if t]
    for f in SAMPLE_FAMILIES:
        for m in f.match:
            # exact name, exact token, or a ≥3-char cue prefixing a token (plurals:
            # pads→pad, claps→clap). NO bare s.startswith(m): a 2-char cue like "cp"
            # must not prefix-match "cpluck"→snare or "808"→"808hc"→bass. Short cues
            # fire only as a whole token; ambiguous names fall through to None (→ CLAP).
            if s == m or m in toks or \
               (len(m) >= 3 and any(t.startswith(m) for t in toks)):
                return f.key
    # drum-machine suffixes only on DM-style names (h2ogmhh, tr909sn) — not "shiloh"
    if re.search(r"gm|dm|tr\d|rytm|\dmt", s) or re.search(r"\d(hh|oh|ch|sn|sd|cp|bd|cy)$", s):
        for suf, fam in _DM_SUFFIX.items():
            if s.endswith(suf):
                return fam
    for sub, fam in _STRONG_CONTAINS:              # gtkick, fbass, weird_dialogs, …
        if sub in s:
            return fam
    return None


# ── style label normalization (corner C metadata is hand-typed & fragmented) ──
STYLE_ALIASES = {
    "nu-jazz": "nujazz", "nujazz": "nujazz", "breakbeat": "breaks", "breaks": "breaks",
    "chip": "chiptune", "chiptune": "chiptune", "hip-hop": "hiphop", "hip": "hiphop",
    "acid-techno": "acid", "punk-dnb": "dnb", "lofi": "lofi", "lo-fi": "lofi",
}


def norm_style(s: str) -> str:
    """Canonicalize a hand-typed style label. Unknown labels pass through lowercased."""
    if not s:
        return s
    k = s.strip().lower()
    return STYLE_ALIASES.get(k, k)

# The whole vocabulary, for gen_tokens (and anyone who needs the fleet paint).
ONTOLOGY = {
    "role": ROLE_FAMILIES,
    "lifecycle": LIFECYCLE_TERMS,
    "agree": AGREE_TERMS,
}


# ── design tokens artifact (generated by tools/gen_tokens.py) ──────────────────
# The machine-readable fleet palette every surface loads. tokens.css for CSS-var
# consumers (armada/ui), tokens.json for runtime consumers (triangle.html fetches
# it and overrides its :root). Validated against this model on emit (DRY contract).
class TokenFamily(BaseModel):
    key: str
    label: str
    glyph: str
    base: str                        # the family's representative hex
    shades: list[str] = Field(default_factory=list)   # light → dark
    match: list[str] = Field(default_factory=list)


class DesignTokens(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    schema_: str = Field(alias="schema")
    as_of: str
    neutrals: dict[str, str]
    brand: dict[str, str]
    role: list[OntologyTerm] = Field(default_factory=list)
    lifecycle: list[OntologyTerm] = Field(default_factory=list)
    agree: list[OntologyTerm] = Field(default_factory=list)
    sample: list[TokenFamily] = Field(default_factory=list)


# ── online presence / distribution (the "where to hear it" SSOT) ──────────────
# Today the site hardcodes these across 7+ components (footer, MusicSection,
# AlbumCarousel, AsSeenAt, Layout, SocialCTA, StreamingFeeds). This is the single
# typed source: artist-level profiles + per-release links, joinable to the catalog
# (track id = .tidal path) and gigs (site slug). The site can later consume the
# generated presence.json to retire the duplication — but it CHOOSES when (we keep
# the data flow one-directional and optional). Per-track links are modelled as a
# capability and populated only where a real direct URL exists (Bandcamp track
# pages, YouTube videos) — never fabricated. Every link carries provenance
# (feedback_metadata_provenance); build_presence stamps a default where omitted.
class Platform(str, Enum):
    bandcamp = "bandcamp"
    spotify = "spotify"
    soundcloud = "soundcloud"
    youtube = "youtube"
    deezer = "deezer"
    apple_music = "apple_music"
    instagram = "instagram"


class LinkKind(str, Enum):
    profile = "profile"      # artist-level identity page
    release = "release"      # album / EP / single landing
    track = "track"          # a single track's direct page
    playlist = "playlist"    # a curated/auto playlist
    video = "video"          # a specific video
    embed = "embed"          # a player-embed URL (iframe src)


class ReleaseKind(str, Enum):
    album = "album"
    ep = "ep"
    single = "single"
    live = "live"            # a live-set release (joins a gig slug)


class PresenceLink(BaseModel):
    """One URL on one platform, with provenance. The atom of the presence graph."""
    platform: Platform
    url: str
    kind: LinkKind = LinkKind.profile
    label: str = ""                    # display label ("Spotify", "Live @ CosmicFest")
    ext_id: str = ""                   # platform-native id (spotify album id, sc user id)
    embed_url: str = ""                # optional iframe/player-embed variant
    prov: Optional[Provenance] = None


class ReleaseTrack(BaseModel):
    """A track as it appears on a release, with any per-platform DIRECT links.
    `track` is the canonical catalog id (.tidal path) when known — the join key."""
    title: str
    track: Optional[str] = None        # canonical catalog id (.tidal path)
    position: Optional[int] = None
    links: list[PresenceLink] = Field(default_factory=list)


class Release(BaseModel):
    """An album/EP/single/live release and its per-platform links."""
    slug: str                          # stable id (matches the bandcamp slug where one exists)
    title: str
    kind: ReleaseKind = ReleaseKind.album
    gig: Optional[str] = None          # site gig slug if this is a live-set release
    year: Optional[int] = None
    links: list[PresenceLink] = Field(default_factory=list)     # release-level
    tracks: list[ReleaseTrack] = Field(default_factory=list)


class Presence(BaseModel):
    """The whole distribution truth: artist profiles + releases. Generated artifact
    presence.json (built from presence.authored.yaml, validated on emit)."""
    model_config = ConfigDict(populate_by_name=True)
    schema_: str = Field("parvagues online presence", alias="schema")
    artist: str = "ParVagues"
    as_of: str
    profiles: list[PresenceLink] = Field(default_factory=list)  # artist-level identities
    releases: list[Release] = Field(default_factory=list)


# ── per-take EDA (corner-B grounding; build_eda.py emits eda_{take}.json) ──────
# The "required first step of mastering": measure each orbit's LEVEL, REGISTER and
# TIME-ACTIVITY from the LOCAL Ardour stems — never infer a sound's role from its
# name (feedback_mastering_eda: meth_bass is a wobble, but its register is decided
# by measured centroid, not the word "bass"). Stem files present ≠ real audio, so the
# EDA flags silent orbits / empty takes (the Take63/64 'export but no audio' case).
# eda_index() auto-discovers eda_*.json → triangle corner-B coverage climbs. Each
# file validated against TakeEDA on emit (parsers-over-copy, [[feedback_parsers_over_copy]]).
class OrbitEDA(BaseModel):
    orbit: int                          # == dN
    role: Optional[str] = None          # generic ROLE label (fallback, NOT authoritative)
    active_pct: float = 0.0             # % of take duration above the lit floor (−52 dB)
    peak_db: float = -120.0
    rms_db: float = -120.0
    centroid: Optional[float] = None    # Hz of the loudest window (None if silent)
    family: Optional[str] = None        # audio_lens.classify_family (measurement-aware)
    bands: dict[str, float] = Field(default_factory=dict)   # band-energy % of the window
    silent: bool = False                # near-silent orbit (empty export / unused)


class TakeEDA(BaseModel):
    """Audio-grounded EDA for one take. The corner-B truth audio_lens measures."""
    model_config = ConfigDict(populate_by_name=True)
    schema_: str = Field("per-take EDA", alias="schema")
    take: str
    gig: Optional[str] = None           # site slug (a pointer, never an audio fact)
    dur_s: float = 0.0
    n_orbits: int = 0                   # orbits with a stem file present
    n_active: int = 0                   # orbits that are not silent
    peak_db: float = -120.0             # loudest orbit (take-level proxy)
    empty: bool = False                 # whole take near-silent (sketch / empty export)
    orbits: list[OrbitEDA] = Field(default_factory=list)
    provenance: Optional[Provenance] = None


class EDAStatus(BaseModel):
    """Stage-0 reachability ledger: per take, can we EDA it and from what?
    reach ∈ {local-stems | local-proxy | freebox-stems | none}. Drives the fetch
    manifest (seek-MINIMIZING) and shows what coverage is one `build_eda all` away."""
    model_config = ConfigDict(populate_by_name=True)
    schema_: str = Field("EDA reachability ledger", alias="schema")
    as_of: str
    n_takes: int = 0
    n_local: int = 0                    # takes reachable from local stems (no fetch)
    n_grounded: int = 0                 # takes with an eda_*.json already emitted
    takes: list[dict] = Field(default_factory=list)   # {take, reach, n_orbits, grounded}
