import { createApp, defineComponent, h } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useChatStream } from '../src/composables/useChatStream'
import { streamRecommendations, StreamTransportError } from '../src/lib/recommendationStream'
import type { AssistantTurn } from '../src/lib/models'
import type { RecommendationRequest, StreamEvent, TrackEvent } from '../src/lib/types'

vi.mock('../src/lib/recommendationStream', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../src/lib/recommendationStream')>()
  return { ...actual, streamRecommendations: vi.fn() }
})

const streamMock = vi.mocked(streamRecommendations)
const unmountCallbacks: Array<() => void> = []

function mountChat(
  createPlaylist: Parameters<typeof useChatStream>[0] = vi.fn(async () => ({ url: null })),
) {
  let chat: ReturnType<typeof useChatStream> | undefined
  const root = document.createElement('div')
  document.body.append(root)
  const app = createApp(
    defineComponent({
      setup() {
        chat = useChatStream(createPlaylist)
        return () => h('div')
      },
    }),
  )
  app.mount(root)
  unmountCallbacks.push(() => {
    app.unmount()
    root.remove()
  })
  if (!chat) throw new Error('Chat composable did not mount.')
  return chat
}

function metadata(requestId: string): StreamEvent {
  return {
    type: 'metadata',
    request_id: requestId,
    intent_summary: `Intent for ${requestId}`,
    candidate_count: 100,
  }
}

function track(rank: number): TrackEvent {
  return {
    type: 'track',
    rank,
    track: {
      id: `track-${rank}`,
      uri: `spotify:track:${rank}`,
      title: `Track ${rank}`,
      artists: ['Artist'],
      album_name: 'Album',
      album_art_url: null,
      external_url: null,
    },
    justification: 'It fits.',
  }
}

function assistantTurns(chat: ReturnType<typeof useChatStream>): AssistantTurn[] {
  return chat.turns.value.filter((turn): turn is AssistantTurn => turn.role === 'assistant')
}

beforeEach(() => {
  streamMock.mockReset()
})

afterEach(() => {
  while (unmountCallbacks.length) unmountCallbacks.pop()?.()
})

