import type { RouteConfig } from '@asteasolutions/zod-to-openapi'
import type { Context, TypedResponse } from 'hono'
import { bearerAuth } from 'hono/bearer-auth'
import { hc } from 'hono/client'
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import type { RouteConfigToTypedResponse } from '../src/index'
import { OpenAPIHono, createRoute, z } from '../src/index'
import type { Equal, Expect } from 'hono/utils/types'
import type { ServerErrorStatusCode } from 'hono/utils/http-status'
import { stringify } from 'yaml'
import { accepts } from 'hono/accepts'

describe('Constructor', () => {
  it('Should not require init object', () => {
    expect(() => new OpenAPIHono()).not.toThrow()
  })

  it('Should accept init object', () => {
    const getPath = () => ''
    const app = new OpenAPIHono({ getPath })
    expect(app.getPath).toBe(getPath)
  })

  it('Should accept a defaultHook', () => {
    type FakeEnv = { Variables: { fake: string }; Bindings: { other: number } }
    const app = new OpenAPIHono<FakeEnv>({
      defaultHook: (_result, c) => {
        // Make sure we're passing context types through
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expectTypeOf(c).toMatchTypeOf<Context<FakeEnv, any, any>>()
      },
    })
    expect(app.defaultHook).toBeDefined()
  })
})

describe('Basic - params', () => {
  const ParamsSchema = z.object({
    id: z
      .string()
      .min(4)
      .openapi({
        param: {
          name: 'id',
          in: 'path',
        },
        example: '12345',
      }),
  })

  const UserSchema = z
    .object({
      id: z.string().openapi({
        example: '12345',
      }),
      name: z.string().openapi({
        example: 'John Doe',
      }),
      age: z.number().openapi({
        example: 42,
      }),
    })
    .openapi('User')

  const HeadersSchema = z.object({
    // Header keys must be in lowercase
    authorization: z.string().openapi({
      example: 'Bearer SECRET',
    }),
  })

  const ErrorSchema = z
    .object({
      ok: z.boolean().openapi({
        example: false,
      }),
    })
    .openapi('Error')

  const route = createRoute({
    method: 'get',
    path: '/users/{id}',
    request: {
      params: ParamsSchema,
      headers: HeadersSchema,
    },
    responses: {
      200: {
        content: {
          'application/json': {
            schema: UserSchema,
          },
        },
        description: 'Get the user',
      },
      400: {
        content: {
          'application/json': {
            schema: ErrorSchema,
          },
        },
        description: 'Error!',
      },
    },
  })

  const app = new OpenAPIHono()

  app.openapi(
    route,
    (c) => {
      const { id } = c.req.valid('param')
      return c.json(
        {
          id,
          age: 20,
          name: 'Ultra-man',
        },
        200 // You should specify the status code even if it's 200.
      )
    },
    (result, c) => {
      if (!result.success) {
        const res = c.json(
          {
            ok: false,
          },
          400
        )
        return res
      }
    }
  )

  app.doc('/doc', {
    openapi: '3.0.0',
    info: {
      version: '1.0.0',
      title: 'My API',
    },
  })

  it('Should return 200 response with correct contents', async () => {
    const res = await app.request('/users/12345', {
      headers: {
        Authorization: 'Bearer TOKEN',
      },
    })
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({
      id: '12345',
      age: 20,
      name: 'Ultra-man',
    })
  })

  it('Should return 400 response with correct contents', async () => {
    const res = await app.request('/users/123')
    expect(res.status).toBe(400)
    expect(await res.json()).toEqual({ ok: false })
  })

  it('Should return OpenAPI documents', async () => {
    const res = await app.request('/doc')
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({
      openapi: '3.0.0',
      info: { version: '1.0.0', title: 'My API' },
      components: {
        schemas: {
          User: {
            type: 'object',
            properties: {
              id: { type: 'string', example: '12345' },
              name: { type: 'string', example: 'John Doe' },
              age: { type: 'number', example: 42 },
            },
            required: ['id', 'name', 'age'],
          },
          Error: {
            type: 'object',
            properties: { ok: { type: 'boolean', example: false } },
            required: ['ok'],
          },
        },
        parameters: {},
      },
      paths: {
        '/users/{id}': {
          get: {
            parameters: [
              {
                schema: { type: 'string', example: '12345', minLength: 4 },
                required: true,
                name: 'id',
                in: 'path',
              },
              {
                schema: { type: 'string', example: 'Bearer SECRET' },
                required: true,
                name: 'authorization',
                in: 'header',
              },
            ],
            responses: {
              '200': {
                description: 'Get the user',
                content: {
                  'application/json': {
                    schema: { $ref: '#/components/schemas/User' },
                  },
                },
              },
              '400': {
                description: 'Error!',
                content: {
                  'application/json': {
                    schema: { $ref: '#/components/schemas/Error' },
                  },
                },
              },
            },
          },
        },
      },
    })
  })
})

