"""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

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]


# ── 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


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)
    # 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)
