import { describe, expect, it, vi } from 'vitest';
import { DbService } from './db.service.js';
import {
  DEFAULT_VIBE_POLICY_VERSION,
  VibeSessionCoordinator,
  VibeSessionLifecycleError,
  VibePlanNotFoundError,
} from './vibe-session-coordinator.service.js';

const SESSION_ID = '11111111-1111-4111-8111-111111111111';
const TRACK_ID = '22222222-2222-4222-8222-222222222222';
const EVENT_ID = '33333333-3333-4333-8333-333333333333';

function session(status: 'active' | 'ended' = 'active') {
  return {
    id: SESSION_ID, user_id: 'user-1', status, seed_track_id: null,
    context: {}, policy_version: DEFAULT_VIBE_POLICY_VERSION,
    started_at: new Date('2026-01-01T00:00:00.000Z'),
    last_event_at: new Date('2026-01-01T00:00:00.000Z'), ended_at: status === 'ended' ? new Date() : null,
  } as any;
}

function plan() {
  return {
    id: 'plan-1', session_id: SESSION_ID, version: 1, reason: 'session_started',
    state_snapshot: { energy: 0.5 }, objective_snapshot: {}, created_at: new Date(),
    items: [{
      plan_version_id: 'plan-1', ordinal: 0, track_id: TRACK_ID, slot_role: 'next',
      candidate_source: 'comfort', score: 0.8, score_breakdown: { relevance: 0.8 },
      explanation: [], committed: false,
    }],
  } as any;
}

function setup() {
  const db = {
    createVibeSession: vi.fn().mockResolvedValue(session()),
    createSessionState: vi.fn().mockResolvedValue(SESSION_ID),
    recordVibeEvent: vi.fn().mockResolvedValue({ event: { id: 'event-1' }, inserted: true }),
    persistVibePlan: vi.fn().mockResolvedValue(plan()),
    publishVibePlan: vi.fn().mockImplementation((input: { version?: number; reason: string }) => Promise.resolve({
      ...plan(), version: input.version ?? 2, reason: input.reason,
    })),
    getVibeSession: vi.fn().mockResolvedValue(session()),
    getVibePlan: vi.fn().mockResolvedValue(plan()),
    endVibeSession: vi.fn().mockResolvedValue(session('ended')),
    endVibeSessionWithEvent: vi.fn().mockResolvedValue({ session: session('ended'), ended: true }),
    resumeVibeSession: vi.fn().mockResolvedValue({ session: session(), resumed: true }),
    serveNextVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }),
    advancePastUnplayableVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }),
    persistNextVibePlan: vi.fn().mockResolvedValue({ ...plan(), version: 2, reason: 'feedback:completed' }),
    getVibePlanForFeedbackEvent: vi.fn().mockResolvedValue(null),
    dislikeTrack: vi.fn().mockResolvedValue(undefined),
  } as unknown as DbService;
  const director = {
    buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
    buildState: vi.fn().mockResolvedValue({ energy: 0.5, noveltyHunger: 0.3 }),
    replan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
  } as any;
  return { db, director, coordinator: new VibeSessionCoordinator(db, director) };
}

