import { DbService, ListenerBelief } from './db.service.js';
import { Candidate, GeneratorContext, Generator, ALL_GENERATORS } from './generators.service.js';

export interface FatigueState {
  artist: Map<string, number>;
  genre: Map<string, number>;
  language: Map<string, number>;
  track: Map<string, number>;
  vocal: number;
}

export interface RecentPlay {
  trackId: string;
  artistId: string | null;
  genreId: string | null;
  bpm: number | null;
  energy: number | null;
  language: string | null;
  vocal: boolean;
  decade: number | null;
  valence: number | null;
}

export interface DiversityBudget {
  dimension: string;
  budgetShare: number;
  horizonMin: number;
  spent: number;
}

export interface RepetitionState {
  recentTrackIds: Set<string>;
  recentArtistIds: Set<string>;
}

const W_ENJOY = 1.0;
const W_FATIGUE = 0.4;
const W_DIVERSITY = 0.3;
const W_ENTROPY = 0.2;
const W_REPETITION = 0.5;

export class SessionDirector {
  constructor(private db: DbService) {}

  // ---------------------------------------------------------------
  // D.1 — Build listener state vector
  // ---------------------------------------------------------------
  async buildState(userId: string, sessionId?: string): Promise<GeneratorContext['state']> {
    let savedState: GeneratorContext['state'] | null = null;

    if (sessionId) {
      const res = await this.db.pgClient.query(
        'SELECT * FROM session_state WHERE session_id = $1 AND user_id = $2',
        [sessionId, userId]
      );
      if (res.rows[0]) {
        const row = res.rows[0] as { state_vector: Record<string, unknown>; context: string | null; started_at: Date };
        savedState = {
          energy: (row.state_vector?.energy as number) ?? 0.5,
          lastArtistIds: (row.state_vector?.lastArtistIds as string[]) ?? [],
          lastGenreIds: (row.state_vector?.lastGenreIds as string[]) ?? [],
          context: row.context,
          noveltyHunger: (row.state_vector?.noveltyHunger as number) ?? 0.3,
          sessionAgeMin: row.started_at
            ? (Date.now() - new Date(row.started_at).getTime()) / 60000
            : 0,
        };
      }
    }

    if (!savedState) {
      const latest = await this.db.getLatestSessionState(userId);
      if (latest) {
        savedState = {
          energy: (latest.state_vector?.energy as number) ?? 0.5,
          lastArtistIds: (latest.state_vector?.lastArtistIds as string[]) ?? [],
          lastGenreIds: (latest.state_vector?.lastGenreIds as string[]) ?? [],
          context: latest.context,
          noveltyHunger: (latest.state_vector?.noveltyHunger as number) ?? 0.3,
          sessionAgeMin: latest.started_at
            ? (Date.now() - new Date(latest.started_at).getTime()) / 60000
            : 0,
        };
      }
    }

    // Compute fresh energy from last 5 completed plays
    const energyRes = await this.db.pgClient.query(
      `SELECT COALESCE(AVG(taf.energy), 0.5) AS energy
       FROM (
         SELECT ph.track_id
         FROM play_history ph
         WHERE ph.user_id = $1 AND ph.completed = true
         ORDER BY ph.played_at DESC
         LIMIT 5
       ) recent
       JOIN track_audio_features taf ON taf.track_id = recent.track_id
       WHERE taf.energy IS NOT NULL`,
      [userId]
    );
    const energy = (energyRes.rows[0]?.energy as number) ?? 0.5;

    // Read novelty hunger from discovery profile
    const noveltyRes = await this.db.pgClient.query(
      `SELECT value FROM listener_beliefs
       WHERE user_id = $1 AND profile = 'discovery' AND dimension = 'novelty_tolerance'
       LIMIT 1`,
      [userId]
    );
    const noveltyHunger = (noveltyRes.rows[0]?.value as number) ?? 0.3;

    // Last distinct artist IDs from recent completed plays.
    // Use a subquery to order first, then DISTINCT — avoids PG's rule that
    // DISTINCT + ORDER BY expressions must appear in the select list.
    const lastArtistsRes = await this.db.pgClient.query(
      `SELECT DISTINCT artist_id FROM (
         SELECT ta.artist_id
         FROM play_history ph
         JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
         WHERE ph.user_id = $1 AND ph.completed = true
         ORDER BY ph.played_at DESC
         LIMIT 40
       ) recent
       LIMIT 10`,
      [userId]
    );
    const lastArtistIds = lastArtistsRes.rows.map((r: { artist_id: string }) => r.artist_id);

    // Last distinct genre IDs
    const lastGenresRes = await this.db.pgClient.query(
      `SELECT DISTINCT genre_id FROM (
         SELECT tg.genre_id
         FROM play_history ph
         JOIN track_genre tg ON tg.track_id = ph.track_id
         WHERE ph.user_id = $1 AND ph.completed = true
         ORDER BY ph.played_at DESC
         LIMIT 40
       ) recent
       LIMIT 10`,
      [userId]
    );
    const lastGenreIds = lastGenresRes.rows.map((r: { genre_id: string }) => r.genre_id);

    const age = savedState?.sessionAgeMin ?? 0;

    return {
      energy,
      lastArtistIds,
      lastGenreIds,
      context: savedState?.context ?? null,
      noveltyHunger,
      sessionAgeMin: age,
    };
  }

