import { Hono } from 'hono'
import { privateKeyToAccount } from 'viem/accounts'
import { parseSiweMessage } from 'viem/siwe'
import { describe, expect, test } from 'vp/test'

import * as Handler from '../../Handler.js'
import * as Kv from '../../Kv.js'
import { auth } from './auth.js'

const account = privateKeyToAccount(
  '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
)
const otherAccount = privateKeyToAccount(
  '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba',
)

describe('challenge', () => {
  test('returns challenge message with chainId, nonce, zero-address placeholder', async () => {
    const { app } = setup()

    const { status, body } = await getChallenge(app, { chainId: 1 })

    expect(status).toBe(200)
    const parsed = parseSiweMessage(body.message!)
    expect(parsed.address).toBe('0x0000000000000000000000000000000000000000')
    expect(parsed.chainId).toBe(1)
    expect(parsed.domain).toBe('wallet.example')
    expect(parsed.uri).toBe('http://wallet.example')
    expect(parsed.version).toBe('1')
    expect(parsed.nonce).toMatch(/^[a-z0-9]+$/)
  })

  test('defaults chainId to 0 when omitted', async () => {
    const { app } = setup()

    const res = await app.request('/challenge', {
      method: 'POST',
      headers: { 'content-type': 'application/json', host: 'wallet.example' },
      body: JSON.stringify({}),
    })

    expect(res.status).toBe(200)
    const { message } = (await res.json()) as { message: string }
    expect(parseSiweMessage(message).chainId).toBe(0)
  })

  test('persists the nonce in the store with TTL', async () => {
    const store = Kv.memory()
    const { app } = setup({ store })

    const { body } = await getChallenge(app, { chainId: 1 })
    const nonce = parseSiweMessage(body.message!).nonce!

    expect(await store.get(`challenge:${nonce}`)).toMatchObject({ chainId: 1 })
  })
})

describe('verify (EOA, cookie mode)', () => {
  test('default: verifies signature, sets cookie, persists session', async () => {
    const store = Kv.memory()
    const { handler, app } = setup({ store })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })

    expect(res.status).toBe(200)
    expect(await res.json()).toMatchInlineSnapshot(`{}`)

    const setCookie = res.headers.get('set-cookie')
    expect(setCookie).toContain('accounts_auth=')
    expect(setCookie).toContain('HttpOnly')
    expect(setCookie).toContain('SameSite=Lax')

    // getSession resolves the persisted payload from a follow-up request.
    const followUp = new Request('http://wallet.example/', {
      headers: { cookie: setCookie!.split(';')[0]! },
    })
    const session = await handler.getSession(followUp)
    expect(session?.address).toBe(account.address)
    expect(session?.chainId).toBe(1)

    // Session is also persisted in the store under `session:` prefix.
    const token = setCookie!.split(';')[0]!.split('=')[1]!
    expect(await store.get(`session:${token}`)).toBeDefined()
  })

  test('rejects replayed nonce with 409', async () => {
    const { app } = setup()

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const ok = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })
    expect(ok.status).toBe(200)

    const replay = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })
    expect(replay.status).toBe(409)
    expect(await replay.json()).toMatchInlineSnapshot(`
      {
        "error": "invalid or replayed nonce",
      }
    `)
  })

  test('rejects signature for a different address with 401', async () => {
    const { app } = setup()

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: otherAccount.address,
      message,
      signature,
    })
    expect(res.status).toBe(401)
    expect(await res.json()).toMatchInlineSnapshot(`
      {
        "error": "signature does not match address",
      }
    `)
  })

  test('rejects domain mismatch with 400', async () => {
    const { app } = setup()

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const tampered = challengeBody.message!.replace('wallet.example', 'evil.example')
    const signature = await account.signMessage({ message: tampered })

    const res = await postVerify(app, {
      address: account.address,
      message: tampered,
      signature,
    })
    expect(res.status).toBe(400)
    expect(await res.json()).toMatchInlineSnapshot(`
      {
        "error": "domain mismatch",
      }
    `)
  })

  test('rejects tampered message (statement injection) with 400', async () => {
    // Regression: an attacker could fetch a valid challenge, inject a
    // benign-looking `statement` into the SIWE text, get a victim to sign
    // it, and replay the signature here. The server must reject any
    // message that doesn't byte-match the challenge it issued.
    const { app } = setup()

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    // SIWE injects `statement` as the line between the address line and
    // the blank line preceding `URI:`. Splice one in.
    const tampered = message.replace(
      /(0x0000000000000000000000000000000000000000\n)\n/,
      '$1\nSign to prove you are human\n\n',
    )
    expect(tampered).not.toBe(message)
    const signature = await account.signMessage({ message: tampered })

    const res = await postVerify(app, {
      address: account.address,
      message: tampered,
      signature,
    })
    expect(res.status).toBe(400)
    expect(await res.json()).toMatchInlineSnapshot(`
      {
        "error": "message mismatch",
      }
    `)
  })

  test('rejects malformed body with 400', async () => {
    const { app } = setup()
    const res = await app.request('/', {
      method: 'POST',
      headers: { 'content-type': 'application/json', host: 'wallet.example' },
      body: '',
    })
    expect(res.status).toBe(400)
  })
})

