import type { Queryable } from './db.js';
import {
  MusicBrainzClient,
  LastFmClient,
  DiscogsClient,
  LrcLibClient,
  CoverArtClient,
  WikimediaClient,
  WikidataClient,
  FanartClient,
  TheAudioDbClient,
  ITunesClient,
  DeezerClient,
  upscaleITunesArtwork,
} from './integrations/index.js';
import { MbSpineWriter } from './mb-spine-writer.js';
import {
  normalizeForMatching,
  jaroWinklerSimilarity,
  findBestMatch,
} from './utils/fuzzy-match.js';

// Cap on how many merged genre tags we persist per track. Keeps track_genre
// from ballooning with long-tail Last.fm/MB tags.
const MAX_TAGS = 8;

// MusicBrainz MBIDs are UUID-format strings, which fit the existing UUID-typed
// artists.mbid column. Guard before writing so a malformed value never aborts
// the UPDATE (and, by extension, the whole track enrichment).
const UUID_RE =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

interface TrackRow {
  id: string;
  title: string;
  artist: string;        // raw artist string from track
  artist_name: string;   // resolved main artist name from track_artists
  artist_mbid: string | null;  // MusicBrainz ID of the main artist
  duration: number | null;
  album_id: string | null;
  album_title: string | null;
  artist_id: string | null;
}

/** Stored as BullMQ's job return value so a completed job never masquerades
 * as an enrichment hit when a toggle was off or every provider had no match. */
export interface EnrichmentJobOutcome {
  outcome: 'updated' | 'unchanged' | 'skipped' | 'not_found' | 'no_result';
  detail?: string;
}

/**
 * Wires the external-integration clients into real metadata enrichment.
 *
 * Every provider call is best-effort: the clients themselves degrade to null/[]
 * when unconfigured or on error, and each provider step here is additionally
 * wrapped in its own try/catch so one failing provider can never abort the
 * others or fail the job. All writes are idempotent and parameterized.
 */
export class EnrichmentService {
  private readonly musicbrainz = new MusicBrainzClient();
  private readonly lastfm = new LastFmClient();
  private readonly discogs = new DiscogsClient();
  private readonly lrclib = new LrcLibClient();
  private readonly coverart = new CoverArtClient();
  private readonly wikimedia = new WikimediaClient();
  private readonly wikidata = new WikidataClient();
  private readonly fanart = new FanartClient();
  private readonly theaudiodb = new TheAudioDbClient();
  private readonly itunes = new ITunesClient();
  private readonly deezer = new DeezerClient();
  constructor(private pgClient: Queryable) {}

  /**
   * Self-provision the enrichment-specific schema additions. Idempotent; mirrors
   * backend/src/db/schema.sql so the worker runs on older DBs. The existing
   * columns it reuses (artists.mbid/discogs_id, albums.year/artwork_id) are
   * guarded with ADD COLUMN IF NOT EXISTS for the same reason.
   */
  async ensureSchema(): Promise<void> {
    await this.pgClient.query(
      `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)
       )`
    );
    // New artist identity tables
    await this.pgClient.query(
      `ALTER TABLE artists ADD COLUMN IF NOT EXISTS canonical_name TEXT`
    );
    await this.pgClient.query(
      `ALTER TABLE artists ADD COLUMN IF NOT EXISTS sort_name TEXT`
    );
    await this.pgClient.query(
      `ALTER TABLE artists ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`
    );
    await this.pgClient.query(
      `ALTER TABLE artists ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`
    );
    await this.pgClient.query(
      `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)
       )`
    );
    await this.pgClient.query(
      `CREATE INDEX IF NOT EXISTS idx_artist_aliases_normalized ON artist_aliases(alias_normalized)`
    );
    await this.pgClient.query(
      `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,
         not_found BOOLEAN DEFAULT FALSE
       )`
    );
    await this.pgClient.query(
      `ALTER TABLE albums ADD COLUMN IF NOT EXISTS mbid UUID`
    );
    await this.pgClient.query(
      `CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL`
    );
    await this.pgClient.query(
      `CREATE INDEX IF NOT EXISTS idx_albums_mbid ON albums(mbid) WHERE mbid IS NOT NULL`
    );
    // Defensive: ensure the reused columns exist on older databases.
    await this.pgClient.query(
      `ALTER TABLE artists ADD COLUMN IF NOT EXISTS mbid UUID`
    );
    await this.pgClient.query(
      `ALTER TABLE artists ADD COLUMN IF NOT EXISTS discogs_id TEXT`
    );
    await this.pgClient.query(
      `ALTER TABLE albums ADD COLUMN IF NOT EXISTS year INTEGER`
    );
    await this.pgClient.query(
      `ALTER TABLE albums ADD COLUMN IF NOT EXISTS artwork_id TEXT`
    );
    // release_date: the actual album release date (from MusicBrainz
    // first-release-date). Distinct from `year` (which can also come from
    // Discogs) — release_date is the full ISO date and serves as a
    // deterministic tiebreaker in album dedup (earlier release = keeper).
    await this.pgClient.query(
      `ALTER TABLE albums ADD COLUMN IF NOT EXISTS release_date DATE`
    );
    // created_at: when the album row was inserted into the DB. Used as a
    // secondary tiebreaker in dedup (oldest row = keeper).
    await this.pgClient.query(
      `ALTER TABLE albums ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`
    );
  }

  private async loadTrack(trackId: string): Promise<TrackRow | null> {
    const res = await this.pgClient.query<TrackRow>(
      `SELECT t.id,
              t.title,
              t.artist,
              t.duration,
              t.album_id,
              al.title AS album_title,
              ar.id    AS artist_id,
              ar.name  AS artist_name,
              ar.mbid  AS artist_mbid
         FROM tracks t
         LEFT JOIN albums al ON al.id = t.album_id
         LEFT JOIN track_artists ta ON ta.track_id = t.id AND ta.role = 'main'
         LEFT JOIN artists ar ON ar.id = ta.artist_id
        WHERE t.id = $1`,
      [trackId]
    );
    return res.rows[0] ?? null;
  }

  /** Idempotent upsert of a genre by name; returns its id. */
  private async upsertGenre(name: string): Promise<string> {
    const res = await this.pgClient.query<{ id: string }>(
      `INSERT INTO genre (name) VALUES ($1)
       ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
       RETURNING id`,
      [name]
    );
    return res.rows[0].id;
  }