  // ---------------------------------------------------------------
  // D.2 — Fatigue model
  // ---------------------------------------------------------------
  async computeFatigue(userId: string): Promise<FatigueState> {
    // Track fatigue: last 7 days, decay time constant 30d (e-folding; half-life ≈ 20.8d)
    const TRACK_DECAY_SEC = 30 * 24 * 3600;
    const trackRes = await this.db.pgClient.query(
      `SELECT ph.track_id,
              LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
       FROM play_history ph
       WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '7 days' AND ph.completed = true
       GROUP BY ph.track_id`,
      [userId, TRACK_DECAY_SEC]
    );
    const track = new Map<string, number>();
    for (const row of trackRes.rows as { track_id: string; fatigue: number }[]) {
      track.set(row.track_id, row.fatigue);
    }

    // Artist fatigue: last 24h, decay time constant 8h (28800s) — this is an e-folding
    // time (EXP(-t/tau)), not a half-life; the actual half-life is tau*ln(2) ≈ 5.5h
    const ARTIST_DECAY_SEC = 8 * 3600;
    const artistRes = await this.db.pgClient.query(
      `SELECT ta.artist_id,
              LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
       FROM play_history ph
       JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
       WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours' AND ph.completed = true
       GROUP BY ta.artist_id`,
      [userId, ARTIST_DECAY_SEC]
    );
    const artist = new Map<string, number>();
    for (const row of artistRes.rows as { artist_id: string; fatigue: number }[]) {
      artist.set(row.artist_id, row.fatigue);
    }

    // Genre fatigue: last 24h, decay time constant 8h (e-folding, not half-life; half-life ≈ 5.5h)
    const genreRes = await this.db.pgClient.query(
      `SELECT tg.genre_id,
              LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
       FROM play_history ph
       JOIN track_genre tg ON tg.track_id = ph.track_id
       WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours' AND ph.completed = true
       GROUP BY tg.genre_id`,
      [userId, ARTIST_DECAY_SEC]
    );
    const genre = new Map<string, number>();
    for (const row of genreRes.rows as { genre_id: string; fatigue: number }[]) {
      genre.set(row.genre_id, row.fatigue);
    }

    // Language fatigue: last 2h, decay time constant 1h (3600s, e-folding; half-life ≈ 0.7h)
    const LANG_DECAY_SEC = 3600;
    const langRes = await this.db.pgClient.query(
      `SELECT tl.language,
              LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
       FROM play_history ph
       JOIN track_lyrics tl ON tl.track_id = ph.track_id
       WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '2 hours' AND ph.completed = true
         AND tl.language IS NOT NULL
       GROUP BY tl.language`,
      [userId, LANG_DECAY_SEC]
    );
    const language = new Map<string, number>();
    for (const row of langRes.rows as { language: string; fatigue: number }[]) {
      language.set(row.language, row.fatigue);
    }

    // Vocal fatigue: fraction of last 2h plays that are vocal (instrumentalness < 0.5)
    const vocalRes = await this.db.pgClient.query(
      `SELECT CASE WHEN COUNT(*) = 0 THEN 0.5
                  ELSE COUNT(*) FILTER (WHERE COALESCE(taf.instrumentalness, 0) < 0.5)::float8 / COUNT(*)::float8
             END AS vocal_fatigue
       FROM play_history ph
       LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id
       WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '2 hours' AND ph.completed = true`,
      [userId]
    );
    const vocal = (vocalRes.rows[0]?.vocal_fatigue as number) ?? 0.5;

    return { artist, genre, language, track, vocal };
  }

