import { describe, it, expect, vi } from 'vitest';
import { ALL_GENERATORS, type GeneratorContext, type Candidate } from './generators.service.js';
import { DbService } from './db.service.js';

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

function makeCtx(overrides: Partial<GeneratorContext> = {}): GeneratorContext {
  return {
    userId: '00000000-0000-0000-0000-000000000000',
    seedTrackId: null,
    seedArtistId: null,
    beliefs: [],
    recentExclusions: [],
    toleranceMap: {},
    state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: null, noveltyHunger: 0.3, sessionAgeMin: 10 },
    ...overrides,
  };
}

// Index generators by name for easy test access
const generatorByName: Record<string, (typeof ALL_GENERATORS)[0]> = {
  comfort: ALL_GENERATORS[0],
  adjacent: ALL_GENERATORS[1],
  deepDive: ALL_GENERATORS[2],
  revival: ALL_GENERATORS[3],
  discovery: ALL_GENERATORS[4],
  novelty: ALL_GENERATORS[5],
  contextual: ALL_GENERATORS[6],
  experimental: ALL_GENERATORS[7],
};

describe('generators', () => {
  describe('comfort', () => {
    it('returns tracks for artists with affinity > 0.5', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'track-1' }, { id: 'track-2' }] });
      const ctx = makeCtx({
        beliefs: [
          { entity_type: 'artist', entity_id: 'artist-1', value: 0.8, confidence: 0.9, profile: 'longterm', dimension: 'affinity' } as any,
        ],
      });
      const results = await generatorByName.comfort(db, ctx);
      expect(results.length).toBeGreaterThanOrEqual(1);
      expect(results[0]).toHaveProperty('trackId');
      expect(results[0]).toHaveProperty('generatorId', 'comfort');
      expect(results[0].explanation.length).toBeGreaterThanOrEqual(1);
    });

    it('accepts the fresh artist affinity shape produced by a promoted track', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'track-1', artist_id: 'artist-1' }] });
      // recordTrackEvidence(add_to_favorites) projects 0.60 * 0.90 = 0.54
      // onto a fresh longterm artist affinity belief.
      const ctx = makeCtx({ beliefs: [
        { entity_type: 'artist', entity_id: 'artist-1', value: 0.54, confidence: 0.05, profile: 'longterm', dimension: 'affinity' } as any,
      ] });
      const results = await generatorByName.comfort(db, ctx);
      expect(results).toHaveLength(1);
      expect(results[0].generatorId).toBe('comfort');
    });

    it('samples two tracks at random from each artist rather than fixing on their top two', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [] });
      const ctx = makeCtx({ beliefs: [
        { entity_type: 'artist', entity_id: 'artist-1', value: 0.8, profile: 'longterm', dimension: 'affinity' } as any,
      ] });

      await generatorByName.comfort(db, ctx);

      const [sql] = (db.pgClient.query as any).mock.calls[0];
      expect(sql).toContain('ORDER BY RANDOM()');
      expect(sql).toContain('ranked.rn <= 10');
      expect(sql).toContain('sampled.pick <= 2');
    });

    it('returns empty when no high-affinity artists', async () => {
      const db = makeMockDb();
      const ctx = makeCtx({ beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.3, profile: 'longterm', dimension: 'affinity' } as any ] });
      const results = await generatorByName.comfort(db, ctx);
      expect(results).toHaveLength(0);
    });
  });

  describe('adjacent', () => {
    it('returns tracks from graph walks', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ reached_artist_id: 'artist-2' }] });
      (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 'track-3' }] });
      const ctx = makeCtx({ seedTrackId: 'track-1', seedArtistId: 'artist-1' });
      const results = await generatorByName.adjacent(db, ctx);
      if (results.length > 0) {
        expect(results[0].explanation.length).toBeGreaterThanOrEqual(1);
      }
    });

    it('returns empty when no seed artist', async () => {
      const results = await generatorByName.adjacent(makeMockDb(), makeCtx());
      expect(results).toHaveLength(0);
    });
  });

  describe('discovery', () => {
    it('respects novelty_tolerance cap', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [{ artist_id: 'a1' }, { artist_id: 'a2' }] });
      (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] });
      const ctx = makeCtx({
        beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, confidence: 0.8, profile: 'longterm', dimension: 'affinity' } as any ],
        toleranceMap: { novelty_tolerance: 0.1 },
      });
      const results = await generatorByName.discovery(db, ctx);
      const maxCandidates = Math.max(2, Math.floor(20 * 0.1));
      expect(results.length).toBeLessThanOrEqual(maxCandidates);
    });
  });

  describe('deepDive', () => {
    it('returns album-ordered tracks for obsession artists', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ album_id: 'alb-1', artist_id: 'a1', title: 'Album 1' }] });
      (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }, { id: 't2' }] });
      const ctx = makeCtx({
        beliefs: [ { entity_type: 'artist', entity_id: 'a1', dimension: 'affinity', value: 0.6, profile: 'obsession' } as any ],
      });
      const results = await generatorByName.deepDive(db, ctx);
      expect(results.length).toBeGreaterThanOrEqual(1);
      expect(results[0].generatorId).toBe('deep-dive');
    });
  });

  describe('revival', () => {
    it('returns tracks for stale high-affinity artists', async () => {
      const db = makeMockDb();
      // First query: stale listener_beliefs
      (db.pgClient.query as any).mockResolvedValueOnce({
        rows: [{ artist_id: 'a1', affinity: 0.7 }]
      });
      // Second query: tracks by those artists
      (db.pgClient.query as any).mockResolvedValue({
        rows: [{ id: 't1' }]
      });
      const ctx = makeCtx({
        beliefs: [ { entity_type: 'artist', entity_id: 'a1', dimension: 'affinity', value: 0.5, profile: 'forgotten' } as any ],
      });
      const results = await generatorByName.revival(db, ctx);
      expect(results.length).toBeGreaterThanOrEqual(1);
      expect(results[0].generatorId).toBe('revival');
    });
  });
  describe('novelty', () => {
    it('returns a candidate when canonical release-date coverage makes a recent track eligible', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] });
      const ctx = makeCtx({
        beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ],
      });
      const results = await generatorByName.novelty(db, ctx);
      expect(results).toHaveLength(1);
      expect(results[0]).toMatchObject({ trackId: 't1', generatorId: 'novelty' });
    });

    it('queries only dated library tracks from the current novelty window', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [] });
      const ctx = makeCtx({
        beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ],
      });

      await generatorByName.novelty(db, ctx);

      const [sql] = (db.pgClient.query as any).mock.calls[0];
      expect(sql).toContain('t.release_date IS NOT NULL');
      expect(sql).toContain("t.release_date >= NOW() - INTERVAL '60 days'");
      expect(sql).toContain("t.state = 'LIBRARY'");
    });
  });

  describe('library fallback', () => {
    it('offers probation recommendations, which no graph generator can reach yet', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'probation-1' }] });

      const results = await ALL_GENERATORS[8](db, makeCtx());

      const [sql] = (db.pgClient.query as any).mock.calls[0];
      expect(sql).toContain("t.state = 'RECOMMENDED' AND t.probation_status = 'probation'");
      expect(results[0]).toMatchObject({ trackId: 'probation-1', generatorId: 'library-fallback' });
    });
  });

  describe('experimental', () => {
    it('returns tracks from unfamiliar genres', async () => {
      const db = makeMockDb();
      (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1', genre_id: 'g1' }] });
      const ctx = makeCtx({
        beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.6, profile: 'longterm', dimension: 'affinity' } as any ],
      });
      const results = await generatorByName.experimental(db, ctx);
      expect(results).toBeDefined();
    });
  });

  describe('contextual', () => {
    it('returns tracks matching context when set', async () => {
      const db = makeMockDb({
        getListenerBeliefs: vi.fn().mockResolvedValue([
          { entity_type: 'artist', entity_id: 'artist-1', value: 0.7, profile: 'contextual', dimension: 'calendar:day:weekday:summer' },
          { entity_type: 'artist', entity_id: 'artist-2', value: 0.9, profile: 'contextual', dimension: 'calendar:night:weekend:summer' },
        ]),
      });
      (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 't1' }] });
      const ctx = makeCtx({
        state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: { localHour: 12, weekday: 1, month: 6 }, noveltyHunger: 0.3, sessionAgeMin: 10 },
      });
      const results = await generatorByName.contextual(db, ctx);
      expect(results).toHaveLength(1);
      expect((db.pgClient.query as any).mock.calls[0][1][0]).toEqual(['artist-1']);
    });

    it('returns empty when no context set', async () => {
      const results = await generatorByName.contextual(makeMockDb(), makeCtx());
      expect(results).toHaveLength(0);
    });
  });
});

describe('ALL_GENERATORS', () => {
  it('contains 9 generators', () => {
    expect(ALL_GENERATORS).toHaveLength(9);
    ALL_GENERATORS.forEach(g => expect(typeof g).toBe('function'));
  });
});