describe('Query', () => {
  const QuerySchema = z.object({
    page: z.string().openapi({
      example: '123',
    }),
  })

  const BooksSchema = z
    .object({
      titles: z.array(z.string().openapi({})),
      page: z.number().openapi({}),
    })
    .openapi('Post')

  const route = createRoute({
    method: 'get',
    path: '/books',
    request: {
      query: QuerySchema,
    },
    responses: {
      200: {
        content: {
          'application/json': {
            schema: BooksSchema,
          },
        },
        description: 'Get books',
      },
    },
  })

  const app = new OpenAPIHono()

  app.openapi(route, (c) => {
    const { page } = c.req.valid('query')
    return c.json({
      titles: ['Good title'],
      page: Number(page),
    })
  })

  it('Should return 200 response with correct contents', async () => {
    const res = await app.request('/books?page=123')
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({
      titles: ['Good title'],
      page: 123,
    })
  })

  it('Should return 400 response with correct contents', async () => {
    const res = await app.request('/books')
    expect(res.status).toBe(400)
  })
})

describe('Header', () => {
  const HeaderSchema = z.object({
    authorization: z.string(),
    'x-request-id': z.string().uuid(),
  })

  const PongSchema = z
    .object({
      'x-request-id': z.string().uuid(),
      authorization: z.string(),
    })
    .openapi('Post')

  const route = createRoute({
    method: 'get',
    path: '/pong',
    request: {
      headers: HeaderSchema,
    },
    responses: {
      200: {
        content: {
          'application/json': {
            schema: PongSchema,
          },
        },
        description: 'Pong',
      },
    },
  })

  const app = new OpenAPIHono()

  app.openapi(route, (c) => {
    const headerData = c.req.valid('header')
    return c.json(headerData)
  })

  it('Should return 200 response with correct contents', async () => {
    const res = await app.request('/pong', {
      headers: {
        'x-request-id': '6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b',
        Authorization: 'Bearer helloworld',
      },
    })
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({
      'x-request-id': '6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b',
      authorization: 'Bearer helloworld',
    })
  })

  it('Should return 400 response with correct contents', async () => {
    const res = await app.request('/pong', {
      headers: {
        'x-request-id': 'invalid-strings',
        Authorization: 'Bearer helloworld',
      },
    })
    expect(res.status).toBe(400)
  })
})

describe('Cookie', () => {
  const CookieSchema = z.object({
    debug: z.enum(['0', '1']),
  })

  const UserSchema = z
    .object({
      name: z.string(),
      debug: z.enum(['0', '1']),
    })
    .openapi('User')

  const route = createRoute({
    method: 'get',
    path: '/api/user',
    request: {
      cookies: CookieSchema,
    },
    responses: {
      200: {
        content: {
          'application/json': {
            schema: UserSchema,
          },
        },
        description: 'Get a user',
      },
    },
  })

  const app = new OpenAPIHono()

  app.openapi(route, (c) => {
    const { debug } = c.req.valid('cookie')
    return c.json({
      name: 'foo',
      debug,
    })
  })

  it('Should return 200 response with correct contents', async () => {
    const res = await app.request('/api/user', {
      headers: {
        Cookie: 'debug=1',
      },
    })
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({
      name: 'foo',
      debug: '1',
    })
  })

  it('Should return 400 response with correct contents', async () => {
    const res = await app.request('/api/user', {
      headers: {
        Cookie: 'debug=2',
      },
    })
    expect(res.status).toBe(400)
  })
})

