// Last.fm 2.0 API client (read-only methods).
//
// Every call appends api_key + format=json and goes through the http base, which
// enforces a small per-host rate limit. Best-effort: with no API key configured,
// or on any error, methods return [] rather than throwing.

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

export interface SimilarArtist {
  name: string;
  /** Similarity 0..1 as reported by Last.fm. */
  match: number;
}

export interface ArtistTag {
  name: string;
  /** Normalised 0..1 weight (relative to the strongest tag). */
  weight: number;
}

export interface SimilarTrack {
  name: string;
  artist: string;
  /** Similarity 0..1 as reported by Last.fm. */
  match: number;
}

// --- Raw Last.fm response shapes (only the fields we read) -----------------

interface LfmSimilarArtist {
  name?: string;
  match?: string | number;
}
interface LfmSimilarArtistsResponse {
  similarartists?: { artist?: LfmSimilarArtist[] };
}

interface LfmTag {
  name?: string;
  count?: string | number;
}
interface LfmTopTagsResponse {
  toptags?: { tag?: LfmTag[] };
}

interface LfmSimilarTrack {
  name?: string;
  match?: string | number;
  artist?: { name?: string };
}
interface LfmSimilarTracksResponse {
  similartracks?: { track?: LfmSimilarTrack[] };
}

interface LfmImage {
  '#text'?: string;
  size?: string;
}
interface LfmArtistInfoResponse {
  artist?: { image?: LfmImage[] };
}

const toNum = (v: string | number | undefined): number => {
  const n = typeof v === 'number' ? v : Number(v);
  return Number.isFinite(n) ? n : 0;
};

export class LastFmClient {
  private readonly cfg: LastFmConfig;
  private readonly userAgent: string;
  private readonly enabled: boolean;

  constructor(cfg: LastFmConfig = integrationsConfig.lastfm) {
    this.cfg = cfg;
    this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact);
    this.enabled = cfg.apiKey.trim() !== '';
    if (!this.enabled) {
      console.warn(
        '[Last.fm] LASTFM_API_KEY not set; client disabled (methods return []).'
      );
    }
  }

  /** Build a method URL with the supplied params plus api_key + format=json. */
  private url(method: string, params: Record<string, string | number>): string {
    const qs = new URLSearchParams({ method, api_key: this.cfg.apiKey, format: 'json' });
    for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
    return `${this.cfg.baseUrl}/?${qs.toString()}`;
  }

  private async get<T>(method: string, params: Record<string, string | number>): Promise<T> {
    return requestJson<T>(this.url(method, params), {
      userAgent: this.userAgent,
      minIntervalMs: this.cfg.minIntervalMs,
    });
  }

  /** artist.getsimilar -> up to `limit` similar artists with 0..1 match. */
  async getSimilarArtists(artist: string, limit = 20): Promise<SimilarArtist[]> {
    if (!this.enabled || artist.trim() === '') return [];
    try {
      const data = await this.get<LfmSimilarArtistsResponse>('artist.getsimilar', {
        artist,
        limit,
        autocorrect: 1,
      });
      const list = data.similarartists?.artist ?? [];
      return list
        .filter((a): a is LfmSimilarArtist & { name: string } => !!a.name)
        .map((a) => ({ name: a.name, match: toNum(a.match) }));
    } catch (err) {
      console.warn('[Last.fm] getSimilarArtists failed:', (err as Error).message);
      return [];
    }
  }

  /** artist.gettoptags -> tags with count normalised to 0..1. */
  async getArtistTopTags(artist: string): Promise<ArtistTag[]> {
    if (!this.enabled || artist.trim() === '') return [];
    try {
      const data = await this.get<LfmTopTagsResponse>('artist.gettoptags', {
        artist,
        autocorrect: 1,
      });
      const tags = (data.toptags?.tag ?? []).filter(
        (t): t is LfmTag & { name: string } => !!t.name
      );
      if (tags.length === 0) return [];
      const maxCount = Math.max(...tags.map((t) => toNum(t.count)), 1);
      return tags
        .map((t) => ({ name: t.name, weight: toNum(t.count) / maxCount }))
        .sort((a, b) => b.weight - a.weight);
    } catch (err) {
      console.warn('[Last.fm] getArtistTopTags failed:', (err as Error).message);
      return [];
    }
  }

  /** artist.getinfo -> best available image URL, or null. */
  async getArtistImageUrl(artist: string): Promise<string | null> {
    if (!this.enabled || artist.trim() === '') return null;
    try {
      const data = await this.get<LfmArtistInfoResponse>('artist.getinfo', {
        artist,
        autocorrect: 1,
      });
      const images = data.artist?.image ?? [];
      // Prefer 'extralarge', fall back through sizes in descending order.
      for (const size of ['extralarge', 'large', 'medium', 'small']) {
        const img = images.find((i) => i.size === size);
        const url = img?.['#text']?.trim();
        if (url && !url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return url;
      }
      return null;
    } catch (err) {
      console.warn('[Last.fm] getArtistImageUrl failed:', (err as Error).message);
      return null;
    }
  }

  /** track.getsimilar -> up to `limit` similar tracks with 0..1 match. */
  async getSimilarTracks(
    artist: string,
    track: string,
    limit = 20
  ): Promise<SimilarTrack[]> {
    if (!this.enabled || artist.trim() === '' || track.trim() === '') return [];
    try {
      const data = await this.get<LfmSimilarTracksResponse>('track.getsimilar', {
        artist,
        track,
        limit,
        autocorrect: 1,
      });
      const list = data.similartracks?.track ?? [];
      return list
        .filter((t): t is LfmSimilarTrack & { name: string } => !!t.name)
        .map((t) => ({
          name: t.name,
          artist: t.artist?.name ?? '',
          match: toNum(t.match),
        }));
    } catch (err) {
      console.warn('[Last.fm] getSimilarTracks failed:', (err as Error).message);
      return [];
    }
  }
}
