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

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

function parseCalendarContext(value: string | null): VibeCalendarContext | null {
  if (!value) return null;
  try {
    const context = JSON.parse(value) as Record<string, unknown>;
    if (!Number.isInteger(context.localHour) || !Number.isInteger(context.weekday) || !Number.isInteger(context.month)
      || (context.localHour as number) < 0 || (context.localHour as number) > 23
      || (context.weekday as number) < 0 || (context.weekday as number) > 6
      || (context.month as number) < 1 || (context.month as number) > 12) return null;
    return {
      localHour: context.localHour as number,
      weekday: context.weekday as number,
      month: context.month as number,
      ...(typeof context.timeZone === 'string' ? { timeZone: context.timeZone } : {}),
    };
  } catch {
    return null;
  }
}

export interface RecentPlay {
  trackId: string;
  artistId: string | null;
  genreId: string | null;
  bpm: number | null;
  energy: number | null;
  language: string | null;
  /** null means audio analysis is unavailable; it is not a vocal track. */
  vocal: boolean | null;
  decade: number | null;
  valence: number | null;
  acousticness?: number | null;
  instrumentalness?: number | null;
  albumId?: string | null;
  producerIds?: string[];
  labelIds?: string[];
}

/** Metadata used only by the planner. A missing value is deliberately not
 * counted as satisfying a diversity target. */
export interface CandidateConstraintMetadata {
  artistId?: string;
  albumId?: string;
  genreId?: string;
  language?: string;
  instrumental?: boolean;
  favorite?: boolean;
  newArtist?: boolean;
  /** No play of this track has ever been recorded for this listener. */
  unheard?: boolean;
  energy?: number;
  bpm?: number;
  valence?: number;
  acousticness?: number;
  instrumentalness?: number;
  decade?: number;
  producerIds?: string[];
  labelIds?: string[];
}

export interface ConstraintRelaxation {
  constraint: string;
  stage: 'soft_budget' | 'arc_precision' | 'freshness';
  reason: string;
}

export interface ConstrainedPlanResult {
  plan: Candidate[];
  relaxations: ConstraintRelaxation[];
}

export interface DiversityBudget {
  dimension: string;
  budgetShare: number;
  horizonMin: number;
  spent: number;
  /** Exact completed-play counts in this budget's configured time horizon.
   * These are planner-only projections, not API contract fields. */
  historicalTotal?: number;
  historicalValues?: Map<string, number>;
}

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

export interface AntiLoopSignal {
  dimension: string;
  /** Every dominant identity. Producer/label candidates are excluded when
   * they match any of these values, not merely the first claim. */
  values: string[];
}

export interface PlanBuildOptions {
  /** Tracks already exposed during this Vibe session; they are ineligible. */
  excludedTrackIds?: Iterable<string>;
  /** Unplayed queue tail retained during a replan. It is part of the same
   * sequence and must consume hard caps and diversity budgets. */
  retainedPlan?: Candidate[];
}

export interface SessionFingerprint {
  artists?: string[];
  genres?: string[];
  sources?: string[];
  context?: Record<string, unknown>;
}

/** A compact Jaccard penalty. It is deliberately capped: last night's shape
 * should make an alternative more attractive, not make good music ineligible. */
export function sessionSimilarityPenalty(
  metadata: CandidateConstraintMetadata | undefined,
  generatorId: string,
  fingerprints: SessionFingerprint[],
): number {
  if (!metadata || fingerprints.length === 0) return 0;
  let strongest = 0;
  for (const fingerprint of fingerprints) {
    let matches = 0;
    let known = 0;
    if (metadata.artistId && fingerprint.artists?.length) { known++; if (fingerprint.artists.includes(metadata.artistId)) matches++; }
    if (metadata.genreId && fingerprint.genres?.length) { known++; if (fingerprint.genres.includes(metadata.genreId)) matches++; }
    if (fingerprint.sources?.length) { known++; if (fingerprint.sources.includes(generatorId)) matches++; }
    if (known > 0) strongest = Math.max(strongest, matches / known);
  }
  return Math.min(0.18, strongest * 0.18);
}

export interface ArcRange {
  min?: number;
  max?: number;
  /** Preferred delta from the immediately preceding track. */
  maxDelta?: number;
}

export interface CallbackToken {
  id: string;
  phase: 'anchor' | 'return';
  /** The return may use the anchor artist, genre, or simply a familiar item. */
  theme: 'artist' | 'genre' | 'favorite';
  minSeparation: number;
  maxSeparation: number;
}

export interface SurpriseDirective {
  /** A surprise is bounded and must be followed by a recovery anchor. */
  recoveryRole: string;
  maxPerPlan: number;
  maxPerHour: number;
}

export interface ArcSlot {
  position: number;
  role: string;
  /** Optional only for legacy callers; getArcSlots always supplies targets. */
  targets?: {
    energy?: ArcRange;
    tempo?: ArcRange;
    valence?: ArcRange;
    acousticness?: ArcRange;
    instrumentality?: ArcRange;
    /** 0 familiar, 1 exploratory. Derived from source and known metadata. */
    novelty?: ArcRange;
  };
  callback?: CallbackToken;
  surprise?: SurpriseDirective;
}

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;
/** What a never-played track is worth against a well-attested favourite. */
const W_UNHEARD = 0.35;
const PLAN_SIZE = 20;
const MAX_ARTIST_PER_PLAN = 2;
const MAX_ALBUM_PER_40_TRACKS = 3;
const ALBUM_HORIZON_TRACKS = 40;

/**
 * The share of a plan reserved for tracks the listener has never played. The
 * score bonus alone leaves this to chance; a floor is what makes every Vibe
 * move somewhere rather than most of them happening to.
 */
const UNHEARD_PLAN_SHARE = 0.25;

/**
 * How long a track stays out of the Vibe after the listener actually hears it.
 * Hard exclusion used to reach back only 40 completed plays, so a track heard
 * last night was a candidate again tonight. Counted from a durable `track_served`
 * event or a play, never from a plan item — most planned tracks are replaced
 * before anyone hears them, and excluding those would burn the library.
 */
const HEARD_COOLDOWN_DAYS = 14;

/**
 * A cooldown must never starve the pool. Whatever the window says, at most this
 * share of the eligible library is held back, keeping the newest exclusions.
 */
const COOLDOWN_LIBRARY_SHARE = 0.4;

/**
 * Producer/label relationships live on artist nodes in the fused graph, not
 * on track nodes.  The graph represents both directions of a relationship,
 * so a track inherits every adjacent lineage identity from its resolved main
 * artist regardless of which artist was recorded as the edge subject.
 */
function lineageIdsSql(predicate: 'produced' | 'same_label_as', artistAlias: string): string {
  return `COALESCE((
    SELECT array_agg(DISTINCT CASE
      WHEN cf.subject_id = ${artistAlias}.artist_id THEN cf.object_id::text
      ELSE cf.subject_id::text
    END)
    FROM claim_fusion cf
    WHERE cf.subject_type = 'artist'
      AND cf.object_type = 'artist'
      AND cf.predicate = '${predicate}'
      AND (cf.subject_id = ${artistAlias}.artist_id OR cf.object_id = ${artistAlias}.artist_id)
  ), ARRAY[]::text[])`;
}

/**
 * Preserve the existing queue, append only genuinely new candidates, and
 * never emit a duplicate. This is deliberately pure so the queue boundary is
 * testable without a database.
 */
export function mergeUniquePlan(
  currentPlan: Candidate[],
  additions: Candidate[],
  excludedTrackIds: Iterable<string> = [],
  targetSize = PLAN_SIZE
): Candidate[] {
  const seen = new Set(excludedTrackIds);
  const merged: Candidate[] = [];

  for (const candidate of [...currentPlan, ...additions]) {
    if (seen.has(candidate.trackId)) continue;
    seen.add(candidate.trackId);
    merged.push(candidate);
    if (merged.length >= targetSize) break;
  }

  return merged;
}

function valuesForDimension(
  metadata: CandidateConstraintMetadata | undefined,
  dimension: string,
): string[] {
  if (!metadata) return [];
  switch (dimension) {
    case 'artist': return metadata.artistId ? [metadata.artistId] : [];
    case 'album': return metadata.albumId ? [metadata.albumId] : [];
    case 'genre': return metadata.genreId ? [metadata.genreId] : [];
    case 'language': return metadata.language ? [metadata.language] : [];
    case 'instrumental': return metadata.instrumental === undefined ? [] : [String(metadata.instrumental)];
    case 'new_artist': return metadata.newArtist ? ['true'] : [];
    case 'favorite': return metadata.favorite ? ['true'] : [];
    case 'vocal': return metadata.instrumental === undefined ? [] : [String(!metadata.instrumental)];
    case 'producer': return metadata.producerIds ?? [];
    case 'label': return metadata.labelIds ?? [];
    case 'energy': return metadata.energy === undefined ? [] : [String(Math.min(3, Math.floor(metadata.energy / 0.25)))];
    case 'bpm': return metadata.bpm === undefined ? [] : [String(Math.floor(metadata.bpm / 20))];
    case 'decade': return metadata.decade === undefined ? [] : [String(metadata.decade)];
    case 'mood': return metadata.valence === undefined ? [] : [String(metadata.valence > 0.5)];
    default: return [];
  }
}

function valueForDimension(metadata: CandidateConstraintMetadata | undefined, dimension: string): string | undefined {
  return valuesForDimension(metadata, dimension)[0];
}