describe('logout', () => {
  test('clears the session cookie and deletes the store entry', async () => {
    const store = Kv.memory()
    const { handler, app } = setup({ store })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const verify = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })
    const sessionCookie = verify.headers.get('set-cookie')!.split(';')[0]!
    const token = sessionCookie.split('=')[1]!

    expect(await store.get(`session:${token}`)).toBeDefined()

    const logout = await app.request('/logout', {
      method: 'POST',
      headers: { cookie: sessionCookie, host: 'wallet.example' },
    })
    expect(logout.status).toBe(204)

    const clearCookie = logout.headers.get('set-cookie')!
    expect(clearCookie).toContain('accounts_auth=')
    expect(clearCookie).toContain('Max-Age=0')

    expect(await store.get(`session:${token}`)).toBeUndefined()
    const followUp = new Request('http://wallet.example/', {
      headers: { cookie: sessionCookie },
    })
    expect(await handler.getSession(followUp)).toBeUndefined()
  })

  test('204 unconditionally even without a session cookie', async () => {
    const { app } = setup()
    const res = await app.request('/logout', {
      method: 'POST',
      headers: { host: 'wallet.example' },
    })
    expect(res.status).toBe(204)
  })
})

describe('verify (token mode)', () => {
  test('returnToken=true returns { token } in body and skips Set-Cookie', async () => {
    const store = Kv.memory()
    const { handler, app } = setup({ store })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: account.address,
      message,
      signature,
      returnToken: true,
    })

    expect(res.status).toBe(200)
    expect(res.headers.get('set-cookie')).toBeNull()

    const { token } = (await res.json()) as { token: string }
    expect(token).toMatch(/^[a-z0-9]+$/)

    expect(await store.get(`session:${token}`)).toBeDefined()

    // Bearer-mode getSession resolves the token.
    const followUp = new Request('http://wallet.example/', {
      headers: { authorization: `Bearer ${token}` },
    })
    const session = await handler.getSession(followUp)
    expect(session?.address).toBe(account.address)
  })
})

describe('cookie: false', () => {
  test('verify always returns { token } in body and never sets a cookie', async () => {
    const store = Kv.memory()
    const { handler, app } = setup({ cookie: false, store })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    // Even without `returnToken: true`, the body carries the token and
    // no Set-Cookie is emitted.
    const res = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })

    expect(res.status).toBe(200)
    expect(res.headers.get('set-cookie')).toBeNull()

    const { token } = (await res.json()) as { token: string }
    expect(token).toMatch(/^[a-z0-9]+$/)
    expect(await store.get(`session:${token}`)).toBeDefined()

    const followUp = new Request('http://wallet.example/', {
      headers: { authorization: `Bearer ${token}` },
    })
    expect((await handler.getSession(followUp))?.address).toBe(account.address)
  })

  test('getSession ignores cookies even when present', async () => {
    const store = Kv.memory()
    const { handler, app } = setup({ cookie: false, store })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })
    const verify = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })
    const { token } = (await verify.json()) as { token: string }

    // Cookie carrying the very token that's valid in the store — but
    // cookie mode is disabled, so it must not resolve a session.
    const req = new Request('http://wallet.example/', {
      headers: { cookie: `accounts_auth=${token}` },
    })
    expect(await handler.getSession(req)).toBeUndefined()

    // Bearer mode still works against the same token.
    const bearerReq = new Request('http://wallet.example/', {
      headers: { authorization: `Bearer ${token}` },
    })
    expect((await handler.getSession(bearerReq))?.address).toBe(account.address)
  })

  test('logout via Authorization: Bearer revokes the session and skips Set-Cookie', async () => {
    const store = Kv.memory()
    const { handler, app } = setup({ cookie: false, store })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })
    const verify = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })
    const { token } = (await verify.json()) as { token: string }
    expect(await store.get(`session:${token}`)).toBeDefined()

    const logout = await app.request('/logout', {
      method: 'POST',
      headers: { host: 'wallet.example', authorization: `Bearer ${token}` },
    })
    expect(logout.status).toBe(204)
    expect(logout.headers.get('set-cookie')).toBeNull()

    expect(await store.get(`session:${token}`)).toBeUndefined()
    const followUp = new Request('http://wallet.example/', {
      headers: { authorization: `Bearer ${token}` },
    })
    expect(await handler.getSession(followUp)).toBeUndefined()
  })
})

