// ---------------------------------------------------------------------------
// Migrations registry
// Add new entries at the END. Never edit or remove existing entries.
// Convention for id: "YYYYMMDD_short_description"
// ---------------------------------------------------------------------------
/** One forward-only, individually atomic schema change, recorded in `schema_migrations`. */
export interface Migration {
  id: string;
  sql: string;
}

export const MIGRATIONS: Migration[] = [
  {
    id: '20260608_track_artists',
    sql: `
      CREATE TABLE IF NOT EXISTS track_artists (
        track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
        artist_id UUID REFERENCES artists(id) ON DELETE CASCADE,
        role TEXT NOT NULL DEFAULT 'main',
        PRIMARY KEY (track_id, artist_id, role)
      );
      CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id);

      -- Backfill main artist from albums for tracks not yet in track_artists
      INSERT INTO track_artists (track_id, artist_id, role)
      SELECT t.id, al.artist_id, 'main'
      FROM tracks t
      JOIN albums al ON al.id = t.album_id
      WHERE NOT EXISTS (
        SELECT 1 FROM track_artists ta WHERE ta.track_id = t.id
      );
    `,
  },
  {
    id: '20260608_clear_lastfm_placeholder_images',
    sql: `
      UPDATE artists SET image_path = NULL
      WHERE image_path LIKE '%2a96cbd8b46e442fc41c2b86b821562f%';
    `,
  },
  {
    // normalize_artist() was extended (schema.sql) to also split collaboration
    // separators ( ; & / ) — not just commas/feat. STORED generated columns are
    // NOT recomputed when the function definition changes, so force a recompute
    // by touching the base column of every dependent row. ensureSchema() (which
    // installs the new function) runs before migrations, so the new definition
    // is already active here.
    id: '20260612_recompute_normalized_artist',
    sql: `
      UPDATE artists SET name = name;
      UPDATE tracks SET artist = artist;
    `,
  },
  {
    // The name-based Wikimedia Commons image fallback (now removed from the
    // enrichment chain) frequently attached the wrong photo. Clear those rows so
    // they fall back to the placeholder / a better source. Verified Wikidata
    // images (fetched via MBID, step 3) also live on wikimedia.org but only on
    // artists that HAVE an mbid, so restricting to mbid IS NULL spares them.
    id: '20260612_clear_namebased_wikimedia_images',
    sql: `
      UPDATE artists SET image_path = NULL
      WHERE mbid IS NULL AND image_path LIKE '%wikimedia.org%';
    `,
  },
  {
    // claim_fusion materialised view + compatibility views for v2 graph.
    // Depends on claims, source_trust tables which are created by schema.sql
    // (run before migrations). The MV resolves truth at read time as a weighted
    // vote across claims per the fusion formula in spec §A.4.
    id: '20260707_claim_fusion',
    sql: `
      CREATE OR REPLACE VIEW claim_fusion AS
      SELECT
        c.subject_type,
        c.subject_id,
        c.predicate,
        c.object_type,
        c.object_id,
        COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id,
        SUM(
          st.trust * c.confidence *
          GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0)
        ) AS fused_value,
        COUNT(*) AS claim_count,
        MAX(c.last_reinforced_at) AS last_reinforced_at
      FROM claims c
      JOIN source_trust st ON st.key = c.source
      GROUP BY c.subject_type, c.subject_id, c.predicate, c.object_type, c.object_id, c.user_id;

      -- Compatibility view: track → artist credits via fusion
      CREATE OR REPLACE VIEW track_artists_v2 AS
      SELECT t.id AS track_id,
             a.id AS artist_id,
             a.name AS artist_name,
             CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role,
             cf.fused_value AS confidence
      FROM tracks t
      JOIN claim_fusion cf
        ON cf.subject_type = 'track' AND cf.subject_id = t.id
        AND cf.predicate IN ('credited_main_on', 'featured_on')
        AND cf.object_type = 'artist'
      JOIN artists a ON a.id = cf.object_id;

      -- Compatibility view: album → artist credits via fusion
      CREATE OR REPLACE VIEW album_artists_v2 AS
      SELECT al.id AS album_id,
             a.id AS artist_id,
             a.name AS artist_name,
             CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role,
             cf.fused_value AS confidence
      FROM albums al
      JOIN claim_fusion cf
        ON cf.subject_type = 'album' AND cf.subject_id = al.id
        AND cf.predicate IN ('credited_main_on_album', 'featured_on_album')
        AND cf.object_type = 'artist'
      JOIN artists a ON a.id = cf.object_id;

    `,
  },
  {
    // Backfill existing data into the claims graph:
    //   1. track_artists → credited_main_on / featured_on claims (source=tag)
    //   2. artist_similar → same_scene_as claims (source=lastfm, confidence=match)
    // This makes the graph immediately usable without waiting for re-enrichment.
    id: '20260707_backfill_claims',
    sql: `
      -- 1. Populate claims from track_artists (tag-derived)
      INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at)
      SELECT
        'track' AS subject_type,
        ta.track_id AS subject_id,
        CASE WHEN ta.role = 'main' THEN 'credited_main_on' ELSE 'featured_on' END AS predicate,
        'artist' AS object_type,
        ta.artist_id AS object_id,
        'tag' AS source,
        1.0 AS confidence,
        NOW() AS evidence_at
      FROM track_artists ta
      ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING;

      -- 2. Populate claims from artist_similar (Last.fm-derived)
      INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at)
      SELECT
        'artist' AS subject_type,
        ar.id AS subject_id,
        'same_scene_as' AS predicate,
        'artist' AS object_type,
        similar_ar.id AS object_id,
        'lastfm' AS source,
        LEAST(asim.match, 1.0) AS confidence,
        COALESCE(asim.fetched_at, NOW()) AS evidence_at
      FROM artist_similar asim
      JOIN artists ar ON ar.id = asim.artist_id
      -- Resolve similar_name to an artist row so object_id is a real entity
      JOIN artists similar_ar ON similar_ar.normalized_name = normalize_artist(asim.similar_name)
      ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING;

      -- 3. Also write claims with source='lastfm' for similar_name that didn't
      --    resolve to an artist row (store as 'artist' object_type with name in raw)
      INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at, raw)
      SELECT
        'artist' AS subject_type,
        ar.id AS subject_id,
        'same_scene_as' AS predicate,
        'artist_name' AS object_type,
        gen_random_uuid() AS object_id,
        'lastfm' AS source,
        LEAST(asim.match, 1.0) AS confidence,
        COALESCE(asim.fetched_at, NOW()) AS evidence_at,
        jsonb_build_object('similar_name', asim.similar_name)
      FROM artist_similar asim
      JOIN artists ar ON ar.id = asim.artist_id
      WHERE NOT EXISTS (
        SELECT 1 FROM artists a WHERE a.normalized_name = normalize_artist(asim.similar_name)
      )
      ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING;
    `,
  },
  {
    id: '20260708_materialize_claim_fusion',
    sql: `
      -- Drop old view + dependent views
      DROP VIEW IF EXISTS claim_fusion CASCADE;
      DROP VIEW IF EXISTS track_artists_v2 CASCADE;
      DROP VIEW IF EXISTS album_artists_v2 CASCADE;

      -- Create materialized view (same query as old view)
      CREATE MATERIALIZED VIEW IF NOT EXISTS claim_fusion AS
      SELECT
        c.subject_type,
        c.subject_id,
        c.predicate,
        c.object_type,
        c.object_id,
        COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id,
        SUM(
          st.trust * c.confidence *
          GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0)
        ) AS fused_value,
        COUNT(*) AS claim_count,
        MAX(c.last_reinforced_at) AS last_reinforced_at
      FROM claims c
      JOIN source_trust st ON st.key = c.source
      GROUP BY c.subject_type, c.subject_id, c.predicate, c.object_type, c.object_id, c.user_id;

      -- Unique index on the MV
      CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_fusion_pk ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'));

      -- Recreate compatibility views (now reading from MV)
      CREATE OR REPLACE VIEW track_artists_v2 AS
      SELECT t.id AS track_id,
             a.id AS artist_id,
             a.name AS artist_name,
             CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role,
             cf.fused_value AS confidence
      FROM tracks t
      JOIN claim_fusion cf
        ON cf.subject_type = 'track' AND cf.subject_id = t.id
        AND cf.predicate IN ('credited_main_on', 'featured_on')
        AND cf.object_type = 'artist'
      JOIN artists a ON a.id = cf.object_id;

      CREATE OR REPLACE VIEW album_artists_v2 AS
      SELECT al.id AS album_id,
             a.id AS artist_id,
             a.name AS artist_name,
             CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role,
             cf.fused_value AS confidence
      FROM albums al
      JOIN claim_fusion cf
        ON cf.subject_type = 'album' AND cf.subject_id = al.id
        AND cf.predicate IN ('credited_main_on_album', 'featured_on_album')
        AND cf.object_type = 'artist'
      JOIN artists a ON a.id = cf.object_id;

      -- Refresh function for the MV
      CREATE OR REPLACE FUNCTION refresh_claim_fusion() RETURNS void AS $$
      BEGIN
        REFRESH MATERIALIZED VIEW CONCURRENTLY claim_fusion;
      END;
      $$ LANGUAGE plpgsql;

      -- Trigger function that notifies on claims changes
      CREATE OR REPLACE FUNCTION notify_claim_fusion_change() RETURNS trigger AS $$
      BEGIN
        NOTIFY claim_fusion_changed;
        RETURN NULL;
      END;
      $$ LANGUAGE plpgsql;

      -- Trigger on claims table
      DROP TRIGGER IF EXISTS trg_claim_fusion_refresh ON claims;
      CREATE TRIGGER trg_claim_fusion_refresh AFTER INSERT OR UPDATE OR DELETE ON claims FOR EACH STATEMENT EXECUTE FUNCTION notify_claim_fusion_change();
    `,
  },
  {
    id: '20260708_fix_claim_fusion_index',
    sql: `
      -- Drop the expression-based unique index that blocks CONCURRENTLY refresh.
      -- The MV's user_id column is already COALESCE'd (non-null) from the SELECT,
      -- so we can use the plain column name instead.
      DROP INDEX IF EXISTS idx_claim_fusion_pk;
      CREATE UNIQUE INDEX idx_claim_fusion_pk
        ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, user_id);
    `,
  },
  {
    id: '20260709_artists_mbid_unique',
    sql: `
      -- Replace the non-unique partial index on artists.mbid with a unique one,
      -- so ON CONFLICT (mbid) works in MbSpineWriter.resolveArtist(). The partial
      -- predicate (WHERE mbid IS NOT NULL) allows multiple artists with no MBID.
      DROP INDEX IF EXISTS idx_artists_mbid;
      CREATE UNIQUE INDEX IF NOT EXISTS artists_mbid_unique
        ON artists (mbid) WHERE mbid IS NOT NULL;
    `,
  },
  {
    id: '20260717_claim_fusion_group_by_coalesce',
    sql: `
      -- Fix duplicate-key failures in REFRESH MATERIALIZED VIEW CONCURRENTLY.
      -- The MV SELECTs COALESCE(user_id, zero-uuid) but GROUPed BY the raw
      -- user_id, so a global enrichment claim (user_id NULL) and a default-user
      -- behavior claim (user_id = zero-uuid, e.g. listener_behavior same_scene_as)
      -- for the same edge fell into separate groups yet collapsed to the same
      -- output key → two rows violating idx_claim_fusion_pk. Group by the same
      -- COALESCE'd expression so they fuse into one row.
      DROP MATERIALIZED VIEW IF EXISTS claim_fusion CASCADE;

      CREATE MATERIALIZED VIEW claim_fusion AS
      SELECT
        c.subject_type,
        c.subject_id,
        c.predicate,
        c.object_type,
        c.object_id,
        COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id,
        SUM(
          st.trust * c.confidence *
          GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0)
        ) AS fused_value,
        COUNT(*) AS claim_count,
        MAX(c.last_reinforced_at) AS last_reinforced_at
      FROM claims c
      JOIN source_trust st ON st.key = c.source
      GROUP BY c.subject_type, c.subject_id, c.predicate, c.object_type, c.object_id,
               COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid);

      CREATE UNIQUE INDEX idx_claim_fusion_pk
        ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, user_id);

      CREATE OR REPLACE VIEW track_artists_v2 AS
      SELECT t.id AS track_id,
             a.id AS artist_id,
             a.name AS artist_name,
             CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role,
             cf.fused_value AS confidence
      FROM tracks t
      JOIN claim_fusion cf
        ON cf.subject_type = 'track' AND cf.subject_id = t.id
        AND cf.predicate IN ('credited_main_on', 'featured_on')
        AND cf.object_type = 'artist'
      JOIN artists a ON a.id = cf.object_id;

      CREATE OR REPLACE VIEW album_artists_v2 AS
      SELECT al.id AS album_id,
             a.id AS artist_id,
             a.name AS artist_name,
             CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role,
             cf.fused_value AS confidence
      FROM albums al
      JOIN claim_fusion cf
        ON cf.subject_type = 'album' AND cf.subject_id = al.id
        AND cf.predicate IN ('credited_main_on_album', 'featured_on_album')
        AND cf.object_type = 'artist'
      JOIN artists a ON a.id = cf.object_id;
    `,
  },
  {
    id: '20260730_claims_dedup_nulls_not_distinct',
    sql: `
      -- The claims uniqueness constraint was declared as a plain
      -- UNIQUE (subject_type, subject_id, predicate, object_type, object_id,
      -- source, user_id). Every objective claim (MB, Discogs, tags) has
      -- user_id IS NULL, and Postgres treats NULLs as distinct, so none of the
      -- ON CONFLICT clauses in upsertClaim() / MbSpineWriter ever fired:
      -- re-enrichment inserted a fresh duplicate row every time instead of
      -- reinforcing. claim_fusion is SUM(trust * confidence * recency), so a
      -- duplicated edge carried N times its intended weight.
      --
      -- Two phases, in this order (the constraint cannot be added while
      -- duplicates exist):
      --   1. collapse each duplicate group into its most recently reinforced
      --      row, carrying forward MAX(last_reinforced_at) / MAX(evidence_at) /
      --      MAX(confidence) so reinforcement recency is not lost;
      --   2. replace the constraint with a NULLS NOT DISTINCT version (PG15+).

      -- Phase 1: dedup.
      CREATE TEMP TABLE claims_dedup ON COMMIT DROP AS
      SELECT
        id,
        ROW_NUMBER() OVER ordered AS rn,
        -- These MUST use the unordered window: an ORDER BY in the window spec
        -- makes the default frame "UNBOUNDED PRECEDING TO CURRENT ROW", turning
        -- MAX() into a running maximum rather than a per-group one.
        MAX(last_reinforced_at) OVER grp AS max_last_reinforced_at,
        MAX(evidence_at)        OVER grp AS max_evidence_at,
        MAX(confidence)         OVER grp AS max_confidence
      FROM claims
      WINDOW
        grp AS (
          PARTITION BY subject_type, subject_id, predicate, object_type, object_id,
                       source,
                       COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid)
        ),
        ordered AS (
          grp ORDER BY last_reinforced_at DESC, evidence_at DESC, id
        );

      -- Keeper of each group absorbs the group's best values.
      UPDATE claims c
      SET last_reinforced_at = d.max_last_reinforced_at,
          evidence_at        = d.max_evidence_at,
          confidence         = d.max_confidence
      FROM claims_dedup d
      WHERE c.id = d.id
        AND d.rn = 1;

      DELETE FROM claims c
      USING claims_dedup d
      WHERE c.id = d.id
        AND d.rn > 1;

      -- Phase 2: replace the constraint. The live DB has drifted from
      -- schema.sql, so find the existing constraint by its definition rather
      -- than assuming Postgres' auto-generated name.
      DO $mig$
      DECLARE
        cname TEXT;
      BEGIN
        FOR cname IN
          SELECT con.conname
          FROM pg_constraint con
          WHERE con.conrelid = 'claims'::regclass
            AND con.contype = 'u'
            AND pg_get_constraintdef(con.oid) LIKE '%subject_type%'
            AND pg_get_constraintdef(con.oid) NOT LIKE '%NULLS NOT DISTINCT%'
        LOOP
          EXECUTE format('ALTER TABLE claims DROP CONSTRAINT %I', cname);
        END LOOP;

        IF NOT EXISTS (
          SELECT 1 FROM pg_constraint
          WHERE conrelid = 'claims'::regclass AND conname = 'claims_edge_source_user_key'
        ) THEN
          ALTER TABLE claims ADD CONSTRAINT claims_edge_source_user_key
            UNIQUE NULLS NOT DISTINCT
            (subject_type, subject_id, predicate, object_type, object_id, source, user_id);
        END IF;
      END $mig$;
    `,
  },
  {
    id: '20260730_feedback_track_id_set_null',
    sql: `
      -- feedback is an audit log, but feedback.track_id was
      -- REFERENCES tracks(id) ON DELETE CASCADE. hardDeleteTrack() /
      -- permanentlyDeleteTrack() insert a 'deleted_permanent' row and then
      -- delete the track, so the audit row deleted itself — which is exactly
      -- why the live feedback table contains zero 'deleted_permanent' rows.
      -- Switch to ON DELETE SET NULL so audit rows outlive their track. Nothing
      -- reads feedback.track_id expecting non-null (there are no SELECTs against
      -- it at all; the only other reference is the dedup merge in
      -- mergeTracks(), which rewrites track_id to the survivor).
      ALTER TABLE feedback ALTER COLUMN track_id DROP NOT NULL;

      DO $mig$
      DECLARE
        cname TEXT;
      BEGIN
        FOR cname IN
          SELECT con.conname
          FROM pg_constraint con
          WHERE con.conrelid = 'feedback'::regclass
            AND con.contype = 'f'
            AND con.confrelid = 'tracks'::regclass
            AND con.confdeltype <> 'n'   -- 'n' = SET NULL; anything else is wrong
        LOOP
          EXECUTE format('ALTER TABLE feedback DROP CONSTRAINT %I', cname);
        END LOOP;

        IF NOT EXISTS (
          SELECT 1 FROM pg_constraint
          WHERE conrelid = 'feedback'::regclass
            AND contype = 'f'
            AND confrelid = 'tracks'::regclass
        ) THEN
          ALTER TABLE feedback
            ADD CONSTRAINT feedback_track_id_fkey
            FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL;
        END IF;
      END $mig$;
    `,
  },
  {
    // Sorts after 20260730_claims_dedup_nulls_not_distinct and
    // 20260730_feedback_track_id_set_null (append-only registry; 'h' > 'f' > 'c').
    id: '20260730_hard_delete_audit_trail',
    sql: `
      -- Real hard deletion of disliked files (invariant §C). Three pieces:
      --
      -- 1. Denormalised identity on the audit row. 20260730_feedback_track_id_set_null
      --    made feedback.track_id ON DELETE SET NULL so the 'deleted_permanent'
      --    audit row outlives the track — but the surviving row no longer says
      --    WHICH track was destroyed. Under real (irreversible) deletion that row
      --    is the only forensic record, so copy the identifying fields into it.
      ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_path TEXT;
      ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_title TEXT;
      ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_artist TEXT;

      -- 2. An explicit "entered HIDDEN" timestamp. The pre-deletion grace period
      --    (7 days, MUZICK_HARD_DELETE_GRACE_DAYS) is measured from it. Until now
      --    the only nearby column was disliked_at, which lines up with HIDDEN
      --    entry solely because 'HIDDEN' happens to be the state DEFAULT and
      --    restoreDislike() deletes the row outright. That is far too incidental a
      --    basis for an irreversible clock, so record it directly.
      --    Added without a DEFAULT first: a DEFAULT would backfill existing rows
      --    with now(), resetting every in-flight dislike's clock.
      ALTER TABLE dislikes ADD COLUMN IF NOT EXISTS hidden_at TIMESTAMP;
      UPDATE dislikes SET hidden_at = disliked_at WHERE hidden_at IS NULL;
      ALTER TABLE dislikes ALTER COLUMN hidden_at SET DEFAULT CURRENT_TIMESTAMP;

      -- 3. A durable marker for files whose DB row is already gone but whose
      --    bytes are not. The sweep commits the transaction FIRST and unlinks
      --    afterwards, so this row is what makes the window crash-safe and an
      --    unlink failure (EROFS, EACCES, EBUSY) recorded rather than swallowed.
      --    Deliberately no FK to tracks: the track row no longer exists.
      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);
    `,
  },
  {
    // System E originally wrote graph_exploration claims without registering it
    // in source_trust, so the FK rejected every graph walk. It also lacked a
    // durable link from an acquired candidate to the track the scanner created.
    id: '20260801_acquisition_pipeline',
    sql: `
      INSERT INTO source_trust (key, trust, description)
      VALUES ('graph_exploration', 0.40,
              'Muzick graph traversal provenance for discovery candidates.')
      ON CONFLICT (key) DO NOTHING;

      ALTER TABLE discovery_candidates
        ADD COLUMN IF NOT EXISTS acquired_track_id UUID
          REFERENCES tracks(id) ON DELETE SET NULL;
      ALTER TABLE discovery_candidates
        ADD COLUMN IF NOT EXISTS acquired_at TIMESTAMPTZ;
      ALTER TABLE discovery_candidates
        ADD COLUMN IF NOT EXISTS acquisition_attempts INTEGER NOT NULL DEFAULT 0;
      ALTER TABLE discovery_candidates
        ADD COLUMN IF NOT EXISTS last_error TEXT;
      CREATE INDEX IF NOT EXISTS idx_discovery_candidates_status
        ON discovery_candidates (status, first_seen_at);
    `,
  },
  {
    // Separate portrait fetching from structural metadata so an operator can
    // re-enrich missing artist images without implicitly enabling MB writes.
    id: '20260801_artist_image_enrichment_toggle',
    sql: `
      INSERT INTO settings (key, value)
      VALUES ('enrich_artist_images', 'false')
      ON CONFLICT (key) DO NOTHING;
    `,
  },
  {
    // Audio analysis v1 stored an arbitrary linear energy scale, which made
    // nearly every mastered track look maximally energetic. Track the analysis
    // contract and source hash so bounded background jobs can safely refresh
    // stale results without repeatedly decoding unchanged files.
    id: '20260801_audio_analysis_v2',
    sql: `
      ALTER TABLE track_audio_features
        ADD COLUMN IF NOT EXISTS analysis_version SMALLINT NOT NULL DEFAULT 0;
      ALTER TABLE track_audio_features
        ADD COLUMN IF NOT EXISTS source_hash TEXT;
      ALTER TABLE track_audio_features
        ADD COLUMN IF NOT EXISTS analyzed_at TIMESTAMPTZ;

      DO $mig$
      BEGIN
        IF NOT EXISTS (
          SELECT 1 FROM pg_constraint
          WHERE conrelid = 'track_audio_features'::regclass
            AND conname = 'track_audio_features_bpm_range'
        ) THEN
          ALTER TABLE track_audio_features
            ADD CONSTRAINT track_audio_features_bpm_range
            CHECK (bpm IS NULL OR (bpm >= 30 AND bpm <= 300)) NOT VALID;
        END IF;
        IF NOT EXISTS (
          SELECT 1 FROM pg_constraint
          WHERE conrelid = 'track_audio_features'::regclass
            AND conname = 'track_audio_features_energy_range'
        ) THEN
          ALTER TABLE track_audio_features
            ADD CONSTRAINT track_audio_features_energy_range
            CHECK (energy IS NULL OR (energy >= 0 AND energy <= 1)) NOT VALID;
        END IF;
        IF NOT EXISTS (
          SELECT 1 FROM pg_constraint
          WHERE conrelid = 'track_audio_features'::regclass
            AND conname = 'track_audio_features_danceability_range'
        ) THEN
          ALTER TABLE track_audio_features
            ADD CONSTRAINT track_audio_features_danceability_range
            CHECK (danceability IS NULL OR (danceability >= 0 AND danceability <= 1)) NOT VALID;
        END IF;
      END $mig$;
    `,
  },
  {
    // tracks.release_date is a denormalised copy of the canonical
    // MusicBrainz release-group date on albums. Before this migration the
    // worker populated albums only, leaving every historical track invisible
    // to the novelty generator. The backfill and triggers make the canonical
    // album value win deterministically for both old and future rows.
    id: '20260801_track_release_dates_from_albums',
    sql: `
      -- The worker used to create this column lazily, but migration execution
      -- must not rely on worker startup order.
      ALTER TABLE albums ADD COLUMN IF NOT EXISTS release_date DATE;

      -- Backfill only mismatches. IS DISTINCT FROM is null-safe and makes the
      -- statement safe to rerun manually for diagnostics/recovery.
      UPDATE tracks AS t
      SET release_date = al.release_date
      FROM albums AS al
      WHERE t.album_id = al.id
        AND t.release_date IS DISTINCT FROM al.release_date;

      CREATE OR REPLACE FUNCTION sync_track_release_date_from_album()
      RETURNS TRIGGER AS $mig$
      BEGIN
        SELECT release_date INTO NEW.release_date
        FROM albums
        WHERE id = NEW.album_id;
        RETURN NEW;
      END;
      $mig$ 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 $mig$
      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;
      $mig$ 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();
    `,
  },
  {
    // Vibe v2 needs an immutable event ledger and revisioned plans. The
    // legacy session_state table remains in place as a derived-state cache so
    // existing v2 endpoints can migrate independently.
    id: '20260801_vibe_session_persistence',
    sql: `
      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);

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

      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)
      );
    `,
  },
  {
    // A material Vibe event is projected into the legacy listener inputs in
    // the same transaction as its ledger write. This marker makes that bridge
    // auditable and exactly-once even when a client retries an event id.
    id: '20260801_vibe_event_projections',
    sql: `
      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()
      );
    `,
  },
  {
    // Session-specific exploration, goals, and deliberately lossy session
    // fingerprints are derived from the immutable Vibe ledger. Keeping them
    // separate from listener_beliefs prevents a transient session from
    // rewriting permanent taste.
    id: '20260802_vibe_context_memory_exploration',
    sql: `
      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_session_feedback_projections (
        event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
        projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
      );
    `,
  },
  {
    // The profile table was introduced after durable sessions. Backfill every
    // pre-existing session before feedback can claim its exactly-once marker;
    // newly created sessions receive their context-derived initial goals in
    // createVibeSession's transaction.
    id: '20260802_vibe_session_profile_backfill',
    sql: `
      INSERT INTO vibe_session_profiles (session_id, user_id)
      SELECT id, user_id FROM vibe_sessions
      ON CONFLICT (session_id) DO NOTHING;
    `,
  },
  {
    // Two external candidate strategies, kept as separate source_trust keys so
    // meta-learning can compare their retention independently: "artists you
    // already play just released something" is a much stronger prior than
    // "Last.fm thinks this sounds similar", and the trust values say so.
    id: '20260806_external_discovery_sources',
    sql: `
      INSERT INTO source_trust (key, trust, description) VALUES
        ('new_release', 0.60,
         'New release by an artist already played from the local library.'),
        ('similar_recommendation', 0.45,
         'External similarity (Last.fm) seeded from local play history.')
      ON CONFLICT (key) DO NOTHING;
    `,
  },
  {
    // play_history is the only durable record of what was listened to and when,
    // and it had two holes that only show up when you try to read a year back:
    //
    //  1. ON DELETE CASCADE meant the gated cleanup sweep silently erased the
    //     plays of every file it removed. A play happened; deleting the file
    //     later does not un-happen it. The FK becomes SET NULL and the track's
    //     identity is denormalised onto the row so it stays readable.
    //  2. No duration, so listening time was only ever inferable from the
    //     track's current duration — itself gone once the file is.
    id: '20260806_play_history_durable_facts',
    sql: `
      ALTER TABLE play_history ADD COLUMN IF NOT EXISTS listened_ms INTEGER;
      ALTER TABLE play_history ADD COLUMN IF NOT EXISTS track_title TEXT;
      ALTER TABLE play_history ADD COLUMN IF NOT EXISTS track_artist TEXT;

      DO $$
      DECLARE fk_name TEXT;
      BEGIN
        SELECT con.conname INTO fk_name
          FROM pg_constraint con
          JOIN pg_class rel ON rel.oid = con.conrelid
          JOIN pg_attribute att ON att.attrelid = rel.oid AND att.attnum = con.conkey[1]
         WHERE rel.relname = 'play_history'
           AND con.contype = 'f'
           AND att.attname = 'track_id'
           AND con.confdeltype = 'c'
         LIMIT 1;
        IF fk_name IS NOT NULL THEN
          EXECUTE format('ALTER TABLE play_history DROP CONSTRAINT %I', fk_name);
          ALTER TABLE play_history
            ADD CONSTRAINT play_history_track_id_fkey
            FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL;
        END IF;
      END $$;

      -- Backfill identity for rows written before the columns existed. Rows whose
      -- track was already cascade-deleted are unrecoverable; this at least stops
      -- the bleeding from here on.
      UPDATE play_history ph
         SET track_title = t.title, track_artist = t.artist
        FROM tracks t
       WHERE t.id = ph.track_id AND ph.track_title IS NULL;
    `,
  },
  {
    // One listener, many browsers. `playback_state` is the single authority for
    // what is playing and which device owns the audio, so a phone can take over
    // from a desktop mid-track. The queue is stored as whole track objects, not
    // ids: the device taking over needs to render the queue immediately, and a
    // snapshot of what was queued at handoff time is the honest thing to move.
    id: '20260808_playback_devices',
    sql: `
      CREATE TABLE IF NOT EXISTS playback_devices (
        id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id      UUID NOT NULL,
        name         TEXT NOT NULL,
        last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
      );
      CREATE INDEX IF NOT EXISTS idx_playback_devices_user
        ON playback_devices (user_id, last_seen_at DESC);

      CREATE TABLE IF NOT EXISTS playback_state (
        user_id     UUID PRIMARY KEY,
        device_id   UUID REFERENCES playback_devices(id) ON DELETE SET NULL,
        track_id    UUID REFERENCES tracks(id) ON DELETE SET NULL,
        queue       JSONB NOT NULL DEFAULT '[]'::jsonb,
        queue_index INTEGER NOT NULL DEFAULT -1,
        position_ms INTEGER NOT NULL DEFAULT 0,
        is_playing  BOOLEAN NOT NULL DEFAULT FALSE,
        -- Monotonic per user. A device applies a snapshot only when it is newer
        -- than the last one it saw, so a delayed delivery cannot rewind anyone.
        version     BIGINT NOT NULL DEFAULT 0,
        updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
      );
    `,
  },
  {
    // Every library scan used to reset a disliked track from HIDDEN back to
    // LIBRARY, so tracks the listener had rejected kept returning in the Vibe.
    // The scanner no longer does that; this puts the tracks it already undid
    // back where the dislike left them. A track whose dislike was deliberately
    // restored has no `dislikes` row and is untouched here.
    // `20260707_backfill_claims` wrote tag-derived credits once, and nothing
    // wrote them afterwards: the scanner filled `track_artists` but left the
    // claim spine to MusicBrainz. Every track added since, and every track
    // MusicBrainz does not know, therefore had no edge in `claim_fusion` and
    // was unreachable by all but one Vibe generator. The scanner now writes
    // these itself; this catches up the tracks it already missed.
    id: '20260810_backfill_tag_credits_since_first_scan',
    sql: `
      INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence)
      SELECT 'track', ta.track_id,
             CASE WHEN ta.role = 'main' THEN 'credited_main_on' ELSE 'featured_on' END,
             'artist', ta.artist_id, 'tag', 1.0
        FROM track_artists ta
        JOIN tracks t ON t.id = ta.track_id
       WHERE t.state IN ('LIBRARY', 'RECOMMENDED')
      ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
      DO NOTHING;
    `,
  },
  {
    // Vibe control has to follow the audio. The running session's id rides with
    // the playback snapshot, so the device taking the audio can adopt the
    // session instead of walking whatever queue it happened to receive — which
    // is what used to happen, silently, every time a listener moved the audio to
    // their phone mid-Vibe.
    //
    // No foreign key on purpose: a report from the owning device carries the
    // whole session, and losing that to a session row that has since been
    // deleted would cost far more than a dangling id.
    id: '20260810_playback_state_vibe_session',
    sql: `
      ALTER TABLE playback_state
        ADD COLUMN IF NOT EXISTS vibe_session_id UUID;
    `,
  },
  {
    id: '20260810_rehide_resurrected_dislikes',
    sql: `
      UPDATE tracks t
         SET state = 'HIDDEN'
        FROM dislikes d
       WHERE d.track_id = t.id
         AND d.deleted_at IS NULL
         AND t.state IN ('LIBRARY', 'RECOMMENDED');
    `,
  },
];
