// MusicBrainz web-service v2 client.
//
// IMPORTANT MB requirements (https://musicbrainz.org/doc/MusicBrainz_API):
//   - A meaningful User-Agent is REQUIRED (set via http base, see buildUserAgent).
//   - Anonymous clients must not exceed 1 request/second. We enforce this through
//     the http base's per-host rate limit (minIntervalMs = 1000 by default).
//
// All methods are best-effort: on missing contact config or any error they log a
// warning and return null / [] rather than throwing, so enrichment never breaks
// the worker.

import { integrationsConfig, MusicBrainzConfig } from './config.js';
import { buildUserAgent, requestJson } from './http.js';

/** Result of a recording lookup: the chosen recording plus linked entities. */
export interface RecordingMatch {
  recordingMbid: string;
  title: string;
  /** Primary credited artist, if any. */
  artistMbid: string | null;
  artistName: string | null;
  /** First associated release (album), if any. */
  releaseMbid: string | null;
  releaseTitle: string | null;
  /** MB search score (0..100), surfaced for callers that want confidence. */
  score: number;
}

/** Result of an artist search: the canonical identity for a name query. */
export interface ArtistMatch {
  artistMbid: string;
  /** MB canonical display name (e.g. "P!nk" not "Pink"). */
  name: string;
  /** MB sort-name (e.g. "Pink, P!" / "Beatles, The"). */
  sortName: string | null;
  /** MB search score (0..100). */
  score: number;
  /** Disambiguation comment when ambiguous (e.g. "US rock band"). */
  disambiguation: string | null;
}

/** Result of a release-group search: the canonical album identity. */
export interface ReleaseGroupMatch {
  releaseGroupMbid: string;
  title: string;
  /** Primary artist MBID on the release-group, if any. */
  artistMbid: string | null;
  artistName: string | null;
  /** First release date as a 4-digit year, if available. */
  year: number | null;
  /** First release date as an ISO string (YYYY-MM-DD or YYYY-MM or YYYY), if available. */
  firstReleaseDate: string | null;
  /** MB search score (0..100). */
  score: number;
}

/** A normalised genre/tag with a 0..1 weight. */
export interface ArtistTag {
  name: string;
  /** Normalised 0..1 weight (relative to the strongest tag for this artist). */
  weight: number;
}

/** One entry in a MusicBrainz artist-credit array. */
export interface RecordingCredit {
  /** The MB artist ID (UUID). */
  artistMbid: string;
  /** Canonical display name from the artist object. */
  artistName: string;
  /** Name as credited (may differ, e.g. "P!nk" vs "Pink"). */
  creditName: string;
  /** Joinphrase that follows this credit (" feat. ", " & ", etc.). */
  joinphrase?: string;
}

/** Full recording fetched by MBID with inc=artist-credits. */
export interface RecordingDetail {
  recordingMbid: string;
  title: string;
  artistCredit: RecordingCredit[];
}

/** Full release-group fetched by MBID with inc=artists. */
export interface ReleaseGroupDetail {
  releaseGroupMbid: string;
  title: string;
  artistCredit: RecordingCredit[];
}

/** One artist-relation entry on an artist-rels lookup. */
export interface ArtistRelation {
  /** MB relation type label, e.g. "member of", "is performance name of". */
  type: string;
  /** "forward" or "backward". */
  direction: string;
  /** The related artist's MBID. */
  targetMbid: string;
  /** The related artist's canonical display name. */
  targetName: string;
}

// --- Raw MB response shapes (only the fields we read) ----------------------

interface MbArtistCredit {
  artist?: { id?: string; name?: string };
  name?: string;
}

interface MbReleaseRef {
  id?: string;
  title?: string;
}

interface MbRecording {
  id: string;
  title: string;
  score?: number;
  'artist-credit'?: MbArtistCredit[];
  releases?: MbReleaseRef[];
}

interface MbRecordingDetail {
  id: string;
  title: string;
  'artist-credit'?: Array<{
    artist?: { id?: string; name?: string };
    name?: string;
    joinphrase?: string;
  }>;
}

interface MbRecordingSearchResponse {
  recordings?: MbRecording[];
}

interface MbReleaseGroupDetail {
  id: string;
  title?: string;
  'artist-credit'?: Array<{
    artist?: { id?: string; name?: string };
    name?: string;
    joinphrase?: string;
  }>;
}