  // ---------------------------------------------------------------
  // D.3 — Diversity budgets
  // ---------------------------------------------------------------
  async getBudgets(userId: string): Promise<DiversityBudget[]> {
    const res = await this.db.pgClient.query(
      'SELECT * FROM diversity_budgets WHERE user_id = $1 ORDER BY dimension',
      [userId]
    );

    let rows: { dimension: string; budget_share: number; horizon_min: number }[];
    if (res.rows.length === 0) {
      await this.db.seedDefaultDiversityBudgets(userId);
      const res2 = await this.db.pgClient.query(
        'SELECT * FROM diversity_budgets WHERE user_id = $1 ORDER BY dimension',
        [userId]
      );
      rows = res2.rows;
    } else {
      rows = res.rows;
    }

    const budgets: DiversityBudget[] = [];
    for (const row of rows) {
      const spent = await this.calcBudgetSpent(userId, row.dimension, row.horizon_min);
      budgets.push({
        dimension: row.dimension,
        budgetShare: row.budget_share,
        horizonMin: row.horizon_min,
        spent,
      });
    }
    return budgets;
  }

  private async calcBudgetSpent(userId: string, dimension: string, horizonMin: number): Promise<number> {
    const interval = `${horizonMin} minutes`;

    switch (dimension) {
      case 'artist': {
        const res = await this.db.pgClient.query(
          `WITH sub AS (
             SELECT COUNT(*) AS cnt
             FROM play_history ph
             JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
             WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true
             GROUP BY ta.artist_id
           )
           SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent
           FROM sub`,
          [userId, interval]
        );
        return (res.rows[0]?.spent as number) ?? 0;
      }
      case 'genre': {
        const res = await this.db.pgClient.query(
          `WITH sub AS (
             SELECT COUNT(*) AS cnt
             FROM play_history ph
             JOIN track_genre tg ON tg.track_id = ph.track_id
             WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true
             GROUP BY tg.genre_id
           )
           SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent
           FROM sub`,
          [userId, interval]
        );
        return (res.rows[0]?.spent as number) ?? 0;
      }
      case 'language': {
        const res = await this.db.pgClient.query(
          `WITH sub AS (
             SELECT tl.language, COUNT(*) AS cnt
             FROM play_history ph
             JOIN track_lyrics tl ON tl.track_id = ph.track_id
             WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true
               AND tl.language IS NOT NULL
             GROUP BY tl.language
           )
           SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent
           FROM sub`,
          [userId, interval]
        );
        return (res.rows[0]?.spent as number) ?? 0;
      }
      case 'instrumental': {
        const res = await this.db.pgClient.query(
          `SELECT COALESCE(
             COUNT(*) FILTER (WHERE COALESCE(taf.instrumentalness, 0) > 0.5)::float8 / NULLIF(COUNT(*), 0),
           0) AS spent
           FROM play_history ph
           LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id
           WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`,
          [userId, interval]
        );
        return (res.rows[0]?.spent as number) ?? 0;
      }
      case 'new_artist': {
        const res = await this.db.pgClient.query(
          `WITH recent_artists AS (
             SELECT DISTINCT ta.artist_id
             FROM play_history ph
             JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
             WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true
           )
           SELECT COALESCE(
             SUM(CASE WHEN NOT EXISTS (
               SELECT 1 FROM play_history ph3
               JOIN track_artists_v2 ta3 ON ta3.track_id = ph3.track_id AND ta3.role = 'main'
               WHERE ph3.user_id = $1 AND ph3.played_at <= NOW() - $2::interval
                 AND ta3.artist_id = ra.artist_id
             ) THEN 1 ELSE 0 END)::float8 / NULLIF(COUNT(*), 0),
           0) AS spent
           FROM recent_artists ra`,
          [userId, interval]
        );
        return (res.rows[0]?.spent as number) ?? 0;
      }
      case 'favorite': {
        const res = await this.db.pgClient.query(
          `SELECT COALESCE(
             COUNT(*) FILTER (WHERE f.track_id IS NOT NULL)::float8 / NULLIF(COUNT(*), 0),
           0) AS spent
           FROM play_history ph
           LEFT JOIN favorites f ON f.track_id = ph.track_id AND f.user_id = $1
           WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`,
          [userId, interval]
        );
        return (res.rows[0]?.spent as number) ?? 0;
      }
      default:
        return 0;
    }
  }

  // ---------------------------------------------------------------
  // D.4 — Arc selection
  // ---------------------------------------------------------------
  pickArc(state: GeneratorContext['state']): string {
    if (state.energy < 0.3) return 'late-night';
    if (state.energy > 0.6 && state.noveltyHunger > 0.5) return 'discovery';
    if (state.energy > 0.6) return 'energetic';
    return 'comfort';
  }

  getArcSlots(arcType: string, count: number): { position: number; role: string }[] {
    const pattern = this.getArcPattern(arcType);
    const slots: { position: number; role: string }[] = [];
    for (let i = 0; i < count; i++) {
      slots.push({ position: i, role: pattern[i % pattern.length] });
    }
    return slots;
  }

