// External discovery sources: where recommendation candidates come from.
//
// Two deliberately separate strategies, one per source_trust key:
//
//   new_release            — an artist you actually play released something new.
//                            Seeded from local play counts, resolved via Deezer.
//   similar_recommendation — Last.fm's "sounds like" neighbours of the tracks
//                            you play most. Weaker prior, wider reach.
//
// Both write track-level rows into discovery_candidates carrying a search spec
// (artist + title) rather than a URL: the acquisition worker resolves that to a
// concrete allow-listed URL at download time. Nothing here touches the
// filesystem or the player; a candidate is only ever an intent to try a track.

import type { Pool } from 'pg';
import { DeezerClient, LastFmClient } from './integrations/index.js';

/** A track we might want to try, before any acquisition source is resolved. */
interface CandidateSeed {
  source: 'new_release' | 'similar_recommendation';
  /** Stable dedup key within the source. */
  externalId: string;
  title: string;
  artist: string;
  /** Local artist this candidate hangs off: the releasing or the seed artist. */
  relatedArtistId: string;
  /** 0..1 prior on this candidate being worth a probation slot. */
  relevance: number;
  raw: Record<string, unknown>;
}

interface TopArtist {
  id: string;
  name: string;
  plays: number;
}

interface TopTrack {
  title: string;
  artist: string;
  artistId: string;
  plays: number;
}

export interface DiscoverySourcesResult {
  considered: number;
  inserted: number;
}

const NEW_RELEASE_WINDOW_DAYS = 120;

export class DiscoverySourcesService {
  constructor(
    private readonly pgPool: Pool,
    private readonly deezer = new DeezerClient(),
    private readonly lastfm = new LastFmClient(),
  ) {}

  // ---------------------------------------------------------------
  // Shared plumbing
  // ---------------------------------------------------------------

  /** Artists ranked by how much of their local catalog actually gets played. */
  private async topArtists(limit: number): Promise<TopArtist[]> {
    const res = await this.pgPool.query<{ id: string; name: string; plays: string }>(
      `SELECT a.id, COALESCE(a.canonical_name, a.name) AS name, SUM(t.play_count)::text AS plays
         FROM tracks t
         JOIN albums al ON al.id = t.album_id
         JOIN artists a ON a.id = al.artist_id
        WHERE t.state = 'LIBRARY' AND t.deleted_at IS NULL AND t.play_count > 0
        GROUP BY a.id, COALESCE(a.canonical_name, a.name)
        ORDER BY SUM(t.play_count) DESC
        LIMIT $1`,
      [limit]
    );
    return res.rows.map((r) => ({ id: r.id, name: r.name, plays: Number(r.plays) }));
  }

  /** Most-played individual tracks — the seeds similarity lookups start from. */
  private async topTracks(limit: number): Promise<TopTrack[]> {
    const res = await this.pgPool.query<{ title: string; artist: string; artist_id: string; play_count: number }>(
      `SELECT t.title, t.artist, al.artist_id, t.play_count
         FROM tracks t
         JOIN albums al ON al.id = t.album_id
        WHERE t.state = 'LIBRARY' AND t.deleted_at IS NULL
          AND t.play_count > 0 AND al.artist_id IS NOT NULL
        ORDER BY t.play_count DESC
        LIMIT $1`,
      [limit]
    );
    return res.rows.map((r) => ({
      title: r.title,
      artist: r.artist,
      artistId: r.artist_id,
      plays: r.play_count,
    }));
  }

  /**
   * True when this artist+title is already a local track, in any state.
   *
   * Any state is deliberate: a retired or quarantined track must not be
   * re-acquired, otherwise every sweep re-downloads what the listener already
   * skipped away.
   */
  private async alreadyKnown(artist: string, title: string): Promise<boolean> {
    const res = await this.pgPool.query<{ exists: boolean }>(
      `SELECT EXISTS (
         SELECT 1 FROM tracks
          WHERE lower(artist) = lower($1) AND lower(title) = lower($2)
       ) AS exists`,
      [artist, title]
    );
    return res.rows[0]?.exists ?? false;
  }

