import { describe, expect, it, vi } from 'vitest';
import type { ContextCandidateForDedup } from '../../../../src/context/ingest/ports.js';
import { type CuratorPaginationInput, CuratorPaginationService } from '../../../../src/context/ingest/context-candidates/curator-pagination.service.js';
import type { ContextCandidateStorePort } from '../../../../src/context/ingest/context-candidates/store.js';

const candidate = (key: string, score: number): ContextCandidateForDedup => ({
  id: `id-${key}`,
  candidateKey: key,
  topic: `Topic ${key}`,
  assertion: `Assertion ${key}`,
  promotionScore: score,
  createdAt: new Date(`2026-04-30T10:0${score % 10}:00.000Z`),
  evidenceChunkIds: [`chunk-${key}`],
  evidenceRefs: [{ stableCitationKey: `ref-${key}` }],
  embedding: null,
  lane: 'full',
});

const promptCandidate = (key: string, score: number) => ({
  candidateKey: key,
  topic: `Topic ${key}`,
  assertion: `Assertion ${key}`,
  rationale: `Rationale ${key}`,
  actionHint: 'create',
  status: 'pending',
  promotionScore: score,
  suggestedPageKey: `page-${key}`,
  evidenceRefs: [{ stableCitationKey: `ref-${key}` }],
});

function makeHarness(overrides: Partial<{ batchSize: number; maxPasses: number; stepBudgetPerPass: number }> = {}) {
  const store = {
    listPendingCandidatesForDedup: vi.fn().mockResolvedValue([]),
    updateCandidateEmbedding: vi.fn().mockResolvedValue(undefined),
    markCandidatesAsMergedToCluster: vi.fn().mockResolvedValue(undefined),
    listBudgetExhaustedCandidatesForCarryForward: vi.fn().mockResolvedValue([]),
    listCurrentRunEvidenceChunksForCarryForward: vi.fn().mockResolvedValue([]),
    insertCandidate: vi.fn().mockResolvedValue({ id: 'candidate-1' }),
    listCandidatesForPromptByKeys: vi
      .fn()
      .mockImplementation((_runId: string, keys: string[]) =>
        Promise.resolve(keys.map((key) => promptCandidate(key, Number(key.replace('c', '')) || 1))),
      ),
    markPendingCandidatesByReason: vi.fn().mockResolvedValue(0),
    summarizeCandidateVerdicts: vi.fn().mockResolvedValue({
      pending: 0,
      promoted: 1,
      merged: 1,
      rejected: 1,
      conflict: 0,
      rejectedByReason: { exceeded_curator_passes: 1 },
    }),
  } satisfies ContextCandidateStorePort;
  const agentRunner = {
    runLoop: vi.fn().mockResolvedValue({ stopReason: 'natural' }),
  };
  const actions: Array<{ target: 'wiki'; type: 'created' | 'updated'; key: string; detail: string }> = [];
  const prompts: string[] = [];
  const service = new CuratorPaginationService({
    store,
    agentRunner: agentRunner as never,
    settings: {
      batchSize: overrides.batchSize ?? 2,
      maxPasses: overrides.maxPasses ?? 2,
      stepBudgetPerPass: overrides.stepBudgetPerPass ?? 7,
    },
  });

  const input = (
    representatives = [candidate('c1', 10), candidate('c2', 9), candidate('c3', 8)],
  ): CuratorPaginationInput => ({
    runId: 'run-1',
    sourceKey: 'notion',
    jobId: 'job-1',
    stageIndex: {
      jobId: 'job-1',
      connectionId: 'c1',
      workUnits: [],
      conflictsResolved: [],
      evictionsApplied: [],
      unmappedFallbacks: [],
    },
    evictionUnit: undefined,
    representatives,
    initialBudget: { creates: 2, updates: 1 },
    modelRole: 'curator',
    buildSystemPrompt: () => 'system prompt',
    buildUserPrompt: ({ runState, items }) => {
      const prompt = `pass=${runState.passNumber}; budget=${runState.budgetRemaining.creates}/${
        runState.budgetRemaining.updates
      }; items=${items.map((item) => item.candidateKey).join(',')}; previous=${runState.previouslyPromotedInRun
        .map((page) => page.pageKey)
        .join(',')}`;
      prompts.push(prompt);
      return prompt;
    },
    buildToolSet: () => ({}),
    getReconciliationActions: () => actions,
  });

  return { store, agentRunner, actions, prompts, service, input };
}

