-- Enums
-- Guarded so this file is idempotent and can be re-applied on every backend boot
-- (CREATE TYPE has no IF NOT EXISTS; swallow the duplicate_object error instead).
DO $$ BEGIN
    CREATE TYPE track_state AS ENUM ('LIBRARY', 'RECOMMENDED', 'HIDDEN', 'MISSING', 'DELETED');
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
    CREATE TYPE track_source_type AS ENUM ('MANUAL', 'RECOMMENDATION');
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
    CREATE TYPE recommendation_status AS ENUM ('ACTIVE', 'RESOLVED', 'FAILED');
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
    CREATE TYPE dislike_state AS ENUM ('HIDDEN', 'WARNED', 'DELETED');
EXCEPTION WHEN duplicate_object THEN null; END $$;

-- Reduce an artist string to its PRIMARY (first-billed) artist, so that every
-- form of a collaboration maps to the same canonical identity:
--   "Artist feat. Guest", "Artist ft. Guest", "Artist x Guest",
--   "Artist & Guest", "Artist; Guest", "Artist, Guest", "Artist / Guest"
-- all map to "Artist". This is the identity used by the normalized_name /
-- normalized_artist generated columns, dedup, and the Vibe engine. The full
-- list of co-billed artists is preserved separately in the track_artists table
-- (populated by the scanner and the split-collab-artists migration); this
-- function intentionally only yields the main artist.
-- Also handles parenthesized feature forms like "(feat. X)".
CREATE OR REPLACE FUNCTION normalize_artist(artist TEXT) RETURNS TEXT AS $$
DECLARE
  result TEXT;
BEGIN
  -- 1. Strip feat/ft/vs/x feature suffixes (+ everything after them), incl.
  --    parenthesized forms like "(feat. X)" / "(Feat. X)".
  result := REGEXP_REPLACE(
    artist,
    '\s*\(?\s*([fF]eat(uring)?\.?|[fF]t\.?|[vV]s\.?|[xX])\s+.*$',
    ''
  );
  -- 2. Cut at the first collaboration separator ( ; & / , ) and keep the part
  --    before it: "$bunny, Metox" -> "$bunny", "Booker & ЗАМАЙ" -> "Booker".
  result := REGEXP_REPLACE(result, '\s*[;&/,].*$', '');
  result := BTRIM(result);
  RETURN result;
END;
$$ LANGUAGE plpgsql IMMUTABLE STRICT;

-- Tables
CREATE TABLE IF NOT EXISTS artists (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    -- Canonical display name (e.g., "P!nk" not "Pink")
    canonical_name TEXT NOT NULL,
    -- Sort name for alphabetical ordering (e.g., "Pink, P!" or "Beatles, The")
    sort_name TEXT,
    -- MusicBrainz ID: the canonical identity. NULL for artists not in MB.
    -- Unique when present so we never create duplicate MB artists.
    mbid UUID UNIQUE,
    -- Fallback: legacy name used before MBID resolution.
    -- Not unique; multiple rows can have same name before dedup.
    name TEXT NOT NULL,
    normalized_name TEXT
        GENERATED ALWAYS AS (normalize_artist(name)) STORED,
    discogs_id TEXT,
    image_path TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name);

-- Artist aliases: alternative names for the same artist.
-- Enables matching "Pink", "P!nk", "PINK" to the same artist_id.
CREATE TABLE IF NOT EXISTS artist_aliases (
    artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
    alias TEXT NOT NULL,
    alias_normalized TEXT GENERATED ALWAYS AS (normalize_artist(alias)) STORED,
    PRIMARY KEY (artist_id, alias)
);

CREATE INDEX IF NOT EXISTS idx_artist_aliases_normalized ON artist_aliases(alias_normalized);