  /** Idempotent upsert of a (track, genre) weight. */
  private async upsertTrackGenre(
    trackId: string,
    genreId: string,
    weight: number
  ): Promise<void> {
    await this.pgClient.query(
      `INSERT INTO track_genre (track_id, genre_id, weight)
       VALUES ($1, $2, $3)
       ON CONFLICT (track_id, genre_id) DO UPDATE SET weight = EXCLUDED.weight`,
      [trackId, genreId, weight]
    );
  }

  /**
   * Resolve artist identity using the full pipeline:
   * 1. Check artist_lookup_cache (normalized name -> MBID)
   * 2. Exact match on normalized_name
   * 3. Fuzzy match (Jaro-Winkler > 0.95)
   * 4. MusicBrainz lookup
   * 5. Create new artist with canonical_name from MB
   * 6. Store aliases for future matching
   */
  async resolveArtistIdentity(rawArtistName: string): Promise<{
    artistId: string;
    canonicalName: string;
    mbid: string | null;
    sortName: string | null;
    isNew: boolean;
  }> {
    const normalized = normalizeForMatching(rawArtistName);
    
    // 1. Check cache first
    const cacheHit = await this.pgClient.query(
      `SELECT mbid, canonical_name, sort_name, not_found
       FROM artist_lookup_cache
       WHERE normalized_name = $1`,
      [normalized]
    );
    if (cacheHit.rows.length > 0) {
      const row = cacheHit.rows[0];
      if (row.not_found) {
        // Negative cache hit - create local artist without MBID
        return this.createLocalArtist(rawArtistName, normalized);
      }
      if (row.mbid) {
        // Look up existing artist by MBID
        const existing = await this.pgClient.query(
          `SELECT id, canonical_name, sort_name FROM artists WHERE mbid = $1`,
          [row.mbid]
        );
        if (existing.rows.length > 0) {
          const canonical = existing.rows[0].canonical_name || rawArtistName;
          return {
            artistId: existing.rows[0].id,
            canonicalName: canonical,
            mbid: row.mbid,
            sortName: existing.rows[0].sort_name || this.generateSortName(canonical),
            isNew: false,
          };
        }
      }
    }

    // 2. Exact match on normalized_name
    const exactMatch = await this.pgClient.query(
      `SELECT id, canonical_name, mbid, sort_name FROM artists WHERE normalized_name = $1`,
      [normalized]
    );
    if (exactMatch.rows.length > 0) {
      const row = exactMatch.rows[0];
      const canonical = row.canonical_name || rawArtistName;
      // Update cache
      await this.updateArtistLookupCache(normalized, row.mbid, row.canonical_name, row.sort_name, false);
      return {
        artistId: row.id,
        canonicalName: canonical,
        mbid: row.mbid,
        sortName: row.sort_name || this.generateSortName(canonical),
        isNew: false,
      };
    }

    // 3. Fuzzy match against existing artists
    const allArtists = await this.pgClient.query(
      `SELECT id, name, canonical_name, mbid, normalized_name FROM artists`
    );
    const fuzzyMatch = findBestMatch(
      normalized,
      allArtists.rows.map(r => ({ 
        ...r, 
        name: r.canonical_name || r.name 
      })),
      0.95
    );
    if (fuzzyMatch) {
      const row = fuzzyMatch.match;
      const canonical = row.canonical_name || rawArtistName;
      // Add alias for future exact matches
      await this.addArtistAlias(row.id, rawArtistName);
      // Update cache
      await this.updateArtistLookupCache(normalized, row.mbid, row.canonical_name, row.sort_name, false);
      return {
        artistId: row.id,
        canonicalName: canonical,
        mbid: row.mbid,
        sortName: row.sort_name || this.generateSortName(canonical),
        isNew: false,
      };
    }

    // 4. MusicBrainz artist search (canonical identity)
    //    Uses the /artist endpoint directly — the correct source for canonical
    //    name + sort-name + MBID. The previous implementation mis-used
    //    lookupRecording(rawName, '') (a recording search with an empty title)
    //    which was unreliable and never returned sort-name.
    if (this.musicbrainz['cfg']?.contact) {
      try {
        const mbArtist = await this.musicbrainz.searchArtist(rawArtistName);
        if (mbArtist && UUID_RE.test(mbArtist.artistMbid)) {
          // Check if an artist with this MBID already exists
          const mbArtistRow = await this.pgClient.query(
            `SELECT id, canonical_name, sort_name FROM artists WHERE mbid = $1`,
            [mbArtist.artistMbid]
          );
          if (mbArtistRow.rows.length > 0) {
            const row = mbArtistRow.rows[0];
            await this.addArtistAlias(row.id, rawArtistName);
            await this.updateArtistLookupCache(normalized, mbArtist.artistMbid, row.canonical_name, row.sort_name, false);
            return {
              artistId: row.id,
              canonicalName: row.canonical_name || mbArtist.name,
              mbid: mbArtist.artistMbid,
              sortName: row.sort_name || mbArtist.sortName || this.generateSortName(mbArtist.name),
              isNew: false,
            };
          }

          // Create new artist with MB canonical name + sort-name
          const canonicalName = mbArtist.name;
          const sortName = mbArtist.sortName || this.generateSortName(canonicalName);

          const newArtist = await this.pgClient.query(
            // normalized_name is a GENERATED ALWAYS column in schema.sql
            // (normalize_artist(name)); writing to it explicitly errors with
            // 428C9 on any database built from schema.sql. Let Postgres derive it.
            `INSERT INTO artists (name, canonical_name, sort_name, mbid)
             VALUES ($1, $2, $3, $4)
             ON CONFLICT (mbid) DO UPDATE SET
               canonical_name = EXCLUDED.canonical_name,
               sort_name = EXCLUDED.sort_name,
               name = EXCLUDED.name
             RETURNING id`,
            [rawArtistName, canonicalName, sortName, mbArtist.artistMbid]
          );

          const artistId = newArtist.rows[0].id;
          await this.addArtistAlias(artistId, rawArtistName);
          await this.updateArtistLookupCache(normalized, mbArtist.artistMbid, canonicalName, sortName, false);

          return {
            artistId,
            canonicalName,
            mbid: mbArtist.artistMbid,
            sortName,
            isNew: true,
          };
        }
      } catch (err) {
        console.warn('[Enrich] MusicBrainz artist search failed:', (err as Error).message);
      }
    }

    // 5. Create local artist without MBID
    return this.createLocalArtist(rawArtistName, normalized);
  }

