import { describe, it, expect } from 'vitest';
import {
  createJob,
  isTerminal,
  reduceJob,
  jobProgress,
  type Job,
  type JobEvent,
} from './queue';

describe('createJob', () => {
  it('starts pending with zeroed counters and no error', () => {
    const job = createJob('abc');
    expect(job).toEqual({
      id: 'abc',
      status: 'pending',
      chunkIndex: 0,
      chunkCount: 0,
    });
    expect(job.error).toBeUndefined();
  });

  it('preserves the given id', () => {
    expect(createJob('xyz-123').id).toBe('xyz-123');
  });
});

describe('isTerminal', () => {
  it('is false for in-flight states', () => {
    expect(isTerminal(createJob('a'))).toBe(false);
    expect(isTerminal({ ...createJob('a'), status: 'decoding' })).toBe(false);
    expect(isTerminal({ ...createJob('a'), status: 'transcribing' })).toBe(
      false,
    );
  });

  it('is true for done, error, and cancelled', () => {
    expect(isTerminal({ ...createJob('a'), status: 'done' })).toBe(true);
    expect(isTerminal({ ...createJob('a'), status: 'error' })).toBe(true);
    expect(isTerminal({ ...createJob('a'), status: 'cancelled' })).toBe(true);
  });
});

describe('reduceJob — full happy path', () => {
  it('pending -> decoding -> transcribing(3) -> chunkDone 1,2,3 -> complete, progress 1', () => {
    let job = createJob('job1');
    expect(job.status).toBe('pending');
    expect(jobProgress(job)).toBe(0);

    job = reduceJob(job, { type: 'startDecode' });
    expect(job.status).toBe('decoding');
    expect(jobProgress(job)).toBe(0);

    job = reduceJob(job, { type: 'startTranscribe', chunkCount: 3 });
    expect(job.status).toBe('transcribing');
    expect(job.chunkCount).toBe(3);
    expect(job.chunkIndex).toBe(0);
    expect(jobProgress(job)).toBe(0);

    job = reduceJob(job, { type: 'chunkDone', index: 1 });
    expect(job.chunkIndex).toBe(1);
    expect(jobProgress(job)).toBeCloseTo(1 / 3, 10);

    job = reduceJob(job, { type: 'chunkDone', index: 2 });
    expect(job.chunkIndex).toBe(2);
    expect(jobProgress(job)).toBeCloseTo(2 / 3, 10);

    job = reduceJob(job, { type: 'chunkDone', index: 3 });
    expect(job.chunkIndex).toBe(3);
    expect(jobProgress(job)).toBe(1);

    job = reduceJob(job, { type: 'complete' });
    expect(job.status).toBe('done');
    expect(job.chunkIndex).toBe(3);
    expect(isTerminal(job)).toBe(true);
    expect(jobProgress(job)).toBe(1);
  });

  it('complete forces chunkIndex up to chunkCount even if chunks were not all reported', () => {
    let job = createJob('j');
    job = reduceJob(job, { type: 'startTranscribe', chunkCount: 5 });
    job = reduceJob(job, { type: 'chunkDone', index: 2 });
    job = reduceJob(job, { type: 'complete' });
    expect(job.status).toBe('done');
    expect(job.chunkIndex).toBe(5);
    expect(jobProgress(job)).toBe(1);
  });

  it('complete with zero chunkCount yields done with progress 1', () => {
    let job = createJob('j');
    job = reduceJob(job, { type: 'complete' });
    expect(job.status).toBe('done');
    expect(job.chunkIndex).toBe(0);
    expect(job.chunkCount).toBe(0);
    // done short-circuits to 1 regardless of counts.
    expect(jobProgress(job)).toBe(1);
  });
});

describe('reduceJob — fail', () => {
  it('sets error status, captures the message, and is terminal', () => {
    let job = createJob('j');
    job = reduceJob(job, { type: 'startDecode' });
    job = reduceJob(job, { type: 'fail', error: 'decode boom' });
    expect(job.status).toBe('error');
    expect(job.error).toBe('decode boom');
    expect(isTerminal(job)).toBe(true);
  });

  it('can fail straight from transcribing', () => {
    let job = createJob('j');
    job = reduceJob(job, { type: 'startTranscribe', chunkCount: 4 });
    job = reduceJob(job, { type: 'chunkDone', index: 1 });
    job = reduceJob(job, { type: 'fail', error: 'gpu lost' });
    expect(job.status).toBe('error');
    expect(job.error).toBe('gpu lost');
    // partial progress is retained on failure
    expect(job.chunkIndex).toBe(1);
    expect(job.chunkCount).toBe(4);
  });
});

describe('reduceJob — cancel', () => {
  it('moves to cancelled and is terminal', () => {
    let job = createJob('j');
    job = reduceJob(job, { type: 'startDecode' });
    job = reduceJob(job, { type: 'cancel' });
    expect(job.status).toBe('cancelled');
    expect(isTerminal(job)).toBe(true);
  });

  it('can cancel from pending', () => {
    const job = reduceJob(createJob('j'), { type: 'cancel' });
    expect(job.status).toBe('cancelled');
  });
});