/** Most common known value, but only when it is actually concentrated. */
export function dominantRecentValue(recent: RecentPlay[], dimension: string): string | undefined {
  const counts = new Map<string, number>();
  for (const play of recent) {
    const values = dimension === 'producer' ? play.producerIds
      : dimension === 'label' ? play.labelIds
        : [dimension === 'artist' ? play.artistId
      : dimension === 'album' ? play.albumId
        : dimension === 'genre' ? play.genreId
          : dimension === 'language' ? play.language
            : dimension === 'vocal' && play.vocal != null ? String(play.vocal)
              : dimension === 'energy' && play.energy != null ? String(Math.min(3, Math.floor(play.energy / 0.25)))
                : dimension === 'bpm' && play.bpm != null ? String(Math.floor(play.bpm / 20))
                  : dimension === 'mood' && play.valence != null ? String(play.valence > 0.5)
                    : dimension === 'decade' && play.decade != null ? String(play.decade) : undefined];
    for (const value of values ?? []) {
      if (value) counts.set(value, (counts.get(value) ?? 0) + 1);
    }
  }
  let dominant: string | undefined;
  let max = 0;
  for (const [value, count] of counts) {
    if (count > max) { dominant = value; max = count; }
  }
  return max >= 2 ? dominant : undefined;
}

function metadataFromRecentPlay(play: RecentPlay): CandidateConstraintMetadata {
  return {
    artistId: play.artistId ?? undefined,
    albumId: play.albumId ?? undefined,
    genreId: play.genreId ?? undefined,
    language: play.language ?? undefined,
    // RecentPlay stores vocal rather than instrumental. Preserve missing audio
    // analysis as unknown so it neither triggers nor dodges vocal rules.
    instrumental: play.vocal == null ? undefined : !play.vocal,
    energy: play.energy ?? undefined,
    bpm: play.bpm ?? undefined,
    valence: play.valence ?? undefined,
    acousticness: play.acousticness ?? undefined,
    instrumentalness: play.instrumentalness ?? undefined,
    decade: play.decade ?? undefined,
    producerIds: play.producerIds ?? [],
    labelIds: play.labelIds ?? [],
  };
}

function candidateNovelty(candidate: Candidate, metadata: CandidateConstraintMetadata | undefined): number {
  if (metadata?.favorite) return 0.05;
  if (metadata?.newArtist) return 0.8;
  if (candidate.generatorId === 'discovery') return 0.7;
  if (candidate.generatorId === 'adjacent') return 0.45;
  if (candidate.generatorId === 'deep-dive') return 0.2;
  return 0.3;
}

function rangeScore(value: number | undefined, range: ArcRange | undefined): { score: number; known: boolean } {
  if (!range) return { score: 1, known: true };
  if (value === undefined || !Number.isFinite(value)) return { score: 0.5, known: false };
  const min = range.min ?? -Infinity;
  const max = range.max ?? Infinity;
  if (value >= min && value <= max) return { score: 1, known: true };
  const distance = value < min ? min - value : value - max;
  // A target miss is a soft degradation. It is never an eligibility failure.
  return { score: Math.max(0, 1 - distance / 0.5), known: true };
}

/**
 * Unknown analysis must remain playable, but it cannot displace a candidate
 * whose measured audio features satisfy this slot. This is intentionally
 * limited to absolute targets: `maxDelta` is a transition preference, not an
 * independent eligibility rule.
 */
function matchesMeasuredArcTargets(
  metadata: CandidateConstraintMetadata | undefined,
  slot: ArcSlot,
): boolean {
  const targets = slot.targets ?? {};
  const dimensions: Array<[number | undefined, ArcRange | undefined]> = [
    [metadata?.energy, targets.energy],
    [metadata?.bpm, targets.tempo],
    [metadata?.valence, targets.valence],
    [metadata?.acousticness, targets.acousticness],
    [metadata?.instrumentalness, targets.instrumentality],
  ];
  const absolute = dimensions.filter(([, range]) => range && (range.min !== undefined || range.max !== undefined));
  if (absolute.length === 0) return false;
  return absolute.every(([value, range]) => {
    if (value === undefined || !Number.isFinite(value)) return false;
    return value >= (range!.min ?? -Infinity) && value <= (range!.max ?? Infinity);
  });
}

function hasAbsoluteArcTargets(slot: ArcSlot): boolean {
  const targets = slot.targets ?? {};
  return [targets.energy, targets.tempo, targets.valence, targets.acousticness, targets.instrumentality]
    .some(range => range && (range.min !== undefined || range.max !== undefined));
}

/** A local DJ transition is useful even with partial analysis: unknown values
 * lower confidence, whereas known values are evaluated smoothly. */
export function scoreArcTransition(
  candidate: Candidate,
  candidateMetadata: CandidateConstraintMetadata | undefined,
  previousMetadata: CandidateConstraintMetadata | undefined,
  slot: ArcSlot,
): { score: number; confidence: number; targetScore: number; transitionScore: number } {
  const targetsForSlot = slot.targets ?? {};
  const targets = [
    rangeScore(candidateMetadata?.energy, targetsForSlot.energy),
    rangeScore(candidateMetadata?.bpm, targetsForSlot.tempo),
    rangeScore(candidateMetadata?.valence, targetsForSlot.valence),
    rangeScore(candidateMetadata?.acousticness, targetsForSlot.acousticness),
    rangeScore(candidateMetadata?.instrumentalness, targetsForSlot.instrumentality),
    rangeScore(candidateNovelty(candidate, candidateMetadata), targetsForSlot.novelty),
  ];
  const targetScore = targets.reduce((total, item) => total + item.score, 0) / targets.length;
  const knownTargets = targets.filter(item => item.known).length;
  const deltas: Array<[number | undefined, number | undefined, ArcRange | undefined, number]> = [
    [candidateMetadata?.energy, previousMetadata?.energy, targetsForSlot.energy, 0.3],
    [candidateMetadata?.bpm, previousMetadata?.bpm, targetsForSlot.tempo, 35],
    [candidateMetadata?.valence, previousMetadata?.valence, targetsForSlot.valence, 0.3],
    [candidateMetadata?.acousticness, previousMetadata?.acousticness, targetsForSlot.acousticness, 0.3],
    [candidateMetadata?.instrumentalness, previousMetadata?.instrumentalness, targetsForSlot.instrumentality, 0.3],
  ];
  const knownDeltas = deltas.filter(([current, previous]) => current !== undefined && previous !== undefined);
  const transitionScore = knownDeltas.length === 0 ? 0.5 : knownDeltas.reduce((total, [current, previous, target, defaultMaxDelta]) => {
    const maxDelta = target?.maxDelta ?? defaultMaxDelta;
    return total + Math.max(0, 1 - Math.abs(current! - previous!) / maxDelta);
  }, 0) / knownDeltas.length;
  const confidence = (knownTargets + knownDeltas.length) / (targets.length + deltas.length);
  return {
    score: targetScore * 0.65 + transitionScore * 0.35,
    confidence,
    targetScore,
    transitionScore,
  };
}

/**
 * The constraint layer is intentionally pure. Ranking supplies its candidate
 * order; this layer chooses a feasible sequence and returns every soft rule it
 * had to relax. Hard session exclusions are applied before this function and
 * are never relaxed here.
 */