  private async createLocalArtist(rawName: string, normalized: string): Promise<{
    artistId: string;
    canonicalName: string;
    mbid: string | null;
    sortName: string;
    isNew: boolean;
  }> {
    const sortName = this.generateSortName(rawName);
    
    const result = await this.pgClient.query(
      // normalized_name is GENERATED ALWAYS (normalize_artist(name)) in
      // schema.sql — inserting it explicitly fails with 428C9. Derived by PG.
      `INSERT INTO artists (name, canonical_name, sort_name)
       VALUES ($1, $2, $3)
       RETURNING id`,
      [rawName, rawName, sortName]
    );
    
    const artistId = result.rows[0].id;
    await this.addArtistAlias(artistId, rawName);
    await this.updateArtistLookupCache(normalized, null, rawName, sortName, true);
    
    return {
      artistId,
      canonicalName: rawName,
      mbid: null,
      sortName,
      isNew: true,
    };
  }

  private generateSortName(name: string): string {
    // Move "The ", "A ", "An " to end: "The Beatles" -> "Beatles, The"
    const match = name.match(/^(The|A|An)\s+(.+)$/i);
    if (match) {
      return `${match[2]}, ${match[1]}`;
    }
    return name;
  }

  private async addArtistAlias(artistId: string, alias: string): Promise<void> {
    try {
      await this.pgClient.query(
        `INSERT INTO artist_aliases (artist_id, alias) VALUES ($1, $2)
         ON CONFLICT (artist_id, alias) DO NOTHING`,
        [artistId, alias]
      );
    } catch {
      // Ignore duplicate
    }
  }

  private async updateArtistLookupCache(
    normalizedName: string,
    mbid: string | null,
    canonicalName: string | null,
    sortName: string | null,
    notFound: boolean
  ): Promise<void> {
    await this.pgClient.query(
      `INSERT INTO artist_lookup_cache (normalized_name, mbid, canonical_name, sort_name, not_found)
       VALUES ($1, $2, $3, $4, $5)
       ON CONFLICT (normalized_name) DO UPDATE SET
         mbid = EXCLUDED.mbid,
         canonical_name = EXCLUDED.canonical_name,
         sort_name = EXCLUDED.sort_name,
         not_found = EXCLUDED.not_found,
         fetched_at = CURRENT_TIMESTAMP`,
      [normalizedName, mbid, canonicalName, sortName, notFound]
    );
  }

  /**
   * Autonomous artist image pipeline with fallback chain:
   * 1. Fanart.tv (by MBID) - best quality, structured
   * 2. TheAudioDB (by MBID) - good coverage
   * 3. Wikidata (MBID -> Wikidata ID -> P18 image)
   * 4. Last.fm (by name)
   * 5. Discogs (by name + MBID)
   * 6. Wikimedia Commons (by name)
   * 7. Placeholder (none)
   */
  async getArtistImage(artistId: string, mbid: string | null, artistName: string): Promise<string | null> {
    // If we already have a good image, skip
    const existing = await this.pgClient.query(
      `SELECT image_path FROM artists WHERE id = $1`,
      [artistId]
    );
    if (existing.rows[0]?.image_path && !existing.rows[0].image_path.includes('2a96cbd8b46e442fc41c2b86b821562f')) {
      return existing.rows[0].image_path;
    }

    // 1. Fanart.tv (best for structured artist images)
    if (mbid) {
      try {
        const fanartImage = await this.fanart.getBestArtistImage(mbid);
        if (fanartImage) {
          await this.updateArtistImage(artistId, fanartImage);
          return fanartImage;
        }
      } catch (err) {
        console.warn('[Enrich] Fanart.tv image failed:', (err as Error).message);
      }
    }

    // 2. TheAudioDB (by MBID)
    if (mbid) {
      try {
        const adbImage = await this.theaudiodb.getBestArtistImage(mbid);
        if (adbImage) {
          await this.updateArtistImage(artistId, adbImage);
          return adbImage;
        }
      } catch (err) {
        console.warn('[Enrich] TheAudioDB image failed:', (err as Error).message);
      }
    }

    // 3. Wikidata (MBID -> Wikidata -> P18)
    if (mbid) {
      try {
        const wikiImage = await this.wikidata.getArtistImageFromMbid(mbid);
        if (wikiImage) {
          await this.updateArtistImage(artistId, wikiImage);
          return wikiImage;
        }
      } catch (err) {
        console.warn('[Enrich] Wikidata image failed:', (err as Error).message);
      }
    }

    // 4. Last.fm (by name)
    try {
      const lfmImage = await this.lastfm.getArtistImageUrl(artistName);
      if (lfmImage) {
        await this.updateArtistImage(artistId, lfmImage);
        return lfmImage;
      }
    } catch (err) {
      console.warn('[Enrich] Last.fm image failed:', (err as Error).message);
    }

    // 5. Discogs (by name + MBID for exact match, fallback to name only)
    try {
      const discogsImage = await this.discogs.getArtistImageUrl(artistName, mbid ?? undefined);
      if (discogsImage) {
        await this.updateArtistImage(artistId, discogsImage);
        return discogsImage;
      }
    } catch (err) {
      console.warn('[Enrich] Discogs image failed:', (err as Error).message);
    }

    // 6. Deezer (by name, no auth). The five sources above cover almost nothing
    // in this library: Fanart needs a key the worker does not have, TheAudioDB
    // and Discogs 404 on most names, Wikidata needs an MBID that 638 of 734
    // artists lack, and Last.fm stopped serving real artist photos.
    try {
      const deezerImage = await this.deezer.searchArtistImage(artistName);
      if (deezerImage) {
        await this.updateArtistImage(artistId, deezerImage);
        return deezerImage;
      }
    } catch (err) {
      console.warn('[Enrich] Deezer image failed:', (err as Error).message);
    }

    // NOTE: Wikimedia Commons "by name" was previously the last fallback, but a
    // blind File-namespace text search ("<name> artist") routinely returns the
    // wrong image entirely (e.g. an unrelated person who shares the name). It has
    // been removed as an image source. MBID-verified Wikidata images (step 3)
    // still flow through and are accurate. Artists with no better match get the
    // placeholder rather than a misleading photo.

    return null;
  }