-- Artist lookup cache: avoids repeated MusicBrainz queries.
-- Keyed by normalized artist name.
CREATE TABLE IF NOT EXISTS artist_lookup_cache (
    normalized_name TEXT PRIMARY KEY,
    mbid UUID,
    canonical_name TEXT,
    sort_name TEXT,
    fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    -- Track if we looked up and found nothing (negative cache)
    not_found BOOLEAN DEFAULT FALSE
);

CREATE TABLE IF NOT EXISTS albums (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    year INTEGER,
    -- Full release date from MusicBrainz first-release-date (YYYY-MM-DD).
    -- More precise than `year` (which can also come from Discogs). Used as a
    -- deterministic tiebreaker in album dedup (earlier release = keeper).
    release_date DATE,
    artwork_id TEXT,
    mbid UUID UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(artist_id, title)
);

CREATE INDEX IF NOT EXISTS idx_albums_mbid ON albums(mbid) WHERE mbid IS NOT NULL;

CREATE TABLE IF NOT EXISTS tracks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    path TEXT UNIQUE NOT NULL,
    hash TEXT NOT NULL,
    title TEXT NOT NULL,
    artist TEXT NOT NULL,
    album_id UUID REFERENCES albums(id) ON DELETE CASCADE,
    duration REAL NOT NULL,
    state track_state DEFAULT 'LIBRARY',
    play_count INTEGER DEFAULT 0,
    skip_count INTEGER DEFAULT 0,
    dislike_count INTEGER DEFAULT 0,
    last_played_at TIMESTAMP,
    mtime REAL,
    source_type track_source_type DEFAULT 'MANUAL',
    quarantined_at TIMESTAMP,
    deleted_at TIMESTAMP,
    release_date DATE
);

-- Add normalized columns as generated columns for existing databases where the
-- CREATE TABLE IF NOT EXISTS above was a no-op (column didn't exist before).

-- `albums.release_date` was introduced after the original albums table. It
-- must exist before the track synchronisation trigger below is compiled.
DO $$ BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_name = 'albums' AND column_name = 'release_date'
    ) THEN
        ALTER TABLE albums ADD COLUMN release_date DATE;
    END IF;
END $$;

DO $$ BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_name = 'tracks' AND column_name = 'release_date'
    ) THEN
        ALTER TABLE tracks ADD COLUMN release_date DATE;
    END IF;
END $$;

CREATE INDEX IF NOT EXISTS idx_tracks_release_date ON tracks (release_date) WHERE release_date IS NOT NULL;

-- An album's MusicBrainz first-release-date is the canonical date for every
-- track on that album. Keep the denormalised tracks.release_date column in
-- lockstep so date-based recommendation queries stay indexable and never have
-- to guess which of two conflicting values is authoritative.
--
-- The trigger intentionally also overwrites a direct tracks.release_date
-- update. There is no per-recording release-date provenance in this schema;
-- accepting an independent track value would silently make novelty results
-- depend on write order. A future per-recording metadata source needs its own
-- canonical/provenance column before changing this rule.
CREATE OR REPLACE FUNCTION sync_track_release_date_from_album()
RETURNS TRIGGER AS $$
BEGIN
    SELECT release_date INTO NEW.release_date
    FROM albums
    WHERE id = NEW.album_id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_tracks_sync_release_date ON tracks;
CREATE TRIGGER trg_tracks_sync_release_date
BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks
FOR EACH ROW EXECUTE FUNCTION sync_track_release_date_from_album();

CREATE OR REPLACE FUNCTION propagate_album_release_date_to_tracks()
RETURNS TRIGGER AS $$
BEGIN
    UPDATE tracks
    SET release_date = NEW.release_date
    WHERE album_id = NEW.id
      AND release_date IS DISTINCT FROM NEW.release_date;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_albums_propagate_release_date ON albums;
CREATE TRIGGER trg_albums_propagate_release_date
AFTER UPDATE OF release_date ON albums
FOR EACH ROW
WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)
EXECUTE FUNCTION propagate_album_release_date_to_tracks();
DO $$ BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_name = 'tracks' AND column_name = 'normalized_artist'
    ) THEN
        ALTER TABLE tracks ADD COLUMN normalized_artist TEXT
            GENERATED ALWAYS AS (normalize_artist(artist)) STORED;
    END IF;