export function selectConstrainedSequence(params: {
  candidates: Candidate[];
  slots: ArcSlot[];
  metadata: Map<string, CandidateConstraintMetadata>;
  budgets: DiversityBudget[];
  roleToGeneratorIds: (role: string) => string[];
  /** Already committed queue entries which remain in front of this refill. */
  retainedPlan?: Candidate[];
  /** Completed plays, newest first, used only for the rolling 40-track album cap. */
  albumHistory?: CandidateConstraintMetadata[];
  explicitIntent?: boolean;
  loopDimension?: string | null;
  loopedValues?: Iterable<string>;
  /** @deprecated use loopedValues; retained for callers during migration. */
  loopedValue?: string;
}): ConstrainedPlanResult {
  const {
    candidates, slots, metadata, budgets, roleToGeneratorIds, retainedPlan = [],
    albumHistory = [], explicitIntent = false, loopDimension, loopedValues = [], loopedValue,
  } = params;
  const relaxations: ConstraintRelaxation[] = [];
  const selected: Candidate[] = [];
  const selectedIds = new Set(retainedPlan.map(candidate => candidate.trackId));
  const counts = new Map<string, Map<string, number>>();
  const budgetByDimension = new Map(budgets.map(b => [b.dimension, b]));
  const lowerDimensions = ['instrumental', 'new_artist', 'favorite'];
  const softDimensions = ['artist', 'genre', 'language'];
  const planLength = retainedPlan.length + slots.length;
  const loopedValueSet = new Set([...loopedValues, ...(loopedValue ? [loopedValue] : [])]);
  let previousMetadata = retainedPlan.length > 0
    ? metadata.get(retainedPlan[retainedPlan.length - 1].trackId)
    : undefined;
  const callbackAnchors = new Map<string, { metadata: CandidateConstraintMetadata; position: number }>();
  // Candidate ranking is already stable. Build each role's preferred pool
  // once, preserving that order, rather than sorting the whole pool for every
  // slot in a long plan.
  const preferredPoolCache = new Map<string, Candidate[]>();
  const orderedForRole = (role: string): Candidate[] => {
    const preferred = roleToGeneratorIds(role);
    const key = preferred.join('\u0000');
    const cached = preferredPoolCache.get(key);
    if (cached) return cached;
    const preferredIds = new Set(preferred);
    const ordered = [
      ...candidates.filter(candidate => preferredIds.has(candidate.generatorId)),
      ...candidates.filter(candidate => !preferredIds.has(candidate.generatorId)),
    ];
    preferredPoolCache.set(key, ordered);
    return ordered;
  };

  const count = (dimension: string, value: string | undefined) =>
    value ? (counts.get(dimension)?.get(value) ?? 0) : 0;
  const increment = (dimension: string, values: Iterable<string>) => {
    const knownValues = [...values];
    if (knownValues.length === 0) return;
    const dimensionCounts = counts.get(dimension) ?? new Map<string, number>();
    for (const value of knownValues) {
      dimensionCounts.set(value, (dimensionCounts.get(value) ?? 0) + 1);
    }
    counts.set(dimension, dimensionCounts);
  };
  const historicalCount = (dimension: string, value: string) =>
    budgetByDimension.get(dimension)?.historicalValues?.get(value) ?? 0;
  const historicalTotal = (dimension: string) =>
    budgetByDimension.get(dimension)?.historicalTotal ?? 0;
  const target = (dimension: string) => {
    const budget = budgetByDimension.get(dimension);
    if (!budget || explicitIntent) return 0;
    // Lower budgets are projected over the actual completed-play sample in the
    // configured horizon plus the retained/replacement sequence. `spent` is a
    // diagnostic share; counts keep this calculation exact.
    const desired = Math.ceil(budget.budgetShare * (historicalTotal(dimension) + planLength));
    return Math.max(0, desired - historicalCount(dimension, 'true'));
  };
  const lowerDeficit = (dimension: string) => Math.max(0, target(dimension) - count(dimension, 'true'));
  const exceedsUpperLimit = (dimension: string, values: string[]) => {
    const budget = budgetByDimension.get(dimension);
    if (!budget || explicitIntent || values.length === 0) return false;
    const denominator = historicalTotal(dimension) + planLength;
    // Preserve a non-zero allowance for a configured category in a short plan.
    const limit = Math.max(1, Math.floor(budget.budgetShare * denominator));
    return values.some(value => historicalCount(dimension, value) + count(dimension, value) + 1 > limit);
  };

  // Retained tracks are already exposed to the client, so they must consume
  // every sequence budget before a replacement is selected.
  for (const retained of retainedPlan) {
    const retainedMetadata = metadata.get(retained.trackId);
    for (const dimension of ['artist', 'genre', 'language', ...lowerDimensions]) {
      increment(dimension, valuesForDimension(retainedMetadata, dimension));
    }
  }
  // Album is a rolling 40-track horizon, not merely a 20-track plan. Unknown
  // identities are deliberately not collapsed into one synthetic album: that
  // would reject unrelated tracks. Known IDs can never evade this cap.
  // Leave room for the retained/replacement sequence so the window is exactly
  // forty tracks at the end of this plan, rather than incorrectly treating a
  // 20-track tail as a 60-track lookback.
  for (const history of albumHistory.slice(0, Math.max(0, ALBUM_HORIZON_TRACKS - planLength))) {
    increment('album', valuesForDimension(history, 'album'));
  }
  for (const retained of retainedPlan) {
    increment('album', valuesForDimension(metadata.get(retained.trackId), 'album'));
  }

  const hardConstraints = {
    artistMaxPerPlan: MAX_ARTIST_PER_PLAN,
    albumMaxPer40Tracks: MAX_ALBUM_PER_40_TRACKS,
    uniqueTracks: true,
  };

  for (let position = 0; position < slots.length; position++) {
    let slot = slots[position];
    let chosen: Candidate | undefined;
    let chosenTransition: ReturnType<typeof scoreArcTransition> | undefined;
    let chosenCallbackScore = 0;
    let chosenSurpriseScore = 0;
    let chosenSelectionScore = 0;
    let chosenRankScore = 0;
    let relaxedSoft = false;
    let relaxedArc = false;
    let downgradedSurpriseGenerators: Set<string> | undefined;

    /** A surprise is only admitted if its declared recovery role still has a
     * hard-feasible anchor after the surprise itself has consumed its caps. */
    const canReserveRecovery = (surpriseCandidate: Candidate, surpriseMetadata: CandidateConstraintMetadata | undefined) => {
      if (!slot.surprise) return true;
      const recoveryGenerators = new Set(roleToGeneratorIds(slot.surprise.recoveryRole));
      const surpriseArtist = valueForDimension(surpriseMetadata, 'artist');
      const surpriseAlbum = valueForDimension(surpriseMetadata, 'album');
      return candidates.some(recovery => {
        if (recovery.trackId === surpriseCandidate.trackId || selectedIds.has(recovery.trackId)) return false;
        if (!recoveryGenerators.has(recovery.generatorId)) return false;
        const recoveryMetadata = metadata.get(recovery.trackId);
        const artist = valueForDimension(recoveryMetadata, 'artist');
        const album = valueForDimension(recoveryMetadata, 'album');
        const prospectiveArtistCount = artist && artist === surpriseArtist ? 1 : 0;
        const prospectiveAlbumCount = album && album === surpriseAlbum ? 1 : 0;
        return (!artist || count('artist', artist) + prospectiveArtistCount < MAX_ARTIST_PER_PLAN)
          && (!album || count('album', album) + prospectiveAlbumCount < MAX_ALBUM_PER_40_TRACKS);
      });
    };

    // A surprise whose recovery cannot be reserved is downgraded before
    // selection. This prevents an unrecoverable high-risk pick from being
    // written to a revision merely because the later recovery slot is empty.
    if (slot.surprise) {
      const surpriseGenerators = new Set(roleToGeneratorIds(slot.role));
      const reservable = candidates.some(candidate =>
        !selectedIds.has(candidate.trackId)
        && surpriseGenerators.has(candidate.generatorId)
        && canReserveRecovery(candidate, metadata.get(candidate.trackId)),
      );
      if (!reservable) {
        relaxations.push({
          constraint: 'surprise_recovery',
          stage: 'arc_precision',
          reason: 'no hard-feasible recovery anchor remained for the requested surprise',
        });
        downgradedSurpriseGenerators = surpriseGenerators;
        slot = {
          ...slot,
          role: slot.surprise.recoveryRole,
          surprise: undefined,
          targets: { ...(slot.targets ?? {}), novelty: { max: 0.35 } },
        };
      }
    }

    for (let pass = 0; pass < 3 && !chosen; pass++) {
      // pass 0: all soft constraints + arc role; pass 1: soft budgets/loop;
      // pass 2: permit an arc-source fallback. Hard sequence caps remain.
      const relaxSoft = pass >= 1;
      const relaxArc = pass >= 2;
      const preferred = roleToGeneratorIds(slot.role);
      const ordered = orderedForRole(slot.role);
      let best: { candidate: Candidate; transition: ReturnType<typeof scoreArcTransition>; callbackScore: number; surpriseScore: number; score: number; rankScore: number } | undefined;
      const fittingArcCandidateExists = hasAbsoluteArcTargets(slot) && ordered.some(candidate => {
        if (selectedIds.has(candidate.trackId)) return false;
        const m = metadata.get(candidate.trackId);
        const artist = valueForDimension(m, 'artist');
        const album = valueForDimension(m, 'album');
        return (!artist || count('artist', artist) < MAX_ARTIST_PER_PLAN)
          && (!album || count('album', album) < MAX_ALBUM_PER_40_TRACKS)
          && matchesMeasuredArcTargets(m, slot);
      });
      for (const [candidateIndex, candidate] of ordered.entries()) {
        if (selectedIds.has(candidate.trackId)) continue;
        // Do not relabel the rejected high-risk item as a recovery merely
        // because source relaxation is allowed for the downgraded slot.
        if (downgradedSurpriseGenerators?.has(candidate.generatorId)) continue;
        const m = metadata.get(candidate.trackId);
        const artist = valueForDimension(m, 'artist');
        const album = valueForDimension(m, 'album');
        if (artist && count('artist', artist) >= MAX_ARTIST_PER_PLAN) continue;
        if (album && count('album', album) >= MAX_ALBUM_PER_40_TRACKS) continue;
        // A surprise is never silently satisfied by an arbitrary comfort
        // fallback. If no explainable exploratory source is available the
        // planner reports a shortened/degraded sequence instead.
        if ((!relaxArc || slot.surprise) && !preferred.includes(candidate.generatorId)) continue;
        // Arc measurements are an enforceable preference when the catalog
        // gives us a hard-feasible measured fit. Sparse analysis falls back
        // gracefully and is documented as an arc-precision relaxation below.
        if (fittingArcCandidateExists && !matchesMeasuredArcTargets(m, slot)) continue;

        if (!relaxSoft && !explicitIntent) {
          if (softDimensions.some(d => exceedsUpperLimit(d, valuesForDimension(m, d)))) continue;
          if (loopDimension && valuesForDimension(m, loopDimension).some(value => loopedValueSet.has(value))) continue;
          const remaining = slots.length - position;
          const deficits = lowerDimensions.filter(d => lowerDeficit(d) > 0);
          if (deficits.length > 0 && remaining <= deficits.reduce((sum, d) => sum + lowerDeficit(d), 0)) {
            if (!deficits.some(d => valuesForDimension(m, d).includes('true'))) continue;
          }
        }
        const transition = scoreArcTransition(candidate, m, previousMetadata, slot);
        const anchor = slot.callback ? callbackAnchors.get(slot.callback.id) : undefined;
        let callbackScore = 0;
        if (slot.callback?.phase === 'return' && anchor) {
          const separation = position - anchor.position;
          const themeMatches = slot.callback.theme === 'favorite'
            ? m?.favorite === true
            : slot.callback.theme === 'artist'
              ? !!m?.artistId && m.artistId === anchor.metadata.artistId
              : !!m?.genreId && m.genreId === anchor.metadata.genreId;
          // The token is preference-only: a hard diversity/repetition rule may
          // still make a literal return impossible, in which case familiarity
          // provides a graceful callback rather than a failed plan.
          const inWindow = separation >= slot.callback.minSeparation && separation <= slot.callback.maxSeparation;
          callbackScore = inWindow && themeMatches ? 1 : (m?.favorite ? 0.35 : 0);
        }
        const surpriseScore = slot.surprise
          ? (candidate.generatorId === 'discovery' || candidate.generatorId === 'adjacent' || m?.newArtist ? 1 : 0)
          : 0;
        if (slot.surprise && !canReserveRecovery(candidate, m)) continue;
        // Keep ranking meaningful, but let the sequence controller prefer a
        // fitting continuation over the next independent highest-ranked song.
        const rankScore = 1 - candidateIndex / Math.max(1, ordered.length);
        const score = rankScore * 0.45 + transition.score * 0.4 + callbackScore * 0.1 + surpriseScore * 0.05;
        if (!best || score > best.score || (score === best.score && candidate.trackId.localeCompare(best.candidate.trackId) < 0)) {
          best = { candidate, transition, callbackScore, surpriseScore, score, rankScore };
        }
      }
      if (best) {
        chosen = best.candidate;
        chosenTransition = best.transition;
        chosenCallbackScore = best.callbackScore;
        chosenSurpriseScore = best.surpriseScore;
        chosenSelectionScore = best.score;
        chosenRankScore = best.rankScore;
        relaxedSoft = relaxSoft;
        relaxedArc = relaxArc;
      }
    }
    if (!chosen) break;
    if (relaxedSoft && !relaxations.some(r => r.stage === 'soft_budget')) {
      relaxations.push({ constraint: loopDimension ?? 'diversity_budget', stage: 'soft_budget', reason: 'eligible inventory could not satisfy projected soft constraints' });
    }
    if ((hasAbsoluteArcTargets(slot) && !matchesMeasuredArcTargets(metadata.get(chosen.trackId), slot))
      && !relaxations.some(r => r.stage === 'arc_precision' && r.constraint === 'arc_precision')) {
      relaxations.push({ constraint: 'arc_precision', stage: 'arc_precision', reason: 'no hard-feasible measured candidate satisfied the requested arc targets' });
    }
    if (relaxedArc && !relaxations.some(r => r.constraint === 'arc_source')) {
      relaxations.push({ constraint: 'arc_source', stage: 'arc_precision', reason: 'no hard-feasible candidate matched the requested arc slot' });
    }
    const chosenMetadata = metadata.get(chosen.trackId);
    const decorated: Candidate = {
      ...chosen,
      plan: {
        slotRole: slot.role,
        score: chosenSelectionScore || (chosenTransition ? chosenTransition.score : chosen.relevance),
        scoreBreakdown: {
          relevance: chosen.relevance,
          rankingPositionScore: chosenRankScore,
          transition: chosenTransition?.transitionScore ?? 0.5,
          arcTarget: chosenTransition?.targetScore ?? 0.5,
          transitionConfidence: chosenTransition?.confidence ?? 0,
          callback: chosenCallbackScore,
          surprise: chosenSurpriseScore,
          selection: chosenSelectionScore,
          weights: { rank: 0.45, transition: 0.4, callback: 0.1, surprise: 0.05 },
        },
        explanation: {
          arcRole: slot.role,
          targets: slot.targets,
          callback: slot.callback ? {
            id: slot.callback.id,
            phase: slot.callback.phase,
            theme: slot.callback.theme,
            minSeparation: slot.callback.minSeparation,
            maxSeparation: slot.callback.maxSeparation,
            matchScore: chosenCallbackScore,
          } : null,
          surprise: slot.surprise ? {
            recoveryRole: slot.surprise.recoveryRole,
            maxPerPlan: slot.surprise.maxPerPlan,
            maxPerHour: slot.surprise.maxPerHour,
            selectionScore: chosenSurpriseScore,
          } : null,
          arcPrecision: {
            measuredFit: matchesMeasuredArcTargets(chosenMetadata, slot),
            enforcedBecauseFitExists: hasAbsoluteArcTargets(slot),
          },
        },
      },
    };
    selected.push(decorated);
    selectedIds.add(chosen.trackId);
    const m = chosenMetadata;
    for (const dimension of ['artist', 'album', 'genre', 'language', ...lowerDimensions]) {
      increment(dimension, valuesForDimension(m, dimension));
    }
    previousMetadata = chosenMetadata;
    if (slot.callback?.phase === 'anchor' && chosenMetadata) {
      callbackAnchors.set(slot.callback.id, { metadata: chosenMetadata, position });
    }
  }
  if (selected.length < slots.length) {
    relaxations.push({ constraint: 'freshness', stage: 'freshness', reason: 'hard exclusions and sequence caps left too few eligible candidates' });
  }
  const policy = {
    selection: 'rank_transition_callback_surprise_v1',
    weights: { rank: 0.45, transition: 0.4, callback: 0.1, surprise: 0.05 },
    arcTargets: 'enforced_when_a_hard_feasible_measured_fit_exists',
    surprise: 'requires_reserved_recovery_anchor',
  };
  const constraints = {
    hard: hardConstraints,
    softBudgetDimensions: [...softDimensions, ...lowerDimensions],
    loopDimension: loopDimension ?? null,
  };
  const objective = { policy, constraints, relaxations };
  return {
    plan: selected.map(candidate => ({
      ...candidate,
      plan: candidate.plan ? {
        ...candidate.plan,
        explanation: { ...candidate.plan.explanation, policy, constraints, relaxations },
        objective,
      } : candidate.plan,
    })),
    relaxations,
  };
}