  private getArcPattern(arcType: string): string[] {
    switch (arcType) {
      case 'comfort':
        return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite'];
      case 'discovery':
        return ['favorite', 'similar', 'new', 'favorite'];
      case 'energetic':
        return ['medium', 'medium', 'high', 'high', 'high', 'peak', 'cooldown', 'cooldown'];
      case 'late-night':
        return ['soft', 'soft', 'ambient', 'ambient', 'acoustic', 'slow'];
      default:
        return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite'];
    }
  }

  private roleToGeneratorIds(role: string): string[] {
    switch (role) {
      case 'known':
      case 'medium':
      case 'soft':
      case 'acoustic':
      case 'slow':
      case 'cooldown':
        return ['comfort'];
      case 'adjacent':
      case 'similar':
        return ['adjacent'];
      case 'favorite':
        return ['deep-dive', 'comfort'];
      case 'new':
      case 'high':
        return ['discovery'];
      case 'peak':
        return ['deep-dive', 'contextual'];
      case 'ambient':
        return ['contextual', 'comfort'];
      default:
        return ['comfort'];
    }
  }

  // ---------------------------------------------------------------
  // D.5 — Entropy, anti-loop
  // ---------------------------------------------------------------
  // NOTE: despite the name, this computes the Herfindahl-Hirschman Index (artist
  // concentration, 0 = maximally diverse, 1 = single artist) — not entropy.
  // `artistIdOf` should resolve the candidate's actual attributed artist; without it
  // this falls back to guessing from the first artist-typed explanation edge, which
  // for some generators (discovery/revival/contextual) isn't the real artist —
  // pass a resolver when a real artist map is available (see rankCandidates).
  computeEntropy(candidates: Candidate[], artistIdOf?: (c: Candidate) => string): number {
    if (candidates.length === 0) return 0;
    const artistCounts = new Map<string, number>();
    for (const c of candidates) {
      let key: string;
      if (artistIdOf) {
        key = artistIdOf(c);
      } else {
        const mainEdge = c.explanation.find(
          e => e.subjectType === 'artist' || e.objectType === 'artist'
        );
        key = mainEdge?.subjectId ?? mainEdge?.objectId ?? 'unknown';
      }
      artistCounts.set(key, (artistCounts.get(key) ?? 0) + 1);
    }
    const n = candidates.length;
    let hhi = 0;
    for (const count of artistCounts.values()) {
      const share = count / n;
      hhi += share * share;
    }
    return hhi;
  }

