import { describe, expect, it } from 'vitest';

import type { ChatMessage } from '../../types';
import { initialState, reducer, type ChatAction, type ChatState } from '../reducer';

/**
 * Regression: an errored turn's banner must PERSIST until a genuinely new
 * answer turn begins. A prior "anti-jitter" fix (clear `error` on
 * STREAM_CHUNK / STREAM_DONE / MESSAGE_USER_ADD) over-reached and wiped the
 * CURRENT turn's error before the user could read it — most visibly the
 * router-404 case, where STREAM_ERROR + a terminal message_end land on the
 * SAME turn so the terminal erased its own banner.
 */

const user = (id: string, content = 'hi'): ChatMessage => ({
  id,
  role: 'user',
  content,
  createdAt: 1,
});

/** Replay a sequence of actions from `initialState`. */
function run(actions: ChatAction[]): ChatState {
  return actions.reduce(reducer, initialState);
}

describe('reducer — errored turn banner persistence', () => {
  it('(a) STREAM_ERROR then STREAM_DONE for the SAME turn keeps the error (router-404)', () => {
    const state = run([
      { type: 'MESSAGE_USER_ADD', message: user('u1') },
      { type: 'STREAM_START', id: 'a1' },
      { type: 'STREAM_ERROR', id: 'a1', message: 'llm: router 404: 404 page not found' },
      // The terminal message_end arrives for the very same turn.
      { type: 'STREAM_DONE', id: 'a1' },
    ]);

    // The banner survives the turn's own terminal — this is the load-bearing
    // assertion (what the user reads). The empty errored placeholder itself is
    // dropped by the HITL empty-bubble logic, which is fine.
    expect(state.error).toBe('llm: router 404: 404 page not found');
    expect(state.errorTurnId).toBe('a1');
    expect(state.isStreaming).toBe(false);
  });

  it('the errored turn keeps its error message when it produced content', () => {
    // Same-turn STREAM_DONE must not wipe the banner even when the errored
    // message survives the empty-bubble drop (it has content).
    const state = run([
      { type: 'STREAM_START', id: 'a1' },
      { type: 'STREAM_CHUNK', delta: 'partial ' },
      { type: 'STREAM_ERROR', id: 'a1', message: 'boom' },
      { type: 'STREAM_DONE', id: 'a1' },
    ]);
    expect(state.error).toBe('boom');
    expect(state.errorTurnId).toBe('a1');
    const msg = state.messages.find((m) => m.id === 'a1');
    expect(msg?.isError).toBe(true);
    expect(msg?.content).toBe('partial ');
  });

  it('a trailing STREAM_CHUNK landing on nothing does not wipe the banner', () => {
    const state = run([
      { type: 'STREAM_START', id: 'a1' },
      { type: 'STREAM_ERROR', id: 'a1', message: 'boom' },
      // STREAM_ERROR cleared isStreaming, so this chunk finds no streaming
      // message (idx === -1) and must not clear the error.
      { type: 'STREAM_CHUNK', delta: 'x' },
    ]);
    expect(state.error).toBe('boom');
    expect(state.errorTurnId).toBe('a1');
  });

  it('(b) STREAM_ERROR then MESSAGE_USER_ADD keeps the error while the user types the next message', () => {
    const state = run([
      { type: 'STREAM_START', id: 'a1' },
      { type: 'STREAM_ERROR', id: 'a1', message: 'boom' },
      { type: 'MESSAGE_USER_ADD', message: user('u2', 'try again') },
    ]);

    expect(state.error).toBe('boom');
    expect(state.errorTurnId).toBe('a1');
    // The new user message is appended.
    expect(state.messages.some((m) => m.id === 'u2')).toBe(true);
  });

  it('(c) a genuinely NEW turn (STREAM_START + STREAM_CHUNK) clears the stale error', () => {
    const errored = run([
      { type: 'STREAM_START', id: 'a1' },
      { type: 'STREAM_ERROR', id: 'a1', message: 'boom' },
      { type: 'MESSAGE_USER_ADD', message: user('u2') },
    ]);
    expect(errored.error).toBe('boom');

    // New turn opens with a fresh placeholder id.
    const afterStart = reducer(errored, { type: 'STREAM_START', id: 'a2' });
    expect(afterStart.error).toBeNull();
    expect(afterStart.errorTurnId).toBeNull();

    // And, independently, the first chunk of a new turn also clears it.
    const viaChunk = reducer(
      reducer(errored, { type: 'STREAM_START', id: 'a2' } as ChatAction),
      { type: 'STREAM_CHUNK', delta: 'Hello' },
    );
    expect(viaChunk.error).toBeNull();
    expect(viaChunk.errorTurnId).toBeNull();
    expect(viaChunk.messages.find((m) => m.id === 'a2')?.content).toBe('Hello');
  });

  it('re-emitted STREAM_START for the errored turn itself does NOT clear its banner', () => {
    // Not a strictly newer turn — same id.
    const state = run([
      { type: 'STREAM_START', id: 'a1' },
      { type: 'STREAM_ERROR', id: 'a1', message: 'boom' },
    ]);
    const again = reducer(state, { type: 'STREAM_START', id: 'a1' });
    // STREAM_START while not streaming re-adds a placeholder, but since the id
    // matches errorTurnId the banner survives.
    expect(again.error).toBe('boom');
    expect(again.errorTurnId).toBe('a1');
  });

  it('(d) HITL empty-placeholder STREAM_DONE still drops the empty bubble', () => {
    // First stream produced only tool calls + approval_required, no text body.
    const state = run([
      { type: 'STREAM_START', id: 'a1' },
      { type: 'STREAM_DONE', id: 'a1' }, // empty content, no toolCalls
    ]);
    expect(state.messages.find((m) => m.id === 'a1')).toBeUndefined();
    expect(state.isStreaming).toBe(false);
    // No error was ever set.
    expect(state.error).toBeNull();
  });

  it('STREAM_ERROR without an id falls back to the last streaming placeholder id', () => {
    const state = run([
      { type: 'STREAM_START', id: 'a1' },
      { type: 'STREAM_ERROR', message: 'network dropped' },
    ]);
    expect(state.error).toBe('network dropped');
    expect(state.errorTurnId).toBe('a1');
  });
});