export class SessionDirector {
  // Kept as an instance seam so build/replan integration tests can inspect
  // the exact constraint horizon without replacing the planner itself.
  private readonly constrainedSequence = selectConstrainedSequence;

  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: parseCalendarContext(row.context),
          noveltyHunger: (row.state_vector?.noveltyHunger as number) ?? 0.3,
          explorationCoefficient: (row.state_vector?.explorationCoefficient as number) ?? 0.3,
          discoveryRadius: (row.state_vector?.discoveryRadius as number) ?? 0.38,
          sessionGoal: row.state_vector?.sessionGoal as { type: string; target: number; progress: number } | undefined,
          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: parseCalendarContext(latest.context),
          noveltyHunger: (latest.state_vector?.noveltyHunger as number) ?? 0.3,
          explorationCoefficient: (latest.state_vector?.explorationCoefficient as number) ?? 0.3,
          discoveryRadius: (latest.state_vector?.discoveryRadius as number) ?? 0.38,
          sessionGoal: latest.state_vector?.sessionGoal as { type: string; target: number; progress: number } | undefined,
          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]
    );
    let energy = (energyRes.rows[0]?.energy as number) ?? 0.5;
    // Audio beliefs are projected from track feedback. Blend the strongest
    // energy preference with the recent-play state so the projection affects
    // the session without making a single old preference a hard constraint.
    const energyBelief = await this.db.pgClient.query<{ entity_id: string; value: number }>(
      `SELECT entity_id, value FROM listener_beliefs
       WHERE user_id = $1 AND entity_type = 'audio' AND entity_id = ANY($2::uuid[])
       ORDER BY value DESC LIMIT 1`,
      [userId, [AUDIO_PREFERENCE_BUCKETS.energy.low, AUDIO_PREFERENCE_BUCKETS.energy.medium, AUDIO_PREFERENCE_BUCKETS.energy.high]]
    );
    const preferredEnergy = energyBelief.rows[0]?.entity_id === AUDIO_PREFERENCE_BUCKETS.energy.low ? 0.2
      : energyBelief.rows[0]?.entity_id === AUDIO_PREFERENCE_BUCKETS.energy.high ? 0.8
        : energyBelief.rows[0]?.entity_id === AUDIO_PREFERENCE_BUCKETS.energy.medium ? 0.5 : null;
    if (preferredEnergy !== null && (energyBelief.rows[0]?.value ?? 0) > 0) {
      energy = energy * 0.65 + preferredEnergy * 0.35;
    }
    // Activity/device/time supply a boot prior only. Once playback exists its
    // contribution stays small so context never masquerades as feedback.
    if (savedState) energy = energy * 0.85 + savedState.energy * 0.15;

    // 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 durableNovelty = (noveltyRes.rows[0]?.value as number) ?? 0.3;
    const explorationCoefficient = Math.max(0, Math.min(1, savedState?.explorationCoefficient ?? 0.3));
    const discoveryRadius = Math.max(0.15, Math.min(0.9, savedState?.discoveryRadius ?? 0.38));
    // A contextual/session feedback signal is only a minority of the input;
    // long-term discovery belief still stabilises the stream across resumes.
    const noveltyHunger = durableNovelty * 0.65 + explorationCoefficient * 0.35;

    // 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,
      explorationCoefficient,
      discoveryRadius,
      sessionGoal: savedState?.sessionGoal,
      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);
    }

    // Album fatigue uses the same recent horizon as artist fatigue. It is a
    // separate signal: several tracks from a compilation should not exhaust
    // every contributing artist, but should still be spread across hours.
    const albumRes = await this.db.pgClient.query(
      `SELECT t.album_id,
              LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
       FROM play_history ph
       JOIN tracks t ON t.id = ph.track_id
       WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours'
         AND ph.completed = true AND t.album_id IS NOT NULL
       GROUP BY t.album_id`,
      [userId, ARTIST_DECAY_SEC]
    );
    const album = new Map<string, number>();
    for (const row of albumRes.rows as { album_id: string; fatigue: number }[]) {
      album.set(row.album_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, album, 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 usage = await this.loadBudgetUsage(userId, row.dimension, row.horizon_min);
      budgets.push({
        dimension: row.dimension,
        budgetShare: row.budget_share,
        horizonMin: row.horizon_min,
        spent: usage.spent,
        historicalTotal: usage.total,
        historicalValues: usage.values,
      });
    }
    return budgets;
  }

  private async loadBudgetUsage(userId: string, dimension: string, horizonMin: number): Promise<{
    spent: number;
    total: number;
    values: Map<string, number>;
  }> {
    const interval = `${horizonMin} minutes`;
    // Each source produces at most one known value per completed track. The
    // completed-play CTE is deliberately kept separate from classification:
    // tracks with sparse metadata stay in the denominator, while only known
    // values contribute to a dimension's numerator.
    const sourceByDimension: Record<string, string> = {
      artist: `SELECT artist.artist_id::text AS value
               FROM completed_plays ph
               LEFT JOIN LATERAL (SELECT ta.artist_id FROM track_artists_v2 ta
                 WHERE ta.track_id = ph.track_id AND ta.role = 'main'
                 ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1) artist ON true`,
      genre: `SELECT genre.genre_id::text AS value
              FROM completed_plays ph
              LEFT JOIN LATERAL (SELECT tg.genre_id FROM track_genre tg
                WHERE tg.track_id = ph.track_id ORDER BY tg.weight DESC, tg.genre_id LIMIT 1) genre ON true`,
      language: `SELECT tl.language::text AS value
                 FROM completed_plays ph LEFT JOIN track_lyrics tl ON tl.track_id = ph.track_id`,
      instrumental: `SELECT CASE WHEN taf.instrumentalness >= 0.5 THEN 'true' ELSE 'false' END AS value
                      FROM completed_plays ph LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id`,
      new_artist: `SELECT CASE WHEN artist.artist_id IS NULL THEN NULL WHEN NOT EXISTS (
                       SELECT 1 FROM play_history old_ph
                       JOIN track_artists_v2 old_ta ON old_ta.track_id = old_ph.track_id AND old_ta.role = 'main'
                       WHERE old_ph.user_id = $1 AND old_ph.completed = true
                         AND old_ph.played_at <= NOW() - $2::interval AND old_ta.artist_id = artist.artist_id
                     ) THEN 'true' ELSE 'false' END AS value
                     FROM completed_plays ph
                     LEFT JOIN LATERAL (SELECT ta.artist_id FROM track_artists_v2 ta
                       WHERE ta.track_id = ph.track_id AND ta.role = 'main'
                       ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1) artist ON true`,
      favorite: `SELECT CASE WHEN f.track_id IS NOT NULL THEN 'true' ELSE 'false' END AS value
                 FROM completed_plays ph LEFT JOIN favorites f ON f.track_id = ph.track_id AND f.user_id = $1`,
    };
    const source = sourceByDimension[dimension];
    if (!source) return { spent: 0, total: 0, values: new Map() };
    const res = await this.db.pgClient.query(
      `WITH completed_plays AS (
         SELECT ph.track_id
         FROM play_history ph
         WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true
       ), values_per_track AS (${source})
       SELECT value, COUNT(*)::int AS cnt FROM values_per_track
       GROUP BY value ORDER BY cnt DESC, value ASC`,
      [userId, interval],
    );
    const values = new Map<string, number>();
    let total = 0;
    let max = 0;
    for (const row of res.rows as { value: string | null; cnt: number | string }[]) {
      const count = Number(row.cnt);
      total += count;
      if (row.value != null) {
        values.set(row.value, count);
        max = Math.max(max, count);
      }
    }
    const lowerBoundDimension = ['instrumental', 'new_artist', 'favorite'].includes(dimension);
    const numerator = lowerBoundDimension ? (values.get('true') ?? 0) : max;
    return { spent: total > 0 ? numerator / total : 0, total, values };
  }

  // ---------------------------------------------------------------
  // D.4 — Arc selection
  // ---------------------------------------------------------------
  pickArc(state: GeneratorContext['state']): string {
    const goal = state.sessionGoal;
    if (goal && goal.progress < goal.target) {
      if (goal.type === 'familiar') return 'comfort';
      if (goal.type === 'discovery' || goal.type === 'artist_introduction') return 'discovery';
    }
    // Calendar time is a gentle tie-breaker: it only selects the quiet arc
    // when no explicit listening goal has priority over it.
    if (state.context && state.context.localHour < 6) return 'late-night';
    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): ArcSlot[] {
    const pattern = this.getArcPattern(arcType);
    const slots: ArcSlot[] = [];
    for (let i = 0; i < count; i++) {
      const template = pattern[i % pattern.length];
      // A repeated template must not replay an old callback token. The token
      // identity is scoped to the cycle so each return has one clear anchor.
      const cycle = Math.floor(i / pattern.length);
      slots.push({
        ...template,
        position: i,
        callback: template.callback ? { ...template.callback, id: `${template.callback.id}:${cycle}` } : undefined,
        surprise: template.surprise && cycle === 0 ? template.surprise : undefined,
      });
    }
    return slots;
  }

  private getArcPattern(arcType: string): Omit<ArcSlot, 'position'>[] {
    const familiar = { novelty: { max: 0.35 } };
    const adjacent = { novelty: { min: 0.2, max: 0.65 } };
    const newDiscovery = { novelty: { min: 0.55, max: 1 } };
    const callbackAnchor: CallbackToken = {
      id: 'comfort-theme', phase: 'anchor', theme: 'artist', minSeparation: 2, maxSeparation: 6,
    };
    const callbackReturn: CallbackToken = {
      ...callbackAnchor, phase: 'return',
    };
    const surprise: SurpriseDirective = { recoveryRole: 'favorite', maxPerPlan: 1, maxPerHour: 1 };
    const energeticSurprise: SurpriseDirective = { recoveryRole: 'cooldown', maxPerPlan: 1, maxPerHour: 1 };
    switch (arcType) {
      case 'comfort':
        return [
          { role: 'known', targets: { energy: { min: 0.3, max: 0.65, maxDelta: 0.2 }, tempo: { maxDelta: 25 }, ...familiar } },
          { role: 'known', targets: { energy: { min: 0.3, max: 0.65, maxDelta: 0.18 }, tempo: { maxDelta: 22 }, ...familiar } },
          { role: 'adjacent', targets: { energy: { min: 0.35, max: 0.7, maxDelta: 0.22 }, tempo: { maxDelta: 28 }, ...adjacent } },
          { role: 'favorite', targets: { energy: { min: 0.3, max: 0.7, maxDelta: 0.25 }, valence: { maxDelta: 0.3 }, ...familiar }, callback: callbackAnchor },
          { role: 'adjacent', targets: { energy: { min: 0.35, max: 0.75, maxDelta: 0.25 }, ...adjacent } },
          { role: 'favorite', targets: { energy: { min: 0.3, max: 0.7, maxDelta: 0.25 }, ...familiar }, callback: callbackReturn },
          { role: 'surprise', targets: { energy: { min: 0.25, max: 0.75, maxDelta: 0.3 }, ...newDiscovery }, surprise },
          { role: 'favorite', targets: { energy: { min: 0.25, max: 0.65, maxDelta: 0.25 }, acousticness: { min: 0.1 }, ...familiar } },
        ];
      case 'discovery':
        return [
          { role: 'favorite', targets: { energy: { maxDelta: 0.25 }, ...familiar }, callback: callbackAnchor },
          { role: 'similar', targets: { energy: { maxDelta: 0.25 }, tempo: { maxDelta: 30 }, ...adjacent } },
          { role: 'surprise', targets: { energy: { maxDelta: 0.3 }, ...newDiscovery }, surprise },
          { role: 'favorite', targets: { energy: { maxDelta: 0.25 }, ...familiar }, callback: callbackReturn },
        ];
      case 'energetic':
        return [
          { role: 'medium', targets: { energy: { min: 0.45, max: 0.7, maxDelta: 0.18 }, tempo: { min: 95, max: 145, maxDelta: 25 }, ...familiar } },
          { role: 'medium', targets: { energy: { min: 0.5, max: 0.75, maxDelta: 0.15 }, tempo: { min: 100, max: 155, maxDelta: 20 }, ...adjacent } },
          { role: 'high', targets: { energy: { min: 0.65, max: 0.9, maxDelta: 0.22 }, tempo: { min: 110, max: 175, maxDelta: 28 }, ...adjacent } },
          { role: 'high', targets: { energy: { min: 0.7, max: 0.95, maxDelta: 0.18 }, tempo: { min: 115, max: 180, maxDelta: 25 }, ...newDiscovery } },
          { role: 'peak', targets: { energy: { min: 0.8, max: 1, maxDelta: 0.2 }, tempo: { min: 120, max: 190, maxDelta: 30 }, valence: { min: 0.45, maxDelta: 0.3 }, ...newDiscovery } },
          { role: 'surprise', targets: { energy: { min: 0.7, max: 1, maxDelta: 0.25 }, tempo: { maxDelta: 35 }, ...newDiscovery }, surprise: energeticSurprise },
          { role: 'cooldown', targets: { energy: { min: 0.45, max: 0.75, maxDelta: 0.3 }, tempo: { maxDelta: 35 }, ...familiar } },
          { role: 'cooldown', targets: { energy: { min: 0.35, max: 0.65, maxDelta: 0.2 }, acousticness: { min: 0.1 }, ...familiar } },
        ];
      case 'late-night':
        return [
          { role: 'soft', targets: { energy: { max: 0.4, maxDelta: 0.15 }, tempo: { max: 115, maxDelta: 20 }, acousticness: { min: 0.2 }, ...familiar } },
          { role: 'soft', targets: { energy: { max: 0.38, maxDelta: 0.12 }, tempo: { max: 110, maxDelta: 18 }, ...familiar } },
          { role: 'ambient', targets: { energy: { max: 0.3, maxDelta: 0.15 }, tempo: { max: 100, maxDelta: 18 }, instrumentality: { min: 0.35 }, ...adjacent } },
          { role: 'ambient', targets: { energy: { max: 0.28, maxDelta: 0.1 }, acousticness: { min: 0.25 }, instrumentality: { min: 0.35 }, ...adjacent } },
          { role: 'acoustic', targets: { energy: { max: 0.45, maxDelta: 0.2 }, acousticness: { min: 0.35 }, ...familiar }, callback: callbackAnchor },
          { role: 'slow', targets: { energy: { max: 0.4, maxDelta: 0.15 }, tempo: { max: 105, maxDelta: 18 }, ...familiar }, callback: callbackReturn },
        ];
      default:
        return this.getArcPattern('comfort');
    }
  }

  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 'surprise':
        return ['discovery', 'adjacent'];
      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<AntiLoopSignal | 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 [value, count] of artistCounts) {
      if (count / n > 0.3) return { dimension: 'artist', values: [value] };
    }

    // 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 [value, count] of genreCounts) {
      if (count / n > 0.4) return { dimension: 'genre', values: [value] };
    }

    // 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 [value, count] of langCounts) {
      if (count / n > 0.5) return { dimension: 'language', values: [value] };
    }

    // 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]++;
      }
      const dominant = quartileCounts.indexOf(Math.max(...quartileCounts));
      if (quartileCounts[dominant] / energies.length > 0.6) return { dimension: 'energy', values: [String(dominant)] };
    }

    // 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 { dimension: 'bpm', values: [String(Math.floor(bpms[0] / 20))] };
    }

    // 6. VOCAL: >80% all-vocal or all-instrumental
    if (n >= 3) {
      const knownVocal = recentPlays.filter(p => p.vocal != null);
      const vocalCount = knownVocal.filter(p => p.vocal).length;
      const vocalRatio = knownVocal.length === 0 ? 0.5 : vocalCount / knownVocal.length;
      if (knownVocal.length >= 3 && (vocalRatio > 0.8 || vocalRatio < 0.2)) {
        return { dimension: 'vocal', values: [String(vocalRatio > 0.8)] };
      }
    }

    // 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 [value, count] of decadeCounts) {
      if (count / n > 0.5) return { dimension: 'decade', values: [String(value)] };
    }

    // 8. PRODUCER: repeated graph lineage across resolved main artists. These
    // edges are artist-to-artist in claim_fusion, so track-subject claims would
    // silently never match real enriched data.
    const trackIds = recentPlays.map(p => p.trackId).filter(Boolean);
    if (trackIds.length > 0) {
      const prodRes = await this.db.pgClient.query(
        `WITH recent_main_artists AS (
           SELECT DISTINCT ON (ph.track_id) ph.track_id, artist.artist_id
           FROM play_history ph
           JOIN LATERAL (
             SELECT ta.artist_id FROM track_artists_v2 ta
             WHERE ta.track_id = ph.track_id AND ta.role = 'main'
             ORDER BY ta.confidence DESC, ta.artist_id
             LIMIT 1
           ) artist ON true
           WHERE ph.track_id = ANY($1::uuid[])
         )
         SELECT CASE WHEN cf.subject_id = recent.artist_id THEN cf.object_id ELSE cf.subject_id END AS lineage_id
         FROM recent_main_artists recent
         JOIN claim_fusion cf ON cf.subject_type = 'artist' AND cf.object_type = 'artist'
           AND cf.predicate = 'produced'
           AND (cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id)
         GROUP BY lineage_id
         HAVING COUNT(DISTINCT recent.track_id) > 3`,
        [trackIds]
      );
      if (prodRes.rows.length > 0) {
        return { dimension: 'producer', values: prodRes.rows.map((row: { lineage_id: string }) => row.lineage_id) };
      }
    }

    // 9. LABEL: same representation and bidirectional handling as producer
    // lineage. `same_label_as` is a graph relationship between artists.
    if (trackIds.length > 0) {
      const labelRes = await this.db.pgClient.query(
        `WITH recent_main_artists AS (
           SELECT DISTINCT ON (ph.track_id) ph.track_id, artist.artist_id
           FROM play_history ph
           JOIN LATERAL (
             SELECT ta.artist_id FROM track_artists_v2 ta
             WHERE ta.track_id = ph.track_id AND ta.role = 'main'
             ORDER BY ta.confidence DESC, ta.artist_id
             LIMIT 1
           ) artist ON true
           WHERE ph.track_id = ANY($1::uuid[])
         )
         SELECT CASE WHEN cf.subject_id = recent.artist_id THEN cf.object_id ELSE cf.subject_id END AS lineage_id
         FROM recent_main_artists recent
         JOIN claim_fusion cf ON cf.subject_type = 'artist' AND cf.object_type = 'artist'
           AND cf.predicate = 'same_label_as'
           AND (cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id)
         GROUP BY lineage_id
         HAVING COUNT(DISTINCT recent.track_id) > 3`,
        [trackIds]
      );
      if (labelRes.rows.length > 0) {
        return { dimension: 'label', values: labelRes.rows.map((row: { lineage_id: string }) => row.lineage_id) };
      }
    }

    // 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 { dimension: 'mood', values: [String(positiveCount === valences.length)] };
      }
    }

    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;
  }

  /**
   * Load the small, planner-facing metadata projection in one round trip.
   * This deliberately uses nullable fields: unknown metadata cannot earn a
   * lower-bound budget credit, but it remains eligible unless a hard rule has
   * enough metadata to apply.
   */
  private async loadConstraintMetadata(
    userId: string,
    trackIds: string[],
  ): Promise<Map<string, CandidateConstraintMetadata>> {
    const metadata = new Map<string, CandidateConstraintMetadata>();
    if (trackIds.length === 0) return metadata;
    const res = await this.db.pgClient.query(
      `SELECT t.id AS track_id, t.album_id, t.release_date,
              artist.artist_id, genre.genre_id, tl.language,
              taf.instrumentalness,
              EXISTS(SELECT 1 FROM favorites f WHERE f.user_id = $1 AND f.track_id = t.id) AS favorite,
              NOT EXISTS(
                SELECT 1 FROM play_history heard
                WHERE heard.user_id = $1 AND heard.track_id = t.id
              ) AS unheard,
              NOT EXISTS(
                SELECT 1 FROM play_history ph
                JOIN track_artists_v2 old_artist ON old_artist.track_id = ph.track_id
                  AND old_artist.role = 'main'
                WHERE ph.user_id = $1 AND ph.completed = true
                  AND old_artist.artist_id = artist.artist_id
              ) AS new_artist,
              taf.energy, taf.bpm, taf.valence, taf.acousticness, taf.instrumentalness,
              ${lineageIdsSql('produced', 'artist')} AS producer_ids,
              ${lineageIdsSql('same_label_as', 'artist')} AS label_ids
       FROM tracks t
       LEFT JOIN LATERAL (
         SELECT ta.artist_id FROM track_artists_v2 ta
         WHERE ta.track_id = t.id AND ta.role = 'main'
         ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1
       ) artist ON true
       LEFT JOIN LATERAL (
         SELECT tg.genre_id FROM track_genre tg
         WHERE tg.track_id = t.id ORDER BY tg.weight DESC LIMIT 1
       ) genre ON true
       LEFT JOIN track_lyrics tl ON tl.track_id = t.id
       LEFT JOIN track_audio_features taf ON taf.track_id = t.id
       WHERE t.id = ANY($2::uuid[])`,
      [userId, trackIds],
    );
    for (const row of res.rows as Array<Record<string, unknown>>) {
      const id = row.track_id as string;
      metadata.set(id, {
        artistId: (row.artist_id as string | null) ?? undefined,
        albumId: (row.album_id as string | null) ?? undefined,
        genreId: (row.genre_id as string | null) ?? undefined,
        language: (row.language as string | null) ?? undefined,
        instrumental: typeof row.instrumentalness === 'number'
          ? (row.instrumentalness as number) >= 0.5 : undefined,
        favorite: row.favorite === true,
        newArtist: row.new_artist === true && row.artist_id != null,
        unheard: row.unheard === true,
        energy: (row.energy as number | null) ?? undefined,
        bpm: (row.bpm as number | null) ?? undefined,
        valence: (row.valence as number | null) ?? undefined,
        acousticness: (row.acousticness as number | null) ?? undefined,
        instrumentalness: (row.instrumentalness as number | null) ?? undefined,
        decade: row.release_date
          ? Math.floor(new Date(row.release_date as string).getFullYear() / 10) * 10 : undefined,
        producerIds: Array.isArray(row.producer_ids) ? row.producer_ids as string[] : [],
        labelIds: Array.isArray(row.label_ids) ? row.label_ids as string[] : [],
      });
    }
    return metadata;
  }

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

    const trackIds = [...new Set(candidates.map(c => c.trackId))];
    const metadata = await this.loadConstraintMetadata(userId, trackIds);
    const artistMap = new Map<string, string>();
    for (const [trackId, item] of metadata) {
      if (item.artistId) artistMap.set(trackId, item.artistId);
    }

    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; inputIndex: number }[] = [];
    for (const [inputIndex, c] of candidates.entries()) {
      const item = metadata.get(c.trackId);
      const artistId = item?.artistId ?? '';
      const genreId = item?.genreId ?? '';
      const albumId = item?.albumId ?? '';
      const language = item?.language ?? '';

      const trackFatigue = fatigue.track.get(c.trackId) ?? 0;
      const artistFatigue = fatigue.artist.get(artistId) ?? 0;
      const albumFatigue = fatigue.album.get(albumId) ?? 0;
      const genreFatigue = fatigue.genre.get(genreId) ?? 0;
      const languageFatigue = fatigue.language.get(language) ?? 0;
      const vocalFatigue = item?.instrumental === undefined ? 0
        : (item.instrumental ? 1 - fatigue.vocal : fatigue.vocal);
      const avgFatigue = (trackFatigue + artistFatigue + albumFatigue + genreFatigue + languageFatigue + vocalFatigue) / 6;

      // 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));
      const novelty = candidateNovelty(c, item);
      const explorationFit = 1 - Math.abs(novelty - (state.discoveryRadius ?? 0.38));
      const sessionSimilarity = sessionSimilarityPenalty(item, c.generatorId, recentSessionFingerprints);

      // A track the listener has never heard scores as if it carried real
      // relevance. Without this the only generator that samples the whole
      // library, library-fallback, entered at relevance 0.05 and lost every
      // slot to the same few well-attested favourites — which is how four
      // fifths of the library stayed unplayed.
      const unheardBonus = item?.unheard ? 1 : 0;

      let score = W_ENJOY * c.relevance
        - W_FATIGUE * avgFatigue
        + W_DIVERSITY * diversityBonus
        + W_ENTROPY * entropyBonus
        + W_UNHEARD * unheardBonus
        + 0.08 * explorationFit
        - sessionSimilarity;

      if (wouldRepeat) {
        score *= 0.1;
      }

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

    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 || a.inputIndex - b.inputIndex || a.candidate.trackId.localeCompare(b.candidate.trackId));
    return scored.map(s => s.candidate);
  }

  // session_state is otherwise only written once when a Vibe session starts — 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,
          explorationCoefficient: state.explorationCoefficient ?? 0.3,
          discoveryRadius: state.discoveryRadius ?? 0.38,
          sessionGoal: state.sessionGoal ?? { type: 'discovery', target: 1, progress: 0 },
        }),
      ]
    );
  }

  /**
   * The surprise budget is consumed when a surprise is actually delivered to
   * playback, not when it is merely present in a preview. Replans copy the
   * unserved tail into a new immutable revision, so counting `slot_role`
   * rows would charge the same unserved surprise once per revision. The
   * `track_served` event is the durable, exactly-once delivery boundary; its
   * plan-version payload lets us identify the precise immutable item that
   * was exposed.
   */
  private async getSessionSurpriseUsage(sessionId: string, userId: string): Promise<{ session: number; hour: number }> {
    const res = await this.db.pgClient.query(
      `WITH surprise_exposures AS (
         SELECT DISTINCT e.id, e.occurred_at
         FROM vibe_events e
         JOIN vibe_plan_items item
           ON item.plan_version_id = CASE
             WHEN e.payload->>'planVersionId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
             THEN (e.payload->>'planVersionId')::uuid
           END
          AND item.ordinal = CASE
             WHEN (e.payload->>'ordinal') ~ '^(0|[1-9][0-9]{0,8})$'
               OR ((e.payload->>'ordinal') ~ '^1[0-9]{9}$' AND (e.payload->>'ordinal') <= '2147483647')
             THEN (e.payload->>'ordinal')::integer
           END
          AND item.track_id = e.track_id
         JOIN vibe_plan_versions version
           ON version.id = item.plan_version_id
          AND version.session_id = e.session_id
         WHERE e.session_id = $1
           AND e.user_id = $2
           AND e.type = 'track_served'
           AND item.slot_role = 'surprise'
       )
       SELECT COUNT(*)::int AS session_count,
              COUNT(*) FILTER (WHERE occurred_at > NOW() - INTERVAL '1 hour')::int AS hour_count
       FROM surprise_exposures`,
      [sessionId, userId],
    );
    const row = res.rows[0] as { session_count?: number | string; hour_count?: number | string } | undefined;
    return { session: Number(row?.session_count ?? 0), hour: Number(row?.hour_count ?? 0) };
  }

  // ---------------------------------------------------------------
  // D.9 — Plan + replan loop
  // ---------------------------------------------------------------
  /**
   * Tracks the listener actually heard inside the cooldown window, newest
   * first, capped so the exclusion can never hold back more than
   * COOLDOWN_LIBRARY_SHARE of what is playable. Both halves matter: a Vibe
   * serves tracks the listener never finishes, and the listener plays tracks
   * outside any Vibe, and both count as "I have heard this recently".
   */
  private async getHeardCooldownTrackIds(userId: string): Promise<string[]> {
    const res = await this.db.pgClient.query(
      `WITH eligible AS (
         SELECT COUNT(*)::int AS total FROM tracks
          WHERE state = 'LIBRARY' OR (state = 'RECOMMENDED' AND probation_status = 'probation')
       ),
       heard AS (
         SELECT track_id, MAX(heard_at) AS heard_at FROM (
           SELECT track_id, played_at AS heard_at
             FROM play_history
            WHERE user_id = $1 AND played_at > NOW() - ($2::int * INTERVAL '1 day')
           UNION ALL
           SELECT track_id, occurred_at AS heard_at
             FROM vibe_events
            WHERE user_id = $1 AND type = 'track_served' AND track_id IS NOT NULL
              AND occurred_at > NOW() - ($2::int * INTERVAL '1 day')
         ) heard_rows
         GROUP BY track_id
       )
       SELECT ranked.track_id FROM (
         SELECT heard.track_id,
                ROW_NUMBER() OVER (ORDER BY heard.heard_at DESC) AS rn,
                eligible.total
           FROM heard CROSS JOIN eligible
       ) ranked
        WHERE ranked.rn <= FLOOR(ranked.total * $3::numeric)`,
      [userId, HEARD_COOLDOWN_DAYS, COOLDOWN_LIBRARY_SHARE],
    );
    return (res.rows as { track_id: string }[]).map(row => row.track_id);
  }

  /**
   * Guarantee the plan's share of never-played tracks. The ranker's bonus makes
   * an unheard track competitive; this makes it certain, by substituting the
   * best unheard candidates the ranker did not select for the lowest-priority
   * heard entries. The first entry is never swapped — that is the track about
   * to play, and the listener asked for it by seeding this Vibe.
   */
  private enforceUnheardFloor(
    plan: Candidate[],
    pool: Candidate[],
    metadata: Map<string, CandidateConstraintMetadata>,
    retainedPlan: Candidate[],
  ): Candidate[] {
    const isUnheard = (candidate: Candidate) => metadata.get(candidate.trackId)?.unheard === true;
    const total = plan.length + retainedPlan.length;
    if (total === 0) return plan;

    const present = [...plan, ...retainedPlan].filter(isUnheard).length;
    let missing = Math.ceil(total * UNHEARD_PLAN_SHARE) - present;
    if (missing <= 0) return plan;

    const planned = new Set([...plan, ...retainedPlan].map(candidate => candidate.trackId));
    // `pool` arrives in ranked order, so this takes the best unused ones.
    const spare = pool.filter(candidate => isUnheard(candidate) && !planned.has(candidate.trackId));
    if (spare.length === 0) return plan;

    const filled = [...plan];
    for (let index = filled.length - 1; index > 0 && missing > 0 && spare.length > 0; index--) {
      if (isUnheard(filled[index])) continue;
      filled[index] = spare.shift()!;
      missing--;
    }
    return filled;
  }

  async buildPlan(
    userId: string,
    sessionId: string,
    seedTrackId?: string,
    options: PlanBuildOptions = {}
  ): Promise<Candidate[]> {
    const retainedPlan = options.retainedPlan ?? [];
    // Durable events outlive any process-local queue. Fetch them here rather
    // than trusting callers to remember the boundary, so a served, skipped,
    // disliked, or otherwise exposed track can never leak into a replacement
    // revision for this session.
    const durableSessionTrackIds = await this.db.getVibeSessionTrackIds(sessionId, userId);
    const recentFingerprintReader = (this.db as Partial<DbService>).getRecentVibeSessionFingerprints;
    const recentSessionFingerprints = recentFingerprintReader
      ? await recentFingerprintReader.call(this.db, userId, sessionId) as SessionFingerprint[]
      : [];
    // Do not let abundant track-level beliefs crowd out the artist/genre
    // affinities required by the discovery generators.
    const beliefGroups = await Promise.all([
      this.db.getListenerBeliefs({ userId, entityType: 'track', limit: 100 }),
      this.db.getListenerBeliefs({ userId, entityType: 'artist', limit: 100 }),
      this.db.getListenerBeliefs({ userId, entityType: 'genre', limit: 75 }),
      this.db.getListenerBeliefs({ userId, entityType: 'audio', limit: 50 }),
    ]);
    const allBeliefs = beliefGroups.flat();

    // Fetch recent completed plays for anti-loop detection
    const recentPlaysRes = await this.db.pgClient.query(
      `SELECT t.id AS track_id, t.album_id, artist.artist_id, tg.genre_id,
              af.bpm, af.energy, af.valence, af.acousticness, af.instrumentalness,
              tl.language,
              t.release_date,
              ${lineageIdsSql('produced', 'artist')} AS producer_ids,
              ${lineageIdsSql('same_label_as', 'artist')} AS label_ids
       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 LATERAL (
         SELECT ta.artist_id FROM track_artists_v2 ta
         WHERE ta.track_id = t.id AND ta.role = 'main'
         ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1
       ) artist ON true
       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 $2`,
      [userId, ALBUM_HORIZON_TRACKS]
    );
    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) ? null : r.instrumentalness < 0.5,
      decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null,
      valence: r.valence ?? null,
      acousticness: r.acousticness ?? null,
      instrumentalness: r.instrumentalness ?? null,
      albumId: r.album_id ?? null,
      producerIds: r.producer_ids ?? [],
      labelIds: r.label_ids ?? [],
    }));

    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 = PLAN_SIZE;
    const rawSlots = this.getArcSlots(arcType, Math.max(0, planSize - retainedPlan.length));
    const surpriseUsage = await this.getSessionSurpriseUsage(sessionId, userId);
    const slots = this.applySurpriseBudget(rawSlots, surpriseUsage, state.sessionAgeMin);

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

    // Every generator receives this as a SQL exclusion list. It is a hard
    // boundary, not a score penalty: a served/skipped track cannot return in
    // a replacement plan while its Vibe session is active.
    const recentExclusionSet = new Set<string>([
      ...recentPlays.map(p => p.trackId),
      ...durableSessionTrackIds,
      ...(await this.getHeardCooldownTrackIds(userId)),
      ...(options.excludedTrackIds ?? []),
    ]);
    if (seedTrackId) recentExclusionSet.add(seedTrackId);
    const recentExclusions = [...recentExclusionSet];
    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 availableGeneratorIds = new Set(allCandidates.map(candidate => candidate.generatorId));
    // A budgeted surprise without an explainable discovery candidate becomes
    // its declared recovery anchor before ranking. This is a deterministic
    // degradation, not a random or mislabeled fallback.
    const effectiveSlots = slots.map(slot => {
      const hasSurpriseSource = !slot.surprise
        || this.roleToGeneratorIds(slot.role).some(id => availableGeneratorIds.has(id));
      if (hasSurpriseSource || !slot.surprise) return slot;
      return {
        ...slot,
        role: slot.surprise.recoveryRole,
        surprise: undefined,
        targets: { ...(slot.targets ?? {}), novelty: { max: 0.35 } },
      };
    });

    const repetitionState = await this.buildRepetitionState(userId);
    const candidateArtistMap = await this.loadArtistMap(
      [...new Set(allCandidates.map(c => c.trackId))]
    );
    // Artist cooldown used to be a 0.1x score multiplier. In a small pool it
    // still selected the same artist, so enforce the configured window before
    // ranking instead.
    const eligibleCandidates = allCandidates.filter(candidate =>
      !recentExclusionSet.has(candidate.trackId) &&
      !repetitionState.recentTrackIds.has(candidate.trackId) &&
      !repetitionState.recentArtistIds.has(candidateArtistMap.get(candidate.trackId) ?? '')
    );

    if (eligibleCandidates.length === 0) {
      return [];
    }
    const ranked = await this.rankCandidates(
      eligibleCandidates, fatigue, budgets, state, repetitionState, userId, recentSessionFingerprints
    );

    const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);

    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 metadata = await this.loadConstraintMetadata(userId, [
      ...new Set([...deduped.map(c => c.trackId), ...retainedPlan.map(c => c.trackId)]),
    ]);
    const constrained = this.constrainedSequence({
      candidates: deduped,
      slots: effectiveSlots,
      metadata,
      budgets,
      roleToGeneratorIds: role => this.roleToGeneratorIds(role),
      retainedPlan,
      albumHistory: recentPlays.map(metadataFromRecentPlay),
      // A seed is an explicit direction. It narrows soft diversity targets but
      // cannot bypass served-track exclusions or artist/album sequence caps.
      explicitIntent: !!seedTrackId,
      loopDimension: loopDim?.dimension,
      loopedValues: loopDim?.values,
    });
    if (constrained.relaxations.length > 0) {
      // Structured diagnostics stay internal until the durable plan API exposes
      // objective snapshots. Do not silently hide a degraded sequence.
      console.warn('Vibe constraint relaxations', { sessionId, relaxations: constrained.relaxations });
    }
    const plan = constrained.plan.slice(0, Math.max(0, planSize - retainedPlan.length));
    return this.enforceUnheardFloor(plan, deduped, metadata, retainedPlan);
  }

  /** Apply the durable delivery budget to fresh arc slots. Retained entries
   * are intentionally absent here: they have not consumed anything until a
   * `track_served` event exists for their immutable revision. */
  private applySurpriseBudget(
    rawSlots: ArcSlot[],
    surpriseUsage: { session: number; hour: number },
    sessionAgeMin: number,
  ): ArcSlot[] {
    // One surprise is allowed in a rolling hour. The session capacity grows
    // slowly (one per listening hour), preserving a recovery anchor after
    // every admitted surprise instead of filling each replan with novelty.
    const sessionSurpriseLimit = Math.max(1, Math.ceil(Math.max(0, sessionAgeMin) / 60));
    let plannedSurprises = 0;
    return rawSlots.map(slot => {
      if (!slot.surprise) return slot;
      const allowed = plannedSurprises < slot.surprise.maxPerPlan
        && surpriseUsage.hour + plannedSurprises < slot.surprise.maxPerHour
        && surpriseUsage.session + plannedSurprises < sessionSurpriseLimit;
      if (allowed) {
        plannedSurprises++;
        return slot;
      }
      return {
        ...slot,
        role: slot.surprise.recoveryRole,
        surprise: undefined,
        targets: { ...(slot.targets ?? {}), novelty: { max: 0.35 } },
      };
    });
  }

  async replan(
    userId: string,
    sessionId: string,
    currentPlan: Candidate[],
    playedTrackIds: string[],
    seedTrackId?: string,
    options: PlanBuildOptions = {}
  ): Promise<Candidate[]> {
    const excludedTrackIds = new Set<string>([
      ...playedTrackIds,
      ...(options.excludedTrackIds ?? []),
    ]);
    // Corrupt/legacy Redis plans can contain duplicates. Clean those before
    // deciding whether the tail needs a refill, and never reintroduce a track
    // reported as played/skipped/disliked by the route.
    const remainingSlots = mergeUniquePlan(
      currentPlan,
      [],
      excludedTrackIds,
      PLAN_SIZE
    );

    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, t.album_id, artist.artist_id, tg.genre_id,
                af.bpm, af.energy, af.valence, af.instrumentalness,
                tl.language,
                t.release_date,
                ${lineageIdsSql('produced', 'artist')} AS producer_ids,
                ${lineageIdsSql('same_label_as', 'artist')} AS label_ids
         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 LATERAL (
           SELECT ta.artist_id FROM track_artists_v2 ta
           WHERE ta.track_id = t.id AND ta.role = 'main'
           ORDER BY ta.confidence DESC, ta.artist_id LIMIT 1
         ) artist ON true
         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) ? null : r.instrumentalness < 0.5,
        decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null,
        valence: r.valence ?? null,
        albumId: r.album_id ?? null,
        producerIds: r.producer_ids ?? [],
        labelIds: r.label_ids ?? [],
      }));

      const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
      if (loopDim) {
        const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, {
          excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]),
          retainedPlan: remainingSlots,
        });
        return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
      }

      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) {
        const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, {
          excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]),
          retainedPlan: remainingSlots,
        });
        return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
      }

      return remainingSlots;
    }

    // Build replacements against both session history and the queue tail. The
    // old code rebuilt from completed plays only, then locally de-duped at the
    // route — yielding an empty tail when every returned candidate was already
    // queued. Keep the valid tail and append only fresh candidates.
    const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, {
      excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]),
      retainedPlan: remainingSlots,
    });
    return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE);
  }

  // ---------------------------------------------------------------
  // 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;
  }
}
