import { describe, expect, it, vi } from 'vitest'
import type { Response } from 'express'
import { z } from 'zod'
import { AppError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError, ValidationError } from './index'
import { errorMiddleware } from './errorMiddleware'

function createResponseStub() {
  const res = {
    status: vi.fn(),
    json: vi.fn(),
  }

  res.status.mockReturnValue(res)
  res.json.mockReturnValue(res)

  return res as unknown as Response & typeof res
}

function handle(error: unknown) {
  const res = createResponseStub()
  errorMiddleware(error, {} as any, res, vi.fn())
  return res
}

describe('AppError subclasses', () => {
  it.each([
    [new ValidationError('Bad payload'), 400, 'validation_error'],
    [new UnauthorizedError(), 401, 'unauthorized'],
    [new ForbiddenError(), 403, 'forbidden'],
    [new NotFoundError(), 404, 'not_found'],
    [new ConflictError(), 409, 'conflict'],
  ])('sets stable status and error code for %s', (error, statusCode, code) => {
    expect(error).toBeInstanceOf(AppError)
    expect(error.statusCode).toBe(statusCode)
    expect(error.error).toBe(code)
  })
})

describe('errorMiddleware', () => {
  it('normalizes Zod errors into validation responses', () => {
    const result = z.object({ email: z.string().email() }).safeParse({ email: 'not-an-email' })
    expect(result.success).toBe(false)

    const res = handle(result.success ? new Error('unexpected') : result.error)

    expect(res.status).toHaveBeenCalledWith(400)
    expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
      error: 'validation_error',
      message: 'Invalid request body',
      statusCode: 400,
      issues: expect.any(Array),
    }))
  })

  it('normalizes Prisma not found errors', () => {
    const res = handle({ code: 'P2025' })

    expect(res.status).toHaveBeenCalledWith(404)
    expect(res.json).toHaveBeenCalledWith({
      error: 'not_found',
      message: 'Resource not found',
      statusCode: 404,
    })
  })

  it('normalizes Prisma unique constraint errors', () => {
    const res = handle({ code: 'P2002' })

    expect(res.status).toHaveBeenCalledWith(409)
    expect(res.json).toHaveBeenCalledWith({
      error: 'conflict',
      message: 'A resource with this value already exists',
      statusCode: 409,
    })
  })

  it.each(['P2021', 'P2022'])('normalizes Prisma schema mismatch errors for %s', (code) => {
    const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)

    const res = handle({ code })

    expect(res.status).toHaveBeenCalledWith(503)
    expect(res.json).toHaveBeenCalledWith({
      error: 'database_schema_mismatch',
      message: 'Database schema is out of date. Run database migrations and retry.',
      statusCode: 503,
      requestId: undefined,
    })

    spy.mockRestore()
  })

  it('preserves AppError metadata in the response body', () => {
    const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' }))

    expect(res.status).toHaveBeenCalledWith(402)
    expect(res.json).toHaveBeenCalledWith({
      error: 'payment_required',
      message: 'Plan required',
      statusCode: 402,
      requiredPlan: 'PRO',
    })
  })

  it('falls back to a 500 internal error response', () => {
    const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)

    const res = handle(new Error('boom'))

    expect(res.status).toHaveBeenCalledWith(500)
    expect(res.json).toHaveBeenCalledWith({ error: 'internal_error', message: 'Internal server error', statusCode: 500, requestId: undefined })

    spy.mockRestore()
  })
})