  async detectAntiLoop(
    state: GeneratorContext['state'],
    fatigue: FatigueState,
    budgets: DiversityBudget[],
    recentPlays: RecentPlay[]
  ): Promise<string | null> {
    const n = recentPlays.length;
    if (n < 3) return null;

    // 1. ARTIST: single artist > 30% of recent plays
    const artistCounts = new Map<string, number>();
    for (const p of recentPlays) {
      if (p.artistId) artistCounts.set(p.artistId, (artistCounts.get(p.artistId) ?? 0) + 1);
    }
    for (const count of artistCounts.values()) {
      if (count / n > 0.3) return 'artist';
    }

    // 2. GENRE: single genre > 40% of recent plays
    const genreCounts = new Map<string, number>();
    for (const p of recentPlays) {
      if (p.genreId) genreCounts.set(p.genreId, (genreCounts.get(p.genreId) ?? 0) + 1);
    }
    for (const count of genreCounts.values()) {
      if (count / n > 0.4) return 'genre';
    }

    // 3. LANGUAGE: single language > 50% of recent plays
    const langCounts = new Map<string, number>();
    for (const p of recentPlays) {
      if (p.language) langCounts.set(p.language, (langCounts.get(p.language) ?? 0) + 1);
    }
    for (const count of langCounts.values()) {
      if (count / n > 0.5) return 'language';
    }

    // 4. ENERGY: >60% of plays in same energy quartile
    const energies = recentPlays.filter(p => p.energy != null).map(p => p.energy!);
    if (energies.length >= 3) {
      const quartileCounts = [0, 0, 0, 0];
      for (const e of energies) {
        const q = Math.min(Math.floor(e / 0.25), 3);
        quartileCounts[q]++;
      }
      if (Math.max(...quartileCounts) / energies.length > 0.6) return 'energy';
    }

    // 5. BPM: all plays within 20 BPM of each other
    const bpms = recentPlays.filter(p => p.bpm != null).map(p => p.bpm!);
    if (bpms.length >= 3) {
      const bpmMin = Math.min(...bpms);
      const bpmMax = Math.max(...bpms);
      if (bpmMax - bpmMin <= 20) return 'bpm';
    }

    // 6. VOCAL: >80% all-vocal or all-instrumental
    if (n >= 3) {
      const vocalCount = recentPlays.filter(p => p.vocal).length;
      const vocalRatio = vocalCount / n;
      if (vocalRatio > 0.8 || vocalRatio < 0.2) return 'vocal';
    }

    // 7. DECADE: >50% from same decade
    const decadeCounts = new Map<number, number>();
    for (const p of recentPlays) {
      if (p.decade != null) decadeCounts.set(p.decade, (decadeCounts.get(p.decade) ?? 0) + 1);
    }
    for (const count of decadeCounts.values()) {
      if (count / n > 0.5) return 'decade';
    }

    // 8. PRODUCER: single producer > 3 tracks
    const trackIds = recentPlays.map(p => p.trackId).filter(Boolean);
    if (trackIds.length > 0) {
      const prodRes = await this.db.pgClient.query(
        `SELECT c.object_id
         FROM claims c
         WHERE c.predicate = 'produced'
           AND c.subject_id = ANY($1::uuid[])
         GROUP BY c.object_id
         HAVING COUNT(DISTINCT c.subject_id) > 3`,
        [trackIds]
      );
      if (prodRes.rows.length > 0) return 'producer';
    }

    // 9. LABEL: single label > 3 tracks
    if (trackIds.length > 0) {
      const labelRes = await this.db.pgClient.query(
        `SELECT c.object_id
         FROM claims c
         WHERE c.predicate = 'same_label_as'
           AND c.subject_id = ANY($1::uuid[])
         GROUP BY c.object_id
         HAVING COUNT(DISTINCT c.subject_id) > 3`,
        [trackIds]
      );
      if (labelRes.rows.length > 0) return 'label';
    }

    // 10. MOOD: all plays same mood (valence > 0.5 = positive, <= 0.5 = negative)
    const valences = recentPlays.filter(p => p.valence != null).map(p => p.valence!);
    if (valences.length >= 3) {
      const positiveCount = valences.filter(v => v > 0.5).length;
      if (positiveCount === valences.length || positiveCount === 0) return 'mood';
    }

    return null;
  }

  // ---------------------------------------------------------------
  // D.6 — Repetition rules
  // ---------------------------------------------------------------
  async checkRepetition(trackId: string, artistId: string, userId: string): Promise<boolean> {
    const rulesRes = await this.db.pgClient.query(
      'SELECT dimension, min_distance FROM repetition_rules WHERE user_id = $1',
      [userId]
    );

    const ruleMap = new Map<string, number>();
    for (const row of rulesRes.rows as { dimension: string; min_distance: number }[]) {
      ruleMap.set(row.dimension, row.min_distance);
    }

    const trackMin = ruleMap.get('track') ?? 120;

    if (trackMin > 0) {
      const res = await this.db.pgClient.query(
        `SELECT 1 FROM play_history
         WHERE user_id = $1 AND track_id = $2 AND completed = true
           AND played_at > NOW() - ($3 || ' minutes')::interval
         LIMIT 1`,
        [userId, trackId, String(trackMin)]
      );
      if (res.rows.length > 0) return true;
    }

    const artistMin = ruleMap.get('artist') ?? 20;
    if (artistId && artistMin > 0) {
      const res = await this.db.pgClient.query(
        `SELECT 1 FROM play_history ph
         JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.artist_id = $2 AND ta.role = 'main'
         WHERE ph.user_id = $1 AND ph.completed = true
           AND ph.played_at > NOW() - ($3 || ' minutes')::interval
         LIMIT 1`,
        [userId, artistId, String(artistMin)]
      );
      if (res.rows.length > 0) return true;
    }

    return false;
  }