  private async updateArtistImage(artistId: string, imageUrl: string): Promise<void> {
    await this.pgClient.query(
      `UPDATE artists SET image_path = $1, updated_at = CURRENT_TIMESTAMP
       WHERE id = $2`,
      [imageUrl, artistId]
    );
  }

  /**
   * Best-effort enrichment for a single track. Each provider is isolated: a
   * provider that returns nothing or throws is logged and skipped without
   * affecting the others. Safe to re-run (stable, no duplicate rows).
   */
  async enrichTrack(trackId: string): Promise<EnrichmentJobOutcome> {
    const track = await this.loadTrack(trackId);
    if (!track) {
      console.warn(`[Enrich] track not found: ${trackId}`);
      return { outcome: 'not_found', detail: 'Track no longer exists.' };
    }

    // Load enrichment settings. Default all to true (best-effort).
    const settings = await this.loadSettings();
    const trackEnrichmentEnabled = settings.enrich_metadata
      || settings.enrich_genres
      || settings.enrich_lyrics;
    if (!trackEnrichmentEnabled) {
      console.log(`[Enrich] track ${trackId}: skipped (all track enrichment toggles disabled)`);
      return { outcome: 'skipped', detail: 'All track enrichment toggles are disabled.' };
    }

    const summary: string[] = [];
    const album = track.album_title ?? undefined;

    // Merged genre/tag signals (name -> max weight) from MB + Last.fm.
    const tagWeights = new Map<string, number>();
    let recordingMatch: Awaited<
      ReturnType<MusicBrainzClient['lookupRecording']>
    > = null;

    // --- 0. Resolve artist identity using new pipeline -----------------------
    let artistIdentity = null;
    if (settings.enrich_metadata && track.artist_id) {
      try {
        artistIdentity = await this.resolveArtistIdentity(track.artist_name);
        summary.push(`artist_id:${artistIdentity.isNew ? 'new' : 'existing'}`);
      } catch (err) {
        console.warn('[Enrich] Artist identity resolution failed:', (err as Error).message);
      }
    }

    // --- a/b. MusicBrainz: recording lookup + artist MBID + artist tags ------
    if (settings.enrich_metadata) {
    try {
      recordingMatch = await this.musicbrainz.lookupRecording(
        track.artist,
        track.title,
        album
      );
      const artistId = artistIdentity?.artistId ?? track.artist_id;
      const artistMbid = artistIdentity?.mbid ?? recordingMatch?.artistMbid;
      
      if (artistMbid && artistId && UUID_RE.test(artistMbid)) {
        await this.pgClient.query(
          `UPDATE artists SET mbid = $1 WHERE id = $2 AND mbid IS DISTINCT FROM $1`,
          [artistMbid, artistId]
        );
        summary.push('mbid');
      }

      // E. Write back MB canonical artist name + sort-name. The recording match
      //    carries the canonical artist name from MB's artist-credit; we persist
      //    it so display/join uses the verified name ("P!nk" not "Pink").
      //    resolveArtistIdentity only sets canonical_name on create/MBID-match;
      //    this catches artists resolved earlier by fuzzy/normalized match that
      //    still carry the raw tag as canonical_name.
      if (recordingMatch?.artistName && artistId) {
        const sortName = this.generateSortName(recordingMatch.artistName);
        await this.pgClient.query(
          `UPDATE artists
              SET canonical_name = $1,
                  sort_name = COALESCE(sort_name, $2),
                  updated_at = CURRENT_TIMESTAMP
            WHERE id = $3
              AND (canonical_name IS DISTINCT FROM $1 OR sort_name IS NULL)`,
          [recordingMatch.artistName, sortName, artistId]
        );
        summary.push('canonical_name');
      }

      // F. MB spine: write credited_main_on / featured_on claims from full
      //    artist-credit (not just the primary artist we already resolved above).
      if (recordingMatch?.recordingMbid) {
        try {
          const spineWriter = new MbSpineWriter(this.pgClient);
          const n = await spineWriter.writeRecordingClaims(
            recordingMatch.recordingMbid,
            track.id,
            this.musicbrainz,
          );
          if (n > 0) summary.push(`spine:${n}`);
        } catch (err) {
          console.warn('[Enrich] MB spine writer failed:', (err as Error).message);
        }
      }

      // F'. MB spine (artist relations): write member_of / alias_of claims from
      //     the artist's MB artist-relations (identity fusion source e.g.
      //     DOOM/Madvillain/Viktor Vaughn collapse). Only when we have a stable
      //     artist MBID and a resolved local artist entity.
      if (artistMbid && artistId && UUID_RE.test(artistMbid)) {
        try {
          const spineWriter = new MbSpineWriter(this.pgClient);
          const n = await spineWriter.writeArtistRelationClaims(
            artistMbid,
            artistId,
            this.musicbrainz,
          );
          if (n > 0) summary.push(`artist-relations:${n}`);
        } catch (err) {
          console.warn('[Enrich] MB artist-relation spine writer failed:', (err as Error).message);
        }
      }

      if (artistMbid) {
        const mbTags = await this.musicbrainz.getArtistTags(artistMbid);
        for (const t of mbTags) {
          tagWeights.set(t.name, Math.max(tagWeights.get(t.name) ?? 0, t.weight));
        }
        if (mbTags.length > 0) summary.push(`mb-tags:${mbTags.length}`);
      }
    } catch (err) {
      console.warn('[Enrich] MusicBrainz step failed:', (err as Error).message);
    }
    } // enrich_metadata

    // NOTE: Artist images are no longer fetched inline here. They run as their
    // own `artist_image` job (enqueued by the scanner), so image HTTP lookups
    // don't block track enrichment. See refreshArtistImage().

    // --- b. Last.fm artist top tags -----------------------------------------
    if (settings.enrich_genres) {
    try {
      const lfmTags = await this.lastfm.getArtistTopTags(track.artist);
      for (const t of lfmTags) {
        tagWeights.set(t.name, Math.max(tagWeights.get(t.name) ?? 0, t.weight));
      }
      if (lfmTags.length > 0) summary.push(`lfm-tags:${lfmTags.length}`);
    } catch (err) {
      console.warn('[Enrich] Last.fm step failed:', (err as Error).message);
    }
    } // enrich_genres

    // --- c. Persist merged tags as genre + track_genre (top MAX_TAGS) -------
    if (settings.enrich_genres) {
    try {
      const top = [...tagWeights.entries()]
        .sort((a, b) => b[1] - a[1])
        .slice(0, MAX_TAGS);
      for (const [name, weight] of top) {
        const genreId = await this.upsertGenre(name);
        await this.upsertTrackGenre(track.id, genreId, weight);
      }
      if (top.length > 0) summary.push(`genres:${top.length}`);
    } catch (err) {
      console.warn('[Enrich] genre persistence failed:', (err as Error).message);
    }
    } // enrich_genres

    // --- d. LRCLib lyrics ----------------------------------------------------
    if (settings.enrich_lyrics) {
    try {
      const lyrics = await this.lrclib.getLyrics(
        track.artist,
        track.title,
        album,
        track.duration ?? undefined
      );
      if (lyrics && (lyrics.plainLyrics || lyrics.syncedLyrics)) {
        await this.pgClient.query(
          `INSERT INTO track_lyrics (track_id, lyrics_text, provider, synced_lyrics)
           VALUES ($1, $2, $3, $4)
           ON CONFLICT (track_id) DO UPDATE SET
             lyrics_text = EXCLUDED.lyrics_text,
             provider = EXCLUDED.provider,
             synced_lyrics = EXCLUDED.synced_lyrics`,
          [
            track.id,
            lyrics.plainLyrics,
            lyrics.provider,
            lyrics.syncedLyrics !== null
              ? JSON.stringify(lyrics.syncedLyrics)
              : null,
          ]
        );
        summary.push('lyrics');
      }
    } catch (err) {
      console.warn('[Enrich] LRCLib step failed:', (err as Error).message);
    }
    } // enrich_lyrics

    // --- e. Discogs: discogs_id + album year (when enrich_metadata is on) ----
    // Album cover art itself is fetched by the separate `album_cover` job.
    if (settings.enrich_metadata) {
    try {
      if (album && track.artist_id) {
        const rel = await this.discogs.searchRelease(track.artist, album);
        if (rel) {
          await this.pgClient.query(
            `UPDATE artists SET discogs_id = $1
              WHERE id = $2 AND discogs_id IS DISTINCT FROM $1`,
            [String(rel.discogsId), track.artist_id]
          );
          summary.push('discogs_id');
          if (track.album_id && rel.year !== null) {
            await this.pgClient.query(
              `UPDATE albums SET year = $1
                WHERE id = $2 AND year IS DISTINCT FROM $1`,
              [rel.year, track.album_id]
            );
            summary.push('year');
          }
        }
      }
    } catch (err) {
      console.warn('[Enrich] Discogs metadata step failed:', (err as Error).message);
    }
    } // enrich_metadata

    // --- f. MusicBrainz release-group: canonical album title + MBID + date ---
    // A release-group is MB's canonical "album" entity (groups all editions of
    // the same album under one stable MBID). We write back the canonical title
    // (fixing misspelled / mistagged album names), the release-group MBID (the
    // stable album identity), the first-release date (full ISO date from MB —
    // more precise than Discogs's year-only), and the year as a fallback when
    // Discogs didn't already set one. Score-gated at 80 to avoid bad matches.
    if (settings.enrich_metadata && track.album_id && album) {
    try {
      const artistNameForSearch = artistIdentity?.canonicalName
        ?? track.artist_name
        ?? track.artist;
      const rg = await this.musicbrainz.searchReleaseGroup(artistNameForSearch, album);
      if (rg && track.album_id) {
        // Set canonical title always; set release-group MBID only when this
        // album doesn't already have one (avoids UNIQUE(mbid) collisions when
        // two local album rows resolve to the same release-group — those are
        // duplicates to be merged separately); fill release_date + year as
        // COALESCE fallbacks so we don't overwrite richer data from Discogs.
        await this.pgClient.query(
          `UPDATE albums
              SET title = $1,
                  mbid = COALESCE(mbid, $2),
                  release_date = COALESCE(release_date, $3::date),
                  year = COALESCE(year, $4::int)
            WHERE id = $5
              AND (title IS DISTINCT FROM $1 OR mbid IS NULL OR release_date IS NULL OR (year IS NULL AND $4::int IS NOT NULL))`,
          [rg.title, rg.releaseGroupMbid, rg.firstReleaseDate, rg.year, track.album_id]
        );
        summary.push(`album:${rg.title === album ? 'mbid' : 'canonical'}`);

        // G'. MB spine (album): write credited_main_on_album / featured_on_album
        //     from the release-group's full artist-credit. Uses the album's MBID
        //     if it's already persisted (avoids re-fetching on re-enrichment).
        try {
          const albumRow = await this.pgClient.query<{ mbid: string | null }>(
            `SELECT mbid FROM albums WHERE id = $1`,
            [track.album_id]
          );
          const albumMbid = albumRow.rows[0]?.mbid ?? rg.releaseGroupMbid;
          if (albumMbid) {
            const spineWriter = new MbSpineWriter(this.pgClient);
            const n = await spineWriter.writeAlbumClaims(
              albumMbid,
              track.album_id,
              this.musicbrainz,
            );
            if (n > 0) summary.push(`album-spine:${n}`);
          }
        } catch (err) {
          console.warn('[Enrich] MB album spine writer failed:', (err as Error).message);
        }
      }
    } catch (err) {
      console.warn('[Enrich] MusicBrainz release-group step failed:', (err as Error).message);
    }
    } // enrich_metadata

    // NOTE: Album cover art is fetched by the separate `album_cover` job (Discogs
    // + Cover Art Archive), not inline here. See refreshAlbumCover().

    console.log(
      `[Enrich] track ${trackId} enriched: ${
        summary.length > 0 ? summary.join(', ') : 'nothing'
      }`
    );
    return summary.length > 0
      ? { outcome: 'updated', detail: summary.join(', ') }
      : { outcome: 'no_result', detail: 'No provider returned usable metadata.' };
  }