describe('JSON', () => {
  const RequestSchema = z.object({
    id: z.number().openapi({}),
    title: z.string().openapi({}),
  })

  const PostSchema = z
    .object({
      id: z.number().openapi({}),
      title: z.string().openapi({}),
    })
    .openapi('Post')

  describe('Content-Type application/json', () => {
    const route = createRoute({
      method: 'post',
      path: '/posts',
      request: {
        body: {
          content: {
            'application/json': {
              schema: RequestSchema,
            },
          },
        },
      },
      responses: {
        200: {
          content: {
            'application/json': {
              schema: PostSchema,
            },
          },
          description: 'Post a post',
        },
      },
    })

    const app = new OpenAPIHono()

    app.openapi(route, (c) => {
      const { id, title } = c.req.valid('json')
      return c.json({
        id,
        title,
      })
    })

    it('Should return 200 response with correct contents', async () => {
      const req = new Request('http://localhost/posts', {
        method: 'POST',
        body: JSON.stringify({
          id: 123,
          title: 'Good title',
        }),
        headers: {
          'Content-Type': 'application/json',
        },
      })

      const res = await app.request(req)

      expect(res.status).toBe(200)
      expect(await res.json()).toEqual({
        id: 123,
        title: 'Good title',
      })
    })

    it('Should return 400 response with correct contents', async () => {
      const req = new Request('http://localhost/posts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({}),
      })
      const res = await app.request(req)
      expect(res.status).toBe(400)
    })

    it('Should return 200 response without a content-type', async () => {
      const req = new Request('http://localhost/posts', {
        method: 'POST',
      })
      const res = await app.request(req)
      expect(res.status).toBe(200)
    })
  })

  describe('Content-Type application/vnd.api+json', () => {
    const route = createRoute({
      method: 'post',
      path: '/posts',
      request: {
        body: {
          content: {
            'application/vnd.api+json': {
              schema: RequestSchema,
            },
          },
        },
      },
      responses: {
        200: {
          content: {
            'application/json': {
              schema: PostSchema,
            },
          },
          description: 'Post a post',
        },
      },
    })

    const app = new OpenAPIHono()

    app.openapi(route, (c) => {
      const { id, title } = c.req.valid('json')
      return c.json({
        id,
        title,
      })
    })

    it('Should return 200 response with correct contents', async () => {
      const req = new Request('http://localhost/posts', {
        method: 'POST',
        body: JSON.stringify({
          id: 123,
          title: 'Good title',
        }),
        headers: {
          'Content-Type': 'application/vnd.api+json',
        },
      })

      const res = await app.request(req)

      expect(res.status).toBe(200)
      expect(await res.json()).toEqual({
        id: 123,
        title: 'Good title',
      })
    })

    it('Should return 400 response with correct contents for empty request data', async () => {
      const req = new Request('http://localhost/posts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/vnd.api+json',
        },
        body: JSON.stringify({}),
      })
      const res = await app.request(req)
      expect(res.status).toBe(400)
    })

    it('Should return 400 response with correct contents for non application/vnd.api+json request', async () => {
      const req = new Request('http://localhost/posts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({}),
      })
      const res = await app.request(req)
      expect(res.status).toBe(400)
    })
  })

  describe('required', () => {
    const route = createRoute({
      method: 'post',
      path: '/posts',
      request: {
        body: {
          content: {
            'application/json': {
              schema: RequestSchema,
            },
          },
          required: true,
        },
      },
      responses: {
        200: {
          description: 'Post a post',
        },
      },
    })
    const app = new OpenAPIHono()
    app.openapi(route, (c) => {
      return c.text('Post success')
    })

    it('Should return 400 response since body is required', async () => {
      const res = await app.request('/posts', {
        method: 'POST',
      })
      expect(res.status).toBe(400)
    })
  })
})

describe('Form', () => {
  const RequestSchema = z.object({
    id: z.string().openapi({}),
    title: z.string().openapi({}),
  })

  const PostSchema = z
    .object({
      id: z.number().openapi({}),
      title: z.string().openapi({}),
    })
    .openapi('Post')

  const route = createRoute({
    method: 'post',
    path: '/posts',
    request: {
      body: {
        content: {
          'application/x-www-form-urlencoded': {
            schema: RequestSchema,
          },
        },
      },
    },
    responses: {
      200: {
        content: {
          'application/json': {
            schema: PostSchema,
          },
        },
        description: 'Post a post',
      },
    },
  })

  const app = new OpenAPIHono()

  app.openapi(route, (c) => {
    const { id, title } = c.req.valid('form')
    return c.json({
      id: Number(id),
      title,
    })
  })

  it('Should return 200 response with correct contents', async () => {
    const searchParams = new URLSearchParams()
    searchParams.append('id', '123')
    searchParams.append('title', 'Good title')
    const req = new Request('http://localhost/posts', {
      method: 'POST',
      body: searchParams,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    })

    const res = await app.request(req)

    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({
      id: 123,
      title: 'Good title',
    })
  })

  it('Should return 200 response without a content-type', async () => {
    const req = new Request('http://localhost/posts', {
      method: 'POST',
    })
    const res = await app.request(req)
    expect(res.status).toBe(200)
  })

  describe('required', () => {
    const route = createRoute({
      method: 'post',
      path: '/posts',
      request: {
        body: {
          content: {
            'application/x-www-form-urlencoded': {
              schema: RequestSchema,
            },
          },
          required: true,
        },
      },
      responses: {
        200: {
          description: 'Post a post',
        },
      },
    })
    const app = new OpenAPIHono()
    app.openapi(route, (c) => {
      return c.text('Post success')
    })

    it('Should return 400 response since body is required', async () => {
      const res = await app.request('/posts', {
        method: 'POST',
      })
      expect(res.status).toBe(400)
    })
  })
})

