import { describe, it, expect, vi } from 'vitest';
import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector, sessionSimilarityPenalty } from './session-director.service.js';
import { DbService } from './db.service.js';
import { ALL_GENERATORS } from './generators.service.js';

function makeMockDb(overrides: Record<string, any> = {}): DbService {
  const mockQuery = vi.fn();
  return {
    pgClient: { query: mockQuery },
    getListenerBeliefs: vi.fn().mockResolvedValue([]),
    getLatestSessionState: vi.fn().mockResolvedValue(null),
    seedDefaultDiversityBudgets: vi.fn().mockResolvedValue(undefined),
    upsertDiversityBudget: vi.fn().mockResolvedValue(undefined),
    ...overrides,
  } as unknown as DbService;
}

describe('SessionDirector', () => {
  const candidate = (trackId: string) => ({
    trackId,
    generatorId: 'test',
    relevance: 1,
    explanation: [],
  });

  describe('session queue exclusions', () => {
    it('keeps more than 100 unique selections without cycling', () => {
      const source = Array.from({ length: 125 }, (_, index) => candidate(`track-${index}`));
      const plan = mergeUniquePlan([], source, [], 125);

      expect(plan).toHaveLength(125);
      expect(new Set(plan.map(item => item.trackId)).size).toBe(125);
    });

    it('drops rapid skips and duplicate-only refill candidates', async () => {
      const director = new SessionDirector(makeMockDb());
      const buildPlan = vi.spyOn(director, 'buildPlan').mockResolvedValue([
        candidate('skipped'),
        candidate('already-queued'),
        candidate('fresh'),
        candidate('fresh'),
      ]);

      const plan = await director.replan(
        'user-1',
        'session-1',
        [candidate('skipped'), candidate('already-queued'), candidate('already-queued')],
        ['skipped'],
        undefined,
        { excludedTrackIds: ['older-skip', 'skipped'] }
      );

      expect(plan.map(item => item.trackId)).toEqual(['already-queued', 'fresh']);
      expect(buildPlan).toHaveBeenCalledWith(
        'user-1',
        'session-1',
        undefined,
        expect.objectContaining({
          excludedTrackIds: expect.any(Set),
        })
      );
      const refillExclusions = (buildPlan.mock.calls[0][3] as any).excludedTrackIds as Set<string>;
      expect(refillExclusions).toEqual(new Set(['older-skip', 'skipped', 'already-queued']));
      expect((buildPlan.mock.calls[0][3] as any).retainedPlan.map((item: any) => item.trackId))
        .toEqual(['already-queued']);
    });

    it('does not append anything when a refill contains only queued or excluded tracks', async () => {
      const director = new SessionDirector(makeMockDb());
      vi.spyOn(director, 'buildPlan').mockResolvedValue([
        candidate('queued'),
        candidate('skipped'),
        candidate('queued'),
      ]);

      const plan = await director.replan(
        'user-1',
        'session-1',
        [candidate('queued')],
        ['skipped'],
        undefined,
        { excludedTrackIds: ['skipped'] }
      );

      expect(plan.map(item => item.trackId)).toEqual(['queued']);
    });
  });

  describe('pickArc', () => {
    const director = new SessionDirector(makeMockDb());

    it('returns late-night for low energy', () => {
      const arc = director.pickArc({ energy: 0.2, noveltyHunger: 0.3, sessionAgeMin: 10 } as any);
      expect(arc).toBe('late-night');
    });

    it('returns discovery for high energy + high novelty', () => {
      const arc = director.pickArc({ energy: 0.7, noveltyHunger: 0.6, sessionAgeMin: 5 } as any);
      expect(arc).toBe('discovery');
    });

    it('returns energetic for high energy + low novelty', () => {
      const arc = director.pickArc({ energy: 0.7, noveltyHunger: 0.3, sessionAgeMin: 5 } as any);
      expect(arc).toBe('energetic');
    });

    it('returns comfort for medium energy', () => {
      const arc = director.pickArc({ energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10 } as any);
      expect(arc).toBe('comfort');
    });
  });

  describe('getArcSlots', () => {
    const director = new SessionDirector(makeMockDb());

    it('returns correct slot count', () => {
      expect(director.getArcSlots('comfort', 20).length).toBe(20);
      expect(director.getArcSlots('discovery', 20).length).toBe(20);
      expect(director.getArcSlots('energetic', 10).length).toBe(10);
      expect(director.getArcSlots('late-night', 8).length).toBe(8);
    });

    it('has valid role names', () => {
      const slots = director.getArcSlots('comfort', 20);
      const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow', 'surprise'];
      slots.forEach(s => expect(validRoles).toContain(s.role));
    });

    it('creates measurable targets, callbacks, and one bounded surprise in the first arc cycle', () => {
      const slots = director.getArcSlots('comfort', 20);
      expect(slots.every(slot => slot.targets && Object.keys(slot.targets).length > 0)).toBe(true);
      expect(slots.filter(slot => slot.surprise)).toHaveLength(1);
      const anchor = slots.find(slot => slot.callback?.phase === 'anchor');
      const callback = slots.find(slot => slot.callback?.phase === 'return');
      expect(anchor?.callback?.id).toBe(callback?.callback?.id);
      expect(anchor?.callback?.minSeparation).toBeGreaterThan(0);
    });
  });

  describe('durable surprise delivery accounting', () => {
    it('counts only exact, served surprise plan-item exposures', async () => {
      const sessionId = '00000000-0000-4000-8000-000000000001';
      const userId = '00000000-0000-4000-8000-000000000002';
      const otherSessionId = '00000000-0000-4000-8000-000000000003';
      const revisionOneId = '00000000-0000-4000-8000-000000000011';
      const revisionTwoId = '00000000-0000-4000-8000-000000000012';
      const otherRevisionId = '00000000-0000-4000-8000-000000000013';
      const now = new Date();
      const recentAt = new Date(now.getTime() - 5 * 60 * 1000);
      const expiredAt = new Date(now.getTime() - 61 * 60 * 1000);
      const retainedTrackId = '00000000-0000-4000-8000-000000000021';
      const servedTrackId = '00000000-0000-4000-8000-000000000022';
      const oldTrackId = '00000000-0000-4000-8000-000000000023';

      // This mirrors the three tables involved in the query. The unserved
      // retained row exists in both immutable revisions, but has no ledger
      // event and therefore must not consume a surprise budget.
      const versions = [
        { id: revisionOneId, sessionId },
        { id: revisionTwoId, sessionId },
        { id: otherRevisionId, sessionId: otherSessionId },
      ];
      const items = [
        { planVersionId: revisionOneId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' },
        { planVersionId: revisionTwoId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' },
        { planVersionId: revisionTwoId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' },
        { planVersionId: revisionTwoId, ordinal: 8, trackId: oldTrackId, slotRole: 'surprise' },
        { planVersionId: revisionTwoId, ordinal: 9, trackId: '00000000-0000-4000-8000-000000000024', slotRole: 'favorite' },
        { planVersionId: otherRevisionId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' },
      ];
      const events = [
        // The exact revision-two association counts once.
        { id: '00000000-0000-4000-8000-000000000031', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt },
        // A valid historical exposure remains in the session total but falls
        // out of the rolling 60-minute counter.
        { id: '00000000-0000-4000-8000-000000000032', sessionId, userId, type: 'track_served', trackId: oldTrackId, payload: { planVersionId: revisionTwoId, ordinal: 8 }, occurredAt: expiredAt },
        { id: '00000000-0000-4000-8000-000000000033', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionOneId, ordinal: 7 }, occurredAt: recentAt }, // wrong ordinal
        { id: '00000000-0000-4000-8000-000000000034', sessionId, userId, type: 'track_served', trackId: retainedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt }, // wrong track
        { id: '00000000-0000-4000-8000-000000000035', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: '00000000-0000-4000-8000-000000000014', ordinal: 7 }, occurredAt: recentAt }, // wrong version
        { id: '00000000-0000-4000-8000-000000000036', sessionId: otherSessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: otherRevisionId, ordinal: 7 }, occurredAt: recentAt }, // wrong session
        { id: '00000000-0000-4000-8000-000000000037', sessionId, userId, type: 'track_finished', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt },
        // Legacy/corrupt payloads must neither cast-fail nor claim an actual
        // surprise exposure when somebody writes directly to the event ledger.
        { id: '00000000-0000-4000-8000-000000000038', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: 'not-a-uuid', ordinal: 7 }, occurredAt: recentAt },
        { id: '00000000-0000-4000-8000-000000000039', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 'not-an-integer' }, occurredAt: recentAt },
        { id: '00000000-0000-4000-8000-000000000040', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: '999999999999999999999999999999999999' }, occurredAt: recentAt },
      ];
      const exposureIds: string[] = [];
      const db = makeMockDb();
      (db.pgClient.query as any).mockImplementation((sql: string, params: unknown[]) => {
        // Faithfully evaluate the query's joins against the in-memory rows;
        // do not treat merely planned items as delivered exposure.
        expect(params).toEqual([sessionId, userId]);
        const matching = events.filter(event => {
          const item = items.find(candidate => candidate.planVersionId === event.payload.planVersionId
            && candidate.ordinal === event.payload.ordinal
            && candidate.trackId === event.trackId);
          const version = item && versions.find(candidate => candidate.id === item.planVersionId);
          return event.sessionId === sessionId
            && event.userId === userId
            && event.type === 'track_served'
            && item?.slotRole === 'surprise'
            && version?.sessionId === event.sessionId;
        });
        exposureIds.push(...new Set(matching.map(event => event.id)));
        return Promise.resolve({
          rows: [{
            session_count: new Set(matching.map(event => event.id)).size,
            hour_count: new Set(matching.filter(event => event.occurredAt > new Date(Date.now() - 60 * 60 * 1000)).map(event => event.id)).size,
          }],
        });
      });

      const usage = await (new SessionDirector(db) as any).getSessionSurpriseUsage(sessionId, userId);

      expect(usage).toEqual({ session: 2, hour: 1 });
      expect(exposureIds).toEqual([
        '00000000-0000-4000-8000-000000000031',
        '00000000-0000-4000-8000-000000000032',
      ]);
      const [sql, params] = (db.pgClient.query as any).mock.calls[0] as [string, unknown[]];
      expect(params).toEqual([sessionId, userId]);
      expect(sql).toContain('SELECT DISTINCT e.id, e.occurred_at');
      expect(sql).toContain("e.payload->>'planVersionId' ~*");
      expect(sql).toContain("THEN (e.payload->>'planVersionId')::uuid");
      expect(sql).toContain("e.payload->>'ordinal') ~ '^(0|[1-9][0-9]{0,8})$'");
      expect(sql).toContain("THEN (e.payload->>'ordinal')::integer");
      expect(sql).toContain('AND item.track_id = e.track_id');
      expect(sql).toContain('AND version.session_id = e.session_id');
      expect(sql).toContain("e.type = 'track_served'");
      expect(sql).toContain("item.slot_role = 'surprise'");
      expect(sql).toContain('WHERE e.session_id = $1');
      expect(sql).toContain('AND e.user_id = $2');
      expect(sql).toContain("occurred_at > NOW() - INTERVAL '1 hour'");
    });
  });

  describe('transition-aware sequence scoring', () => {
    it('prefers a smooth, on-arc candidate and treats missing analysis as lower confidence', () => {
      const slot = {
        position: 0,
        role: 'high',
        targets: { energy: { min: 0.7, max: 0.9, maxDelta: 0.2 }, tempo: { min: 120, max: 160, maxDelta: 25 } },
      };
      const previous = { energy: 0.72, bpm: 132 };
      const smooth = scoreArcTransition(candidate('smooth'), { energy: 0.78, bpm: 140 }, previous, slot);
      const abrupt = scoreArcTransition(candidate('abrupt'), { energy: 0.15, bpm: 72 }, previous, slot);
      const unknown = scoreArcTransition(candidate('unknown'), {}, previous, slot);

      expect(smooth.score).toBeGreaterThan(abrupt.score);
      expect(unknown.confidence).toBeLessThan(smooth.confidence);
      expect(unknown.score).toBeGreaterThan(0);
    });

    it('selects a callback inside its separation window while preserving hard caps', () => {
      const candidates = ['anchor', 'bridge-a', 'bridge-b', 'return', 'other'].map(id => ({ ...candidate(id), generatorId: 'comfort' }));
      const metadata = new Map([
        ['anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }],
        ['bridge-a', { artistId: 'a2', albumId: 'a2', energy: 0.5 }],
        ['bridge-b', { artistId: 'a3', albumId: 'a3', energy: 0.5 }],
        ['return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.5 }],
        ['other', { artistId: 'a4', albumId: 'a5', favorite: false, energy: 0.5 }],
      ]);
      const token = { id: 'theme', theme: 'artist' as const, minSeparation: 2, maxSeparation: 4 };
      const result = selectConstrainedSequence({
        candidates,
        metadata,
        budgets: [],
        roleToGeneratorIds: () => ['comfort'],
        slots: [
          { position: 0, role: 'known', targets: {}, callback: { ...token, phase: 'anchor' as const } },
          { position: 1, role: 'known', targets: {} },
          { position: 2, role: 'known', targets: {} },
          { position: 3, role: 'favorite', targets: {}, callback: { ...token, phase: 'return' as const } },
        ],
      });
      expect(result.plan.map(item => item.trackId)).toEqual(['anchor', 'bridge-a', 'bridge-b', 'return']);
      expect(result.plan[3].plan?.scoreBreakdown.callback).toBe(1);
    });

    it('plans an energetic rise through peak and cooldown when measured candidates exist', () => {
      const db = makeMockDb();
      const director = new SessionDirector(db);
      const slots = director.getArcSlots('energetic', 8);
      const entries = [
        ['medium-1', 'comfort', 0.55, 110], ['medium-2', 'comfort', 0.62, 125],
        ['high-1', 'discovery', 0.72, 135], ['high-2', 'discovery', 0.8, 150],
        ['peak', 'deep-dive', 0.9, 165], ['surprise', 'discovery', 0.85, 155],
        ['cooldown-1', 'comfort', 0.62, 130], ['cooldown-2', 'comfort', 0.5, 110],
      ] as const;
      const candidates = entries.map(([trackId, generatorId]) => ({ ...candidate(trackId), generatorId }));
      const metadata: Map<string, any> = new Map(entries.map(([trackId, generatorId, energy, bpm], index) => [trackId, {
        artistId: `artist-${index}`, albumId: `album-${index}`, energy, bpm,
        valence: 0.6, acousticness: trackId.startsWith('cooldown') ? 0.3 : 0.1,
        favorite: String(trackId).startsWith('medium') || String(trackId).startsWith('cooldown'),
        newArtist: generatorId === 'discovery',
      }]));
      const result = selectConstrainedSequence({
        candidates, slots, metadata, budgets: [],
        roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
      });

      expect(result.plan.map(item => item.trackId)).toEqual(entries.map(([trackId]) => trackId));
      expect(result.plan.map(item => metadata.get(item.trackId)?.energy))
        .toEqual([0.55, 0.62, 0.72, 0.8, 0.9, 0.85, 0.62, 0.5]);
      expect(result.relaxations).toEqual([]);
    });

    it('keeps the familiar-new-familiar discovery callback intact', () => {
      const db = makeMockDb();
      const director = new SessionDirector(db);
      const candidates = [
        { ...candidate('favorite-anchor'), generatorId: 'deep-dive' },
        { ...candidate('adjacent'), generatorId: 'adjacent' },
        { ...candidate('new'), generatorId: 'discovery' },
        { ...candidate('favorite-return'), generatorId: 'deep-dive' },
      ];
      const metadata = new Map([
        ['favorite-anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }],
        ['adjacent', { artistId: 'bridge', albumId: 'a2', energy: 0.55 }],
        ['new', { artistId: 'new', albumId: 'a3', newArtist: true, energy: 0.6 }],
        ['favorite-return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.55 }],
      ]);
      const result = selectConstrainedSequence({
        candidates, slots: director.getArcSlots('discovery', 4), metadata, budgets: [],
        roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
      });

      expect(result.plan.map(item => item.trackId))
        .toEqual(['favorite-anchor', 'adjacent', 'new', 'favorite-return']);
      expect(result.plan[3].plan?.explanation.callback).toMatchObject({ phase: 'return', matchScore: 1 });
    });

    it('downgrades a surprise deterministically when no preferred recovery anchor is feasible', () => {
      const db = makeMockDb();
      const director = new SessionDirector(db);
      const candidates = [
        { ...candidate('favorite-anchor'), generatorId: 'deep-dive' },
        { ...candidate('adjacent'), generatorId: 'adjacent' },
        { ...candidate('unrecoverable-surprise'), generatorId: 'discovery' },
        // This may fill the downgraded favourite slot only through the
        // explicitly persisted arc-source relaxation; it cannot reserve a
        // recovery for the surprise because it is not a favourite source.
        { ...candidate('fallback'), generatorId: 'contextual' },
      ];
      const metadata = new Map(candidates.map((item, index) => [item.trackId, {
        artistId: `artist-${index}`, albumId: `album-${index}`,
        favorite: item.trackId === 'favorite-anchor', newArtist: item.trackId === 'unrecoverable-surprise', energy: 0.5,
      }]));
      const result = selectConstrainedSequence({
        candidates, slots: director.getArcSlots('discovery', 3), metadata, budgets: [],
        roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
      });

      expect(result.plan.map(item => item.trackId)).toEqual(['favorite-anchor', 'adjacent', 'fallback']);
      expect(result.plan[2].plan?.slotRole).toBe('favorite');
      expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'surprise_recovery' }));
    });

    it('falls back safely with sparse audio analysis and records arc precision', () => {
      const db = makeMockDb();
      const director = new SessionDirector(db);
      const result = selectConstrainedSequence({
        candidates: [
          { ...candidate('unknown-analysis'), generatorId: 'comfort' },
          { ...candidate('wrong-energy'), generatorId: 'comfort' },
        ],
        slots: director.getArcSlots('energetic', 1),
        metadata: new Map([
          ['unknown-analysis', { artistId: 'a1', albumId: 'x1' }],
          ['wrong-energy', { artistId: 'a2', albumId: 'x2', energy: 0.1, bpm: 70 }],
        ]),
        budgets: [], roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
      });

      expect(result.plan.map(item => item.trackId)).toEqual(['unknown-analysis']);
      expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'arc_precision' }));
      expect(result.plan[0].plan?.explanation.arcPrecision).toMatchObject({ measuredFit: false });
    });
  });

  describe('computeEntropy', () => {
    const director = new SessionDirector(makeMockDb());

    it('returns 0 for empty set', () => {
      expect(director.computeEntropy([])).toBe(0);
    });

    it('returns 1 for all-same-artist', () => {
      const candidates = [
        { trackId: 't1', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] },
        { trackId: 't2', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] },
      ] as any;
      expect(director.computeEntropy(candidates)).toBe(1);
    });

    it('returns ~0.5 for two-artist split', () => {
      const candidates = [
        { trackId: 't1', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] },
        { trackId: 't2', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] },
      ] as any;
      const hhi = director.computeEntropy(candidates);
      expect(hhi).toBeCloseTo(0.5);
    });
  });

  describe('rankCandidates', () => {
    it('sorts candidates by score descending', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [] });
      const director = new SessionDirector(db);

      const candidates = [
        { trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] },
        { trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] },
      ];
      const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
      const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }];
      const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };

      const repetitionState = { recentTrackIds: new Set<string>(), recentArtistIds: new Set<string>() };
      const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, repetitionState);
      expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance);
    });

    it('lifts a never-played track above a familiar one of similar relevance', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({
        rows: [
          { track_id: 'heard', artist_id: 'a1', unheard: false, favorite: false },
          { track_id: 'unheard', artist_id: 'a2', unheard: true, favorite: false },
        ],
      });
      const director = new SessionDirector(db);

      const candidates = [
        { trackId: 'heard', generatorId: 'comfort', relevance: 0.6, explanation: [] },
        { trackId: 'unheard', generatorId: 'library-fallback', relevance: 0.4, explanation: [] },
      ] as any;
      const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
      const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
      const repetitionState = { recentTrackIds: new Set<string>(), recentArtistIds: new Set<string>() };

      const ranked = await director.rankCandidates(candidates, fatigue, [], state, repetitionState);

      expect(ranked[0].trackId).toBe('unheard');
    });
  });

  describe('unheard floor', () => {
    const director = new SessionDirector(makeMockDb());
    const meta = (unheard: boolean) => ({ unheard });

    it('substitutes unheard candidates for the lowest-priority heard entries', () => {
      const plan = ['p0', 'p1', 'p2', 'p3'].map(candidate);
      const pool = [...plan, ...['u1', 'u2'].map(candidate)];
      const metadata = new Map([
        ...plan.map(item => [item.trackId, meta(false)] as const),
        ['u1', meta(true)] as const,
        ['u2', meta(true)] as const,
      ]);

      const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []);

      // One in four of a four-item plan, taken from the tail.
      expect(filled.map((item: any) => item.trackId)).toEqual(['p0', 'p1', 'p2', 'u1']);
    });

    it('never displaces the track about to play', () => {
      const plan = [candidate('p0')];
      const pool = [candidate('p0'), candidate('u1')];
      const metadata = new Map([['p0', meta(false)] as const, ['u1', meta(true)] as const]);

      const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []);

      expect(filled.map((item: any) => item.trackId)).toEqual(['p0']);
    });

    it('leaves a plan that already meets the floor alone', () => {
      const plan = [candidate('u1'), candidate('p1'), candidate('p2'), candidate('p3')];
      const pool = [...plan, candidate('u2')];
      const metadata = new Map([
        ['u1', meta(true)] as const,
        ['p1', meta(false)] as const,
        ['p2', meta(false)] as const,
        ['p3', meta(false)] as const,
        ['u2', meta(true)] as const,
      ]);

      const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []);

      expect(filled.map((item: any) => item.trackId)).toEqual(['u1', 'p1', 'p2', 'p3']);
    });

    it('counts the retained tail towards the floor', () => {
      const plan = [candidate('p0'), candidate('p1')];
      const retained = [candidate('u0'), candidate('r1')];
      const pool = [...plan, candidate('u1')];
      const metadata = new Map([
        ['p0', meta(false)] as const,
        ['p1', meta(false)] as const,
        ['u0', meta(true)] as const,
        ['r1', meta(false)] as const,
        ['u1', meta(true)] as const,
      ]);

      const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, retained);

      expect(filled.map((item: any) => item.trackId)).toEqual(['p0', 'p1']);
    });
  });

  describe('recent session fingerprint penalty', () => {
    it('softly penalizes a repeated session shape without excluding it', () => {
      const repeated = sessionSimilarityPenalty(
        { artistId: 'artist-1', genreId: 'genre-1' },
        'comfort',
        [{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }],
      );
      const newShape = sessionSimilarityPenalty(
        { artistId: 'artist-2', genreId: 'genre-2' },
        'discovery',
        [{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }],
      );
      expect(repeated).toBeGreaterThan(0);
      expect(repeated).toBeLessThanOrEqual(0.18);
      expect(newShape).toBe(0);
    });
  });

  describe('sequence constraints', () => {
    const slots = Array.from({ length: 10 }, (_, position) => ({ position, role: 'known' }));
    const budgets = [
      { dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 },
      { dimension: 'genre', budgetShare: 0.4, horizonMin: 30, spent: 0 },
      { dimension: 'language', budgetShare: 0.6, horizonMin: 30, spent: 0 },
      { dimension: 'instrumental', budgetShare: 0.1, horizonMin: 30, spent: 0 },
      { dimension: 'new_artist', budgetShare: 0.15, horizonMin: 60, spent: 0 },
      { dimension: 'favorite', budgetShare: 0.25, horizonMin: 60, spent: 0 },
    ];
    const roleToGeneratorIds = () => ['comfort'];

    it('projects budgets while enforcing artist and album caps across the sequence', () => {
      const candidates = Array.from({ length: 15 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' }));
      const metadata = new Map(candidates.map((item, i) => [item.trackId, {
        artistId: i < 5 ? 'overplayed-artist' : `artist-${i}`,
        albumId: i < 4 ? 'overplayed-album' : `album-${i}`,
        genreId: i < 6 ? 'genre-a' : 'genre-b',
        language: i < 7 ? 'ja' : 'en',
        instrumental: i === 7,
        newArtist: i === 8 || i === 9,
        favorite: i === 10 || i === 11 || i === 12,
      }]));

      const result = selectConstrainedSequence({ candidates, slots, metadata, budgets, roleToGeneratorIds });
      expect(result.plan).toHaveLength(10);
      const ids = result.plan.map(item => item.trackId);
      expect(ids.filter(id => metadata.get(id)?.artistId === 'overplayed-artist')).toHaveLength(2);
      expect(ids.filter(id => metadata.get(id)?.albumId === 'overplayed-album').length).toBeLessThanOrEqual(3);
      expect(ids.filter(id => metadata.get(id)?.instrumental)).toHaveLength(1);
      expect(ids.filter(id => metadata.get(id)?.newArtist)).toHaveLength(2);
      expect(ids.filter(id => metadata.get(id)?.favorite)).toHaveLength(3);
    });

    it('corrects the detected dimension directly before relaxing it', () => {
      const candidates = ['ja-1', 'ja-2', 'en-1', 'en-2'].map(trackId => ({ ...candidate(trackId), generatorId: 'comfort' }));
      const metadata = new Map([
        ['ja-1', { artistId: 'a1', albumId: 'x1', language: 'ja' }],
        ['ja-2', { artistId: 'a2', albumId: 'x2', language: 'ja' }],
        ['en-1', { artistId: 'a3', albumId: 'x3', language: 'en' }],
        ['en-2', { artistId: 'a4', albumId: 'x4', language: 'en' }],
      ]);
      const result = selectConstrainedSequence({
        candidates,
        slots: slots.slice(0, 2),
        metadata,
        budgets: [],
        roleToGeneratorIds,
        loopDimension: 'language',
        loopedValue: 'ja',
      });
      expect(result.plan.map(item => item.trackId)).toEqual(['en-1', 'en-2']);
      expect(result.relaxations).toEqual([]);
    });

    it('records a structured soft relaxation without violating hard album caps', () => {
      const candidates = Array.from({ length: 5 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' }));
      const metadata = new Map(candidates.map((item, i) => [item.trackId, {
        artistId: `artist-${i}`,
        albumId: i < 4 ? 'single-album' : `album-${i}`,
        language: 'ja',
      }]));
      const result = selectConstrainedSequence({
        candidates, slots: slots.slice(0, 5), metadata, budgets: [], roleToGeneratorIds,
        loopDimension: 'language', loopedValue: 'ja',
      });
      expect(result.plan.filter(item => metadata.get(item.trackId)?.albumId === 'single-album')).toHaveLength(3);
      expect(result.relaxations).toContainEqual(expect.objectContaining({ stage: 'soft_budget' }));
    });

    it('counts the retained queue tail against hard artist caps before selecting replacements', () => {
      const retained = [candidate('queued-a1'), candidate('queued-a2')];
      const candidates = [candidate('same-artist'), candidate('other-artist')].map(item => ({ ...item, generatorId: 'comfort' }));
      const metadata = new Map([
        ['queued-a1', { artistId: 'artist-a', albumId: 'queued-album-1' }],
        ['queued-a2', { artistId: 'artist-a', albumId: 'queued-album-2' }],
        ['same-artist', { artistId: 'artist-a', albumId: 'replacement-album' }],
        ['other-artist', { artistId: 'artist-b', albumId: 'replacement-album-2' }],
      ]);

      const result = selectConstrainedSequence({
        candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, retainedPlan: retained,
      });
      expect(result.plan.map(item => item.trackId)).toEqual(['other-artist']);
    });

    it('enforces the three-track album limit across the rolling 40-play history', () => {
      const candidates = [candidate('same-album'), candidate('new-album')].map(item => ({ ...item, generatorId: 'comfort' }));
      const metadata = new Map([
        ['same-album', { artistId: 'a4', albumId: 'album-a' }],
        ['new-album', { artistId: 'a5', albumId: 'album-b' }],
      ]);
      const albumHistory = Array.from({ length: 40 }, (_, index) => ({
        artistId: `history-${index}`,
        albumId: index < 3 ? 'album-a' : `history-album-${index}`,
      }));

      const result = selectConstrainedSequence({
        candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, albumHistory,
      });
      expect(result.plan.map(item => item.trackId)).toEqual(['new-album']);
    });

    it('projects budgets over historical counts and the planned horizon with track-consistent denominators', () => {
      const candidates = [candidate('ja'), candidate('en')].map(item => ({ ...item, generatorId: 'comfort' }));
      const metadata = new Map([
        ['ja', { artistId: 'a1', albumId: 'x1', genreId: 'j-pop' }],
        ['en', { artistId: 'a2', albumId: 'x2', genreId: 'rock' }],
      ]);
      const historicalValues = new Map([['j-pop', 4], ['rock', 1]]);
      const result = selectConstrainedSequence({
        candidates,
        slots: slots.slice(0, 1),
        metadata,
        budgets: [{ dimension: 'genre', budgetShare: 0.6, horizonMin: 30, spent: 0.8, historicalTotal: 5, historicalValues }],
        roleToGeneratorIds,
      });
      // 4 / 5 becomes 4 / 6 if rock is selected; a fifth j-pop track would
      // exceed the 60% cap. The selector must use history + proposal, not only
      // the one-track replacement queue.
      expect(result.plan.map(item => item.trackId)).toEqual(['en']);
    });

    it('does not treat unknown instrumentation as a vocal/instrumental budget credit', () => {
      const candidates = [candidate('unknown'), candidate('instrumental')].map(item => ({ ...item, generatorId: 'comfort' }));
      const metadata = new Map([
        ['unknown', { artistId: 'a1', albumId: 'x1' }],
        ['instrumental', { artistId: 'a2', albumId: 'x2', instrumental: true }],
      ]);
      const result = selectConstrainedSequence({
        candidates,
        slots: slots.slice(0, 1),
        metadata,
        budgets: [{ dimension: 'instrumental', budgetShare: 1, horizonMin: 30, spent: 0, historicalTotal: 0, historicalValues: new Map() }],
        roleToGeneratorIds,
      });
      expect(result.plan.map(item => item.trackId)).toEqual(['instrumental']);
    });

    it('excludes candidates matching any detected producer or label, not only their first claim', () => {
      const candidates = [candidate('producer-match'), candidate('label-match'), candidate('safe')].map(item => ({ ...item, generatorId: 'comfort' }));
      const metadata = new Map([
        ['producer-match', { artistId: 'a1', albumId: 'x1', producerIds: ['other', 'producer-loop'] }],
        ['label-match', { artistId: 'a2', albumId: 'x2', labelIds: ['other', 'label-loop'] }],
        ['safe', { artistId: 'a3', albumId: 'x3', producerIds: ['safe-producer'], labelIds: ['safe-label'] }],
      ]);
      const producerResult = selectConstrainedSequence({
        candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds,
        loopDimension: 'producer', loopedValues: ['producer-loop'],
      });
      const labelResult = selectConstrainedSequence({
        candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds,
        loopDimension: 'label', loopedValues: ['label-loop'],
      });
      expect(producerResult.plan.map(item => item.trackId)).not.toContain('producer-match');
      expect(labelResult.plan.map(item => item.trackId)).not.toContain('label-match');
    });

    it('keeps stable candidate order while reusing a role preference pool', () => {
      const candidates = [
        { ...candidate('comfort-first'), generatorId: 'comfort' },
        { ...candidate('adjacent-first'), generatorId: 'adjacent' },
        { ...candidate('comfort-second'), generatorId: 'comfort' },
        { ...candidate('adjacent-second'), generatorId: 'adjacent' },
      ];
      const metadata = new Map(candidates.map((item, index) => [item.trackId, {
        artistId: `artist-${index}`, albumId: `album-${index}`,
      }]));
      const result = selectConstrainedSequence({
        candidates,
        slots: Array.from({ length: 4 }, (_, position) => ({ position, role: position % 2 ? 'adjacent' : 'known' })),
        metadata,
        budgets: [],
        roleToGeneratorIds: role => role === 'adjacent' ? ['adjacent'] : ['comfort'],
      });
      expect(result.plan.map(item => item.trackId)).toEqual([
        'comfort-first', 'adjacent-first', 'comfort-second', 'adjacent-second',
      ]);
    });
  });

  describe('anti-loop signals', () => {
    const variedRecentPlays = Array.from({ length: 4 }, (_, index) => ({
      trackId: `00000000-0000-0000-0000-00000000000${index + 1}`,
      artistId: `artist-${index}`,
      genreId: `genre-${index}`,
      language: `lang-${index}`,
      bpm: 80 + index * 30,
      energy: index / 3,
      vocal: null,
      decade: 1980 + index * 10,
      valence: index % 2,
      albumId: `album-${index}`,
      producerIds: [],
      labelIds: [],
    }));

    it('returns fused producer lineage from resolved main artists', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [{ lineage_id: 'producer-a' }, { lineage_id: 'producer-b' }] });
      const director = new SessionDirector(db);
      const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays);
      expect(signal).toEqual({ dimension: 'producer', values: ['producer-a', 'producer-b'] });
      const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
      expect(sql).toContain('claim_fusion cf');
      expect(sql).toContain("cf.subject_type = 'artist'");
      expect(sql).toContain('cf.object_id = recent.artist_id');
      expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id');
    });

    it('returns label identities after a producer check finds no loop', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any)
        .mockResolvedValueOnce({ rows: [] })
        .mockResolvedValueOnce({ rows: [{ lineage_id: 'label-a' }] });
      const director = new SessionDirector(db);
      const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays);
      expect(signal).toEqual({ dimension: 'label', values: ['label-a'] });
      const sql = (db.pgClient.query as any).mock.calls[1][0] as string;
      expect(sql).toContain("cf.predicate = 'same_label_as'");
      expect(sql).toContain('cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id');
    });
  });

  describe('planner metadata and integration boundaries', () => {
    it('counts every completed play in a budget horizon while only classifying known values', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({
        rows: [{ value: 'rock', cnt: 3 }, { value: null, cnt: 2 }],
      });
      const director = new SessionDirector(db);
      const usage = await (director as any).loadBudgetUsage('user-1', 'genre', 30);
      expect(usage).toMatchObject({ total: 5, spent: 0.6 });
      expect(usage.values).toEqual(new Map([['rock', 3]]));
      const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
      expect(sql).toContain('WITH completed_plays AS');
      expect(sql).not.toContain('WHERE value IS NOT NULL');
    });

    it('loads producer and label lineage from fused relationships of the resolved main artist', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({
        rows: [{
          track_id: 'track-1', artist_id: 'artist-1', album_id: 'album-1', genre_id: null,
          language: null, instrumentalness: null, favorite: false, new_artist: false,
          energy: null, bpm: null, valence: null, release_date: null,
          producer_ids: ['producer-from-object', 'producer-from-subject'],
          label_ids: ['label-from-object', 'label-from-subject'],
        }],
      });
      const director = new SessionDirector(db);
      const metadata = await (director as any).loadConstraintMetadata('user-1', ['track-1']);
      expect(metadata.get('track-1')).toMatchObject({
        artistId: 'artist-1',
        producerIds: ['producer-from-object', 'producer-from-subject'],
        labelIds: ['label-from-object', 'label-from-subject'],
      });
      const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
      expect(sql).toContain('FROM claim_fusion cf');
      expect(sql).toContain("cf.predicate = 'produced'");
      expect(sql).toContain("cf.predicate = 'same_label_as'");
      expect(sql).toContain('cf.subject_id = artist.artist_id OR cf.object_id = artist.artist_id');
      expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id');
    });

    it('carries the retained tail and all 40 album-history plays through replan into constraint selection', async () => {
      const db = makeMockDb({
        getVibeSessionTrackIds: vi.fn().mockResolvedValue([]),
        getListenerBeliefs: vi.fn().mockResolvedValue([]),
      });
      const director = new SessionDirector(db);
      const history = Array.from({ length: 40 }, (_, index) => ({
        track_id: `history-${index}`, album_id: index < 3 ? 'history-album' : `old-album-${index}`,
        artist_id: `history-artist-${index}`, genre_id: null, bpm: null, energy: null,
        valence: null, instrumentalness: null, language: null, release_date: null,
        producer_ids: [], label_ids: [],
      }));
      (db.pgClient.query as any).mockImplementation((sql: string, params: unknown[] = []) => {
        if (sql.includes('FROM play_history ph') && sql.includes('LIMIT $2')) return Promise.resolve({ rows: history });
        if (sql.includes('WHERE t.id = ANY($2::uuid[])')) {
          const ids = params[1] as string[];
          return Promise.resolve({ rows: ids.map(trackId => ({
            track_id: trackId,
            artist_id: `artist-${trackId}`,
            album_id: trackId === 'history-album-candidate' ? 'history-album' : `album-${trackId}`,
            genre_id: null, language: null, instrumentalness: null, favorite: false,
            new_artist: false, energy: null, bpm: null, valence: null, release_date: null,
            producer_ids: trackId === 'producer-loop-candidate' ? ['producer-loop'] : [],
            label_ids: [],
          })) });
        }
        return Promise.resolve({ rows: [] });
      });
      vi.spyOn(director, 'buildState').mockResolvedValue({
        energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 0, lastArtistIds: [], lastGenreIds: [], context: null,
      });
      vi.spyOn(director, 'computeFatigue').mockResolvedValue({
        artist: new Map(), album: new Map(), genre: new Map(), language: new Map(), track: new Map(), vocal: 0.5,
      });
      vi.spyOn(director, 'getBudgets').mockResolvedValue([]);
      vi.spyOn(director, 'buildRepetitionState').mockResolvedValue({ recentTrackIds: new Set(), recentArtistIds: new Set() });
      vi.spyOn(director, 'rankCandidates').mockImplementation(async candidates => candidates);
      vi.spyOn(director, 'detectAntiLoop').mockResolvedValue({ dimension: 'producer', values: ['producer-loop'] });
      vi.spyOn(director as any, 'loadArtistMap').mockResolvedValue(new Map());

      const originalGenerators = [...ALL_GENERATORS];
      ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async () => [
        { ...candidate('producer-loop-candidate'), generatorId: 'comfort' },
        { ...candidate('history-album-candidate'), generatorId: 'comfort' },
        ...Array.from({ length: 5 }, (_, index) => ({ ...candidate(`safe-candidate-comfort-${index}`), generatorId: 'comfort' })),
        ...Array.from({ length: 4 }, (_, index) => ({ ...candidate(`safe-candidate-adjacent-${index}`), generatorId: 'adjacent' })),
        ...Array.from({ length: 2 }, (_, index) => ({ ...candidate(`safe-candidate-favorite-${index}`), generatorId: 'deep-dive' })),
      ]);
      let captured: any;
      vi.spyOn(director as any, 'constrainedSequence').mockImplementation((params: any) => {
        captured = params;
        return selectConstrainedSequence(params);
      });
      try {
        const retained = Array.from({ length: 9 }, (_, index) => ({ ...candidate(`queued-${index}`), generatorId: 'comfort' }));
        const plan = await director.replan('user-1', 'session-1', retained, []);
        expect(captured.retainedPlan.map((item: { trackId: string }) => item.trackId)).toEqual(retained.map(item => item.trackId));
        expect(captured.albumHistory).toHaveLength(40);
        expect(captured.loopDimension).toBe('producer');
        expect(captured.metadata.get('producer-loop-candidate').producerIds).toEqual(['producer-loop']);
        expect(plan.map(item => item.trackId)).not.toContain('history-album-candidate');
        expect(plan.map(item => item.trackId)).toContain('safe-candidate-comfort-0');
      } finally {
        ALL_GENERATORS.splice(0, ALL_GENERATORS.length, ...originalGenerators);
      }
    });
  });

  describe('heard cooldown', () => {
    it('hands generators every track heard inside the window as a hard exclusion', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockImplementation((sql: string) => {
        if (sql.includes('heard_rows')) return Promise.resolve({ rows: [{ track_id: 'heard-last-week' }] });
        return Promise.resolve({ rows: [] });
      });
      const director = new SessionDirector(db);
      vi.spyOn(director, 'buildState').mockResolvedValue({
        energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 0, lastArtistIds: [], lastGenreIds: [], context: null,
      });
      vi.spyOn(director, 'computeFatigue').mockResolvedValue({
        artist: new Map(), album: new Map(), genre: new Map(), language: new Map(), track: new Map(), vocal: 0.5,
      });
      vi.spyOn(director, 'getBudgets').mockResolvedValue([]);
      vi.spyOn(director, 'buildRepetitionState').mockResolvedValue({ recentTrackIds: new Set(), recentArtistIds: new Set() });
      vi.spyOn(director as any, 'loadArtistMap').mockResolvedValue(new Map());
      (db as any).getVibeSessionTrackIds = vi.fn().mockResolvedValue([]);

      const originalGenerators = [...ALL_GENERATORS];
      let seenExclusions: string[] = [];
      ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async (_db, ctx) => {
        seenExclusions = [...ctx.recentExclusions];
        return [{ ...candidate('heard-last-week'), generatorId: 'comfort' }];
      });
      try {
        const plan = await director.buildPlan('user-1', 'session-1');
        expect(seenExclusions).toContain('heard-last-week');
        expect(plan.map(item => item.trackId)).not.toContain('heard-last-week');
      } finally {
        ALL_GENERATORS.splice(0, ALL_GENERATORS.length, ...originalGenerators);
      }
    });
  });

  describe('buildState', () => {
    it('returns state with default values when no prior session', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [] });
      (db.getListenerBeliefs as any).mockResolvedValue([]);
      (db.getLatestSessionState as any).mockResolvedValue(null);
      const director = new SessionDirector(db);
      const state = await director.buildState('user-1');
      expect(state).toHaveProperty('energy');
      expect(state).toHaveProperty('noveltyHunger');
      expect(state).toHaveProperty('sessionAgeMin');
      expect(typeof state.energy).toBe('number');
    });
  });
});
