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

describe('DbService v2 methods', () => {
  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('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');
    });
  });
});