describe('JSON and Form', () => {
  const functionInForm = vi.fn()
  const functionInJSON = vi.fn()
  const route = createRoute({
    method: 'post',
    path: '/hello',
    request: {
      body: {
        content: {
          'application/x-www-form-urlencoded': {
            schema: z.custom(() => {
              functionInForm()
              return true
            }),
          },
          'application/json': {
            schema: z.custom(() => {
              functionInJSON()
              return true
            }),
          },
        },
      },
    },
    responses: {
      200: {
        description: 'response',
      },
    },
  })

  const app = new OpenAPIHono()

  app.openapi(route, (c) => {
    return c.json(0)
  })

  test('functionInJSON should not be called when the body is Form', async () => {
    const form = new FormData()
    form.append('foo', 'foo')
    await app.request('/hello', {
      method: 'POST',
      body: form,
    })
    expect(functionInForm).toHaveBeenCalled()
    expect(functionInJSON).not.toHaveBeenCalled()
    functionInForm.mockReset()
    functionInJSON.mockReset()
  })
  test('functionInForm should not be called when the body is JSON', async () => {
    await app.request('/hello', {
      method: 'POST',
      body: JSON.stringify({ foo: 'foo' }),
      headers: {
        'content-type': 'application/json',
      },
    })
    expect(functionInForm).not.toHaveBeenCalled()
    expect(functionInJSON).toHaveBeenCalled()
    functionInForm.mockReset()
    functionInJSON.mockReset()
  })
})

describe('JSON and Text response', () => {
  const route = createRoute({
    method: 'get',
    path: '/hello',
    responses: {
      200: {
        content: {
          'application/json': {
            schema: z.object({}),
          },
          'text/plain': {
            schema: z.string(),
          },
        },
        description: 'response',
      },
    },
  })

  const app = new OpenAPIHono()

  app.openapi(route, (c) => {
    const mimeTypes = ['application/json', 'text/plain']
    if (
      accepts(c, {
        default: mimeTypes[0],
        header: 'Accept',
        supports: mimeTypes,
      }) === mimeTypes[0]
    ) {
      return c.json({})
    }
    return c.text('')
  })

  test('should respond with JSON fallback', async () => {
    const res = await app.request('/hello', {
      method: 'GET',
    })
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({})
  })
  test('should respond with Text', async () => {
    const res = await app.request('/hello', {
      method: 'GET',
      headers: {
        accept: 'text/plain',
      },
    })
    expect(res.status).toBe(200)
    expect(await res.text()).toEqual('')
  })
})

describe('Input types', () => {
  const ParamsSchema = z.object({
    id: z
      .string()
      .transform(Number)
      .openapi({
        param: {
          name: 'id',
          in: 'path',
        },
        example: '123',
      }),
  })

  const QuerySchema = z.object({
    age: z
      .string()
      .transform(Number)
      .openapi({
        param: {
          name: 'age',
          in: 'query',
        },
        example: '42',
      }),
  })

  const BodySchema = z
    .object({
      sex: z.enum(['male', 'female']).openapi({}),
    })
    .openapi('User')

  const UserSchema = z
    .object({
      id: z.number().openapi({
        example: 123,
      }),
      name: z.string().openapi({
        example: 'John Doe',
      }),
      age: z.number().openapi({
        example: 42,
      }),
      sex: z.enum(['male', 'female']).openapi({
        example: 'male',
      }),
    })
    .openapi('User')

  const route = createRoute({
    method: 'patch',
    path: '/users/{id}',
    request: {
      params: ParamsSchema,
      query: QuerySchema,
      body: {
        content: {
          'application/json': {
            schema: BodySchema,
          },
        },
      },
    },
    responses: {
      200: {
        content: {
          'application/json': {
            schema: UserSchema,
          },
        },
        description: 'Update a user',
      },
    },
  })

  const app = new OpenAPIHono()

  app.openapi(route, (c) => {
    const { id } = c.req.valid('param')
    const { age } = c.req.valid('query')
    const { sex } = c.req.valid('json')

    return c.json({
      id,
      age,
      sex,
      name: 'Ultra-man',
    })
  })

  it('Should return 200 response with correct typed contents', async () => {
    const res = await app.request('/users/123?age=42', {
      method: 'PATCH',
      body: JSON.stringify({ sex: 'male' }),
      headers: {
        'Content-Type': 'application/json',
      },
    })
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({
      id: 123,
      age: 42,
      sex: 'male',
      name: 'Ultra-man',
    })
  })

  // @ts-expect-error it should throw an error if the types are wrong
  app.openapi(route, (c) => {
    return c.json({
      id: '123', // should be number
      age: 42,
      sex: 'male' as const,
      name: 'Success',
    })
  })

  // @ts-expect-error it should throw an error if the status code is wrong
  app.openapi(route, (c) => {
    return c.json(
      {
        id: 123,
        age: 42,
        sex: 'male' as const,
        name: 'Success',
      },
      404
    )
  })
})

