import { describe, it, expect, vi } from 'vitest';
import { DbService } from './db.service.js';

function makeService(): { service: DbService; mockQuery: ReturnType<typeof vi.fn> } {
  const mockQuery = vi.fn();
  const service = new DbService({ query: mockQuery } as any);
  return { service, mockQuery };
}

function makeTransactionalService(): { service: DbService; poolQuery: ReturnType<typeof vi.fn>; clientQuery: ReturnType<typeof vi.fn> } {
  const poolQuery = vi.fn();
  const clientQuery = vi.fn();
  const service = new DbService({
    query: poolQuery,
    connect: vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() }),
  } as any);
  return { service, poolQuery, clientQuery };
}

describe('DbService v2 methods', () => {
  describe('durable Vibe sessions', () => {
    it('projects unfamiliar feedback into exploration exactly once behind its own marker', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const event = {
        id: 'event-1', session_id: 'session-1', user_id: 'user-1', track_id: 'track-1',
        type: 'completed', occurred_at: new Date(), client_event_id: null,
        position_ms: null, duration_ms: null, payload: {},
      } as any;
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill
        .mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker
        .mockResolvedValueOnce({ rows: [{ context: {} }] }) // no calendar context on legacy session
        .mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity
        .mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence
        .mockResolvedValueOnce({ rowCount: 1 }) // discovery belief
        .mockResolvedValueOnce({ rows: [] }) // no artist/genre targets
        .mockResolvedValueOnce({ rows: [] }) // no audio targets
        .mockResolvedValueOnce({ rows: [{
          exploration_coefficient: 0.36, discovery_radius: 0.434,
          goals: { type: 'familiar', target: 1, progress: 1 },
        }] })
        .mockResolvedValueOnce({ rows: [] }) // session_state projection
        .mockResolvedValueOnce({ rows: [] }) // COMMIT
        .mockResolvedValueOnce({ rows: [] }) // BEGIN retry
        .mockResolvedValueOnce({ rows: [] }) // profile upsert retry
        .mockResolvedValueOnce({ rows: [] }) // marker conflict
        .mockResolvedValueOnce({ rows: [] }); // COMMIT retry

      await service.projectVibeSessionFeedback(event);
      await service.projectVibeSessionFeedback(event);

      expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_session_profiles');
      expect(clientQuery.mock.calls[2][0]).toContain('vibe_session_feedback_projections');
      expect(clientQuery.mock.calls[3][0]).toContain('SELECT context FROM vibe_sessions');
      expect(clientQuery.mock.calls[4][0]).toContain('EXISTS (SELECT 1 FROM play_history');
      expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO evidence');
      expect(clientQuery.mock.calls[9][0]).toContain('exploration_coefficient');
      expect(clientQuery.mock.calls[9][0]).toContain('ELSE goals END');
      expect(clientQuery.mock.calls[9][0]).toContain('ELSE $3::real END');
      expect(clientQuery.mock.calls[10][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 }));
      expect(clientQuery.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO evidence'))).toHaveLength(1);
    });

    it('creates, reads, and ends sessions scoped to their user', async () => {
      const { service, poolQuery, clientQuery } = makeTransactionalService();
      const session = {
        id: 'session-1', user_id: 'user-1', status: 'active', seed_track_id: null,
        context: { activity: 'focus' }, policy_version: 'v2.1',
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [] }) // user advisory lock
        .mockResolvedValueOnce({ rows: [] }) // active-session lock
        .mockResolvedValueOnce({ rows: [session] }) // insert
        .mockResolvedValueOnce({ rows: [] }); // COMMIT
      poolQuery
        .mockResolvedValueOnce({ rows: [session] })
        .mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] });

      await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' })).resolves.toEqual(session);
      await expect(service.getVibeSession('session-1', 'user-1')).resolves.toEqual(session);
      await expect(service.endVibeSession('session-1', 'user-1')).resolves.toMatchObject({ status: 'ended' });

      expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock');
      expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE");
      expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions');
      expect(clientQuery.mock.calls[3][1]).toEqual(expect.arrayContaining([
        'user-1', null, expect.any(String), 'v2.1',
        JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
      ]));
      expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual({});
      expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2');
      expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())');
      expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
    });

    it('starts sessions without inventing client context', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const session = { id: 'session-1', user_id: 'user-1', status: 'active' };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [] }) // user advisory lock
        .mockResolvedValueOnce({ rows: [] }) // active-session lock
        .mockResolvedValueOnce({ rows: [session] }) // insert
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' });
      const insertParameters = clientQuery.mock.calls[3][1];
      expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]);
      expect(JSON.parse(insertParameters[2])).toEqual({});
      expect(insertParameters.slice(3)).toEqual([
        'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
      ]);
    });

    it('replaces an owned active session and writes its terminal event before starting another', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [] }) // user advisory lock
        .mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // active lock
        .mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // replace
        .mockResolvedValueOnce({ rows: [] }) // terminal event
        .mockResolvedValueOnce({ rows: [replacement] }) // new session
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' }))
        .resolves.toEqual(replacement);
      expect(clientQuery.mock.calls[3][0]).toContain("status = 'replaced'");
      expect(clientQuery.mock.calls[4][0]).toContain("'session_ended'");
      expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO vibe_sessions');
    });

    it('records retry-safe events and reports whether the event was inserted', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const event = {
        id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
        user_id: 'user-1', track_id: 'track-1', type: 'skipped', occurred_at: new Date(),
        position_ms: 1_500, duration_ms: 10_000, payload: { reason: 'next' },
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock session
        .mockResolvedValueOnce({ rows: [event] }) // existing retry
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      const result = await service.recordVibeEvent({
        sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
        trackId: 'track-1', type: 'skipped', positionMs: 1_500, durationMs: 10_000,
        payload: { reason: 'next' },
      });

      expect(result.inserted).toBe(false);
      expect(result.event.id).toBe('event-1');
      const [sql, values] = clientQuery.mock.calls[1];
      expect(sql).toContain('FOR UPDATE');
      expect(values).toEqual([
        'session-1', 'user-1',
      ]);
      expect(clientQuery.mock.calls).toHaveLength(5);
      expect(clientQuery.mock.calls[3][0]).toContain('vibe_event_projections');
      expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
        expect.stringContaining('UPDATE vibe_sessions')
      );
    });

    it('records a non-material event without mutating session context', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const event = {
        id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null,
        type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null,
        payload: { source: 'player' },
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
        .mockResolvedValueOnce({ rows: [event] }) // insert
        .mockResolvedValueOnce({ rows: [] }) // last event timestamp
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await service.recordVibeEvent({
        sessionId: 'session-1', userId: 'user-1', type: 'progress', payload: { source: 'player' },
      });

      const values = clientQuery.mock.calls[2][1];
      const storedPayload = JSON.parse(values[8]);
      expect(storedPayload).toEqual({ source: 'player' });
      expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
        expect.stringContaining('SET context = $3::jsonb'),
      );
    });

    it('does not apply a retry body to an existing event', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const canonicalEvent = {
        id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null,
        type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null,
        payload: { source: 'player' },
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
        .mockResolvedValueOnce({ rows: [canonicalEvent] }) // canonical retry event
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      const result = await service.recordVibeEvent({
        sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'progress',
        payload: { source: 'retry' },
      });

      expect(result).toEqual({ event: canonicalEvent, inserted: false });
      expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
        expect.stringContaining('SET context = $3::jsonb'),
      );
    });

    it('projects material feedback once with the durable event transaction', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const event = {
        id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
        user_id: 'user-1', track_id: 'track-1', type: 'completed', occurred_at: new Date(),
        position_ms: null, duration_ms: null, payload: {},
      };
      const evidence = vi.spyOn(service as any, 'recordTrackEvidence').mockResolvedValue('evidence-1');
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
        .mockResolvedValueOnce({ rows: [] }) // no existing idempotency key
        .mockResolvedValueOnce({ rows: [event] }) // event insert
        .mockResolvedValueOnce({ rows: [{ event_id: 'event-1' }] }) // projection marker
        .mockResolvedValueOnce({ rows: [] }) // play history
        .mockResolvedValueOnce({ rows: [] }) // track counter
        .mockResolvedValueOnce({ rows: [] }) // session timestamp
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await expect(service.recordVibeEvent({
        sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
        type: 'completed', trackId: 'track-1',
      })).resolves.toMatchObject({ inserted: true, event: { id: 'event-1' } });

      expect(clientQuery.mock.calls.map(([sql]) => sql)).toEqual(expect.arrayContaining([
        expect.stringContaining('vibe_event_projections'),
        expect.stringContaining('INSERT INTO play_history'),
      ]));
      expect(evidence).toHaveBeenCalledTimes(1);
    });

    it('rejects an event when no owned session is returned', async () => {
      const { service, clientQuery } = makeTransactionalService();
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [] }) // session lookup
        .mockResolvedValueOnce({ rows: [] }); // ROLLBACK
      await expect(service.recordVibeEvent({
        sessionId: 'session-1', userId: 'other-user', type: 'completed',
      })).rejects.toThrow('not found or is not owned');
    });

    it('rejects new events for terminal sessions but returns an existing idempotent retry', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const event = {
        id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
        user_id: 'user-1', track_id: null, type: 'completed', occurred_at: new Date(),
        position_ms: null, duration_ms: null, payload: {},
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN: idempotent retry
        .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
        .mockResolvedValueOnce({ rows: [event] })
        .mockResolvedValueOnce({ rows: [] }) // COMMIT
        .mockResolvedValueOnce({ rows: [] }) // BEGIN: new event
        .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
        .mockResolvedValueOnce({ rows: [] }) // no matching retry
        .mockResolvedValueOnce({ rows: [] }); // ROLLBACK

      await expect(service.recordVibeEvent({
        sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'completed',
      })).resolves.toEqual({ event, inserted: false });
      await expect(service.recordVibeEvent({
        sessionId: 'session-1', userId: 'user-1', clientEventId: 'new-event-1', type: 'completed',
      })).rejects.toThrow('Cannot record a new event for ended Vibe session');

      expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
        expect.stringContaining('INSERT INTO vibe_events')
      );
    });

    it('locks the terminal transition with its event and makes terminal retries no-ops', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
      const ended = { ...active, status: 'ended', ended_at: new Date() };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [active] }) // lock
        .mockResolvedValueOnce({ rows: [] }) // terminal event
        .mockResolvedValueOnce({ rows: [ended] }) // status transition
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await expect(service.endVibeSessionWithEvent('session-1', 'user-1'))
        .resolves.toEqual({ session: ended, ended: true });
      expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
      expect(clientQuery.mock.calls[2][0]).toContain("'session_ended'");
      expect(clientQuery.mock.calls[3][0]).toContain("status = 'ended'");
    });

    it('resumes an owned session once and records session_resumed in the same lock', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ ...active, status: 'paused' }] }) // lock
        .mockResolvedValueOnce({ rows: [] }) // no old resume event
        .mockResolvedValueOnce({ rows: [active] }) // activate
        .mockResolvedValueOnce({ rows: [] }) // ledger event
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await expect(service.resumeVibeSession('session-1', 'user-1'))
        .resolves.toEqual({ session: active, resumed: true });
      expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
      expect(clientQuery.mock.calls[4][0]).toContain("'session_resumed'");
    });
  });

  describe('durable Vibe plans', () => {
    it('publishes a revision and its plan_published event in one transaction', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const published = {
        id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
        state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
        .mockResolvedValueOnce({ rows: [published] }) // header
        .mockResolvedValueOnce({ rows: [] }) // item
        .mockResolvedValueOnce({ rows: [] }) // plan_published
        .mockResolvedValueOnce({ rows: [] }) // timestamp
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await expect(service.publishVibePlan({
        sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
        stateSnapshot: {}, objectiveSnapshot: {},
        items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
      })).resolves.toMatchObject({ version: 1, items: [{ track_id: 'track-1' }] });

      expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
      expect(clientQuery.mock.calls[4][0]).toContain("'plan_published'");
      expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('COMMIT');
    });

    it('rolls back the plan header and items if writing plan_published fails', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const published = {
        id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
        state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
        .mockResolvedValueOnce({ rows: [published] }) // header
        .mockResolvedValueOnce({ rows: [] }) // item
        .mockRejectedValueOnce(new Error('ledger write failed'))
        .mockResolvedValueOnce({ rows: [] }); // ROLLBACK

      await expect(service.publishVibePlan({
        sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
        stateSnapshot: {}, objectiveSnapshot: {},
        items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
      })).rejects.toThrow('ledger write failed');
      expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('ROLLBACK');
      expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain('COMMIT');
    });

    it('refuses a delayed initial publication after a concurrent start replaced its session', async () => {
      const { service, clientQuery } = makeTransactionalService();
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'replaced' }] })
        .mockResolvedValueOnce({ rows: [] }); // ROLLBACK

      await expect(service.publishVibePlan({
        sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
        stateSnapshot: {}, objectiveSnapshot: {}, items: [],
      })).rejects.toThrow('Cannot publish a plan for replaced Vibe session');
      expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('INSERT INTO vibe_plan_versions'));
    });

    it('reads every durable session track as a replacement-plan exclusion', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValueOnce({ rows: [{ track_id: 'served' }, { track_id: 'skipped' }, { track_id: 'disliked' }] });
      await expect(service.getVibeSessionTrackIds('session-1', 'user-1'))
        .resolves.toEqual(['served', 'skipped', 'disliked']);
      expect(mockQuery.mock.calls[0][0]).toContain('SELECT DISTINCT e.track_id');
      expect(mockQuery.mock.calls[0][0]).toContain('e.track_id IS NOT NULL');
    });

    it('writes a header and all items in one transaction', async () => {
      const { service, clientQuery } = makeTransactionalService();
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{
          id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
          state_snapshot: { energy: 0.5 }, objective_snapshot: { freshness: 0.4 }, created_at: new Date(),
        }] })
        .mockResolvedValueOnce({ rows: [] }) // item
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      const plan = await service.persistVibePlan({
        sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
        stateSnapshot: { energy: 0.5 }, objectiveSnapshot: { freshness: 0.4 },
        items: [{
          ordinal: 0, track_id: 'track-1', slot_role: 'anchor', candidate_source: 'comfort',
          score: 0.91, score_breakdown: { affinity: 0.8 }, explanation: [{ because: 'favourite' }], committed: true,
        }],
      });

      expect(plan.items[0].plan_version_id).toBe('plan-1');
      expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_plan_versions');
      expect(clientQuery.mock.calls[2][0]).toContain('INSERT INTO vibe_plan_items');
      expect(clientQuery.mock.calls[3][0]).toBe('COMMIT');
    });

    it('serves and commits one next item under the session lock', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const item = {
        plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
        candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
        .mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
        .mockResolvedValueOnce({ rows: [item] }) // commit item
        .mockResolvedValueOnce({ rows: [] }) // track_served event
        .mockResolvedValueOnce({ rows: [] }) // timestamp
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await expect(service.serveNextVibePlanItem('session-1', 'user-1')).resolves.toEqual({ item, stale: false });
      expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
      expect(clientQuery.mock.calls[3][0]).toContain('SET committed = true');
      expect(clientQuery.mock.calls[3][1]).toEqual(['plan-1']);
      expect(clientQuery.mock.calls[4][0]).toContain("'track_served'");
    });

    it('returns a newer preview signal without committing when the expected plan is stale', async () => {
      const { service, clientQuery } = makeTransactionalService();
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
        .mockResolvedValueOnce({ rows: [{ id: 'plan-2', version: 2 }] }) // latest
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
        .resolves.toEqual({ item: null, stale: true });
      expect(clientQuery.mock.calls).toHaveLength(4);
      expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
    });

    it('returns the original item when a version-aware next request is retried', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const item = {
        plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
        candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
        .mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest
        .mockResolvedValueOnce({ rows: [item] }) // prior served item
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
        .resolves.toEqual({ item, stale: false });
      expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
    });

    it('advances a served unplayable item with a separate idempotent event and commits one replacement', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const replacement = {
        plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null,
        candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true,
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
        .mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
        .mockResolvedValueOnce({ rows: [] }) // no prior playback_error event
        .mockResolvedValueOnce({ rows: [{ track_id: 'track-1', ordinal: 0 }] }) // current served cursor
        .mockResolvedValueOnce({ rows: [{ id: 'error-1' }] }) // playback_error event
        .mockResolvedValueOnce({ rows: [replacement] }) // commit replacement
        .mockResolvedValueOnce({ rows: [] }) // replacement track_served event
        .mockResolvedValueOnce({ rows: [] }) // playback_error result payload
        .mockResolvedValueOnce({ rows: [] }) // session timestamp
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
        expectedPlanVersion: 1,
        planVersionId: 'plan-1',
        ordinal: 0,
        trackId: 'track-1',
        eventId: 'event-1',
      })).resolves.toEqual({ item: replacement, stale: false });

      expect(clientQuery.mock.calls[5][0]).toContain("'playback_error'");
      expect(clientQuery.mock.calls[6][0]).toContain('SET committed = true');
      expect(clientQuery.mock.calls[7][0]).toContain("'track_served'");
      expect(clientQuery.mock.calls[8][0]).toContain('UPDATE vibe_events SET payload');
    });

    it('refuses an old served cursor when events share a timestamp by ordering the immutable plan ordinal', async () => {
      const { service, clientQuery } = makeTransactionalService();
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
        .mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
        .mockResolvedValueOnce({ rows: [] }) // no prior playback_error event
        // The lower-ordinal event can have a lexically greater UUID at the
        // same occurred_at. The cursor must still be the highest immutable
        // plan ordinal, never whichever UUID sorts last.
        .mockResolvedValueOnce({ rows: [{ track_id: 'track-2', ordinal: 1 }] }) // current served cursor
        .mockResolvedValueOnce({ rows: [] }); // ROLLBACK

      await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
        expectedPlanVersion: 1,
        planVersionId: 'plan-1',
        ordinal: 0,
        trackId: 'track-1',
        eventId: 'event-1',
      })).rejects.toThrow('not the current served cursor');

      expect(clientQuery.mock.calls[4][0]).toContain('JOIN vibe_plan_items');
      expect(clientQuery.mock.calls[4][0]).toContain('ORDER BY i.ordinal DESC');
      expect(clientQuery.mock.calls[4][0]).not.toContain('id DESC');
      expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
    });

    it('retries an unplayable advancement with the same event id without consuming another item', async () => {
      const { service, clientQuery } = makeTransactionalService();
      const replacement = {
        plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null,
        candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true,
      };
      clientQuery
        .mockResolvedValueOnce({ rows: [] }) // BEGIN
        .mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
        .mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
        .mockResolvedValueOnce({ rows: [{ type: 'playback_error', payload: {
          planVersionId: 'plan-1', ordinal: 0, trackId: 'track-1',
          advancedTo: { planVersionId: 'plan-1', ordinal: 1 },
        } }] }) // prior explicit advancement
        .mockResolvedValueOnce({ rows: [replacement] }) // canonical replacement
        .mockResolvedValueOnce({ rows: [] }); // COMMIT

      await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
        expectedPlanVersion: 1,
        planVersionId: 'plan-1',
        ordinal: 0,
        trackId: 'track-1',
        eventId: 'event-1',
      })).resolves.toEqual({ item: replacement, stale: false });

      expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
      expect(clientQuery.mock.calls).toHaveLength(6);
    });

    it('reads the latest revision and reconstructs ordered plan items', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rows: [{
        id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
        state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
        item_plan_version_id: 'plan-2', ordinal: 0, track_id: 'track-2', slot_role: 'next',
        candidate_source: 'adjacent', score: 0.8, score_breakdown: { transition: 0.7 },
        explanation: [{ because: 'similar artist' }], committed: true,
      }, {
        id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
        state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
        item_plan_version_id: 'plan-2', ordinal: 1, track_id: 'track-3', slot_role: null,
        candidate_source: 'discovery', score: 0.6, score_breakdown: { novelty: 0.5 },
        explanation: [], committed: false,
      }] });

      const plan = await service.getVibePlan('session-1', 'user-1');

      expect(plan?.version).toBe(2);
      expect(plan?.items.map((item) => item.track_id)).toEqual(['track-2', 'track-3']);
      expect(mockQuery.mock.calls[0][0]).toContain('SELECT MAX(version) FROM vibe_plan_versions');
      expect(mockQuery.mock.calls[0][1]).toEqual(['session-1', 'user-1', null]);
    });
  });

  describe('createAlbum', () => {
    it('persists the canonical release date instead of discarding it', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rows: [{ id: 'album-1' }] });

      await service.createAlbum({
        id: 'album-1', artist_id: 'artist-1', title: 'Album',
        year: 2026, release_date: '2026-07-15', artwork_id: null,
      });

      const [sql, params] = mockQuery.mock.calls[0];
      expect(sql).toContain('release_date');
      expect(params[3]).toBe('2026-07-15');
    });
  });

  describe('recordPlay durability', () => {
    it('copies the track identity onto the row and records what was heard', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rows: [{ id: 'history-1' }] });

      await service.recordPlay('user-1', 'track-1', false, undefined, 91_000);

      const [sql, params] = mockQuery.mock.calls[0];
      // A LEFT JOIN, not a plain VALUES: the insert must still happen when the
      // track id resolves to nothing.
      expect(sql).toContain('LEFT JOIN tracks');
      expect(sql).toContain('t.title');
      expect(sql).toContain('t.artist');
      expect(params).toEqual(['user-1', 'track-1', null, false, 91_000]);
    });

    it('leaves listened_ms null when the caller has no duration to report', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rows: [{ id: 'history-1' }] });

      await service.recordPlay('user-1', 'track-1', false);

      expect(mockQuery.mock.calls[0][1][4]).toBeNull();
    });
  });

  describe('upsertClaim', () => {
    it('calls INSERT ... ON CONFLICT with correct parameters', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rows: [{ id: 'claim-1' }] });

      const id = await service.upsertClaim({
        subject_type: 'track',
        subject_id: 'track-1',
        predicate: 'credited_main_on',
        object_type: 'artist',
        object_id: 'artist-1',
        source: 'mb',
        confidence: 1.0,
      });

      expect(id).toBe('claim-1');
      expect(mockQuery).toHaveBeenCalledTimes(1);
      const [sql, params] = mockQuery.mock.calls[0];
      expect(sql).toContain('INSERT INTO claims');
      expect(sql).toContain('ON CONFLICT');
      expect(params).toContain('track');
      expect(params).toContain('track-1');
      expect(params).toContain('credited_main_on');
    });

    it('handles user_id null for objective claims', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rows: [{ id: 'c1' }] });
      await service.upsertClaim({
        subject_type: 'artist', subject_id: 'a1', predicate: 'alias_of',
        object_type: 'artist', object_id: 'a2', source: 'listener_behavior',
        user_id: 'user-1',
      });
      const params = mockQuery.mock.calls[0][1];
      expect(params[0]).toBe('user-1');
    });
  });

  describe('getClaimsBySubject', () => {
    it('filters by subject type and id', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rows: [] });
      await service.getClaimsBySubject('track', 'track-1');
      const [sql, params] = mockQuery.mock.calls[0];
      expect(sql).toContain('subject_type = $1');
      expect(sql).toContain('subject_id = $2');
      expect(params).toEqual(['track', 'track-1']);
    });

    it('optionally filters by predicate and user_id', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rows: [] });
      await service.getClaimsBySubject('artist', 'a1', 'alias_of', 'user-1');
      const [sql] = mockQuery.mock.calls[0];
      expect(sql).toContain('predicate');
      expect(sql).toContain('user_id IS NULL');
    });
  });

  describe('recordEvidence', () => {
    it('appends evidence row', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rows: [{ id: 'ev-1' }] });
      const id = await service.recordEvidence({
        user_id: 'user-1', entity_type: 'track', entity_id: 'track-1',
        signal: 'playback_completed', profile: 'longterm', weight: 0.10,
      });
      expect(id).toBe('ev-1');
      const [sql] = mockQuery.mock.calls[0];
      expect(sql).toContain('INSERT INTO evidence');
    });
  });

  describe('updateListenerBelief', () => {
    it('UPSERTs with delta formula', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rowCount: 1 });
      await service.updateListenerBelief({
        user_id: 'user-1', profile: 'longterm',
        entity_type: 'track', entity_id: 'track-1',
        dimension: 'affinity', value_delta: 0.10,
      });
      const [sql] = mockQuery.mock.calls[0];
      expect(sql).toContain('INSERT INTO listener_beliefs');
      expect(sql).toContain('ON CONFLICT');
      expect(sql).toContain('GREATEST(-1.0, LEAST(1.0');
    });
  });

  describe('recordEvidence → belief derivation wiring', () => {
    it('derives a fresh longterm affinity belief after playback_completed', async () => {
      const { service, mockQuery } = makeService();
      // first call: INSERT evidence → returns id; second call: UPSERT belief → rowCount 1
      mockQuery
        .mockResolvedValueOnce({ rows: [{ id: 'ev-1' }] })
        .mockResolvedValueOnce({ rowCount: 1 });

      const id = await service.recordEvidence({
        user_id: 'user-1', entity_type: 'track', entity_id: 'track-1',
        signal: 'playback_completed', profile: 'longterm', weight: 0.10,
      });

      expect(id).toBe('ev-1');
      expect(mockQuery).toHaveBeenCalledTimes(2);

      // First call INSERTs the evidence row.
      const [evidSql, evidParams] = mockQuery.mock.calls[0];
      expect(evidSql).toContain('INSERT INTO evidence');
      expect(evidParams[4]).toBe('longterm'); // profile
      expect(evidParams[5]).toBe(0.10);       // weight

      // Second call UPSERTs the matching listener_belief. For a fresh
      // belief the INSERT path sets value = weight directly (per spec §B.4
      // INSERT branch), so the resulting row has value=0.10, confidence=0.05.
      const [beliefSql, beliefParams] = mockQuery.mock.calls[1];
      expect(beliefSql).toContain('INSERT INTO listener_beliefs');
      expect(beliefSql).toContain('ON CONFLICT');
      // [user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta]
      expect(beliefParams[0]).toBe('user-1');
      expect(beliefParams[1]).toBe('longterm');
      expect(beliefParams[2]).toBe('track');
      expect(beliefParams[3]).toBe('track-1');
      expect(beliefParams[4]).toBe('affinity');
      expect(beliefParams[5]).toBe(0.10); // value_delta = weight
      expect(beliefParams[6]).toBe(0.05); // confidence_delta default
    });

    it('maps play_of_never_seen to the novelty_tolerance dimension', async () => {
      const { service, mockQuery } = makeService();
      mockQuery
        .mockResolvedValueOnce({ rows: [{ id: 'ev-2' }] })
        .mockResolvedValueOnce({ rowCount: 1 });

      await service.recordEvidence({
        user_id: 'user-1', entity_type: 'track', entity_id: 'track-2',
        signal: 'play_of_never_seen', profile: 'discovery', weight: 0.05,
      });

      const beliefParams = mockQuery.mock.calls[1][1] as unknown[];
      expect(beliefParams[4]).toBe('novelty_tolerance');
      expect(beliefParams[1]).toBe('discovery');
      expect(beliefParams[5]).toBe(0.05);
    });

    it('still appends an evidence row before deriving the belief', async () => {
      const { service, mockQuery } = makeService();
      mockQuery
        .mockResolvedValueOnce({ rows: [{ id: 'ev-3' }] })
        .mockResolvedValueOnce({ rowCount: 1 });
      const id = await service.recordEvidence({
        user_id: 'user-1', entity_type: 'track', entity_id: 'track-3',
        signal: 'skip_quick', profile: 'negative', weight: -0.20,
      });
      expect(id).toBe('ev-3');
      expect(mockQuery.mock.calls[0][0]).toContain('INSERT INTO evidence');
      expect(mockQuery.mock.calls[1][0]).toContain('listener_beliefs');
      expect(mockQuery.mock.calls[1][1][4]).toBe('affinity');
    });
  });

  describe('track evidence propagation', () => {
    it('projects a favourite onto artist, genre, and present audio dimensions', async () => {
      const { service, mockQuery } = makeService();
      let evidenceNumber = 0;
      mockQuery.mockImplementation((sql: string) => {
        if (sql.includes('INSERT INTO evidence')) return Promise.resolve({ rows: [{ id: `ev-${++evidenceNumber}` }] });
        if (sql.includes('WITH artist_ids')) {
          return Promise.resolve({ rows: [
            { entity_type: 'artist', entity_id: 'artist-1' },
            { entity_type: 'genre', entity_id: 'genre-1' },
          ] });
        }
        if (sql.includes('SELECT energy, bpm, valence')) {
          return Promise.resolve({ rows: [{ energy: 0.81, bpm: 128, valence: 0.22 }] });
        }
        return Promise.resolve({ rowCount: 1, rows: [] });
      });

      await service.recordTrackEvidence({
        user_id: 'user-1', track_id: 'track-1', signal: 'add_to_favorites',
        profile: 'longterm', weight: 0.60,
      });

      const evidenceWrites = mockQuery.mock.calls
        .filter(([sql]) => (sql as string).includes('INSERT INTO evidence'))
        .map(([, params]) => params as unknown[]);
      expect(evidenceWrites.map(params => params[1])).toEqual([
        'track', 'artist', 'genre', 'audio', 'audio', 'audio',
      ]);

      const beliefWrites = mockQuery.mock.calls
        .filter(([sql]) => (sql as string).includes('INSERT INTO listener_beliefs'))
        .map(([, params]) => params as unknown[]);
      const artistBelief = beliefWrites.find(params => params[2] === 'artist');
      const genreBelief = beliefWrites.find(params => params[2] === 'genre');
      expect(artistBelief?.[3]).toBe('artist-1');
      expect(artistBelief?.[5]).toBeCloseTo(0.54); // one favourite is a usable comfort signal
      expect(genreBelief?.[5]).toBeCloseTo(0.27);
      expect(beliefWrites.filter(params => params[2] === 'audio')).toHaveLength(3);
    });

    it('rebuilds shared beliefs from local completed plays and feedback without appending evidence', async () => {
      const clientQuery = vi.fn((sql: string) => {
        if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') return Promise.resolve({ rows: [] });
        if (sql.includes('DELETE FROM listener_beliefs')) return Promise.resolve({ rowCount: 0, rows: [] });
        if (sql.includes('FROM play_history ph')) {
          return Promise.resolve({ rows: [{
            track_id: 'track-1', signal: 'playback_completed', profile: 'longterm', weight: 0.10,
          }] });
        }
        if (sql.includes('WITH artist_ids')) return Promise.resolve({ rows: [{ entity_type: 'artist', entity_id: 'artist-1' }] });
        if (sql.includes('SELECT energy, bpm, valence')) return Promise.resolve({ rows: [] });
        return Promise.resolve({ rowCount: 1, rows: [] });
      });
      const connect = vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() });
      const service = new DbService({ query: vi.fn(), connect } as any);

      const result = await service.rebuildDerivedListenerBeliefs('user-1');

      expect(result).toEqual({ interactions: 1, beliefs: 1 });
      expect(clientQuery.mock.calls.some(([sql]) => (sql as string).includes('FROM feedback f'))).toBe(true);
      expect(clientQuery.mock.calls.some(([sql]) => (sql as string).includes('INSERT INTO evidence'))).toBe(false);
      const beliefCall = (clientQuery.mock.calls as unknown as Array<[string, unknown[]]>).
        find(([sql]) => sql.includes('INSERT INTO listener_beliefs'));
      const beliefParams = beliefCall?.[1] ?? [];
      expect(beliefParams.slice(1, 5)).toEqual(['longterm', 'artist', 'artist-1', 'affinity']);
    });
  });

  describe('getFusedTrackArtists', () => {
    it('reads from claim_fusion view', async () => {
      const { service, mockQuery } = makeService();
      mockQuery.mockResolvedValue({ rows: [{ id: 'a1', name: 'Artist 1', role: 'main', confidence: 0.9 }] });
      const result = await service.getFusedTrackArtists('track-1');
      const [sql] = mockQuery.mock.calls[0];
      expect(sql).toContain('claim_fusion');
      expect(result).toHaveLength(1);
      expect(result[0].role).toBe('main');
    });
  });
});
