import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { callRoute } from '../integration/api/helpers/call-route'
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
import { resetSystemState } from '../helpers/db-reset'
import { prisma } from '../helpers/prisma'
import { seedMinimalDomainState } from './helpers/seed'
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
import { createFixtureEpisode, createFixtureNovelProject, createFixtureProject, createFixtureUser } from '../helpers/fixtures'

type FakeAiResult = {
  text: string
  reasoning?: string
}

type FakeVoiceLineRow = {
  lineIndex: number
  speaker: string
  content: string
  emotionStrength: number
  matchedPanel: {
    storyboardId: string
    panelIndex: number
  }
}

const textState = vi.hoisted(() => ({
  aiResults: [] as FakeAiResult[],
  voiceLineResults: [] as FakeVoiceLineRow[],
  parseFailureCount: 0,
  orchestratorClipId: 'clip-seed',
}))

vi.mock('@/lib/ai-runtime', async () => {
  const actual = await vi.importActual<typeof import('@/lib/ai-runtime')>('@/lib/ai-runtime')
  return {
    ...actual,
    executeAiTextStep: vi.fn(async () => {
      const next = textState.aiResults.shift()
      if (!next) {
        return {
          text: '{"ok":true}',
          reasoning: '',
          usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
          completion: { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } },
        }
      }
      return {
        text: next.text,
        reasoning: next.reasoning || '',
        usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
        completion: { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } },
      }
    }),
  }
})

vi.mock('@/lib/novel-promotion/script-to-storyboard/orchestrator', async () => {
  const actual = await vi.importActual<typeof import('@/lib/novel-promotion/script-to-storyboard/orchestrator')>(
    '@/lib/novel-promotion/script-to-storyboard/orchestrator',
  )
  return {
    ...actual,
    runScriptToStoryboardOrchestrator: vi.fn(async () => ({
      clipPanels: [
        {
          clipId: textState.orchestratorClipId,
          clipIndex: 0,
          finalPanels: [
            {
              panel_number: 1,
              shot_type: 'close-up',
              camera_move: 'static',
              description: 'system generated panel',
              video_prompt: 'system video prompt',
              location: 'Office',
              characters: ['Narrator'],
            },
          ],
        },
      ],
      summary: {
        totalPanelCount: 1,
        totalStepCount: 4,
      },
    })),
  }
})

vi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', async () => {
  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/script-to-storyboard-helpers')>(
    '@/lib/workers/handlers/script-to-storyboard-helpers',
  )
  return {
    ...actual,
    parseVoiceLinesJson: vi.fn(() => {
      if (textState.parseFailureCount > 0) {
        textState.parseFailureCount -= 1
        throw new Error('invalid voice json')
      }
      return textState.voiceLineResults
    }),
    persistStoryboardsAndPanels: vi.fn(async (input: { episodeId: string }) => {
      const clip = await prisma.novelPromotionClip.findFirst({
        where: { episodeId: input.episodeId },
        orderBy: { createdAt: 'asc' },
      })
      if (!clip) {
        throw new Error(`TEST_CLIP_NOT_FOUND: ${input.episodeId}`)
      }
      const storyboard = await prisma.novelPromotionStoryboard.create({
        data: {
          id: 'storyboard-1',
          episodeId: input.episodeId,
          clipId: clip.id,
          panelCount: 1,
        },
      })
      const panel = await prisma.novelPromotionPanel.create({
        data: {
          id: 'panel-1',
          storyboardId: storyboard.id,
          panelIndex: 1,
          panelNumber: 1,
          shotType: 'close-up',
          cameraMove: 'static',
          description: 'system generated panel',
          videoPrompt: 'system video prompt',
          location: 'Office',
          characters: JSON.stringify(['Narrator']),
        },
      })
      return [{ storyboardId: storyboard.id, panels: [{ id: panel.id, panelIndex: 1 }] }]
    }),
  }
})

vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))

vi.mock('@/lib/workers/handlers/llm-stream', () => ({
  createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
  createWorkerLLMStreamCallbacks: vi.fn(() => ({
    onStage: vi.fn(),
    onChunk: vi.fn(),
    onComplete: vi.fn(),
    onError: vi.fn(),
    flush: vi.fn(async () => undefined),
  })),
}))

async function seedScriptToStoryboardState() {
  const user = await createFixtureUser()
  const project = await createFixtureProject(user.id)
  const novelProject = await createFixtureNovelProject(project.id)
  const episode = await createFixtureEpisode(novelProject.id)
  const clip = await prisma.novelPromotionClip.create({
    data: {
      episodeId: episode.id,
      summary: 'script clip',
      content: 'clip content',
      screenplay: 'screenplay text',
      location: 'Office',
      characters: JSON.stringify(['Narrator']),
    },
  })
  await prisma.novelPromotionCharacter.create({
    data: {
      novelPromotionProjectId: novelProject.id,
      name: 'Narrator',
    },
  })
  await prisma.novelPromotionLocation.create({
    data: {
      novelPromotionProjectId: novelProject.id,
      name: 'Office',
      summary: 'Office',
    },
  })
  textState.orchestratorClipId = clip.id
  return { user, project, novelProject, episode, clip }
}