describe('Routers', () => {
  const RequestSchema = z.object({
    id: z.number().openapi({}),
  })

  const PostSchema = z
    .object({
      id: z.number().openapi({}),
    })
    .openapi('Post')

  const route = createRoute({
    method: 'post',
    path: '/posts',
    request: {
      body: {
        content: {
          'application/json': {
            schema: RequestSchema,
          },
        },
      },
    },
    responses: {
      200: {
        content: {
          'application/json': {
            schema: PostSchema,
          },
        },
        description: 'Post a post',
      },
    },
  })
  it('Should include definitions from nested routers', async () => {
    const router = new OpenAPIHono().openapi(route, (ctx) => {
      return ctx.json({ id: 123 })
    })

    router.openAPIRegistry.register('Id', z.number())

    router.openAPIRegistry.registerParameter(
      'Key',
      z.number().openapi({
        param: { in: 'path' },
      })
    )

    router.openAPIRegistry.registerWebhook({
      method: 'post',
      path: '/postback',
      responses: {
        200: {
          description: 'Receives a post back',
        },
      },
    })

    const app = new OpenAPIHono().route('/api', router)
    const json = app.getOpenAPI31Document({
      openapi: '3.1.0',
      info: {
        title: 'My API',
        version: '1.0.0',
      },
    })

    expect(json.components?.schemas).toHaveProperty('Id')
    expect(json.components?.schemas).toHaveProperty('Post')
    expect(json.components?.parameters).toHaveProperty('Key')
    expect(json.paths).toHaveProperty('/api/posts')
    expect(json.webhooks).toHaveProperty('/api/postback')

    const res = await app.request('/api/posts', {
      method: 'POST',
      body: JSON.stringify({ id: 123 }),
      headers: {
        'Content-Type': 'application/json',
      },
    })
    expect(res.status).toBe(200)
  })
})

describe('Multi params', () => {
  const ParamsSchema = z.object({
    id: z.string(),
    tagName: z.string(),
  })

  const route = createRoute({
    method: 'get',
    path: '/users/{id}/tags/{tagName}',
    request: {
      params: ParamsSchema,
    },
    responses: {
      200: {
        // eslint-disable-next-line quotes
        description: "Get the user's tag",
      },
    },
  })

  const app = new OpenAPIHono()

  app.openapi(route, (c) => {
    const { id, tagName } = c.req.valid('param')
    return c.json({
      id,
      tagName,
    })
  })

  it('Should return 200 response with correct contents', async () => {
    const res = await app.request('/users/123/tags/baseball')
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({
      id: '123',
      tagName: 'baseball',
    })
  })
})

describe('basePath()', () => {
  const route = createRoute({
    method: 'get',
    path: '/message',
    responses: {
      200: {
        description: 'Get message',
      },
    },
  })

  const app = new OpenAPIHono().basePath('/api')
  app.openapi(route, (c) => c.json({ message: 'Hello' }))
  app.doc('/doc', {
    openapi: '3.0.0',
    info: {
      version: '1.0.0',
      title: 'My API',
    },
  })

  it('Should return 200 response without type errors - /api/message', async () => {
    const res = await app.request('/api/message')
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({ message: 'Hello' })
  })

  it('Should return 200 response - /api/doc', async () => {
    const res = await app.request('/api/doc')
    expect(res.status).toBe(200)
  })

  it('Should retain defaultHook of the parent app', async () => {
    const defaultHook = () => {}
    const app = new OpenAPIHono({
      defaultHook,
    }).basePath('/api')
    expect(app.defaultHook).toBeDefined()
    expect(app.defaultHook).toBe(defaultHook)
  })

  it('Should include base path in typings', () => {
    const routes = new OpenAPIHono()
      .basePath('/api')
      .openapi(route, (c) => c.json({ message: 'Hello' }))

    const client = hc<typeof routes>('http://localhost/')

    expect(client.api.message.$url().pathname).toBe('/api/message')
  })

  it('Should add the base path to paths', async () => {
    const res = await app.request('/api/doc')
    expect(res.status).toBe(200)
    const data = (await res.json()) as any
    expect(Object.keys(data.paths)[0]).toBe('/api/message')
  })
})

