import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ApiError } from '@/lib/api-errors'
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
import { createRun } from '@/lib/run-runtime/service'
import { submitTask } from '@/lib/task/submitter'
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createTestUser, seedBalance } from '../../helpers/billing-fixtures'

const queueState = vi.hoisted(() => ({
  mode: 'success' as 'success' | 'fail',
  errorMessage: 'queue add failed',
}))
const addTaskJobMock = vi.hoisted(() => vi.fn(async () => ({ id: 'mock-job' })))
const publishTaskEventMock = vi.hoisted(() => vi.fn(async () => ({})))

vi.mock('@/lib/task/queues', () => ({
  addTaskJob: addTaskJobMock,
}))

vi.mock('@/lib/task/publisher', () => ({
  publishTaskEvent: publishTaskEventMock,
}))

addTaskJobMock.mockImplementation(async () => {
    if (queueState.mode === 'fail') {
      throw new Error(queueState.errorMessage)
    }
    return { id: 'mock-job' }
})

describe('billing/submitter integration', () => {
  beforeEach(async () => {
    await resetBillingState()
    process.env.BILLING_MODE = 'ENFORCE'
    queueState.mode = 'success'
    queueState.errorMessage = 'queue add failed'
    vi.clearAllMocks()
  })

  it('builds billing info server-side for billable task submission', async () => {
    const user = await createTestUser()
    await seedBalance(user.id, 10)

    const result = await submitTask({
      userId: user.id,
      locale: 'en',
      projectId: 'project-a',
      type: TASK_TYPE.VOICE_LINE,
      targetType: 'VoiceLine',
      targetId: 'line-a',
      payload: { maxSeconds: 5 },
    })

    expect(result.success).toBe(true)
    const task = await prisma.task.findUnique({ where: { id: result.taskId } })
    expect(task).toBeTruthy()
    const billing = task?.billingInfo as { billable?: boolean; source?: string } | null
    expect(billing?.billable).toBe(true)
    expect(billing?.source).toBe('task')
  })

  it('marks task as failed when balance is insufficient', async () => {
    const user = await createTestUser()
    await seedBalance(user.id, 0)

    const billingInfo = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 10 })
    expect(billingInfo?.billable).toBe(true)

    await expect(
      submitTask({
        userId: user.id,
        locale: 'en',
        projectId: 'project-b',
        type: TASK_TYPE.VOICE_LINE,
        targetType: 'VoiceLine',
        targetId: 'line-b',
        payload: { maxSeconds: 10 },
        billingInfo,
      }),
    ).rejects.toMatchObject({ code: 'INSUFFICIENT_BALANCE' } satisfies Pick<ApiError, 'code'>)

    const task = await prisma.task.findFirst({
      where: {
        userId: user.id,
        type: TASK_TYPE.VOICE_LINE,
      },
      orderBy: { createdAt: 'desc' },
    })

    expect(task).toBeTruthy()
    expect(task?.status).toBe('failed')
    expect(task?.errorCode).toBe('INSUFFICIENT_BALANCE')
  })

  it('allows billable task submission without computed billingInfo in OFF mode (regression)', async () => {
    process.env.BILLING_MODE = 'OFF'
    const user = await createTestUser()

    const result = await submitTask({
      userId: user.id,
      locale: 'en',
      projectId: 'project-c',
      type: TASK_TYPE.IMAGE_CHARACTER,
      targetType: 'CharacterAppearance',
      targetId: 'appearance-c',
      payload: {},
    })

    expect(result.success).toBe(true)
    const task = await prisma.task.findUnique({ where: { id: result.taskId } })
    expect(task).toBeTruthy()
    expect(task?.errorCode).toBeNull()
    expect(task?.billingInfo).toBeNull()
  })

  it('keeps strict billingInfo validation in ENFORCE mode (regression)', async () => {
    process.env.BILLING_MODE = 'ENFORCE'
    const user = await createTestUser()
    await seedBalance(user.id, 10)

    await expect(
      submitTask({
        userId: user.id,
        locale: 'en',
        projectId: 'project-d',
        type: TASK_TYPE.IMAGE_CHARACTER,
        targetType: 'CharacterAppearance',
        targetId: 'appearance-d',
        payload: {},
      }),
    ).rejects.toMatchObject({ code: 'INVALID_PARAMS' } satisfies Pick<ApiError, 'code'>)

    const task = await prisma.task.findFirst({
      where: {
        userId: user.id,
        type: TASK_TYPE.IMAGE_CHARACTER,
      },
      orderBy: { createdAt: 'desc' },
    })

    expect(task).toBeTruthy()
    expect(task?.status).toBe('failed')
    expect(task?.errorCode).toBe('INVALID_PARAMS')
    expect(task?.errorMessage).toContain('missing server-generated billingInfo')
  })

  it('rolls back billing freeze and marks task failed when queue enqueue fails', async () => {
    const user = await createTestUser()
    await seedBalance(user.id, 10)
    queueState.mode = 'fail'
    queueState.errorMessage = 'queue unavailable'

    await expect(
      submitTask({
        userId: user.id,
        locale: 'en',
        projectId: 'project-e',
        type: TASK_TYPE.VOICE_LINE,
        targetType: 'VoiceLine',
        targetId: 'line-e',
        payload: { maxSeconds: 6 },
      }),
    ).rejects.toMatchObject({ code: 'EXTERNAL_ERROR' } satisfies Pick<ApiError, 'code'>)

    const task = await prisma.task.findFirst({
      where: {
        userId: user.id,
        type: TASK_TYPE.VOICE_LINE,
      },
      orderBy: { createdAt: 'desc' },
    })
    const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })

    expect(task).toBeTruthy()
    expect(task?.status).toBe('failed')
    expect(task?.errorCode).toBe('ENQUEUE_FAILED')
    expect(task?.errorMessage).toContain('queue unavailable')
    expect(task?.billingInfo).toMatchObject({
      billable: true,
      status: 'rolled_back',
    })
    expect(balance?.balance).toBeCloseTo(10, 8)
    expect(balance?.frozenAmount).toBeCloseTo(0, 8)
    expect(await prisma.balanceFreeze.count()).toBe(1)
    const freeze = await prisma.balanceFreeze.findFirst({ orderBy: { createdAt: 'desc' } })
    expect(freeze?.status).toBe('rolled_back')
  })

  it('reuses the active core analysis run instead of creating a second run', async () => {
    process.env.BILLING_MODE = 'OFF'
    const user = await createTestUser()
    const existingTask = await prisma.task.create({
      data: {
        userId: user.id,
        projectId: 'project-core',
        episodeId: 'episode-core',
        type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
        targetType: 'NovelPromotionEpisode',
        targetId: 'episode-core',
        status: TASK_STATUS.QUEUED,
        payload: {
          episodeId: 'episode-core',
          analysisModel: 'model-core',
          meta: { locale: 'zh' },
        },
        queuedAt: new Date(),
      },
    })
    const run = await createRun({
      userId: user.id,
      projectId: 'project-core',
      episodeId: 'episode-core',
      workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
      taskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
      taskId: existingTask.id,
      targetType: 'NovelPromotionEpisode',
      targetId: 'episode-core',
      input: {
        episodeId: 'episode-core',
        analysisModel: 'model-core',
        meta: { locale: 'zh' },
      },
    })
    await prisma.task.update({
      where: { id: existingTask.id },
      data: {
        payload: {
          episodeId: 'episode-core',
          analysisModel: 'model-core',
          runId: run.id,
          meta: { locale: 'zh', runId: run.id },
        },
      },
    })

    const result = await submitTask({
      userId: user.id,
      locale: 'zh',
      projectId: 'project-core',
      episodeId: 'episode-core',
      type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
      targetType: 'NovelPromotionEpisode',
      targetId: 'episode-core',
      payload: {
        episodeId: 'episode-core',
        analysisModel: 'model-core',
      },
      dedupeKey: 'story_to_script:episode-core',
    })

    expect(result.deduped).toBe(true)
    expect(result.taskId).toBe(existingTask.id)
    expect(result.runId).toBe(run.id)
    expect(await prisma.graphRun.count()).toBe(1)
    expect(addTaskJobMock).not.toHaveBeenCalled()
  })

  it('reattaches a new task to the existing active run when the old task is already terminal', async () => {
    process.env.BILLING_MODE = 'OFF'
    const user = await createTestUser()
    const failedTask = await prisma.task.create({
      data: {
        userId: user.id,
        projectId: 'project-core-retry',
        episodeId: 'episode-core-retry',
        type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
        targetType: 'NovelPromotionEpisode',
        targetId: 'episode-core-retry',
        status: TASK_STATUS.FAILED,
        errorCode: 'TEST_FAILED',
        errorMessage: 'old task already failed',
        payload: {
          episodeId: 'episode-core-retry',
          analysisModel: 'model-core',
          meta: { locale: 'zh' },
        },
        queuedAt: new Date(),
        finishedAt: new Date(),
      },
    })
    const run = await createRun({
      userId: user.id,
      projectId: 'project-core-retry',
      episodeId: 'episode-core-retry',
      workflowType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
      taskType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
      taskId: failedTask.id,
      targetType: 'NovelPromotionEpisode',
      targetId: 'episode-core-retry',
      input: {
        episodeId: 'episode-core-retry',
        analysisModel: 'model-core',
        meta: { locale: 'zh' },
      },
    })

    const result = await submitTask({
      userId: user.id,
      locale: 'zh',
      projectId: 'project-core-retry',
      episodeId: 'episode-core-retry',
      type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
      targetType: 'NovelPromotionEpisode',
      targetId: 'episode-core-retry',
      payload: {
        episodeId: 'episode-core-retry',
        analysisModel: 'model-core',
      },
      dedupeKey: 'script_to_storyboard:episode-core-retry',
    })

    expect(result.deduped).toBe(false)
    expect(result.runId).toBe(run.id)
    expect(result.taskId).not.toBe(failedTask.id)

    const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
    const newTask = await prisma.task.findUnique({ where: { id: result.taskId } })
    expect(refreshedRun?.taskId).toBe(result.taskId)
    expect(newTask?.status).toBe(TASK_STATUS.QUEUED)
    expect(newTask?.payload).toMatchObject({
      runId: run.id,
    })
  })
})