describe('VibeSessionCoordinator', () => {
  it('creates an authoritative session, shadow state, initial plan revision, and ledger events', async () => {
    const { db, director, coordinator } = setup();

    const response = await coordinator.start('user-1', {});

    expect(response).toMatchObject({ sessionId: SESSION_ID, planVersion: 1, now: { track_id: TRACK_ID } });
    expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({
      userId: 'user-1', policyVersion: DEFAULT_VIBE_POLICY_VERSION,
    }));
    expect(db.createSessionState).toHaveBeenCalledWith('user-1', undefined, expect.any(Object), SESSION_ID);
    expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, undefined);
    expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
      sessionId: SESSION_ID, version: 1, reason: 'session_started',
      items: [expect.objectContaining({ track_id: TRACK_ID, committed: false })],
    }));
    expect((db.recordVibeEvent as any).mock.calls.map(([input]: any[]) => input.type))
      .toEqual(['session_started']);
  });

  it('persists a director-selected arc role and explainable sequence score', async () => {
    const { db, director, coordinator } = setup();
    (director.buildPlan as any).mockResolvedValueOnce([{
      trackId: TRACK_ID, generatorId: 'discovery', relevance: 0.7, explanation: [{ predicate: 'near' }],
      plan: {
        slotRole: 'surprise', score: 0.82,
        scoreBreakdown: { relevance: 0.7, transition: 0.9, arcTarget: 0.85 },
        explanation: { arcRole: 'surprise', surprise: { recoveryRole: 'favorite' } },
      },
    }]);

    await coordinator.start('user-1', {});

    expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
      items: [expect.objectContaining({
        slot_role: 'surprise', score: 0.82,
        score_breakdown: expect.objectContaining({ transition: 0.9 }),
        explanation: expect.objectContaining({
          paths: [{ predicate: 'near' }],
          planner: expect.objectContaining({ arcRole: 'surprise' }),
        }),
      })],
    }));
  });

  it('returns the canonical replacement on an idempotent material-event retry without replanning', async () => {
    const { db, coordinator } = setup();
    (db.recordVibeEvent as any).mockResolvedValueOnce({
      event: { id: 'event-1', client_event_id: EVENT_ID, type: 'skipped' }, inserted: false,
    });
    (db.getVibePlanForFeedbackEvent as any).mockResolvedValueOnce({ ...plan(), version: 2 });

    const response = await coordinator.appendEvent('user-1', SESSION_ID, {
      eventId: EVENT_ID, type: 'skipped', trackId: TRACK_ID,
    });

    expect(response).toMatchObject({ idempotent: true, planVersion: 2, replanned: false, replanReason: null });
    expect(db.recordVibeEvent).toHaveBeenCalledWith(expect.objectContaining({ clientEventId: EVENT_ID }));
    expect(db.persistNextVibePlan).not.toHaveBeenCalled();
  });

  it('records a durable dislike so the track is gone from later sessions too', async () => {
    const { db, coordinator } = setup();

    await coordinator.appendEvent('user-1', SESSION_ID, {
      eventId: EVENT_ID, type: 'disliked', trackId: TRACK_ID,
    });

    expect(db.dislikeTrack).toHaveBeenCalledWith('user-1', TRACK_ID);
  });

  it('does not dislike a track twice when its event is delivered again', async () => {
    const { db, coordinator } = setup();
    (db.recordVibeEvent as any).mockResolvedValueOnce({
      event: { id: 'event-1', client_event_id: EVENT_ID, type: 'disliked' }, inserted: false,
    });

    await coordinator.appendEvent('user-1', SESSION_ID, {
      eventId: EVENT_ID, type: 'disliked', trackId: TRACK_ID,
    });

    expect(db.dislikeTrack).not.toHaveBeenCalled();
  });

  it('leaves the library alone for feedback that is not a dislike', async () => {
    const { db, coordinator } = setup();

    await coordinator.appendEvent('user-1', SESSION_ID, { type: 'skipped', trackId: TRACK_ID });

    expect(db.dislikeTrack).not.toHaveBeenCalled();
  });

  it('recovers a material feedback replan when its first persistence attempt failed', async () => {
    const { db, coordinator } = setup();
    (db.publishVibePlan as any).mockRejectedValueOnce(new Error('temporary database failure'));
    await expect(coordinator.appendEvent('user-1', SESSION_ID, {
      eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID,
    })).rejects.toThrow('temporary database failure');

    (db.recordVibeEvent as any).mockResolvedValueOnce({
      event: { id: 'event-1', client_event_id: EVENT_ID, type: 'completed' }, inserted: false,
    });
    const recovered = await coordinator.appendEvent('user-1', SESSION_ID, {
      eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID,
    });

    expect(db.publishVibePlan).toHaveBeenCalledTimes(2);
    expect(recovered).toMatchObject({ idempotent: true, replanned: true, planVersion: 2 });
  });

  it('persists a replacement revision for material feedback and returns its preview', async () => {
    const { db, director, coordinator } = setup();
    const response = await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });

    expect(director.replan).toHaveBeenCalledWith(
      'user-1', SESSION_ID, expect.any(Array), [TRACK_ID], TRACK_ID,
      { excludedTrackIds: new Set() },
    );
    expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
      sessionId: SESSION_ID, reason: 'feedback:completed', items: [expect.objectContaining({ committed: false })],
    }));
    expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 });
  });

  it('creates a neutral durable profile until listening behaviour provides evidence', async () => {
    const { db, coordinator } = setup();

    await coordinator.start('user-1', {});

    expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({
      profile: expect.objectContaining({
        goals: { type: 'discovery', target: 1, progress: 0 },
        explorationCoefficient: 0.3,
        discoveryRadius: 0.38,
      }),
    }));
  });

  it('passes the durable unserved callback tail into the retention-aware replan', async () => {
    const { db, director, coordinator } = setup();
    (db.getVibePlan as any).mockResolvedValue({
      ...plan(),
      items: [{
        ...plan().items[0],
        slot_role: 'favorite',
        explanation: {
          paths: [],
          planner: { arcRole: 'favorite', callback: { id: 'theme:0', phase: 'return' } },
        },
      }],
    });

    await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: 'other-track' });

    expect(director.replan).toHaveBeenCalledWith(
      'user-1', SESSION_ID,
      [expect.objectContaining({
        trackId: TRACK_ID,
        plan: expect.objectContaining({ slotRole: 'favorite', explanation: expect.objectContaining({ arcRole: 'favorite' }) }),
      })],
      ['other-track'], 'other-track', { excludedTrackIds: new Set() },
    );
  });

  it('keeps the durable seed excluded when feedback supplies a different local replan anchor', async () => {
    const { db, director, coordinator } = setup();
    const seedTrackId = '44444444-4444-4444-8444-444444444444';
    (db.getVibeSession as any).mockResolvedValue({ ...session(), seed_track_id: seedTrackId });

    await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });

    expect(director.replan).toHaveBeenCalledWith(
      'user-1',
      SESSION_ID,
      expect.any(Array),
      [TRACK_ID],
      TRACK_ID,
      { excludedTrackIds: new Set([seedTrackId]) },
    );
  });

  it('resumes only the caller-owned session and serves a plan item through the durable API', async () => {
    const { db, coordinator } = setup();
    const resumed = await coordinator.start('user-1', { resumeSessionId: SESSION_ID });
    const served = await coordinator.serveNext('user-1', SESSION_ID);

    expect(db.resumeVibeSession).toHaveBeenCalledWith(SESSION_ID, 'user-1');
    expect(resumed.sessionId).toBe(SESSION_ID);
    expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1');
    expect(served.now).toMatchObject({ track_id: TRACK_ID });
  });

  it('returns a lifecycle conflict when the ledger rejects a new terminal-session event', async () => {
    const { db, coordinator } = setup();
    (db.recordVibeEvent as any).mockRejectedValueOnce(new Error('Cannot record a new event for ended Vibe session'));

    await expect(coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed' }))
      .rejects.toBeInstanceOf(VibeSessionLifecycleError);
  });

  it('distinguishes a missing requested revision from an empty latest plan', async () => {
    const { db, coordinator } = setup();
    (db.getVibePlan as any).mockResolvedValueOnce(null);
    await expect(coordinator.getPlan('user-1', SESSION_ID, 99)).rejects.toBeInstanceOf(VibePlanNotFoundError);
  });

  it('returns 404-worthy failure when a session has no latest plan', async () => {
    const { db, coordinator } = setup();
    (db.getVibePlan as any).mockResolvedValueOnce(null);
    await expect(coordinator.getPlan('user-1', SESSION_ID)).rejects.toBeInstanceOf(VibePlanNotFoundError);
  });

  it('does not commit a stale version-aware next request and returns the current preview', async () => {
    const { db, coordinator } = setup();
    (db.serveNextVibePlanItem as any).mockResolvedValueOnce({ item: null, stale: true });
    const result = await coordinator.serveNext('user-1', SESSION_ID, 1);

    expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', 1);
    expect(result).toMatchObject({ planVersion: 1, now: { track_id: TRACK_ID } });
  });

  it('advances past an unplayable served item using a distinct idempotent event protocol', async () => {
    const { db, coordinator } = setup();
    const planVersionId = '44444444-4444-4444-8444-444444444444';
    const eventId = '55555555-5555-4555-8555-555555555555';

    const result = await coordinator.advancePastUnplayable('user-1', SESSION_ID, {
      expectedPlanVersion: 1,
      planVersionId,
      ordinal: 0,
      trackId: TRACK_ID,
      eventId,
    });

    expect(db.advancePastUnplayableVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', {
      expectedPlanVersion: 1, planVersionId, ordinal: 0, trackId: TRACK_ID, eventId,
    });
    expect(result.now).toMatchObject({ track_id: TRACK_ID });
  });

  it('ends an active session once and preserves its latest persisted plan', async () => {
    const { db, coordinator } = setup();
    const response = await coordinator.end('user-1', SESSION_ID);

    expect(db.endVibeSessionWithEvent).toHaveBeenCalledWith(SESSION_ID, 'user-1');
    expect(response.session.status).toBe('ended');
    expect(response.planVersion).toBe(1);
  });

  it('does not publish an initial plan when another start replaced the session while planning', async () => {
    const { db, coordinator } = setup();
    (db.publishVibePlan as any).mockRejectedValueOnce(new Error('Cannot publish a plan for replaced Vibe session'));

    await expect(coordinator.start('user-1', {})).rejects.toBeInstanceOf(VibeSessionLifecycleError);
    expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ version: 1 }));
  });
});