END $$;

DO $$ BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_name = 'artists' AND column_name = 'normalized_name'
    ) THEN
        ALTER TABLE artists ADD COLUMN normalized_name TEXT
            GENERATED ALWAYS AS (normalize_artist(name)) STORED;
    END IF;
END $$;

CREATE INDEX IF NOT EXISTS idx_tracks_hash ON tracks(hash);
CREATE INDEX IF NOT EXISTS idx_tracks_normalized_artist ON tracks(normalized_artist);
CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name);

-- Tracks integrity issues found by the periodic integrity-sweep worker.
-- Created with IF NOT EXISTS so the worker can self-provision this table at
-- runtime on databases that predate this schema change (see IntegrityService.ensureSchema).
CREATE TABLE IF NOT EXISTS track_integrity_issues (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
    issue_type TEXT NOT NULL,            -- 'CORRUPT_METADATA' | 'MISSING_FILE'
    status TEXT NOT NULL DEFAULT 'OPEN', -- 'OPEN' | 'FIXED' | 'NEEDS_REVIEW'
    details TEXT,                        -- human-readable: e.g. the corrupted value
    detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    resolved_at TIMESTAMP,
    UNIQUE(track_id, issue_type)
);

-- Last.fm artist similarity, feeds Vibe discovery. Populated by the worker's
-- `artist_similarity` job (see EnrichmentService.refreshArtistSimilarity).
-- Created with IF NOT EXISTS so the worker can self-provision this table at
-- runtime on databases that predate this schema change.
--
-- mbid storage decision: artists.mbid is typed UUID and MusicBrainz MBIDs are
-- themselves UUID-format strings, so the worker stores the artist MBID directly
-- in the existing artists.mbid column (guarded with a UUID-shape check before
-- the write). No mbid_text column was needed.
CREATE TABLE IF NOT EXISTS artist_similar (
    artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
    similar_name TEXT NOT NULL,
    match REAL NOT NULL DEFAULT 0,
    fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (artist_id, similar_name)
);