describe('With hc', () => {
  describe('Multiple routes', () => {
    const app = new OpenAPIHono()

    const createPostRoute = createRoute({
      method: 'post',
      path: '/posts',
      operationId: 'createPost',
      responses: {
        200: {
          description: 'A post',
        },
      },
    })

    const createBookRoute = createRoute({
      method: 'post',
      path: '/books',
      operationId: 'createBook',
      responses: {
        200: {
          description: 'A book',
        },
      },
    })

    const routes = app
      .openapi(createPostRoute, (c) => {
        return c.json(0)
      })
      .openapi(createBookRoute, (c) => {
        return c.json(0)
      })

    const client = hc<typeof routes>('http://localhost/')

    it('Should return correct URL without type errors', () => {
      expect(client.posts.$url().pathname).toBe('/posts')
      expect(client.books.$url().pathname).toBe('/books')
    })
  })

  describe('defaultHook', () => {
    const app = new OpenAPIHono({
      defaultHook: (result, c) => {
        if (!result.success) {
          const res = c.json(
            {
              ok: false,
              source: 'defaultHook',
            },
            400
          )
          return res
        }
      },
    })

    const TitleSchema = z.object({
      title: z.string().openapi({}),
    })

    function errorResponse() {
      return {
        400: {
          content: {
            'application/json': {
              schema: z.object({
                ok: z.boolean().openapi({}),
                source: z.enum(['routeHook', 'defaultHook']).openapi({}),
              }),
            },
          },
          description: 'A validation error',
        },
      } satisfies RouteConfig['responses']
    }

    const createPostRoute = createRoute({
      method: 'post',
      path: '/posts',
      operationId: 'createPost',
      request: {
        body: {
          content: {
            'application/json': {
              schema: TitleSchema,
            },
          },
        },
      },
      responses: {
        200: {
          content: {
            'application/json': {
              schema: TitleSchema,
            },
          },
          description: 'A post',
        },
        ...errorResponse(),
      },
    })
    const createBookRoute = createRoute({
      method: 'post',
      path: '/books',
      operationId: 'createBook',
      request: {
        body: {
          content: {
            'application/json': {
              schema: TitleSchema,
            },
          },
        },
      },
      responses: {
        200: {
          content: {
            'application/json': {
              schema: TitleSchema,
            },
          },
          description: 'A book',
        },
        ...errorResponse(),
      },
    })

    // use the defaultHook
    app.openapi(createPostRoute, (c) => {
      const { title } = c.req.valid('json')
      return c.json({ title }, 200)
    })

    // use a routeHook
    app.openapi(
      createBookRoute,
      (c) => {
        const { title } = c.req.valid('json')
        return c.json({ title }, 200)
      },
      (result, c) => {
        if (!result.success) {
          return c.json(
            {
              ok: false,
              source: 'routeHook' as const,
            },
            400
          )
        }
      }
    )

    it('uses the defaultHook', async () => {
      const res = await app.request('/posts', {
        method: 'POST',
        body: JSON.stringify({ bad: 'property' }),
        headers: {
          'Content-Type': 'application/json',
        },
      })
      expect(res.status).toBe(400)
      expect(await res.json()).toEqual({
        ok: false,
        source: 'defaultHook',
      })
    })

    it('it uses the route hook instead of the defaultHook', async () => {
      const res = await app.request('/books', {
        method: 'POST',
        body: JSON.stringify({ bad: 'property' }),
        headers: {
          'Content-Type': 'application/json',
        },
      })
      expect(res.status).toBe(400)
      expect(await res.json()).toEqual({
        ok: false,
        source: 'routeHook',
      })
    })
  })
})

describe('It allows the response type to be Response', () => {
  const app = new OpenAPIHono()

  app.openapi(
    createRoute({
      method: 'get',
      path: '/no-content',
      responses: {
        204: {
          description: 'No Content',
        },
      },
    }),
    (c) => {
      return c.body(null, 204)
    }
  )

  it('should return a 204 response without a type error', async () => {
    const res = await app.request('/no-content')
    expect(res.status).toBe(204)
    expect(res.body).toBe(null)
  })
})