describe('system - text workflows', () => {
  let workers: SystemWorkers = {}

  beforeEach(async () => {
    vi.resetModules()
    vi.clearAllMocks()
    textState.aiResults = []
    textState.voiceLineResults = []
    textState.parseFailureCount = 0
    textState.orchestratorClipId = 'clip-seed'
    await resetSystemState()
    installAuthMocks()
  })

  afterEach(async () => {
    await stopSystemWorkers(workers)
    workers = {}
    resetAuthMockState()
  })

  it('script-to-storyboard success -> persists storyboard/panel/voiceLine and completes task', async () => {
    const seeded = await seedScriptToStoryboardState()
    mockAuthenticated(seeded.user.id)
    textState.aiResults = [{ text: 'voice-lines-json' }]
    textState.voiceLineResults = [
      {
        lineIndex: 1,
        speaker: 'Narrator',
        content: 'Hello world',
        emotionStrength: 0.8,
        matchedPanel: {
          storyboardId: seeded.clip.id,
          panelIndex: 0,
        },
      },
    ]
    workers = await startSystemWorkers(['text'])

    const mod = await import('@/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route')
    const response = await callRoute(
      mod.POST,
      'POST',
      { locale: 'zh', episodeId: seeded.episode.id },
      { params: { projectId: seeded.project.id } },
    )

    expect(response.status).toBe(200)
    const json = await response.json() as { taskId: string }
    const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
    expect(task.status).toBe('completed')
    expect(task.type).toBe('script_to_storyboard_run')
    expect(task.result).toEqual(expect.objectContaining({
      episodeId: seeded.episode.id,
      panelCount: 1,
      voiceLineCount: 1,
    }))

    const storyboards = await prisma.novelPromotionStoryboard.findMany({
      where: { episodeId: seeded.episode.id },
      select: { id: true, panelCount: true },
    })
    expect(storyboards.length).toBeGreaterThan(0)

    const persistedVoiceLines = await prisma.novelPromotionVoiceLine.findMany({
      where: { episodeId: seeded.episode.id },
      orderBy: { lineIndex: 'asc' },
      select: {
        lineIndex: true,
        speaker: true,
        content: true,
        matchedPanelId: true,
        matchedPanelIndex: true,
      },
    })
    expect(persistedVoiceLines).toEqual([
      {
        lineIndex: 1,
        speaker: 'Narrator',
        content: 'Hello world',
        matchedPanelId: expect.any(String),
        matchedPanelIndex: 0,
      },
    ])

    const eventTypes = await listTaskEventTypes(json.taskId)
    expectLifecycleEvents(eventTypes, 'completed')
  })

  it('script-to-storyboard parse retry -> second attempt succeeds', async () => {
    const seeded = await seedScriptToStoryboardState()
    mockAuthenticated(seeded.user.id)
    textState.aiResults = [
      { text: 'invalid-voice-json' },
      { text: 'valid-voice-json' },
    ]
    textState.voiceLineResults = [
      {
        lineIndex: 1,
        speaker: 'Narrator',
        content: 'Retry success',
        emotionStrength: 0.4,
        matchedPanel: {
          storyboardId: seeded.clip.id,
          panelIndex: 0,
        },
      },
    ]
    textState.parseFailureCount = 1
    workers = await startSystemWorkers(['text'])

    const mod = await import('@/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route')
    const response = await callRoute(
      mod.POST,
      'POST',
      { locale: 'zh', episodeId: seeded.episode.id },
      { params: { projectId: seeded.project.id } },
    )

    const json = await response.json() as { taskId: string }
    const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
    expect(task.status).toBe('completed')
    expect(task.result).toEqual(expect.objectContaining({
      voiceLineCount: 1,
    }))

    const voiceLines = await prisma.novelPromotionVoiceLine.findMany({
      where: { episodeId: seeded.episode.id },
      select: { content: true },
    })
    expect(voiceLines).toEqual([{ content: 'Retry success' }])
  })

  it('insert-panel invalid ai payload -> task fails and no dirty panel remains', async () => {
    const seeded = await seedMinimalDomainState()
    mockAuthenticated(seeded.user.id)
    textState.aiResults = [{ text: 'not-json' }]
    workers = await startSystemWorkers(['text'])

    const beforeCount = await prisma.novelPromotionPanel.count({
      where: { storyboardId: seeded.storyboard.id },
    })

    const mod = await import('@/app/api/novel-promotion/[projectId]/insert-panel/route')
    const response = await callRoute(
      mod.POST,
      'POST',
      {
        locale: 'zh',
        storyboardId: seeded.storyboard.id,
        insertAfterPanelId: seeded.panel.id,
      },
      { params: { projectId: seeded.project.id } },
    )

    expect(response.status).toBe(200)
    const json = await response.json() as { taskId: string }
    const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
    expect(task.status).toBe('failed')

    const afterCount = await prisma.novelPromotionPanel.count({
      where: { storyboardId: seeded.storyboard.id },
    })
    expect(afterCount).toBe(beforeCount)

    const eventTypes = await listTaskEventTypes(json.taskId)
    expectLifecycleEvents(eventTypes, 'failed')
  })
})