  /**
   * Refresh Last.fm similar-artist rows for one artist. Best-effort and
   * idempotent (upsert keyed on (artist_id, similar_name)).
   */
  /**
   * Read enrichment settings from the database. Returns an object with boolean
   * values for each toggle; defaults to true when a row is missing or malformed.
   */
  private async loadSettings(): Promise<{
    enrich_metadata: boolean;
    enrich_artist_images: boolean;
    enrich_genres: boolean;
    enrich_cover_art: boolean;
    enrich_lyrics: boolean;
    enrich_artist_similarity: boolean;
    enrich_audio_analysis: boolean;
  }> {
    const rows = await this.pgClient.query(
      `SELECT key, value FROM settings
       WHERE key IN ('enrich_metadata','enrich_artist_images','enrich_genres','enrich_cover_art',
                     'enrich_lyrics','enrich_artist_similarity','enrich_audio_analysis')`
    );
    const map: Record<string, boolean> = {};
    for (const r of rows.rows) {
      map[r.key] = r.value === 'true';
    }
    return {
      enrich_metadata: map.enrich_metadata ?? true,
      // This migration is intentionally opt-in: image cleanup must never be
      // followed by an unrequested external API fan-out on the next scan.
      enrich_artist_images: map.enrich_artist_images ?? false,
      enrich_genres: map.enrich_genres ?? true,
      enrich_cover_art: map.enrich_cover_art ?? true,
      enrich_lyrics: map.enrich_lyrics ?? true,
      enrich_artist_similarity: map.enrich_artist_similarity ?? true,
      enrich_audio_analysis: map.enrich_audio_analysis ?? false,
    };
  }