interface MbRelation {
  type?: string;
  direction?: string;
  // MusicBrainz returns the related entity under a typed key (`artist` for
  // artist-rels); recent MB versions may also surface a generic `target`
  // string. We read `artist.id` / `artist.name` defensively.
  artist?: { id?: string; name?: string };
  target?: string;
}

interface MbRelationList {
  'target-type'?: string;
  relations?: MbRelation[];
}

interface MbArtistRelationsResponse {
  id?: string;
  name?: string;
  // Modern MB: top-level `relations` array.
  relations?: MbRelation[];
  // Older MB: `relation-list` array, each grouping relations by target-type.
  'relation-list'?: MbRelationList[];
}

interface MbTag {
  name?: string;
  count?: number;
}

interface MbGenre {
  name?: string;
  count?: number;
}

interface MbArtistResponse {
  tags?: MbTag[];
  genres?: MbGenre[];
}

interface MbArtistSearch {
  id: string;
  name?: string;
  'sort-name'?: string;
  score?: number;
  disambiguation?: string;
}

interface MbArtistSearchResponse {
  artists?: MbArtistSearch[];
}

interface MbReleaseGroupSearch {
  id: string;
  title?: string;
  score?: number;
  'first-release-date'?: string;
  'artist-credit'?: MbArtistCredit[];
}

interface MbReleaseGroupSearchResponse {
  'release-groups'?: MbReleaseGroupSearch[];
}

/**
 * Log a failed MusicBrainz call.
 *
 * Every public method swallows errors and returns null/[] so enrichment never
 * breaks. That makes a *rate-limited* MusicBrainz indistinguishable from "no
 * data exists for your library" — the job still reports success. So throttling
 * (429) and MB's own 503 "your requests are exceeding the allowable rate limit"
 * are escalated to console.error with an explicit, greppable message, while
 * ordinary failures stay at warn.
 */
function logMbFailure(op: string, err: unknown): void {
  const e = err as { status?: number; message?: string; body?: string };
  const status = typeof e?.status === 'number' ? e.status : undefined;
  const msg = e?.message ?? String(err);
  const rateLimited =
    status === 429 ||
    (status === 503 && /rate limit/i.test(`${e?.body ?? ''} ${msg}`));

  if (rateLimited) {
    console.error(
      `[MusicBrainz] RATE LIMITED (HTTP ${status}) on ${op} — results are ` +
        `INCOMPLETE, not empty. Enrichment will report success with missing ` +
        `data. Check the 1 req/s throttle and MUSICBRAINZ_CONTACT. ${msg}`
    );
    return;
  }
  console.warn(`[MusicBrainz] ${op} failed:`, msg);
}

export class MusicBrainzClient {
  private readonly cfg: MusicBrainzConfig;
  private readonly userAgent: string;
  private readonly enabled: boolean;

  constructor(cfg: MusicBrainzConfig = integrationsConfig.musicbrainz) {
    this.cfg = cfg;
    this.userAgent = buildUserAgent(cfg.contact);
    this.enabled = cfg.contact.trim() !== '';
    if (!this.enabled) {
      console.warn(
        '[MusicBrainz] MUSICBRAINZ_CONTACT not set; client disabled (methods return null/[]).'
      );
    }
  }