describe('reduceJob — events after terminal are ignored', () => {
  const terminalStatuses: Array<Job['status']> = ['done', 'error', 'cancelled'];
  const followups: JobEvent[] = [
    { type: 'startDecode' },
    { type: 'startTranscribe', chunkCount: 9 },
    { type: 'chunkDone', index: 5 },
    { type: 'complete' },
    { type: 'fail', error: 'late failure' },
    { type: 'cancel' },
  ];

  for (const status of terminalStatuses) {
    for (const ev of followups) {
      it(`${status} job ignores ${ev.type} (same reference returned)`, () => {
        const terminal: Job = {
          id: 'j',
          status,
          chunkIndex: 2,
          chunkCount: 4,
          ...(status === 'error' ? { error: 'orig' } : {}),
        };
        const result = reduceJob(terminal, ev);
        // Unchanged AND same reference (no allocation).
        expect(result).toBe(terminal);
        expect(result).toEqual(terminal);
      });
    }
  }
});

describe('reduceJob — purity (no mutation, new object)', () => {
  it('does not mutate its input', () => {
    const job = createJob('j');
    const snapshot = { ...job };
    const next = reduceJob(job, { type: 'startDecode' });
    expect(job).toEqual(snapshot);
    expect(next).not.toBe(job);
    expect(next.status).toBe('decoding');
    // original untouched
    expect(job.status).toBe('pending');
  });

  it('returns a new object for every state-changing event', () => {
    let job = createJob('j');
    const seen = new Set<Job>();
    seen.add(job);

    const events: JobEvent[] = [
      { type: 'startDecode' },
      { type: 'startTranscribe', chunkCount: 2 },
      { type: 'chunkDone', index: 1 },
      { type: 'chunkDone', index: 2 },
      { type: 'complete' },
    ];

    for (const ev of events) {
      const next = reduceJob(job, ev);
      expect(seen.has(next)).toBe(false);
      seen.add(next);
      job = next;
    }
  });

  it('chunkDone does not mutate the input when clamping/ignoring', () => {
    const job: Job = {
      id: 'j',
      status: 'transcribing',
      chunkIndex: 3,
      chunkCount: 3,
    };
    const snapshot = { ...job };
    const next = reduceJob(job, { type: 'chunkDone', index: 1 });
    expect(job).toEqual(snapshot); // input untouched
    expect(next).not.toBe(job); // still a fresh object
    expect(next.chunkIndex).toBe(3); // monotonic: stays at 3
  });
});

describe('reduceJob — chunkDone clamping and monotonicity', () => {
  it('clamps index to chunkCount', () => {
    let job = createJob('j');
    job = reduceJob(job, { type: 'startTranscribe', chunkCount: 3 });
    job = reduceJob(job, { type: 'chunkDone', index: 99 });
    expect(job.chunkIndex).toBe(3);
    expect(jobProgress(job)).toBe(1);
  });

  it('never moves backward (monotonic) across chunkDone events', () => {
    let job = createJob('j');
    job = reduceJob(job, { type: 'startTranscribe', chunkCount: 10 });

    const sequence = [3, 1, 5, 2, 5, 6, 4, 10, 7];
    let prevProgress = jobProgress(job);
    let prevIndex = job.chunkIndex;

    for (const idx of sequence) {
      job = reduceJob(job, { type: 'chunkDone', index: idx });
      expect(job.chunkIndex).toBeGreaterThanOrEqual(prevIndex);
      expect(jobProgress(job)).toBeGreaterThanOrEqual(prevProgress);
      prevIndex = job.chunkIndex;
      prevProgress = jobProgress(job);
    }
    expect(job.chunkIndex).toBe(10);
    expect(jobProgress(job)).toBe(1);
  });

  it('treats a smaller index as a no-op for progress', () => {
    let job = createJob('j');
    job = reduceJob(job, { type: 'startTranscribe', chunkCount: 4 });
    job = reduceJob(job, { type: 'chunkDone', index: 3 });
    job = reduceJob(job, { type: 'chunkDone', index: 1 });
    expect(job.chunkIndex).toBe(3);
  });
});

describe('jobProgress', () => {
  it('is 0 before chunkCount is known', () => {
    expect(jobProgress(createJob('j'))).toBe(0);
    expect(
      jobProgress({ ...createJob('j'), status: 'decoding' }),
    ).toBe(0);
  });

  it('is the chunk ratio while transcribing', () => {
    const job: Job = {
      id: 'j',
      status: 'transcribing',
      chunkIndex: 1,
      chunkCount: 4,
    };
    expect(jobProgress(job)).toBe(0.25);
  });

  it('is 1 for a done job regardless of counts', () => {
    expect(
      jobProgress({ id: 'j', status: 'done', chunkIndex: 0, chunkCount: 0 }),
    ).toBe(1);
  });

  it('reflects partial progress for error/cancelled (not forced to 1)', () => {
    const errored: Job = {
      id: 'j',
      status: 'error',
      chunkIndex: 1,
      chunkCount: 4,
      error: 'x',
    };
    expect(jobProgress(errored)).toBe(0.25);

    const cancelled: Job = {
      id: 'j',
      status: 'cancelled',
      chunkIndex: 2,
      chunkCount: 4,
    };
    expect(jobProgress(cancelled)).toBe(0.5);
  });
});