describe('session: false', () => {
  test('verify returns {} with no token, no Set-Cookie, no store write', async () => {
    const store = Kv.memory()
    const { handler, app } = setup({ session: false, store })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })

    expect(res.status).toBe(200)
    expect(await res.json()).toMatchInlineSnapshot(`{}`)
    expect(res.headers.get('set-cookie')).toBeNull()

    // getSession always resolves undefined; even a manually-seeded
    // session token must not leak through.
    await store.set('session:tok', { address: account.address }, { ttl: 60 })
    const req = new Request('http://wallet.example/', {
      headers: { authorization: 'Bearer tok' },
    })
    expect(await handler.getSession(req)).toBeUndefined()
  })

  test('returnToken: true is ignored when session: false', async () => {
    const { app } = setup({ session: false })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: account.address,
      message,
      signature,
      returnToken: true,
    })

    expect(res.status).toBe(200)
    expect(await res.json()).toMatchInlineSnapshot(`{}`)
  })

  test('logout route is not mounted (returns 404)', async () => {
    const store = Kv.memory()
    const { app } = setup({ session: false, store })

    await store.set('session:tok', { address: account.address }, { ttl: 60 })

    const res = await app.request('/logout', {
      method: 'POST',
      headers: { host: 'wallet.example', authorization: 'Bearer tok' },
    })
    expect(res.status).toBe(404)
    // The seeded entry survives — no logout route, nothing to delete.
    expect(await store.get('session:tok')).toBeDefined()
  })

  test('onAuthenticate still runs and can reject before the short-circuit', async () => {
    let invoked = false
    const { app } = setup({
      session: false,
      onAuthenticate: () => {
        invoked = true
        throw new Error('blocked')
      },
    })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })

    expect(res.status).toBe(401)
    expect(invoked).toBe(true)
    expect(await res.json()).toMatchInlineSnapshot(`
      {
        "error": "blocked",
      }
    `)
  })
})

describe('onAuthenticate', () => {
  test('invoked with verified address, chainId, message, signature, request', async () => {
    const calls: Array<{
      address: string
      chainId: number
      message: string
      signature: string
      requestUrl: string
    }> = []

    const { app } = setup({
      onAuthenticate: ({ address, chainId, message, signature, request }) => {
        calls.push({
          address,
          chainId,
          message,
          signature,
          requestUrl: request.url,
        })
      },
    })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })

    expect(res.status).toBe(200)
    expect(calls).toHaveLength(1)
    expect(calls[0]).toMatchObject({
      address: account.address,
      chainId: 1,
      message,
      signature,
    })
    expect(calls[0]?.requestUrl).toContain('/')
  })

  test('not invoked when signature verification fails', async () => {
    let invoked = false
    const { app } = setup({
      onAuthenticate: () => {
        invoked = true
      },
    })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: otherAccount.address,
      message,
      signature,
    })

    expect(res.status).toBe(401)
    expect(invoked).toBe(false)
  })

  test('throwing rejects the request with 401 and surfaces the error message', async () => {
    const store = Kv.memory()
    const { app } = setup({
      store,
      onAuthenticate: () => {
        throw new Error('user is blocked')
      },
    })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })

    expect(res.status).toBe(401)
    expect(await res.json()).toMatchInlineSnapshot(`
      {
        "error": "user is blocked",
      }
    `)
    expect(res.headers.get('set-cookie')).toBeNull()
  })

  test('returning a Response merges body fields and status onto the verify response', async () => {
    const { app } = setup({
      onAuthenticate: () => Response.json({ jwt: 'eyJ...', userId: 'u_42' }, { status: 201 }),
    })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: account.address,
      message,
      signature,
      returnToken: true,
    })

    expect(res.status).toBe(201)
    const { token, ...rest } = (await res.json()) as Record<string, unknown>
    expect(token).toMatch(/^[a-z0-9]+$/)
    expect(rest).toMatchInlineSnapshot(`
      {
        "jwt": "eyJ...",
        "userId": "u_42",
      }
    `)
  })

  test('returned Response without `session: false` still issues a session token', async () => {
    const store = Kv.memory()
    const { handler, app } = setup({
      store,
      onAuthenticate: () => Response.json({ extra: 'meta' }),
    })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const res = await postVerify(app, {
      address: account.address,
      message,
      signature,
    })

    expect(res.status).toBe(200)
    expect(((await res.json()) as Record<string, unknown>).extra).toBe('meta')

    const setCookie = res.headers.get('set-cookie')
    expect(setCookie).toContain('accounts_auth=')
    const token = setCookie!.split(';')[0]!.split('=')[1]!
    expect(await store.get(`session:${token}`)).toBeDefined()

    // getSession resolves the issued session via the cookie.
    const followUp = new Request('http://wallet.example/', {
      headers: { cookie: setCookie!.split(';')[0]! },
    })
    const sessionPayload = await handler.getSession(followUp)
    expect(sessionPayload?.address).toBe(account.address)
  })

  test('async hook is awaited before issuing the session', async () => {
    let resolveHook: (() => void) | undefined
    const blocker = new Promise<void>((resolve) => {
      resolveHook = resolve
    })
    let hookFinished = false

    const { app } = setup({
      onAuthenticate: async () => {
        await blocker
        hookFinished = true
      },
    })

    const { body: challengeBody } = await getChallenge(app, { chainId: 1 })
    const message = challengeBody.message!
    const signature = await account.signMessage({ message })

    const verifyPromise = postVerify(app, {
      address: account.address,
      message,
      signature,
    })

    // Yield once to let verify dispatch into the hook.
    await new Promise((r) => setTimeout(r, 10))
    expect(hookFinished).toBe(false)

    resolveHook!()
    const res = await verifyPromise
    expect(res.status).toBe(200)
    expect(hookFinished).toBe(true)
  })
})