  // Batched version of checkRepetition for ranking a whole candidate pool:
  // loads repetition_rules once, then one query for recently-played
  // tracks/artists within the max window, and checks membership in JS
  // instead of 2-3 sequential queries per candidate.
  async buildRepetitionState(userId: string): Promise<RepetitionState> {
    const rulesRes = await this.db.pgClient.query(
      'SELECT dimension, min_distance FROM repetition_rules WHERE user_id = $1',
      [userId]
    );
    const ruleMap = new Map<string, number>();
    for (const row of rulesRes.rows as { dimension: string; min_distance: number }[]) {
      ruleMap.set(row.dimension, row.min_distance);
    }
    const trackMin = ruleMap.get('track') ?? 120;
    const artistMin = ruleMap.get('artist') ?? 20;

    const recentTrackIds = new Set<string>();
    const recentArtistIds = new Set<string>();
    const maxMin = Math.max(trackMin, artistMin);
    if (maxMin > 0) {
      const res = await this.db.pgClient.query(
        `SELECT ph.track_id, ta.artist_id, ph.played_at
         FROM play_history ph
         LEFT JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
         WHERE ph.user_id = $1 AND ph.completed = true
           AND ph.played_at > NOW() - ($2 || ' minutes')::interval`,
        [userId, String(maxMin)]
      );
      const now = Date.now();
      for (const row of res.rows as { track_id: string; artist_id: string | null; played_at: Date }[]) {
        const ageMin = (now - new Date(row.played_at).getTime()) / 60000;
        if (trackMin > 0 && ageMin <= trackMin) recentTrackIds.add(row.track_id);
        if (artistMin > 0 && row.artist_id && ageMin <= artistMin) recentArtistIds.add(row.artist_id);
      }
    }

    return { recentTrackIds, recentArtistIds };
  }

  // Shared track_id -> main artist_id lookup, used by rankCandidates and replan.
  private async loadArtistMap(trackIds: string[]): Promise<Map<string, string>> {
    const artistMap = new Map<string, string>();
    if (trackIds.length === 0) return artistMap;
    const artRes = await this.db.pgClient.query(
      `SELECT DISTINCT ON (ta.track_id) ta.track_id, ta.artist_id
       FROM track_artists_v2 ta
       WHERE ta.track_id = ANY($1::uuid[]) AND ta.role = 'main'`,
      [trackIds]
    );
    for (const row of artRes.rows as { track_id: string; artist_id: string }[]) {
      artistMap.set(row.track_id, row.artist_id);
    }
    return artistMap;
  }

  // ---------------------------------------------------------------
  // D.8 — Multi-objective ranking
  // ---------------------------------------------------------------
  async rankCandidates(
    candidates: Candidate[],
    fatigue: FatigueState,
    budgets: DiversityBudget[],
    state: GeneratorContext['state'],
    repetitionState: RepetitionState
  ): Promise<Candidate[]> {
    if (candidates.length === 0) return [];

    const trackIds = [...new Set(candidates.map(c => c.trackId))];
    const artistMap = await this.loadArtistMap(trackIds);

    const genreMap = new Map<string, string>();
    if (trackIds.length > 0) {
      const genreRes = await this.db.pgClient.query(
        `SELECT DISTINCT ON (tg.track_id) tg.track_id, tg.genre_id
         FROM track_genre tg
         WHERE tg.track_id = ANY($1::uuid[])
         ORDER BY tg.track_id, tg.weight DESC`,
        [trackIds]
      );
      for (const row of genreRes.rows as { track_id: string; genre_id: string }[]) {
        genreMap.set(row.track_id, row.genre_id);
      }
    }

    const currentEntropy = this.computeEntropy(candidates, c => artistMap.get(c.trackId) ?? 'unknown');
    const targetEntropy = 0.55;

    // Per-candidate artist share within this batch, for a real per-candidate
    // entropy contribution instead of the batch-wide constant.
    const artistBatchCounts = new Map<string, number>();
    for (const c of candidates) {
      const aid = artistMap.get(c.trackId) ?? '';
      artistBatchCounts.set(aid, (artistBatchCounts.get(aid) ?? 0) + 1);
    }

    const scored: { candidate: Candidate; score: number }[] = [];
    for (const c of candidates) {
      const artistId = artistMap.get(c.trackId) ?? '';
      const genreId = genreMap.get(c.trackId) ?? '';

      const trackFatigue = fatigue.track.get(c.trackId) ?? 0;
      const artistFatigue = fatigue.artist.get(artistId) ?? 0;
      const genreFatigue = fatigue.genre.get(genreId) ?? 0;
      const avgFatigue = (trackFatigue + artistFatigue + genreFatigue) / 3;

      // diversityBonus: this artist's own fatigue-weighted share — varies per candidate.
      const diversityBonus = 1 - artistFatigue;
      // entropyBonus: reward candidates whose artist is underrepresented in this batch.
      const artistShare = (artistBatchCounts.get(artistId) ?? 0) / candidates.length;
      const entropyBonus = 1 - artistShare;
      const wouldRepeat =
        repetitionState.recentTrackIds.has(c.trackId) ||
        (!!artistId && repetitionState.recentArtistIds.has(artistId));

      let score = W_ENJOY * c.relevance
        - W_FATIGUE * avgFatigue
        + W_DIVERSITY * diversityBonus
        + W_ENTROPY * entropyBonus;

      if (wouldRepeat) {
        score *= 0.1;
      }

      scored.push({ candidate: c, score });
    }

    const entropyDrift = Math.abs(currentEntropy - targetEntropy);
    if (entropyDrift > 0.2) {
      const genCounts = new Map<string, number>();
      for (const s of scored) {
        genCounts.set(s.candidate.generatorId, (genCounts.get(s.candidate.generatorId) ?? 0) + 1);
      }
      const maxCount = Math.max(...genCounts.values(), 1);
      for (const s of scored) {
        const genCount = genCounts.get(s.candidate.generatorId) ?? 0;
        s.score += (1 - genCount / maxCount) * 0.15;
      }
    }

    scored.sort((a, b) => b.score - a.score);
    return scored.map(s => s.candidate);
  }