  /**
   * Fetch/refresh a single artist's image via the fallback chain. Runs as the
   * dedicated `artist_image` job so image lookups don't run inline with track
   * enrichment. Best-effort and idempotent — getArtistImage() short-circuits
   * when a good image already exists. Gated by the dedicated
   * enrich_artist_images toggle, independently of structural metadata.
   */
  async refreshArtistImage(artistId: string): Promise<EnrichmentJobOutcome> {
    const settings = await this.loadSettings();
    if (!settings.enrich_artist_images) {
      console.log(`[Enrich] artist image ${artistId}: skipped (enrich_artist_images disabled)`);
      return { outcome: 'skipped', detail: 'enrich_artist_images is disabled.' };
    }

    const res = await this.pgClient.query<{ name: string; canonical_name: string | null; mbid: string | null; image_path: string | null }>(
      `SELECT name, canonical_name, mbid, image_path FROM artists WHERE id = $1`,
      [artistId]
    );
    const artist = res.rows[0];
    if (!artist) {
      console.warn(`[Enrich] artist not found for image: ${artistId}`);
      return { outcome: 'not_found', detail: 'Artist no longer exists.' };
    }
    if (artist.image_path) return { outcome: 'unchanged', detail: 'Artist already has an image.' };

    const url = await this.getArtistImage(artistId, artist.mbid, artist.canonical_name ?? artist.name);
    console.log(`[Enrich] artist image ${artistId}: ${url ? 'set' : 'none'}`);
    return url
      ? { outcome: 'updated', detail: 'Artist image was set.' }
      : { outcome: 'no_result', detail: 'No verified artist image was found.' };
  }

  /**
   * Fetch/refresh a single album's cover art. Runs as the dedicated `album_cover`
   * job. Best-effort and idempotent — skips when the album already has artwork.
   * Gated by enrich_cover_art.
   *
   * =================== COVER ART FALLBACK CHAIN =======================
   * Five sources are tried in order of accuracy + coverage:
   *   1. Cover Art Archive (release-group) — exact MB-verified match via the
   *      release-group MBID now stored in albums.mbid. Always correct when it
   *      hits; no search step needed.
   *   2. iTunes Search — best coverage of any source, 600×600 artwork. No auth.
   *   3. Deezer Search — high quality (1000×1000 cover_xl), no auth.
   *   4. Discogs — good for vinyl/physical releases; also yields discogs_id.
   *   5. Cover Art Archive (release) — last resort, via a recording search to
   *      resolve a release MBID. Slowest, but catches releases without a
   *      release-group cover.
   * Each step short-circuits on the first hit.
   */
  async refreshAlbumCover(albumId: string): Promise<EnrichmentJobOutcome> {
    const settings = await this.loadSettings();
    if (!settings.enrich_cover_art) {
      return { outcome: 'skipped', detail: 'enrich_cover_art is disabled.' };
    }

    const albumRes = await this.pgClient.query<{
      title: string;
      mbid: string | null;
      artist_id: string | null;
      artwork_id: string | null;
    }>(
      `SELECT title, mbid, artist_id, artwork_id FROM albums WHERE id = $1`,
      [albumId]
    );
    const album = albumRes.rows[0];
    if (!album) {
      console.warn(`[Enrich] album not found for cover: ${albumId}`);
      return { outcome: 'not_found', detail: 'Album no longer exists.' };
    }
    if (album.artwork_id) return { outcome: 'unchanged', detail: 'Album already has artwork.' };

    let artistName = '';
    if (album.artist_id) {
      const a = await this.pgClient.query<{ name: string; canonical_name: string | null }>(
        `SELECT name, canonical_name FROM artists WHERE id = $1`,
        [album.artist_id]
      );
      artistName = a.rows[0]?.canonical_name ?? a.rows[0]?.name ?? '';
    }

    // 1. Cover Art Archive (release-group) — exact, no search, no auth.
    //    albums.mbid now holds a release-group MBID from the MB release-group
    //    search step in enrichTrack.
    if (album.mbid) {
      try {
        const coverUrl = await this.coverart.getReleaseGroupCoverUrl(album.mbid);
        if (coverUrl) {
          await this.pgClient.query(
            `UPDATE albums SET artwork_id = $1 WHERE id = $2 AND artwork_id IS DISTINCT FROM $1`,
            [coverUrl, albumId]
          );
          console.log(`[Enrich] album cover ${albumId}: caa-release-group`);
          return { outcome: 'updated', detail: 'Cover Art Archive release-group.' };
        }
      } catch (err) {
        console.warn('[Enrich] album cover CAA release-group step failed:', (err as Error).message);
      }
    }

    // 2. iTunes Search — best coverage, 600×600, no auth.
    if (artistName) {
      try {
        const itunesAlbum = await this.itunes.searchAlbum(artistName, album.title);
        if (itunesAlbum) {
          const coverUrl = upscaleITunesArtwork(itunesAlbum.artworkUrl100, 600);
          await this.pgClient.query(
            `UPDATE albums SET artwork_id = $1 WHERE id = $2 AND artwork_id IS DISTINCT FROM $1`,
            [coverUrl, albumId]
          );
          console.log(`[Enrich] album cover ${albumId}: itunes`);
          return { outcome: 'updated', detail: 'iTunes artwork.' };
        }
      } catch (err) {
        console.warn('[Enrich] album cover iTunes step failed:', (err as Error).message);
      }
    }

    // 3. Deezer Search — high quality (1000×1000), no auth.
    if (artistName) {
      try {
        const deezerAlbum = await this.deezer.searchAlbum(artistName, album.title);
        if (deezerAlbum) {
          await this.pgClient.query(
            `UPDATE albums SET artwork_id = $1 WHERE id = $2 AND artwork_id IS DISTINCT FROM $1`,
            [deezerAlbum.coverXl, albumId]
          );
          console.log(`[Enrich] album cover ${albumId}: deezer`);
          return { outcome: 'updated', detail: 'Deezer artwork.' };
        }
      } catch (err) {
        console.warn('[Enrich] album cover Deezer step failed:', (err as Error).message);
      }
    }

    // 4. Discogs — good for vinyl/physical releases; also yields discogs_id.
    if (artistName) {
      try {
        const rel = await this.discogs.searchRelease(artistName, album.title);
        if (rel?.coverImage) {
          await this.pgClient.query(
            `UPDATE albums SET artwork_id = $1 WHERE id = $2 AND artwork_id IS DISTINCT FROM $1`,
            [rel.coverImage, albumId]
          );
          // Also persist discogs_id on the artist if this is the first time.
          if (album.artist_id && rel.discogsId) {
            await this.pgClient.query(
              `UPDATE artists SET discogs_id = $1 WHERE id = $2 AND discogs_id IS DISTINCT FROM $1`,
              [String(rel.discogsId), album.artist_id]
            );
          }
          console.log(`[Enrich] album cover ${albumId}: discogs`);
          return { outcome: 'updated', detail: 'Discogs artwork.' };
        }
      } catch (err) {
        console.warn('[Enrich] album cover Discogs step failed:', (err as Error).message);
      }
    }

    // 5. Cover Art Archive (release) — last resort, via a recording search to
    //    resolve a release MBID. Slowest, but catches releases without a
    //    release-group cover.
    try {
      const trackRes = await this.pgClient.query<{ title: string }>(
        `SELECT title FROM tracks WHERE album_id = $1 LIMIT 1`,
        [albumId]
      );
      const sample = trackRes.rows[0];
      if (sample && artistName) {
        const rec = await this.musicbrainz.lookupRecording(artistName, sample.title, album.title);
        if (rec?.releaseMbid) {
          const coverUrl = await this.coverart.getReleaseCoverUrl(rec.releaseMbid);
          if (coverUrl) {
            await this.pgClient.query(
              `UPDATE albums SET artwork_id = $1 WHERE id = $2 AND artwork_id IS NULL`,
              [coverUrl, albumId]
            );
            console.log(`[Enrich] album cover ${albumId}: caa-release`);
            return { outcome: 'updated', detail: 'Cover Art Archive release.' };
          }
        }
      }
    } catch (err) {
      console.warn('[Enrich] album cover CAA release step failed:', (err as Error).message);
    }

    console.log(`[Enrich] album cover ${albumId}: none`);
    return { outcome: 'no_result', detail: 'No cover provider returned artwork.' };
  }