describe('Path normalization', () => {
  const createRootApp = () => {
    const app = new OpenAPIHono()
    app.doc('/doc', {
      openapi: '3.0.0',
      info: {
        version: '1.0.0',
        title: 'My API',
      },
    })
    return app
  }

  const generateRoute = (path: string) => {
    return createRoute({
      path,
      method: 'get',
      responses: {
        204: {
          description: 'No Content',
        },
      },
    })
  }

  const handler = (c: Context) => c.body(null, 204)

  describe('Duplicate slashes in the root path', () => {
    const app = createRootApp()
    const childApp = new OpenAPIHono()

    childApp.openapi(generateRoute('/child'), handler)
    app.route('/', childApp)

    it('Should remove duplicate slashes', async () => {
      const res = await app.request('/doc')
      expect(res.status).toBe(200)
      expect(await res.json()).toEqual({
        openapi: '3.0.0',
        info: {
          version: '1.0.0',
          title: 'My API',
        },
        components: {
          schemas: {},
          parameters: {},
        },
        paths: {
          '/child': {
            get: {
              responses: {
                204: {
                  description: 'No Content',
                },
              },
            },
          },
        },
      })
    })
  })

  describe('Duplicate slashes in the child path', () => {
    const app = createRootApp()
    const childApp = new OpenAPIHono()
    const grandchildApp = new OpenAPIHono()

    grandchildApp.openapi(generateRoute('/granchild'), handler)
    childApp.route('/', grandchildApp)
    app.route('/api', childApp)

    it('Should remove duplicate slashes', async () => {
      const res = await app.request('/doc')
      expect(res.status).toBe(200)
      expect(await res.json()).toEqual({
        openapi: '3.0.0',
        info: {
          version: '1.0.0',
          title: 'My API',
        },
        components: {
          schemas: {},
          parameters: {},
        },
        paths: {
          '/api/granchild': {
            get: {
              responses: {
                204: {
                  description: 'No Content',
                },
              },
            },
          },
        },
      })
    })
  })

  describe('Duplicate slashes in the trailing path', () => {
    const app = createRootApp()
    const childApp = new OpenAPIHono()
    const grandchildApp = new OpenAPIHono()

    grandchildApp.openapi(generateRoute('/'), handler)
    childApp.route('/', grandchildApp)
    app.route('/api', childApp)

    it('Should remove duplicate slashes', async () => {
      const res = await app.request('/doc')
      expect(res.status).toBe(200)
      expect(await res.json()).toEqual({
        openapi: '3.0.0',
        info: {
          version: '1.0.0',
          title: 'My API',
        },
        components: {
          schemas: {},
          parameters: {},
        },
        paths: {
          '/api': {
            get: {
              responses: {
                204: {
                  description: 'No Content',
                },
              },
            },
          },
        },
      })
    })
  })
})

describe('Context can be accessible in the doc route', () => {
  const app = new OpenAPIHono<{ Bindings: { TITLE: string } }>()

  app.openapi(
    createRoute({
      method: 'get',
      path: '/no-content',
      responses: {
        204: {
          description: 'No Content',
        },
      },
    }),
    (c) => {
      return c.body(null, 204)
    }
  )

  app.doc('/doc', (context) => ({
    openapi: '3.0.0',
    info: {
      version: '1.0.0',
      title: context.env.TITLE,
    },
  }))

  it('Should return with the title set as specified in env', async () => {
    const res = await app.request('/doc', undefined, { TITLE: 'My API' })
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({
      openapi: '3.0.0',
      info: {
        version: '1.0.0',
        title: 'My API',
      },
      components: {
        schemas: {},
        parameters: {},
      },
      paths: {
        '/no-content': {
          get: {
            responses: {
              204: {
                description: 'No Content',
              },
            },
          },
        },
      },
    })
  })
})

describe('Named params in nested routes', () => {
  const root = new OpenAPIHono()
  root.doc('/doc', () => ({
    openapi: '3.0.0',
    info: {
      version: '1.0.0',
      title: 'my API',
    },
  }))

  const sub = new OpenAPIHono()
  sub.openapi(
    createRoute({
      method: 'get',
      path: '/sub/{subId}',
      responses: {
        200: {
          description: 'Nested response',
        },
      },
    }),
    (c) => c.json({ params: c.req.param() })
  )

  root.route('/root/:rootId', sub)

  it('Should return a correct content', async () => {
    const res = await root.request('/root/123/sub/456')
    expect(res.status).toBe(200)
    const data = await res.json()
    expect(data).toEqual({
      params: {
        subId: '456',
        rootId: '123',
      },
    })
  })

  it('Should return a correct path', async () => {
    const res = await root.request('/doc')
    expect(res.status).toBe(200)
    const data = (await res.json()) as { paths: string[] }
    expect(Object.keys(data['paths'])[0]).toBe('/root/{rootId}/sub/{subId}')
  })
})