CREATE TABLE IF NOT EXISTS genre (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL UNIQUE,
    parent_id UUID REFERENCES genre(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS track_genre (
    track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
    genre_id UUID REFERENCES genre(id) ON DELETE CASCADE,
    weight DECIMAL NOT NULL DEFAULT 1.0,
    PRIMARY KEY (track_id, genre_id)
);

CREATE TABLE IF NOT EXISTS dislikes (
    track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
    disliked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    -- When the track entered HIDDEN. The pre-deletion grace period
    -- (MUZICK_HARD_DELETE_GRACE_DAYS, default 7 days) is measured from here —
    -- separate from, and much longer than, grace_hours below. Recorded
    -- explicitly rather than inferred from disliked_at, because an irreversible
    -- clock must not rest on 'HIDDEN' merely being the state DEFAULT.
    hidden_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    warned_at TIMESTAMP,
    deleted_at TIMESTAMP,
    -- HIDDEN -> WARNED delay (the ntfy warning), NOT the deletion delay.
    grace_hours INTEGER DEFAULT 48,
    state dislike_state DEFAULT 'HIDDEN'
);

CREATE TABLE IF NOT EXISTS favorites (
    user_id UUID NOT NULL,
    track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS recommendation_batch (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    status recommendation_status DEFAULT 'ACTIVE',
    last_interaction_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    seed_track_id UUID REFERENCES tracks(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS recommendation_batch_track (
    batch_id UUID REFERENCES recommendation_batch(id) ON DELETE CASCADE,
    track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
    PRIMARY KEY (batch_id, track_id)
);

-- Play history: one row per playback event. Feeds the Vibe engine's
-- "Success-Driven Center" rule (a completed play moves the active batch's center)
-- and the feedback learning loop. Created with IF NOT EXISTS so it can be
-- self-provisioned on databases that predate this schema change.
-- A play is a historical fact: it stays true after the file is gone. Hence
-- ON DELETE SET NULL rather than CASCADE, plus the denormalised title/artist so
-- a row whose track was hard-deleted is still readable. listened_ms is what was
-- actually heard, recorded at play time; tracks.duration is not a substitute
-- because it disappears with the track.
CREATE TABLE IF NOT EXISTS play_history (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
    batch_id UUID REFERENCES recommendation_batch(id) ON DELETE SET NULL,
    completed BOOLEAN NOT NULL DEFAULT false,
    listened_ms INTEGER,
    track_title TEXT,
    track_artist TEXT,
    played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_play_history_user_played_at ON play_history(user_id, played_at DESC);

-- Feedback: explicit user signals consumed by the Vibe scorer's feedback
-- learning loop. action is one of 'promoted' | 'disliked' | 'skipped' | 'deleted_permanent'.
CREATE TABLE IF NOT EXISTS feedback (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    -- SET NULL, not CASCADE: feedback is an audit log and must outlive the
    -- track. With CASCADE the 'deleted_permanent' row written by
    -- hardDeleteTrack() deletes itself as soon as the track row goes.
    track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
    action TEXT NOT NULL,
    -- Denormalised track identity, written on 'deleted_permanent' rows only.
    -- track_id goes NULL when the track is deleted, so without these the audit
    -- row survives but no longer says what was destroyed.
    track_path TEXT,
    track_title TEXT,
    track_artist TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_feedback_user_action ON feedback(user_id, action);

-- Files whose DB row has been deleted but whose bytes are still on disk.
-- The cleanup sweep commits its transaction BEFORE unlinking, so this table is
-- the durable record of that window: a row is inserted with the deletion and
-- removed only once the unlink succeeds. A row that lingers means the unlink
-- failed (EROFS/EACCES/...) or the worker died mid-sweep; the next sweep retries
-- it. No FK to tracks — the track is already gone.
CREATE TABLE IF NOT EXISTS pending_file_deletions (
    path TEXT PRIMARY KEY,
    track_id UUID,
    track_title TEXT,
    track_artist TEXT,
    requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    attempts INTEGER NOT NULL DEFAULT 0,
    last_attempt_at TIMESTAMP,
    last_error TEXT
);

CREATE INDEX IF NOT EXISTS idx_pending_file_deletions_requested
  ON pending_file_deletions(requested_at);

CREATE TABLE IF NOT EXISTS track_audio_features (
    track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
    bpm REAL,
    key TEXT,
    energy REAL,
    danceability REAL,
    -- Reserved legacy columns: Vibe readers tolerate these as NULL. The local
    -- analyzer intentionally does not claim to infer them.
    valence REAL,
    acousticness REAL,
    instrumentalness REAL,
    liveness REAL,
    valence_score REAL,
    tempo REAL,
    analysis_version SMALLINT NOT NULL DEFAULT 0,
    source_hash TEXT,
    analyzed_at TIMESTAMPTZ,
    CONSTRAINT track_audio_features_bpm_range CHECK (bpm IS NULL OR (bpm >= 30 AND bpm <= 300)),
    CONSTRAINT track_audio_features_energy_range CHECK (energy IS NULL OR (energy >= 0 AND energy <= 1)),
    CONSTRAINT track_audio_features_danceability_range CHECK (danceability IS NULL OR (danceability >= 0 AND danceability <= 1))
);

CREATE TABLE IF NOT EXISTS track_lyrics (
    track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
    lyrics_text TEXT,
    provider TEXT,
    language VARCHAR(10),
    synced_lyrics JSONB
);

-- Enrichment settings: toggles that control which external-enrichment steps the
-- worker runs. Default all to true (best-effort, credentials-permitting).
CREATE TABLE IF NOT EXISTS settings (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO settings (key, value) VALUES ('enrich_metadata', 'true') ON CONFLICT (key) DO NOTHING;
-- Artist portraits are independent from structural metadata. Keeping this
-- separate lets an operator re-fill artwork without re-running MusicBrainz
-- canonicalisation across the entire library.
INSERT INTO settings (key, value) VALUES ('enrich_artist_images', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_cover_art', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_genres', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_lyrics', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_artist_similarity', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_audio_analysis', 'false') ON CONFLICT (key) DO NOTHING;


-- ==========================================================================
-- v2 Recommendation Engine — System A: Knowledge Graph (probabilistic fusion)
-- ==========================================================================

-- Source trust weights. One row per source of claims. Tunable.
CREATE TABLE IF NOT EXISTS source_trust (
    key         TEXT PRIMARY KEY,
    trust       REAL NOT NULL CHECK (trust >= 0 AND trust <= 1.0),
    description TEXT NOT NULL
);

INSERT INTO source_trust (key, trust, description) VALUES
    ('curated',           1.00, 'Manual / human-curated claim. Never decayed.'),
    ('mb',                0.90, 'MusicBrainz structural spine. High-trust seed; not infallible.'),
    ('cover_art_archive', 0.85, 'Cover Art Archive, MB-backed.'),
    ('discogs',           0.75, 'Discogs release/artist credits.'),
    ('lastfm',            0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'),
    -- A first-party record of how a candidate was reached through the graph.
    -- This is deliberately separate from Last.fm/MB: it describes the
    -- traversal strategy, not a claim made by an external provider.
    ('graph_exploration', 0.40, 'Muzick graph traversal provenance for discovery candidates.'),
    ('listener_behavior', 0.40, 'Derived from observed play patterns. User-keyed.'),
    ('tag',               0.30, 'File-tag-derived via scanner heuristic. Lowest trust.')
ON CONFLICT (key) DO NOTHING;

-- Claims: the spine of the graph. One row per (subject, predicate, object, source).
-- user_id is NULL for objective claims (MB, Discogs, tags), non-NULL for
-- listener-behavior-derived claims.
CREATE TABLE IF NOT EXISTS claims (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id             UUID,
    subject_type        TEXT NOT NULL,
    subject_id          UUID NOT NULL,
    predicate           TEXT NOT NULL,
    object_type         TEXT NOT NULL,
    object_id           UUID NOT NULL,
    source              TEXT NOT NULL REFERENCES source_trust(key),
    confidence          REAL NOT NULL DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1.0),
    evidence_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_reinforced_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    raw                 JSONB,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    -- NULLS NOT DISTINCT (PG15+) is load-bearing: user_id is NULL for every
    -- objective claim, and with default NULLS DISTINCT semantics the
    -- ON CONFLICT clauses in upsertClaim() / MbSpineWriter never fire, so
    -- re-enrichment inserts duplicates instead of reinforcing.
    CONSTRAINT claims_edge_source_user_key
      UNIQUE NULLS NOT DISTINCT
      (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
);

CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims (subject_type, subject_id, predicate);
CREATE INDEX IF NOT EXISTS idx_claims_object  ON claims (object_type, object_id, predicate);
CREATE INDEX IF NOT EXISTS idx_claims_user    ON claims (user_id) WHERE user_id IS NOT NULL;

-- recording_mbid on tracks — the structural spine anchor for the graph
DO $$ BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_name = 'tracks' AND column_name = 'recording_mbid'
    ) THEN
        ALTER TABLE tracks ADD COLUMN recording_mbid UUID;
    END IF;
END $$;

CREATE INDEX IF NOT EXISTS idx_tracks_recording_mbid ON tracks (recording_mbid) WHERE recording_mbid IS NOT NULL;

-- ==========================================================================
-- System B: Listener Model
-- ==========================================================================

-- Evidence: every observed interaction that should influence a belief.
-- Append-only. Never edited or deleted (purge policy separate).
CREATE TABLE IF NOT EXISTS evidence (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id       UUID NOT NULL,
    entity_type   TEXT NOT NULL,
    entity_id     UUID NOT NULL,
    signal        TEXT NOT NULL,
    profile       TEXT NOT NULL,
    weight        REAL NOT NULL,
    context       JSONB,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_evidence_user_entity ON evidence (user_id, entity_type, entity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_evidence_user_profile ON evidence (user_id, profile, created_at DESC);

-- Listener beliefs: the derived state. Continuously decayed; reinforced by evidence.
CREATE TABLE IF NOT EXISTS listener_beliefs (
    user_id              UUID NOT NULL,
    profile              TEXT NOT NULL,
    entity_type          TEXT NOT NULL,
    entity_id            UUID NOT NULL,
    dimension            TEXT NOT NULL,
    value                REAL NOT NULL CHECK (value >= -1.0 AND value <= 1.0),
    confidence           REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1.0),
    evidence_count       INTEGER NOT NULL DEFAULT 0,
    last_reinforced_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_decayed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (user_id, profile, entity_type, entity_id, dimension)
);

CREATE INDEX IF NOT EXISTS idx_listener_beliefs_user_profile ON listener_beliefs (user_id, profile, entity_type, entity_id);

-- ==========================================================================
-- System D: Session Director
-- ==========================================================================

-- Per-session state; persisted across heartbeats so resumes stay coherent.
CREATE TABLE IF NOT EXISTS session_state (
    session_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id           UUID NOT NULL,
    started_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_interaction  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    context           TEXT,
    state_vector      JSONB NOT NULL DEFAULT '{}'::jsonb
);

CREATE INDEX IF NOT EXISTS idx_session_state_user ON session_state (user_id, last_interaction DESC);

-- Durable Vibe v2 session ledger. session_state remains a rebuildable cache for
-- the existing director; these tables are the authoritative record for the
-- next-generation, versioned planner.
CREATE TABLE IF NOT EXISTS vibe_sessions (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id       UUID NOT NULL,
    status        TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')),
    seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
    context       JSONB NOT NULL DEFAULT '{}'::jsonb,
    policy_version TEXT NOT NULL,
    started_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ended_at      TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
    ON vibe_sessions (user_id, last_event_at DESC);

-- Compact, derived memory for the session director. Fingerprints are a
-- deliberately lossy description of session shape (not a track list) and are
-- used only as a soft freshness signal against recent sessions.
CREATE TABLE IF NOT EXISTS vibe_session_profiles (
    session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE,
    user_id UUID NOT NULL,
    fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb,
    goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb,
    exploration_coefficient REAL NOT NULL DEFAULT 0.30
        CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1),
    discovery_radius REAL NOT NULL DEFAULT 0.38
        CHECK (discovery_radius >= 0 AND discovery_radius <= 1),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated
    ON vibe_session_profiles (user_id, updated_at DESC);

CREATE TABLE IF NOT EXISTS vibe_events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    client_event_id UUID,
    session_id      UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
    user_id         UUID NOT NULL,
    track_id        UUID REFERENCES tracks(id) ON DELETE SET NULL,
    type            TEXT NOT NULL,
    occurred_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    position_ms     INTEGER,
    duration_ms     INTEGER,
    payload         JSONB NOT NULL DEFAULT '{}'::jsonb,
    UNIQUE (session_id, client_event_id)
);

CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred
    ON vibe_events (session_id, occurred_at);
CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred
    ON vibe_events (user_id, occurred_at DESC);

-- Exactly-once projection marker for material Vibe feedback. The immutable
-- event remains authoritative; this row proves its effect was applied to the
-- listener inputs without double-counting an idempotent client retry.
CREATE TABLE IF NOT EXISTS vibe_event_projections (
    event_id     UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
    projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Separate from listener-belief projection because exploration is session
-- state. It lets a failed post-event replan safely retry the exact same
-- adaptation without counting the feedback twice.
CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections (
    event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
    projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS vibe_plan_versions (
    id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    session_id         UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
    version            INTEGER NOT NULL CHECK (version > 0),
    reason             TEXT NOT NULL,
    state_snapshot     JSONB NOT NULL,
    objective_snapshot JSONB NOT NULL,
    created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (session_id, version)
);

CREATE TABLE IF NOT EXISTS vibe_plan_items (
    plan_version_id  UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
    ordinal          INTEGER NOT NULL CHECK (ordinal >= 0),
    track_id         UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
    slot_role        TEXT,
    candidate_source TEXT NOT NULL,
    score            REAL NOT NULL,
    score_breakdown  JSONB NOT NULL,
    explanation      JSONB NOT NULL,
    committed        BOOLEAN NOT NULL DEFAULT false,
    PRIMARY KEY (plan_version_id, ordinal),
    UNIQUE (plan_version_id, track_id)
);

-- Diversity budgets for the session director's planner.
CREATE TABLE IF NOT EXISTS diversity_budgets (
    user_id      UUID NOT NULL,
    dimension    TEXT NOT NULL,
    budget_share REAL NOT NULL,
    horizon_min  INTEGER NOT NULL,
    PRIMARY KEY (user_id, dimension, horizon_min)
);

-- Adaptive minimum-distance repetition rules.
CREATE TABLE IF NOT EXISTS repetition_rules (
    user_id      UUID NOT NULL,
    dimension    TEXT NOT NULL,
    min_distance INTEGER NOT NULL,
    PRIMARY KEY (user_id, dimension)
);

-- ==========================================================================
-- System E: Acquisition Pipeline
-- ==========================================================================

-- Discovery candidates: tracks not yet in the library, identified by E.
CREATE TABLE IF NOT EXISTS discovery_candidates (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    source          TEXT NOT NULL,
    external_id     TEXT NOT NULL,
    title           TEXT,
    artist_credit   JSONB,
    notes           JSONB,
    first_seen_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_eval_at    TIMESTAMPTZ,
    status          TEXT NOT NULL DEFAULT 'candidate',
    -- Filled only after a worker scanned a successfully acquired file. Keeping
    -- this FK makes candidate -> local-track provenance auditable and avoids
    -- guessing from filename metadata later.
    acquired_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
    acquired_at       TIMESTAMPTZ,
    acquisition_attempts INTEGER NOT NULL DEFAULT 0,
    last_error        TEXT,
    UNIQUE (source, external_id)
);

CREATE INDEX IF NOT EXISTS idx_discovery_candidates_status
  ON discovery_candidates (status, first_seen_at);

-- Probation status for acquired tracks.
DO $$ BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_name = 'tracks' AND column_name = 'probation_status'
    ) THEN
        ALTER TABLE tracks ADD COLUMN probation_status TEXT
            DEFAULT 'retained'
            CHECK (probation_status IN ('probation', 'retained', 'retired'));
    END IF;
END $$;

DO $$ BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_name = 'tracks' AND column_name = 'probation_entered_at'
    ) THEN
        ALTER TABLE tracks ADD COLUMN probation_entered_at TIMESTAMPTZ;
    END IF;
END $$;

CREATE INDEX IF NOT EXISTS idx_tracks_probation ON tracks (probation_status) WHERE probation_status = 'probation';

-- ==========================================================================
-- Phase 4 (preserved): Image candidates
-- ==========================================================================

CREATE TABLE IF NOT EXISTS image_candidates (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    entity_type TEXT NOT NULL CHECK (entity_type IN ('artist', 'album')),
    entity_id UUID NOT NULL,
    source TEXT NOT NULL,
    url TEXT,
    width INTEGER,
    verified BOOLEAN DEFAULT FALSE,
    fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (entity_type, entity_id, source)
);

CREATE INDEX IF NOT EXISTS idx_image_candidates_entity ON image_candidates (entity_type, entity_id);