describe('useChatStream', () => {
  it('retains repeated warnings that share a code', async () => {
    streamMock.mockImplementation(async (_request, _signal, onEvent) => {
      onEvent(metadata('warnings'))
      onEvent({ type: 'warning', code: 'limited_pool', message: 'First warning.' })
      onEvent({ type: 'warning', code: 'limited_pool', message: 'Second warning.' })
      onEvent({ type: 'done', track_count: 0, total_ms: 5 })
    })
    const chat = mountChat()

    await chat.send('warnings')

    expect(assistantTurns(chat)[0]?.warnings).toEqual([
      { type: 'warning', code: 'limited_pool', message: 'First warning.' },
      { type: 'warning', code: 'limited_pool', message: 'Second warning.' },
    ])
  })

  it('keeps error terminal when a late done callback arrives', async () => {
    streamMock.mockImplementation(async (_request, _signal, onEvent) => {
      onEvent(metadata('error'))
      onEvent({ type: 'error', code: 'upstream', message: 'Upstream failed.' })
      onEvent({ type: 'done', track_count: 0, total_ms: 5 })
    })
    const chat = mountChat()

    await chat.send('error')

    const turn = assistantTurns(chat)[0]
    expect(turn).toMatchObject({ status: 'error', error: { code: 'upstream' } })
    expect(turn?.completion).toBeNull()
    expect(turn?.transportFailure).toBeNull()
  })

  it('stores a metadata-free terminal error as a normal stream error', async () => {
    streamMock.mockImplementation(async (_request, _signal, onEvent) => {
      onEvent({ type: 'error', code: 'first_stage_failed', message: 'Planning failed.' })
    })
    const chat = mountChat()

    await chat.send('error')

    expect(assistantTurns(chat)[0]).toMatchObject({
      status: 'error',
      requestId: null,
      error: { code: 'first_stage_failed', message: 'Planning failed.' },
      transportFailure: null,
    })
  })

  it('stores an incomplete stream as a transport failure', async () => {
    streamMock.mockRejectedValue(
      new StreamTransportError('unexpected_eof', 'The stream ended early.'),
    )
    const chat = mountChat()

    await chat.send('failure')

    expect(assistantTurns(chat)[0]).toMatchObject({
      status: 'error',
      transportFailure: { kind: 'unexpected_eof' },
    })
  })

  it('caps request history at 12 turns', async () => {
    const requests: RecommendationRequest[] = []
    streamMock.mockImplementation(async (request, _signal, onEvent) => {
      requests.push(request)
      onEvent(metadata(request.query))
      onEvent({ type: 'done', track_count: 0, total_ms: 5 })
    })
    const chat = mountChat()

    for (let index = 0; index < 8; index += 1) {
      await chat.send(`query ${index}`)
    }

    expect(requests.at(-1)?.history).toHaveLength(12)
    expect(requests.at(-1)?.history[0]?.role).toBe('user')
  })

  it('caps prior recommendations at 50 tracks', async () => {
    const requests: RecommendationRequest[] = []
    streamMock.mockImplementation(async (request, _signal, onEvent) => {
      requests.push(request)
      onEvent(metadata(request.query))
      if (requests.length === 1) {
        for (let rank = 1; rank <= 60; rank += 1) onEvent(track(rank))
        onEvent({ type: 'done', track_count: 60, total_ms: 5 })
      } else {
        onEvent({ type: 'done', track_count: 0, total_ms: 5 })
      }
    })
    const chat = mountChat()

    await chat.send('first')
    await chat.send('second')

    expect(requests[1]?.prior_recommendations).toHaveLength(50)
    expect(requests[1]?.prior_recommendations.at(-1)?.rank).toBe(50)
  })

  it('aborts a replaced stream and ignores its late callbacks and failure', async () => {
    interface PendingStream {
      signal: AbortSignal
      onEvent: (event: StreamEvent) => void
      resolve: () => void
      reject: (error: unknown) => void
    }
    const pending: PendingStream[] = []
    streamMock.mockImplementation(
      (_request, signal, onEvent) =>
        new Promise<void>((resolve, reject) => {
          pending.push({ signal, onEvent, resolve, reject })
        }),
    )
    const chat = mountChat()

    const firstSend = chat.send('first')
    const secondSend = chat.send('second')
    expect(pending[0]?.signal.aborted).toBe(true)

    pending[0]?.onEvent(metadata('late-first'))
    pending[0]?.onEvent(track(1))
    pending[0]?.reject(new DOMException('Aborted', 'AbortError'))
    pending[1]?.onEvent(metadata('second'))
    pending[1]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
    pending[1]?.resolve()
    await Promise.all([firstSend, secondSend])

    const [firstTurn, secondTurn] = assistantTurns(chat)
    expect(firstTurn).toMatchObject({
      status: 'error',
      requestId: null,
      tracks: [],
      transportFailure: { kind: 'cancelled' },
    })
    expect(secondTurn).toMatchObject({ status: 'done', requestId: 'second' })
  })

  it('does not relabel a terminal turn while its reader is cleaning up', async () => {
    interface PendingStream {
      signal: AbortSignal
      onEvent: (event: StreamEvent) => void
      resolve: () => void
    }
    const pending: PendingStream[] = []
    streamMock.mockImplementation(
      (_request, signal, onEvent) =>
        new Promise<void>((resolve) => {
          pending.push({ signal, onEvent, resolve })
        }),
    )
    const chat = mountChat()

    const firstSend = chat.send('first')
    pending[0]?.onEvent(metadata('first'))
    pending[0]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
    const secondSend = chat.send('second')
    expect(pending[0]?.signal.aborted).toBe(true)

    pending[0]?.resolve()
    pending[1]?.onEvent(metadata('second'))
    pending[1]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
    pending[1]?.resolve()
    await Promise.all([firstSend, secondSend])

    expect(assistantTurns(chat).map((turn) => turn.status)).toEqual(['done', 'done'])
  })

  it('caps a programmatic query at 1000 characters', async () => {
    let receivedQuery = ''
    streamMock.mockImplementation(async (request, _signal, onEvent) => {
      receivedQuery = request.query
      onEvent(metadata('query-limit'))
      onEvent({ type: 'done', track_count: 0, total_ms: 5 })
    })
    const chat = mountChat()

    await chat.send('x'.repeat(1200))

    expect(receivedQuery).toHaveLength(1000)
  })

  it('uses the bare query as the capped playlist name', async () => {
    const createPlaylist = vi.fn(async () => ({ url: null }))
    streamMock.mockImplementation(async (_request, _signal, onEvent) => {
      onEvent(metadata('playlist'))
      onEvent(track(1))
      onEvent({ type: 'done', track_count: 1, total_ms: 5 })
    })
    const chat = mountChat(createPlaylist)
    const query = `Late-night instrumental focus ${'x'.repeat(100)}`

    await chat.send(query)
    const turn = assistantTurns(chat)[0]
    if (!turn) throw new Error('Assistant turn was not created.')
    await chat.savePlaylist(turn.id)

    expect(createPlaylist).toHaveBeenCalledWith({
      schema_version: 1,
      name: query.slice(0, 100),
      track_uris: ['spotify:track:1'],
    })
  })
})