  // session_state is otherwise only written once at /v2/vibe/start — persist the
  // freshly-computed state vector here so it evolves across the session instead of
  // buildState always reading back the boot defaults.
  async persistState(sessionId: string, userId: string, state: GeneratorContext['state']): Promise<void> {
    await this.db.pgClient.query(
      `UPDATE session_state
       SET state_vector = $3::jsonb, last_interaction = NOW()
       WHERE session_id = $1 AND user_id = $2`,
      [
        sessionId,
        userId,
        JSON.stringify({
          energy: state.energy,
          lastArtistIds: state.lastArtistIds,
          lastGenreIds: state.lastGenreIds,
          noveltyHunger: state.noveltyHunger,
        }),
      ]
    );
  }

  // ---------------------------------------------------------------
  // D.9 — Plan + replan loop
  // ---------------------------------------------------------------
  async buildPlan(userId: string, sessionId: string, seedTrackId?: string): Promise<Candidate[]> {
    const allBeliefs = await this.db.getListenerBeliefs({ userId, limit: 200 });

    // Fetch recent completed plays for anti-loop detection
    const recentPlaysRes = await this.db.pgClient.query(
      `SELECT t.id AS track_id, ta.artist_id, tg.genre_id,
              af.bpm, af.energy, af.valence, af.instrumentalness,
              tl.language,
              t.release_date
       FROM play_history ph
       JOIN tracks t ON t.id = ph.track_id
       LEFT JOIN track_audio_features af ON af.track_id = t.id
       LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main'
       LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = (
         SELECT MAX(weight) FROM track_genre WHERE track_id = t.id
       )
       LEFT JOIN track_lyrics tl ON tl.track_id = t.id
       WHERE ph.user_id = $1 AND ph.completed = true
       ORDER BY ph.played_at DESC
       LIMIT 20`,
      [userId]
    );
    const recentPlays: RecentPlay[] = recentPlaysRes.rows.map((r: any) => ({
      trackId: r.track_id,
      artistId: r.artist_id ?? null,
      genreId: r.genre_id ?? null,
      bpm: r.bpm ?? null,
      energy: r.energy ?? null,
      language: r.language ?? null,
      vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5,
      decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null,
      valence: r.valence ?? null,
    }));

    const state = await this.buildState(userId, sessionId);
    await this.persistState(sessionId, userId, state);
    const fatigue = await this.computeFatigue(userId);
    const budgets = await this.getBudgets(userId);

    const arcType = this.pickArc(state);
    const planSize = 20;
    const slots = this.getArcSlots(arcType, planSize);

    let seedArtistId: string | null = null;
    if (seedTrackId) {
      seedArtistId = await this.resolveSeedArtistId(seedTrackId) ?? null;
    }

    const recentExclusions: string[] = recentPlays.map(p => p.trackId);
    const toleranceMap: Record<string, number> = {};
    const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery');
    for (const b of discoveryBeliefs) {
      if (b.dimension) toleranceMap[b.dimension] = b.value;
    }

    const ctx: GeneratorContext = {
      userId,
      seedTrackId: seedTrackId ?? null,
      seedArtistId,
      beliefs: allBeliefs,
      recentExclusions,
      toleranceMap,
      state,
    };

    const allCandidates: Candidate[] = [];
    for (const gen of ALL_GENERATORS) {
      const result = await gen(this.db, ctx);
      allCandidates.push(...result);
    }

    if (allCandidates.length === 0) {
      return [];
    }

    const repetitionState = await this.buildRepetitionState(userId);
    const ranked = await this.rankCandidates(
      allCandidates, fatigue, budgets, state, repetitionState
    );

    const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
    let forcedExperimental = false;
    if (loopDim && ranked.length > 0) {
      // Anti-loop candidates are already present in `ranked` — just pull them to the
      // front instead of re-running all generators and re-ranking from scratch.
      const injected = ranked.filter(
        c => c.generatorId === 'experimental' || c.generatorId === 'discovery'
      );
      ranked.unshift(...injected);
      forcedExperimental = true;
    }

    const seen = new Set<string>();
    const deduped: Candidate[] = [];
    for (const c of ranked) {
      if (!seen.has(c.trackId)) {
        seen.add(c.trackId);
        deduped.push(c);
      }
    }

    const plan: Candidate[] = [];
    const usedTrackIds = new Set<string>();

    if (!forcedExperimental) {
      const unused = [...deduped];
      for (const slot of slots) {
        const prefGenIds = this.roleToGeneratorIds(slot.role);
        let idx = unused.findIndex(
          c => prefGenIds.includes(c.generatorId) && !usedTrackIds.has(c.trackId)
        );
        if (idx === -1) {
          idx = unused.findIndex(c => !usedTrackIds.has(c.trackId));
        }
        if (idx === -1) break;
        const chosen = unused[idx];
        usedTrackIds.add(chosen.trackId);
        plan.push(chosen);
        unused.splice(idx, 1);
      }

      if (plan.length < planSize) {
        for (const c of deduped) {
          if (plan.length >= planSize) break;
          if (!usedTrackIds.has(c.trackId)) {
            usedTrackIds.add(c.trackId);
            plan.push(c);
          }
        }
      }
    } else {
      for (const c of deduped) {
        if (plan.length >= planSize) break;
        plan.push(c);
      }
    }

    return plan.slice(0, planSize);
  }