  async refreshArtistSimilarity(artistId: string): Promise<void> {
    const settings = await this.loadSettings();
    if (!settings.enrich_artist_similarity) return;

    const res = await this.pgClient.query<{ name: string }>(
      `SELECT name FROM artists WHERE id = $1`,
      [artistId]
    );
    const artist = res.rows[0];
    if (!artist) {
      console.warn(`[Enrich] artist not found: ${artistId}`);
      return;
    }

    let count = 0;
    try {
      const similar = await this.lastfm.getSimilarArtists(artist.name);
      for (const s of similar) {
        // Store the similar_name run through normalize_artist() so the Vibe
        // engine's join (similar_name = tracks.normalized_artist) matches
        // case- and feature-insensitively. Without this, Last.fm's "The
        // Beatles" never joins to a track tagged "the beatles".
        const normalizedSimilar = await this.pgClient.query(
          `SELECT normalize_artist($1) AS n`,
          [s.name]
        );
        const similarKey = normalizedSimilar.rows[0]?.n ?? s.name;
        await this.pgClient.query(
          `INSERT INTO artist_similar (artist_id, similar_name, match, fetched_at)
           VALUES ($1, $2, $3, NOW())
           ON CONFLICT (artist_id, similar_name) DO UPDATE SET
             match = EXCLUDED.match,
             fetched_at = NOW()`,
          [artistId, similarKey, s.match]
        );
        count++;
      }
    } catch (err) {
      console.warn(
        '[Enrich] refreshArtistSimilarity failed:',
        (err as Error).message
      );
    }

    console.log(
      `[Enrich] artist ${artistId} (${artist.name}) similarity: ${count} rows`
    );
  }

