import { describe, expect, it, vi } from 'vitest';
import { Worker } from './worker.js';
import type { AppConfig } from './config.js';
import type { Job } from './db/repository.js';

function makeConfig(maxConcurrency?: number): AppConfig {
  return {
    provider: {
      model: 'test-model',
      workers: [{ id: 'worker-1', endpoint: 'http://localhost:11434/v1', ...(maxConcurrency ? { maxConcurrency } : {}) }],
    },
    worktreeDir: '/tmp/worker-test',
    concurrency: 1,
    maxMovements: 30,
    retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900] },
    ask: { maxPerJob: 2 },
    subtasks: { maxDepth: 2 },
    tools: {
      searxngUrl: 'http://localhost:8080', visionModel: 'v', visionTimeout: 60,
      visionMaxTokens: 1024, webfetchTimeout: 30, websearchTimeout: 15, webfetchAllowedHosts: [],
    },
  } as AppConfig;
}
function makeJob(id: string): Job {
  return {
    id, repo: 'acme/demo', issueNumber: 1, prNumber: null, status: 'running',
    pieceName: 'general', requiredRole: 'auto', requiredProfile: 'auto', currentMovement: null,
    instruction: 'x', branchName: null, worktreePath: null, attempt: 1, maxAttempts: 3,
    nextRetryAt: null, errorSummary: null, resumeMovement: null, askCount: 0, workerId: 'worker-1',
    parentJobId: null, subtaskDepth: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
  } as Job;
}
// Build a worker whose heavy parts (initialize, executeJob) are stubbed; executeJob is gated.
function buildWorker(maxConcurrency?: number) {
  const jobs = [makeJob('j1'), makeJob('j2'), makeJob('j3')];
  let idx = 0;
  const repo = {
    recoverStuckRunningJobs: vi.fn(),
    claimNextRetryJob: vi.fn().mockResolvedValue(null),
    claimNextJob: vi.fn().mockImplementation(async () => jobs[idx++] ?? null),
    updateWorkerNodeHealth: vi.fn().mockResolvedValue(undefined),
  };
  const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'm', repo as never, makeConfig(maxConcurrency));
  const started: string[] = [];
  const resolvers: Array<() => void> = [];
  (worker as never as Record<string, unknown>)['initialize'] = vi.fn().mockResolvedValue(true);
  (worker as never as Record<string, unknown>)['running'] = true;
  (worker as never as Record<string, unknown>)['executeJob'] = vi.fn().mockImplementation((job: Job) =>
    new Promise<void>((resolve) => { started.push(job.id); resolvers.push(resolve); }));
  const get = (k: string) => (worker as never as Record<string, unknown>)[k];
  const call = (k: string) => (get(k) as () => Promise<void>).call(worker);
  return { worker, repo, started, resolvers, get, call };
}
const tick = () => new Promise((r) => setTimeout(r, 0));

describe('Worker max_concurrency (A1 slot-based)', () => {
  it('runs up to maxConcurrency jobs concurrently and refills a freed slot', async () => {
    const w = buildWorker(2);
    await w.call('processNext');
    expect(w.started).toEqual(['j1', 'j2']);     // 2 slots filled
    expect(w.get('inflight')).toBe(2);
    expect(w.repo.claimNextJob).toHaveBeenCalledTimes(2); // stopped at max, did not over-claim

    w.resolvers[0]();                             // j1 finishes
    await tick();
    expect(w.get('inflight')).toBe(1);
    await w.call('processNext');                  // refill
    expect(w.started).toEqual(['j1', 'j2', 'j3']);
    expect(w.get('inflight')).toBe(2);
  });

  it('is single-flight when maxConcurrency is unset (backward compatible)', async () => {
    const w = buildWorker(undefined);
    await w.call('processNext');
    expect(w.started).toEqual(['j1']);
    expect(w.get('inflight')).toBe(1);
    expect(w.repo.claimNextJob).toHaveBeenCalledTimes(1);
  });

  it('waitForCompletion resolves only after all inflight jobs finish', async () => {
    const w = buildWorker(2);
    await w.call('processNext');
    let done = false;
    const wait = (w.worker.waitForCompletion(2000)).then((r) => { done = r; });
    await tick();
    expect(done).toBe(false);
    w.resolvers[0](); w.resolvers[1](); await tick(); await tick();
    await wait;
    expect(done).toBe(true);
    expect(w.get('inflight')).toBe(0);
  });

  it('decrements inflight even if a job throws', async () => {
    const w = buildWorker(2);
    (w.get('executeJob') as { mockImplementation: (f: unknown) => void }).mockImplementation(async () => { throw new Error('boom'); });
    await w.call('processNext');
    await tick();
    expect(w.get('inflight')).toBe(0); // both threw, counter restored
  });
});
