import { describe, test, expect } from 'bun:test';
import { createSessionLogStore, type Delivery, type EventWriter } from './session-log';
import type { ChatEvent, MessageCost } from '../../api/chat/types';

const COST: MessageCost = { inputTokens: 1, outputTokens: 2, totalUSD: 0.0001 };

/**
 * A writer that finishes its appends in the WORST possible order — the last one first — so the test
 * fails unless the store serialises commits itself. `lastSeq` starts empty (a fresh session) unless a
 * seed is given.
 */
function reverseOrderWriter(opts: { seed?: number; failOn?: (n: number) => boolean } = {}) {
  const pending: Array<{ resolve: (seq: number) => void; reject: (err: Error) => void }> = [];
  let next = 100;
  const writes: Array<{ sessionId: string; type: string; prevSeq?: number }> = [];

  const writer: EventWriter = {
    lastSeq: async () => opts.seed,
    append: (sessionId, msg) => {
      writes.push({ sessionId, type: msg.type, prevSeq: msg.prevSeq });
      return new Promise<number>((resolve, reject) => pending.push({ resolve, reject }));
    },
  };

  // Settle everything queued so far, newest first.
  function settleAll() {
    const batch = pending.splice(0).reverse();
    for (const p of batch) {
      const n = next++;
      if (opts.failOn?.(n)) p.reject(new Error(`write ${n} failed`));
      else p.resolve(n);
    }
  }

  return { writer, settleAll, writes, pendingCount: () => pending.length };
}

function collector() {
  const got: Delivery[] = [];
  return { got, deliver: (d: Delivery) => got.push(d) };
}

/** Let the store's promise chain drain, settling writes as they queue up. */
async function drain(settleAll: () => void, pendingCount: () => number) {
  for (let i = 0; i < 50; i++) {
    await Promise.resolve();
    if (pendingCount() > 0) settleAll();
  }
}

const TURN: ChatEvent[] = [
  { type: 'delta', text: 'Let me look.' },
  { type: 'tool:start', toolCallId: 't1', toolName: 'Read', toolInput: { file: 'a.ts' } },
  { type: 'tool:result', toolCallId: 't1', output: 'ok', isError: false },
  { type: 'delta', text: 'Found it.' },
  { type: 'result', cost: COST },
];

describe('createSessionLogStore', () => {
  test('deliveries keep push order even when the writes finish backwards', async () => {
    const { got, deliver } = collector();
    const { writer, settleAll, pendingCount } = reverseOrderWriter();
    const store = createSessionLogStore(deliver, writer);

    for (const e of TURN) store.push('s1', e);
    await drain(settleAll, pendingCount);

    expect(got.map((d) => d.msg.type)).toEqual([
      'assistant:delta',
      'assistant:text',
      'tool:start',
      'tool:result',
      'assistant:delta',
      'assistant:text',
      'result',
    ]);
  });

  test('cursors ascend and each durable message chains to the one before it', async () => {
    const { got, deliver } = collector();
    const { writer, settleAll, pendingCount } = reverseOrderWriter();
    const store = createSessionLogStore(deliver, writer);

    for (const e of TURN) store.push('s1', e);
    await drain(settleAll, pendingCount);

    const durable = got.filter((d) => d.seq !== undefined);
    expect(durable).toHaveLength(5);

    const seqs = durable.map((d) => d.seq!);
    expect([...seqs].sort((a, b) => a - b)).toEqual(seqs);

    // First durable message of a fresh session makes no continuity claim; the rest point at their predecessor.
    expect(durable[0]!.msg.prevSeq).toBeUndefined();
    for (let i = 1; i < durable.length; i++) {
      expect(durable[i]!.msg.prevSeq).toBe(durable[i - 1]!.seq);
    }
  });

  test('a session that predates this process picks the chain up from the stored cursor', async () => {
    const { got, deliver } = collector();
    const { writer, settleAll, pendingCount } = reverseOrderWriter({ seed: 42 });
    const store = createSessionLogStore(deliver, writer);

    store.push('s1', { type: 'text', text: 'after a restart' });
    await drain(settleAll, pendingCount);

    expect(got).toHaveLength(1);
    expect(got[0]!.msg.prevSeq).toBe(42);
  });

  test('deltas are delivered without a cursor', async () => {
    const { got, deliver } = collector();
    const { writer, settleAll, pendingCount } = reverseOrderWriter();
    const store = createSessionLogStore(deliver, writer);

    store.push('s1', { type: 'delta', text: 'hi' });
    await drain(settleAll, pendingCount);

    expect(got).toEqual([{ sessionId: 's1', msg: { type: 'assistant:delta', text: 'hi' } }]);
  });

  test('durable:false skips the writer entirely but still delivers everything', async () => {
    const { got, deliver } = collector();
    const { writer, settleAll, writes, pendingCount } = reverseOrderWriter();
    const store = createSessionLogStore(deliver, writer);

    for (const e of TURN) store.push('job-uuid', e, false);
    await drain(settleAll, pendingCount);

    expect(writes).toHaveLength(0);
    expect(got).toHaveLength(7);
    expect(got.every((d) => d.seq === undefined)).toBe(true);
  });

  test('a failed write delivers live without a cursor and leaves the chain intact', async () => {
    const { got, deliver } = collector();
    // Fail the second successful-cursor slot, i.e. one write in the middle of the turn.
    const { writer, settleAll, pendingCount } = reverseOrderWriter({ failOn: (n) => n === 101 });
    const store = createSessionLogStore(deliver, writer);

    store.push('s1', { type: 'text', text: 'one' });
    await drain(settleAll, pendingCount);
    store.push('s1', { type: 'text', text: 'two' });
    await drain(settleAll, pendingCount);
    store.push('s1', { type: 'text', text: 'three' });
    await drain(settleAll, pendingCount);

    expect(got.map((d) => d.msg.type)).toEqual(['assistant:text', 'assistant:text', 'assistant:text']);

    const [first, failed, third] = got as [Delivery, Delivery, Delivery];
    expect(first.seq).toBe(100);
    // The message the client cannot replay carries neither a cursor nor a continuity claim.
    expect(failed.seq).toBeUndefined();
    // ...and the next write chains from the cursor the client actually still holds, not from the hole.
    expect(third.msg.prevSeq).toBe(first.seq);
    expect(third.seq).toBe(102);
  });

  test('sessions are independent, and drop forgets a session', async () => {
    const { got, deliver } = collector();
    const { writer, settleAll, pendingCount } = reverseOrderWriter();
    const store = createSessionLogStore(deliver, writer);

    store.push('a', { type: 'delta', text: 'from-a' });
    store.push('b', { type: 'delta', text: 'from-b' });
    store.push('a', { type: 'result', cost: COST });
    await drain(settleAll, pendingCount);

    // 'a' flushed only its own buffer.
    const texts = got.filter((d) => d.msg.type === 'assistant:text');
    expect(texts).toHaveLength(1);
    expect(texts[0]).toMatchObject({ sessionId: 'a', msg: { text: 'from-a' } });

    // After a drop, a new turn on the same key starts from a clean buffer and re-reads the stored cursor.
    store.drop('a');
    got.length = 0;
    store.push('a', { type: 'result', cost: COST });
    await drain(settleAll, pendingCount);
    expect(got.map((d) => d.msg.type)).toEqual(['result']);
  });
});