  async replan(
    userId: string,
    sessionId: string,
    currentPlan: Candidate[],
    playedTrackIds: string[],
    seedTrackId?: string
  ): Promise<Candidate[]> {
    const remainingSlots = currentPlan.filter(
      c => !playedTrackIds.includes(c.trackId)
    );

    if (remainingSlots.length >= 10 && currentPlan.length > 0) {
      const fatigue = await this.computeFatigue(userId);
      const budgets = await this.getBudgets(userId);
      const state = await this.buildState(userId, sessionId);
      await this.persistState(sessionId, userId, state);

      // Fetch recent plays for anti-loop
      const recentPlaysRes = await this.db.pgClient.query(
        `SELECT t.id AS track_id, ta.artist_id, tg.genre_id,
                af.bpm, af.energy, af.valence, af.instrumentalness,
                tl.language,
                t.release_date
         FROM play_history ph
         JOIN tracks t ON t.id = ph.track_id
         LEFT JOIN track_audio_features af ON af.track_id = t.id
         LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main'
         LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = (
           SELECT MAX(weight) FROM track_genre WHERE track_id = t.id
         )
         LEFT JOIN track_lyrics tl ON tl.track_id = t.id
         WHERE ph.user_id = $1 AND ph.completed = true
         ORDER BY ph.played_at DESC
         LIMIT 20`,
        [userId]
      );
      const recentPlays: RecentPlay[] = recentPlaysRes.rows.map((r: any) => ({
        trackId: r.track_id,
        artistId: r.artist_id ?? null,
        genreId: r.genre_id ?? null,
        bpm: r.bpm ?? null,
        energy: r.energy ?? null,
        language: r.language ?? null,
        vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5,
        decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null,
        valence: r.valence ?? null,
      }));

      const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
      if (loopDim) {
        return this.buildPlan(userId, sessionId, seedTrackId);
      }

      const planArtistMap = await this.loadArtistMap([...new Set(currentPlan.map(c => c.trackId))]);
      const entropy = this.computeEntropy(currentPlan, c => planArtistMap.get(c.trackId) ?? 'unknown');
      if (Math.abs(entropy - 0.55) > 0.2) {
        return this.buildPlan(userId, sessionId, seedTrackId);
      }

      return remainingSlots;
    }

    return this.buildPlan(userId, sessionId, seedTrackId);
  }

  // ---------------------------------------------------------------
  // Private helpers
  // ---------------------------------------------------------------
  private async resolveSeedArtistId(seedTrackId: string): Promise<string | undefined> {
    const res = await this.db.pgClient.query(
      `SELECT ta.artist_id
       FROM track_artists_v2 ta
       WHERE ta.track_id = $1 AND ta.role = 'main'
       LIMIT 1`,
      [seedTrackId]
    );
    if (res.rows[0]?.artist_id) return res.rows[0].artist_id as string;

    const fallback = await this.db.pgClient.query(
      `SELECT al.artist_id
       FROM tracks t
       JOIN albums al ON al.id = t.album_id
       WHERE t.id = $1`,
      [seedTrackId]
    );
    return fallback.rows[0]?.artist_id as string | undefined;
  }
}