  /**
   * Insert a candidate plus the relevance claim `evalCandidates` reads.
   *
   * The claim's object is the local artist the candidate came from. For a new
   * release that is the releasing artist; for a similarity hit it is the seed.
   * Either way it is what the existing per-artist diversity cap should count.
   */
  private async insertCandidate(seed: CandidateSeed): Promise<boolean> {
    // The (source, external_id) unique key does not catch the same song reaching
    // us twice under different ids — a Deezer single and the album that carries
    // it, or a Last.fm hit for something a new release already proposed. Name
    // equality does, in any candidate state, for the same reason alreadyKnown
    // ignores track state.
    const duplicate = await this.pgPool.query<{ exists: boolean }>(
      `SELECT EXISTS (
         SELECT 1 FROM discovery_candidates
          WHERE lower(title) = lower($1)
            AND lower(artist_credit->0->>'name') = lower($2)
       ) AS exists`,
      [seed.title, seed.artist]
    );
    if (duplicate.rows[0]?.exists) return false;

    const notes = {
      discovery_source: seed.source,
      ...seed.raw,
      // The acquisition worker turns this into a concrete URL. No URL is
      // recorded here because nothing has vetted one yet.
      acquisition: {
        query: `${seed.artist} ${seed.title}`,
        expectedTitle: seed.title,
        expectedArtist: seed.artist,
      },
    };

    const dcRes = await this.pgPool.query<{ id: string }>(
      `INSERT INTO discovery_candidates (source, external_id, title, artist_credit, notes)
       VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
       ON CONFLICT (source, external_id) DO NOTHING
       RETURNING id`,
      [
        seed.source,
        seed.externalId,
        seed.title,
        JSON.stringify([{ name: seed.artist, artist_id: seed.relatedArtistId }]),
        JSON.stringify(notes),
      ]
    );
    if (dcRes.rows.length === 0) return false;

    await this.pgPool.query(
      `INSERT INTO claims (
         subject_type, subject_id, predicate, object_type, object_id,
         source, confidence, raw
       ) VALUES ('discovery_candidate', $1::uuid, 'discovery_candidate', 'artist', $2::uuid,
                 $3, $4, $5::jsonb)
       ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
       DO UPDATE SET confidence = EXCLUDED.confidence, last_reinforced_at = NOW()`,
      [
        dcRes.rows[0].id,
        seed.relatedArtistId,
        seed.source,
        Math.max(0, Math.min(1, seed.relevance)),
        JSON.stringify({ discovery_source: seed.source, ...seed.raw }),
      ]
    );
    return true;
  }

  // ---------------------------------------------------------------
  // new_release
  // ---------------------------------------------------------------

  /**
   * For the `artistLimit` most-played local artists, find releases from the
   * last NEW_RELEASE_WINDOW_DAYS whose tracks we do not own.
   *
   * ponytail: `tracksPerAlbum` tracks per new album, not the whole thing. A
   * probation slot per track is the expensive resource, and one representative
   * track is enough to learn whether the release lands. Raise it if retention
   * on this source turns out high.
   */
  async discoverNewReleases(artistLimit = 15, tracksPerAlbum = 2): Promise<DiscoverySourcesResult> {
    const since = new Date(Date.now() - NEW_RELEASE_WINDOW_DAYS * 86_400_000)
      .toISOString()
      .slice(0, 10);
    let considered = 0;
    let inserted = 0;

    for (const artist of await this.topArtists(artistLimit)) {
      const albums = await this.deezer.getArtistAlbumsSince(artist.name, since);
      for (const album of albums) {
        const titles = await this.deezer.getAlbumTracks(album.id);
        let takenFromAlbum = 0;
        for (const title of titles) {
          if (takenFromAlbum >= tracksPerAlbum) break;
          considered++;
          if (await this.alreadyKnown(artist.name, title)) continue;
          const ok = await this.insertCandidate({
            source: 'new_release',
            externalId: `deezer:${album.id}:${title.toLowerCase()}`,
            title,
            artist: artist.name,
            relatedArtistId: artist.id,
            // A new release by someone already in heavy rotation is the
            // strongest prior this system has short of an explicit request.
            relevance: 0.75,
            raw: {
              album: album.title,
              release_date: album.releaseDate,
              record_type: album.recordType,
              seed_artist_plays: artist.plays,
            },
          });
          if (ok) {
            inserted++;
            takenFromAlbum++;
          }
        }
      }
    }

    return { considered, inserted };
  }

  // ---------------------------------------------------------------
  // similar_recommendation
  // ---------------------------------------------------------------

  /**
   * Ask Last.fm what sounds like the tracks played most, and keep the hits by
   * artists other than the seed's — a different track by the same artist is a
   * library gap, not a discovery.
   */
  async discoverRecommendations(seedLimit = 10, perSeed = 5): Promise<DiscoverySourcesResult> {
    let considered = 0;
    let inserted = 0;

    for (const seed of await this.topTracks(seedLimit)) {
      const similar = await this.lastfm.getSimilarTracks(seed.artist, seed.title, perSeed * 4);
      let takenFromSeed = 0;
      for (const hit of similar) {
        if (takenFromSeed >= perSeed) break;
        considered++;
        if (hit.artist.toLowerCase() === seed.artist.toLowerCase()) continue;
        if (await this.alreadyKnown(hit.artist, hit.name)) continue;
        const ok = await this.insertCandidate({
          source: 'similar_recommendation',
          externalId: `lastfm:${hit.artist.toLowerCase()}:${hit.name.toLowerCase()}`,
          title: hit.name,
          artist: hit.artist,
          relatedArtistId: seed.artistId,
          // Last.fm's own match score, floored so a weak-but-present match
          // still clears the acquisition gate's relevance > 0.3.
          relevance: Math.max(0.35, Math.min(0.7, hit.match)),
          raw: {
            seed_artist: seed.artist,
            seed_title: seed.title,
            seed_plays: seed.plays,
            lastfm_match: hit.match,
          },
        });
        if (ok) {
          inserted++;
          takenFromSeed++;
        }
      }
    }

    return { considered, inserted };
  }
}