describe('Handle "Conflicting names for parameter"', () => {
  const app = new OpenAPIHono()
  app.openapi(
    createRoute({
      method: 'get',
      path: '/posts/{foo}', // should be `id`
      request: {
        params: z.object({
          id: z.string().openapi({
            param: {
              name: 'foo',
              in: 'path',
            },
          }),
        }),
      },
      responses: {
        200: {
          description: 'response',
        },
      },
    }),
    (c) => c.text('foo')
  )

  app.doc('/doc', () => ({
    openapi: '3.0.0',
    info: {
      version: '1.0.0',
      title: 'my API',
    },
  }))

  app.doc31('/doc31', () => ({
    openapi: '3.1.0',
    info: {
      version: '1.0.0',
      title: 'my API',
    },
  }))

  it('Should return a 500 response correctly - /doc', async () => {
    const res = await app.request('/doc')
    expect(res.status).toBe(500)
    const data = await res.json()
    expect(data).toEqual({
      data: {
        key: 'name',
        values: ['id', 'foo'],
      },
      message: 'Conflicting names for parameter',
    })
  })

  it('Should return a 500 response correctly - /doc31', async () => {
    const res = await app.request('/doc31')
    expect(res.status).toBe(500)
    const data = await res.json()
    expect(data).toEqual({
      data: {
        key: 'name',
        values: ['id', 'foo'],
      },
      message: 'Conflicting names for parameter',
    })
  })
})

describe('Middleware', () => {
  const app = new OpenAPIHono()
  app.openapi(
    createRoute({
      method: 'get',
      path: '/books',
      middleware: [
        (c, next) => {
          c.header('x-foo', 'bar')
          return next()
        },
      ],
      responses: {
        200: {
          description: 'response',
        },
      },
    }),
    (c) => c.text('foo')
  )

  it('Should have the header set by the middleware', async () => {
    const res = await app.request('/books')
    expect(res.status).toBe(200)
    expect(res.headers.get('x-foo')).toBe('bar')
  })
})

describe('RouteConfigToTypedResponse', () => {
  const ParamsSchema = z.object({
    id: z
      .string()
      .min(4)
      .openapi({
        param: {
          name: 'id',
          in: 'path',
        },
        example: '12345',
      }),
  })
  const UserSchema = z
    .object({
      name: z.string().openapi({
        example: 'John Doe',
      }),
      age: z.number().openapi({
        example: 42,
      }),
    })
    .openapi('User')

  const ErrorSchema = z
    .object({
      ok: z.boolean().openapi({
        example: false,
      }),
    })
    .openapi('Error')

  it('Should return types correctly', () => {
    const route = {
      method: 'post' as any,
      path: '/users/{id}',
      request: {
        params: ParamsSchema,
      },
      responses: {
        200: {
          content: {
            'application/json': {
              schema: UserSchema,
            },
          },
          description: 'Get the user',
        },
        400: {
          content: {
            'application/json': {
              schema: ErrorSchema,
            },
          },
          description: 'Error!',
        },
        '5XX': {
          content: {
            'application/json': {
              schema: ErrorSchema,
            },
          },
          description: 'Server Error!',
        },
      },
    }

    type Actual = RouteConfigToTypedResponse<typeof route>

    type Expected =
      | TypedResponse<
          {
            name: string
            age: number
          },
          200,
          'json'
        >
      | TypedResponse<
          {
            ok: boolean
          },
          400,
          'json'
        >
      | TypedResponse<
          {
            ok: boolean
          },
          ServerErrorStatusCode,
          'json'
        >
    type verify = Expect<Equal<Expected, Actual>>
  })
})

describe('Generate YAML', () => {
  it('Should generate YAML with Middleware', async () => {
    const app = new OpenAPIHono()
    app.openapi(
      createRoute({
        method: 'get',
        path: '/books',
        middleware: [
          bearerAuth({
            verifyToken: (_, __) => {
              return true
            },
          }),
        ],
        responses: {
          200: {
            description: 'Books',
            content: {
              'application/json': {
                schema: z.array(
                  z.object({
                    title: z.string(),
                  })
                ),
              },
            },
          },
        },
      }),
      (c) => c.json([{ title: 'foo' }])
    )
    const doc = app.getOpenAPI31Document({
      openapi: '3.1.0',
      info: {
        title: 'My API',
        version: '1.0.0',
      },
    })
    expect(() => stringify(doc)).to.not.throw()
  })
})