  private async get<T>(path: string): Promise<T> {
    return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
      userAgent: this.userAgent,
      minIntervalMs: this.cfg.minIntervalMs,
    });
  }

  /**
   * Search the recording endpoint for the best match for artist/title(/album)
   * and return its MBID plus the linked artist + first release, or null if no
   * match (or the client is disabled / errors).
   *
   * `minScore` (default 85): MB returns results pre-sorted by a 0..100 search
   * score. Below this threshold the top hit is more often wrong than right, so
   * we return null rather than poisoning identity with a bad MBID. Pass a lower
   * value only for deliberately fuzzy matching.
   */
  async lookupRecording(
    artist: string,
    title: string,
    album?: string,
    minScore = 85
  ): Promise<RecordingMatch | null> {
    if (!this.enabled) return null;

    // Build a fielded Lucene query; each value is quoted + escaped.
    const esc = (v: string): string => v.replace(/(["\\])/g, '\\$1');
    const terms = [`artist:"${esc(artist)}"`, `recording:"${esc(title)}"`];
    if (album && album.trim() !== '') terms.push(`release:"${esc(album)}"`);
    const query = terms.join(' AND ');

    const path = `/recording?query=${encodeURIComponent(query)}&fmt=json&limit=5`;

    try {
      const data = await this.get<MbRecordingSearchResponse>(path);
      const recordings = data.recordings ?? [];
      if (recordings.length === 0) return null;

      // MB returns results pre-sorted by score; take the top entry.
      const best = recordings[0];
      const score = typeof best.score === 'number' ? best.score : 0;
      if (score < minScore) return null;

      const credit = best['artist-credit']?.[0];
      const release = best.releases?.[0];

      return {
        recordingMbid: best.id,
        title: best.title,
        artistMbid: credit?.artist?.id ?? null,
        artistName: credit?.artist?.name ?? credit?.name ?? null,
        releaseMbid: release?.id ?? null,
        releaseTitle: release?.title ?? null,
        score,
      };
    } catch (err) {
      logMbFailure('lookupRecording', err);
      return null;
    }
  }

  /**
   * Search the artist endpoint for the canonical identity matching a name.
   * Returns the best match above `minScore` (default 85), or null. This is the
   * correct endpoint for artist canonicalisation — unlike recording search it
   * returns the authoritative `name` + `sort-name` directly.
   */
  async searchArtist(name: string, minScore = 85): Promise<ArtistMatch | null> {
    if (!this.enabled) return null;
    if (name.trim() === '') return null;

    const esc = (v: string): string => v.replace(/(["\\])/g, '\\$1');
    const path = `/artist?query=${encodeURIComponent(`artist:"${esc(name)}"`)}&fmt=json&limit=5`;

    try {
      const data = await this.get<MbArtistSearchResponse>(path);
      const artists = data.artists ?? [];
      if (artists.length === 0) return null;

      const best = artists[0];
      const score = typeof best.score === 'number' ? best.score : 0;
      if (score < minScore) return null;

      return {
        artistMbid: best.id,
        name: best.name ?? name,
        sortName: best['sort-name'] ?? null,
        score,
        disambiguation: best.disambiguation ?? null,
      };
    } catch (err) {
      logMbFailure('searchArtist', err);
      return null;
    }
  }

  /**
   * Search the release-group endpoint for the canonical album identity matching
   * an artist + album title. Returns the best match above `minScore` (default
   * 80 — album titles vary more than artist names, so a slightly looser
   * threshold avoids missing legitimate matches), or null.
   *
   * A release-group is MB's canonical "album" entity: it groups all
   * editions/releases of the same album under one stable MBID, making it the
   * right value for `albums.mbid`.
   */
  async searchReleaseGroup(
    artist: string,
    album: string,
    minScore = 80
  ): Promise<ReleaseGroupMatch | null> {
    if (!this.enabled) return null;
    if (artist.trim() === '' || album.trim() === '') return null;

    const esc = (v: string): string => v.replace(/(["\\])/g, '\\$1');
    const query = `artist:"${esc(artist)}" AND release:"${esc(album)}"`;
    const path = `/release-group?query=${encodeURIComponent(query)}&fmt=json&limit=5`;

    try {
      const data = await this.get<MbReleaseGroupSearchResponse>(path);
      const groups = data['release-groups'] ?? [];
      if (groups.length === 0) return null;

      const best = groups[0];
      const score = typeof best.score === 'number' ? best.score : 0;
      if (score < minScore) return null;

      const credit = best['artist-credit']?.[0];
      const firstReleaseDate = best['first-release-date'] ?? null;
      const year = firstReleaseDate
        ? parseInt(firstReleaseDate.slice(0, 4), 10)
        : null;

      return {
        releaseGroupMbid: best.id,
        title: best.title ?? album,
        artistMbid: credit?.artist?.id ?? null,
        artistName: credit?.artist?.name ?? credit?.name ?? null,
        year: Number.isFinite(year) ? year : null,
        firstReleaseDate,
        score,
      };
    } catch (err) {
      logMbFailure('searchReleaseGroup', err);
      return null;
    }
  }

  /**
   * Fetch an artist's tags + genres and return them normalised to 0..1 weights
   * (relative to the strongest tag). Returns [] if none / disabled / error.
   */
  async getArtistTags(artistMbid: string): Promise<ArtistTag[]> {
    if (!this.enabled) return [];
    if (!artistMbid) return [];

    const path = `/artist/${encodeURIComponent(artistMbid)}?inc=tags+genres&fmt=json`;

    try {
      const data = await this.get<MbArtistResponse>(path);
      const raw = [...(data.genres ?? []), ...(data.tags ?? [])];

      // Merge by name (genre + tag lists can overlap), keeping the max count.
      const byName = new Map<string, number>();
      for (const t of raw) {
        const name = t.name?.trim();
        if (!name) continue;
        const count = typeof t.count === 'number' ? t.count : 0;
        byName.set(name, Math.max(byName.get(name) ?? 0, count));
      }
      if (byName.size === 0) return [];

      const maxCount = Math.max(...byName.values(), 1);
      return [...byName.entries()]
        .map(([name, count]) => ({ name, weight: count / maxCount }))
        .sort((a, b) => b.weight - a.weight);
    } catch (err) {
      logMbFailure('getArtistTags', err);
      return [];
    }
  }

  /**
   * Fetch a recording by its stable MBID with the full artist-credit array.
   * Unlike lookupRecording (search), this is a direct ID lookup with no search
   * step, making it the correct endpoint for spine-claim generation.
   */
  async getRecording(recordingMbid: string): Promise<RecordingDetail | null> {
    if (!this.enabled) return null;
    if (!recordingMbid) return null;

    const path = `/recording/${encodeURIComponent(recordingMbid)}?inc=artist-credits&fmt=json`;

    try {
      const data = await this.get<MbRecordingDetail>(path);
      if (!data || !data.id) return null;

      const credit = (data['artist-credit'] ?? [])
        .map(c => ({
          artistMbid: c.artist?.id ?? '',
          artistName: c.artist?.name ?? c.name ?? '',
          creditName: c.name ?? c.artist?.name ?? '',
          joinphrase: c.joinphrase,
        }))
        .filter(c => c.artistMbid !== '');

      return {
        recordingMbid: data.id,
        title: data.title,
        artistCredit: credit,
      };
    } catch (err) {
      logMbFailure('getRecording', err);
      return null;
    }
  }

  /**
   * Fetch a release-group by its stable MBID with the full artist-credit
   * array (`inc=artists`). Direct ID lookup — the analogous endpoint to
   * getRecording() for album spine-claim generation.
   */
  async getReleaseGroup(releaseGroupMbid: string): Promise<ReleaseGroupDetail | null> {
    if (!this.enabled) return null;
    if (!releaseGroupMbid) return null;

    const path = `/release-group/${encodeURIComponent(releaseGroupMbid)}?inc=artists&fmt=json`;

    try {
      const data = await this.get<MbReleaseGroupDetail>(path);
      if (!data || !data.id) return null;

      const credit = (data['artist-credit'] ?? [])
        .map(c => ({
          artistMbid: c.artist?.id ?? '',
          artistName: c.artist?.name ?? c.name ?? '',
          creditName: c.name ?? c.artist?.name ?? '',
          joinphrase: c.joinphrase,
        }))
        .filter(c => c.artistMbid !== '');

      return {
        releaseGroupMbid: data.id,
        title: data.title ?? '',
        artistCredit: credit,
      };
    } catch (err) {
      logMbFailure('getReleaseGroup', err);
      return null;
    }
  }

  /**
   * Fetch an artist's artist-relations (`inc=artist-rels`) and return the
   * relation list normalised. Direct ID lookup — the source for `member_of`
   * and `alias_of` claim generation.
   *
   * The MB web service exposes relations both as a modern top-level `relations`
   * array and the legacy `relation-list` groupings keyed by `target-type`; we
   * read both defensively. Only entries with a target artist MBID are kept.
   */
  async getArtistRelations(artistMbid: string): Promise<ArtistRelation[]> {
    if (!this.enabled) return [];
    if (!artistMbid) return [];

    const path = `/artist/${encodeURIComponent(artistMbid)}?inc=artist-rels&fmt=json`;

    try {
      const data = await this.get<MbArtistRelationsResponse>(path);
      if (!data) return [];

      const raw: MbRelation[] = [];
      if (Array.isArray(data.relations)) {
        raw.push(...data.relations);
      }
      for (const list of data['relation-list'] ?? []) {
        if (list['target-type'] === 'artist' && Array.isArray(list.relations)) {
          raw.push(...list.relations);
        }
      }

      return raw
        .map(r => ({
          type: r.type ?? '',
          direction: r.direction ?? 'forward',
          targetMbid: r.artist?.id ?? r.target ?? '',
          targetName: r.artist?.name ?? '',
        }))
        .filter(r => r.targetMbid !== '');
    } catch (err) {
      logMbFailure('getArtistRelations', err);
      return [];
    }
  }
}