  /**
   * Analyze current artist identification state.
   * Returns stats about MBID coverage, potential duplicates, etc.
   */
  async analyzeArtistIdentification(): Promise<{
    totalArtists: number;
    withMbid: number;
    withoutMbid: number;
    withCanonicalName: number;
    withImage: number;
    potentialDuplicates: Array<{ name: string; count: number; ids: string[] }>;
    aliasCount: number;
    cacheSize: number;
  }> {
    const totalRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artists`);
    const mbidRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artists WHERE mbid IS NOT NULL`);
    const canonicalRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artists WHERE canonical_name IS NOT NULL`);
    const imageRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artists WHERE image_path IS NOT NULL AND image_path != ''`);
    const aliasRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artist_aliases`);
    const cacheRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artist_lookup_cache`);

    // Find potential duplicates by normalized name
    const dupRes = await this.pgClient.query(
      `SELECT normalized_name, COUNT(*)::int AS cnt, array_agg(id) AS ids, array_agg(name) AS names
       FROM artists
       GROUP BY normalized_name
       HAVING COUNT(*) > 1
       ORDER BY cnt DESC
       LIMIT 20`
    );

    const potentialDuplicates = dupRes.rows.map(r => ({
      name: r.normalized_name,
      count: r.cnt,
      ids: r.ids,
    }));

    return {
      totalArtists: totalRes.rows[0].n,
      withMbid: mbidRes.rows[0].n,
      withoutMbid: totalRes.rows[0].n - mbidRes.rows[0].n,
      withCanonicalName: canonicalRes.rows[0].n,
      withImage: imageRes.rows[0].n,
      potentialDuplicates,
      aliasCount: aliasRes.rows[0].n,
      cacheSize: cacheRes.rows[0].n,
    };
  }

  /**
   * Verify a specific artist's identity resolution.
   * Shows what the pipeline would produce for a given raw name.
   */
  async verifyArtistIdentity(rawName: string): Promise<{
    input: string;
    normalized: string;
    cacheHit: boolean;
    exactMatch: { id: string; canonical_name: string; mbid: string | null } | null;
    fuzzyMatch: { id: string; canonical_name: string; mbid: string | null; score: number } | null;
    mbResult: { artistMbid: string | null; artistName: string | null; sortName: string | null; score: number } | null;
    resolved: { artistId: string; canonicalName: string; mbid: string | null; isNew: boolean } | null;
  }> {
    const normalized = normalizeForMatching(rawName);

    // Check cache
    const cacheHit = await this.pgClient.query(
      `SELECT * FROM artist_lookup_cache WHERE normalized_name = $1`,
      [normalized]
    );

    // Exact match
    const exactMatch = await this.pgClient.query(
      `SELECT id, canonical_name, mbid FROM artists WHERE normalized_name = $1`,
      [normalized]
    );

    // Fuzzy match
    const allArtists = await this.pgClient.query(
      `SELECT id, name, canonical_name, mbid, normalized_name FROM artists`
    );
    const fuzzyMatch = findBestMatch(
      normalized,
      allArtists.rows.map(r => ({ 
        ...r, 
        name: r.canonical_name || r.name 
      })),
      0.95
    );

    // MusicBrainz artist search
    let mbResult = null;
    if (this.musicbrainz['cfg']?.contact) {
      try {
        const mbArtist = await this.musicbrainz.searchArtist(rawName);
        if (mbArtist) {
          mbResult = {
            artistMbid: mbArtist.artistMbid,
            artistName: mbArtist.name,
            sortName: mbArtist.sortName,
            score: mbArtist.score,
          };
        }
      } catch {
        // Ignore
      }
    }

    // Full resolution
    let resolved = null;
    try {
      resolved = await this.resolveArtistIdentity(rawName);
    } catch {
      // Ignore
    }

    return {
      input: rawName,
      normalized,
      cacheHit: cacheHit.rows.length > 0,
      exactMatch: exactMatch.rows[0] ?? null,
      fuzzyMatch: fuzzyMatch ? { ...fuzzyMatch.match, score: fuzzyMatch.score } : null,
      mbResult,
      resolved,
    };
  }

  /**
   * Merge duplicate albums — albums with the same title (case-insensitive) that
   * should be a single row. This happens when:
   *   - Compilation albums were scanned before the albumartist fix, creating
   *     one album row per track artist instead of one under "Various Artists".
   *   - Albums with slight title variations (whitespace, case) across files.
   *
   * Strategy (preferring the "best" row as keeper):
   *   1. Group by lower(title).
   *   2. Within each group, prefer rows with an MBID, then with artwork, then
   *      with the most tracks, then oldest.
   *   3. Move all tracks from loser albums to the keeper.
   *   4. Move artwork/year/mbid from any loser onto the keeper if the keeper
   *      lacks them (COALESCE-style — don't lose metadata).
   *   5. Delete the loser rows (ON DELETE CASCADE handles remaining refs).
   *
   * Also merges by MBID when two album rows share the same release-group MBID
   * (can happen when the release-group search wrote the same MBID to two
   * pre-existing rows before they were merged).
   *
   * Returns the number of albums merged away.
   */
  async dedupAlbums(): Promise<number> {
    let merged = 0;

    // --- Pass 1: merge by shared MBID ---
    const mbidDups = await this.pgClient.query(
      `SELECT mbid, array_agg(id ORDER BY
         CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END,
         release_date NULLS LAST,
         created_at
       ) AS ids
       FROM albums WHERE mbid IS NOT NULL
       GROUP BY mbid HAVING COUNT(*) > 1`
    );
    for (const row of mbidDups.rows) {
      const [keepId, ...loserIds] = row.ids as string[];
      for (const loserId of loserIds) {
        await this.mergeAlbum(keepId, loserId);
        merged++;
      }
    }

    // --- Pass 2: merge by same title (case-insensitive) ---
    // Group albums by lower(title) and merge within each group. We only merge
    // when the titles are truly the same album — the albumartist fix prevents
    // future duplicates, but existing data needs this cleanup. Prefer rows
    // with an MBID, then with artwork, then earliest release_date, then oldest.
    const titleDups = await this.pgClient.query(
      `SELECT lower(title) AS lower_title, array_agg(id ORDER BY
         CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
         CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END,
         release_date NULLS LAST,
         created_at
       ) AS ids
       FROM albums
       GROUP BY lower(title) HAVING COUNT(*) > 1`
    );
    for (const row of titleDups.rows) {
      const [keepId, ...loserIds] = row.ids as string[];
      for (const loserId of loserIds) {
        await this.mergeAlbum(keepId, loserId);
        merged++;
      }
    }

    console.log(`[Enrich] Album dedup: merged ${merged} duplicate album(s)`);
    return merged;
  }

  /**
   * Merge one album into another: move tracks, fold metadata, delete the loser.
   */
  private async mergeAlbum(keepId: string, loserId: string): Promise<void> {
    // Fold any metadata the keeper is missing from the loser before moving
    // tracks (so we don't lose artwork/year/mbid/release_date when we delete).
    await this.pgClient.query(
      `UPDATE albums SET
         artwork_id = COALESCE(artwork_id, src.artwork_id),
         year = COALESCE(year, src.year),
         mbid = COALESCE(mbid, src.mbid),
         release_date = COALESCE(release_date, src.release_date)
       FROM (SELECT artwork_id, year, mbid, release_date FROM albums WHERE id = $2) AS src
       WHERE albums.id = $1`,
      [keepId, loserId]
    );
    // Move tracks to the keeper.
    await this.pgClient.query(
      'UPDATE tracks SET album_id = $1 WHERE album_id = $2',
      [keepId, loserId]
    );
    // Delete the loser (ON DELETE CASCADE handles any remaining FK refs).
    await this.pgClient.query('DELETE FROM albums WHERE id = $1', [loserId]);
    console.log(`[Enrich] Album dedup: merged ${loserId} -> ${keepId}`);
  }
}