describe('CuratorPaginationService', () => {
  it('paginates representatives and carries budget plus previous wiki writes into later passes', async () => {
    const harness = makeHarness({ batchSize: 2, maxPasses: 2, stepBudgetPerPass: 7 });
    harness.agentRunner.runLoop.mockImplementation(() => {
      if (harness.actions.length === 0) {
        harness.actions.push({ target: 'wiki', type: 'created', key: 'page-c1', detail: 'Created C1' });
      }
      return { stopReason: 'natural' };
    });

    const result = await harness.service.reconcile(harness.input());

    expect(harness.agentRunner.runLoop).toHaveBeenCalledTimes(2);
    expect(harness.agentRunner.runLoop).toHaveBeenCalledWith(expect.objectContaining({ modelRole: 'curator' }));
    expect(harness.prompts[0]).toContain('pass=1');
    expect(harness.prompts[0]).toContain('budget=2/1');
    expect(harness.prompts[0]).toContain('items=c1,c2');
    expect(harness.prompts[1]).toContain('pass=2');
    expect(harness.prompts[1]).toContain('budget=1/1');
    expect(harness.prompts[1]).toContain('previous=page-c1');
    expect(result.report).toMatchObject({
      passesRun: 2,
      topicsExamined: 3,
      topicsByVerdict: { promoted: 1, merged: 1, rejected: 1, conflict: 0 },
      topicsRejectedByReason: { exceeded_curator_passes: 1 },
      budgetExhausted: false,
    });
  });

  it('marks unprocessed representatives when maxPasses is exhausted', async () => {
    const harness = makeHarness({ batchSize: 1, maxPasses: 1 });

    await harness.service.reconcile(harness.input([candidate('c1', 10), candidate('c2', 9)]));

    expect(harness.store.markPendingCandidatesByReason).toHaveBeenCalledWith({
      runId: 'run-1',
      candidateKeys: ['c2'],
      rejectionReason: 'exceeded_curator_passes',
    });
    expect(harness.store.markPendingCandidatesByReason).toHaveBeenCalledWith({
      runId: 'run-1',
      candidateKeys: ['c1', 'c2'],
      rejectionReason: 'exceeded_curator_passes',
    });
  });

  it('marks remaining pending representatives when run budget is exhausted', async () => {
    const harness = makeHarness({ batchSize: 1, maxPasses: 5 });
    const input = harness.input([candidate('c1', 10), candidate('c2', 9), candidate('c3', 8)]);
    input.initialBudget = { creates: 1, updates: 0 };
    harness.agentRunner.runLoop.mockImplementation(() => {
      harness.actions.push({ target: 'wiki', type: 'created', key: 'page-c1', detail: 'Created C1' });
      return { stopReason: 'natural' };
    });

    const result = await harness.service.reconcile(input);

    expect(harness.agentRunner.runLoop).toHaveBeenCalledTimes(1);
    expect(harness.store.markPendingCandidatesByReason).toHaveBeenCalledWith({
      runId: 'run-1',
      candidateKeys: ['c1', 'c2', 'c3'],
      rejectionReason: 'exceeded_run_budget',
    });
    expect(result.report.budgetExhausted).toBe(true);
  });

  it('marks a failed pass with curator_pass_error and continues to later batches', async () => {
    const harness = makeHarness({ batchSize: 1, maxPasses: 3 });
    harness.agentRunner.runLoop
      .mockResolvedValueOnce({ stopReason: 'error', error: new Error('provider timeout') })
      .mockResolvedValue({ stopReason: 'natural' });

    const result = await harness.service.reconcile(harness.input([candidate('c1', 10), candidate('c2', 9)]));

    expect(harness.agentRunner.runLoop).toHaveBeenCalledTimes(2);
    expect(harness.store.markPendingCandidatesByReason).toHaveBeenCalledWith({
      runId: 'run-1',
      candidateKeys: ['c1'],
      rejectionReason: 'curator_pass_error',
    });
    expect(result.warnings).toContain('Curator pass 1 failed: provider timeout');
  });

  it('skips when there are no representatives and no reconciliation work', async () => {
    const harness = makeHarness();

    const result = await harness.service.reconcile(harness.input([]));

    expect(result.skipped).toBe(true);
    expect(harness.agentRunner.runLoop).not.toHaveBeenCalled();
    expect(result.report.topicsExamined).toBe(0);
  });
});