describe('getSession', () => {
  test('returns undefined when no cookie is present', async () => {
    const { handler } = setup()
    const req = new Request('http://wallet.example/')
    expect(await handler.getSession(req)).toBeUndefined()
  })

  test('prefers Authorization: Bearer over cookie', async () => {
    const store = Kv.memory()
    const { handler, app } = setup({ store })

    // Issue session #1 via cookie mode.
    const ch1 = await getChallenge(app, { chainId: 1 })
    const sig1 = await account.signMessage({ message: ch1.body.message! })
    const v1 = await postVerify(app, {
      address: account.address,
      message: ch1.body.message!,
      signature: sig1,
    })
    const cookie = v1.headers.get('set-cookie')!.split(';')[0]!

    // Issue session #2 via token mode for a different address.
    const ch2 = await getChallenge(app, { chainId: 1 })
    const sig2 = await otherAccount.signMessage({ message: ch2.body.message! })
    const v2 = await postVerify(app, {
      address: otherAccount.address,
      message: ch2.body.message!,
      signature: sig2,
      returnToken: true,
    })
    const { token } = (await v2.json()) as { token: string }

    // When both are present, the bearer wins.
    const req = new Request('http://wallet.example/', {
      headers: { cookie, authorization: `Bearer ${token}` },
    })
    const session = await handler.getSession(req)
    expect(session?.address).toBe(otherAccount.address)
  })
})

describe('store: atomic `take` preferred, non-atomic fallback', () => {
  test('Kv.memory() (has `take`) is accepted', () => {
    expect(() => auth({ store: Kv.memory() })).not.toThrow()
  })

  test('store without `take` falls back to non-atomic get + delete', async () => {
    // The fallback path is racy on eventually-consistent stores but
    // works correctly in single-process serial usage. Verify the
    // handler still constructs and the verify endpoint can consume a
    // challenge end-to-end.
    const noTake: Kv.Kv = (() => {
      const map = new Map<string, unknown>()
      return {
        async get(key) {
          return map.get(key) as never
        },
        async set(key, value) {
          map.set(key, value)
        },
        async delete(key) {
          map.delete(key)
        },
      }
    })()
    const handler = auth({ domain: 'wallet.example', store: noTake })
    const app = new Hono()
    app.route('/', handler)

    const challenge = await app.request('/challenge', {
      method: 'POST',
      headers: { 'content-type': 'application/json', host: 'wallet.example' },
      body: JSON.stringify({ chainId: 1 }),
    })
    expect(challenge.status).toBe(200)
  })
})

describe('origin / trustProxy', () => {
  test('default: ignores `x-forwarded-host` and `x-forwarded-proto`', async () => {
    // No domain pin — relies on host header. trustProxy defaults to false.
    const handler = auth()
    const app = new Hono()
    app.route('/', handler)

    const res = await app.request('/challenge', {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        host: 'real.example',
        'x-forwarded-host': 'attacker.example',
        'x-forwarded-proto': 'http',
      },
      body: JSON.stringify({ chainId: 1 }),
    })
    const body = (await res.json()) as { message: string }
    const parsed = parseSiweMessage(body.message)
    expect(parsed.domain).toBe('real.example')
  })

  test('trustProxy: true → honors `x-forwarded-host` and `x-forwarded-proto`', async () => {
    const handler = auth({ trustProxy: true })
    const app = new Hono()
    app.route('/', handler)

    const res = await app.request('/challenge', {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        host: 'internal.example',
        'x-forwarded-host': 'app.example',
        'x-forwarded-proto': 'https',
      },
      body: JSON.stringify({ chainId: 1 }),
    })
    const body = (await res.json()) as { message: string }
    const parsed = parseSiweMessage(body.message)
    expect(parsed.domain).toBe('app.example')
    expect(parsed.uri).toBe('https://app.example')
  })

  test('origin: pinned origin overrides host and forwarded headers', async () => {
    const handler = auth({
      origin: 'https://app.example.com',
      trustProxy: true,
    })
    const app = new Hono()
    app.route('/', handler)

    const res = await app.request('/challenge', {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        host: 'internal.example',
        'x-forwarded-host': 'attacker.example',
        'x-forwarded-proto': 'http',
      },
      body: JSON.stringify({ chainId: 1 }),
    })
    const body = (await res.json()) as { message: string }
    const parsed = parseSiweMessage(body.message)
    expect(parsed.domain).toBe('app.example.com')
    expect(parsed.uri).toBe('https://app.example.com')
  })

  test('origin: invalid URL throws at construction time', async () => {
    expect(() => auth({ origin: 'not-a-url' })).toThrowErrorMatchingInlineSnapshot(
      `[Error: \`auth({ origin })\` must be a valid absolute URL. Got: not-a-url]`,
    )
  })

  test('default trustProxy: true on Cloudflare Workers runtime', async () => {
    // Spoof the Cloudflare Workers runtime marker.
    const originalNavigator = globalThis.navigator
    Object.defineProperty(globalThis, 'navigator', {
      value: { userAgent: 'Cloudflare-Workers' },
      configurable: true,
    })
    try {
      const handler = auth()
      const app = new Hono()
      app.route('/', handler)

      const res = await app.request('/challenge', {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          host: 'internal.example',
          'x-forwarded-host': 'app.example',
          'x-forwarded-proto': 'https',
        },
        body: JSON.stringify({ chainId: 1 }),
      })
      const body = (await res.json()) as { message: string }
      const parsed = parseSiweMessage(body.message)
      expect(parsed.domain).toBe('app.example')
      expect(parsed.uri).toBe('https://app.example')
    } finally {
      Object.defineProperty(globalThis, 'navigator', {
        value: originalNavigator,
        configurable: true,
      })
    }
  })
})

describe('Handler.compose integration', () => {
  test('mounts under a custom path and routes correctly', async () => {
    const composed = Handler.compose([auth({ domain: 'wallet.example' })], {
      path: '/api/auth',
    })

    const challengeRes = await composed.request('/api/auth/challenge', {
      method: 'POST',
      headers: { 'content-type': 'application/json', host: 'wallet.example' },
      body: JSON.stringify({ chainId: 1 }),
    })
    expect(challengeRes.status).toBe(200)

    const notFound = await composed.request('/api/auth/whatever', {
      method: 'GET',
      headers: { host: 'wallet.example' },
    })
    expect(notFound.status).toBe(404)
  })
})

function setup(options: Parameters<typeof auth>[0] = {}) {
  const handler = auth({ domain: 'wallet.example', ...options })
  // Mount under '/' so tests hit /challenge, /, /logout directly.
  const app = new Hono()
  app.route('/', handler)
  return { handler, app }
}

async function getChallenge(app: Hono, body: { chainId: number }) {
  const res = await app.request('/challenge', {
    method: 'POST',
    headers: { 'content-type': 'application/json', host: 'wallet.example' },
    body: JSON.stringify(body),
  })
  return { status: res.status, body: (await res.json()) as { message?: string; error?: string } }
}

async function postVerify(
  app: Hono,
  body: {
    address: string
    message: string
    signature: string
    returnToken?: boolean
  },
) {
  const res = await app.request('/', {
    method: 'POST',
    headers: { 'content-type': 'application/json', host: 'wallet.example' },
    body: JSON.stringify(body),
  })
  return res
}
