/**
 * OAuth token endpoint security regressions — S10-002, S10-004, S10-005.
 */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Hono } from 'hono'
import * as authModule from '@zync/auth'
import {
  getOAuthClientByClientId,
  lookupAuthorizationCode,
  markAuthorizationCodeUsed,
  insertTokenPair,
} from '@zync/db/queries'
import type { AppEnv } from '../src/types'

vi.mock('@zync/db/queries', async (importOriginal) => {
  const actual = await importOriginal<typeof import('@zync/db/queries')>()
  return {
    ...actual,
    createDb: vi.fn(() => ({})),
    getOAuthClientByClientId: vi.fn(),
    lookupAuthorizationCode: vi.fn(),
    markAuthorizationCodeUsed: vi.fn(),
    insertTokenPair: vi.fn(),
    lookupRefreshToken: vi.fn(),
    rotateOAuthRefreshToken: vi.fn(),
    revokeOAuthTokenFamily: vi.fn(),
  }
})

const CLIENT_ID = 'zync-mobile'
const CLIENT_ROW_ID = 'client-row-id'
const REDIRECT_URI = 'zync://oauth/callback'
const IP = '203.0.113.10'
const CLIENT_SECRET = 'test-client-secret'

const publicClient = {
  id: CLIENT_ROW_ID,
  clientId: CLIENT_ID,
  clientSecretHash: '',
  redirectUris: [REDIRECT_URI],
}

const confidentialClient = {
  id: 'conf-client-row-id',
  clientId: 'zapier_zync',
  clientSecretHash: 'stored-secret-hash',
  redirectUris: ['https://zapier.com/callback'],
}

function makeKv() {
  const store = new Map<string, string>()
  return {
    get: vi.fn(async (key: string) => store.get(key) ?? null),
    put: vi.fn(async (key: string, value: string) => {
      store.set(key, value)
    }),
    delete: vi.fn(async (key: string) => {
      store.delete(key)
    }),
    _store: store,
  }
}

function makeEnv(kv = makeKv()) {
  return {
    HYPERDRIVE: {} as AppEnv['Bindings']['HYPERDRIVE'],
    RATELIMIT_KV: kv as unknown as KVNamespace,
    JWT_SECRET: 'test-secret',
  } as AppEnv['Bindings']
}

async function makeTokenApp() {
  const { tokenRoute } = await import('../src/routes/oauth/token')
  const app = new Hono<AppEnv>()
  app.route('/oauth', tokenRoute)
  return app
}

async function postToken(
  env: AppEnv['Bindings'],
  body: Record<string, string>,
  ip = IP,
) {
  const app = await makeTokenApp()
  const params = new URLSearchParams(body)
  const req = new Request('http://localhost/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'CF-Connecting-IP': ip,
    },
    body: params.toString(),
  })
  return app.fetch(req, env)
}

async function makePkcePair() {
  const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
  const challenge = authModule.base64urlEncode(digest)
  return { verifier, challenge }
}

describe('POST /oauth/token (S10-002 PKCE for public clients)', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    vi.mocked(getOAuthClientByClientId).mockResolvedValue(publicClient as never)
    vi.mocked(markAuthorizationCodeUsed).mockResolvedValue(true)
    vi.mocked(insertTokenPair).mockResolvedValue(undefined)
  })

  it('rejects public client code exchange when stored code has no PKCE challenge', async () => {
    vi.mocked(lookupAuthorizationCode).mockResolvedValue({
      id: 'code-id',
      oauthClientId: CLIENT_ROW_ID,
      redirectUri: REDIRECT_URI,
      usedAt: null,
      expiresAt: new Date(Date.now() + 60_000),
      codeChallenge: null,
      tenantId: 'tenant-id',
      userId: 'user-id',
      scope: 'read:customers',
    } as never)

    const res = await postToken(makeEnv(), {
      grant_type: 'authorization_code',
      client_id: CLIENT_ID,
      redirect_uri: REDIRECT_URI,
      code: 'plain-auth-code',
    })

    expect(res.status).toBe(400)
    const body = await res.json() as { error: string; error_description: string }
    expect(body.error).toBe('invalid_grant')
    expect(body.error_description).toBe('PKCE required for public clients')
    expect(markAuthorizationCodeUsed).not.toHaveBeenCalled()
  })

  it('rejects public client without code_verifier when PKCE challenge stored', async () => {
    const { challenge } = await makePkcePair()
    vi.mocked(lookupAuthorizationCode).mockResolvedValue({
      id: 'code-id',
      oauthClientId: CLIENT_ROW_ID,
      redirectUri: REDIRECT_URI,
      usedAt: null,
      expiresAt: new Date(Date.now() + 60_000),
      codeChallenge: challenge,
      tenantId: 'tenant-id',
      userId: 'user-id',
      scope: 'read:customers',
    } as never)

    const res = await postToken(makeEnv(), {
      grant_type: 'authorization_code',
      client_id: CLIENT_ID,
      redirect_uri: REDIRECT_URI,
      code: 'plain-auth-code',
    })

    expect(res.status).toBe(400)
    const body = await res.json() as { error_description: string }
    expect(body.error_description).toBe('code_verifier required')
  })
})

describe('POST /oauth/token (S10-004 rate limit on code guesses)', () => {
  const realHashToken = authModule.hashToken

  beforeEach(() => {
    vi.clearAllMocks()
    vi.mocked(getOAuthClientByClientId).mockResolvedValue(confidentialClient as never)
    vi.mocked(lookupAuthorizationCode).mockResolvedValue(null)
    vi.spyOn(authModule, 'hashToken').mockImplementation(async (plain: string) => {
      if (plain === CLIENT_SECRET) return confidentialClient.clientSecretHash
      return realHashToken(plain)
    })
    vi.spyOn(authModule, 'timingSafeEqual').mockReturnValue(true)
  })

  it('increments per client_id+IP counter on invalid authorization codes', async () => {
    const kv = makeKv()
    const env = makeEnv(kv)
    const rateLimitKey = `oauth_token:${confidentialClient.clientId}:${IP}`

    for (let i = 0; i < 20; i++) {
      const res = await postToken(env, {
        grant_type: 'authorization_code',
        client_id: confidentialClient.clientId,
        client_secret: CLIENT_SECRET,
        redirect_uri: confidentialClient.redirectUris[0]!,
        code: `guess-${i}`,
      })
      expect(res.status).toBe(400)
    }

    expect(kv._store.get(rateLimitKey)).toBe('20')

    const res = await postToken(env, {
      grant_type: 'authorization_code',
      client_id: confidentialClient.clientId,
      client_secret: CLIENT_SECRET,
      redirect_uri: confidentialClient.redirectUris[0]!,
      code: 'guess-21',
    })
    expect(res.status).toBe(429)
    const body = await res.json() as { error: string }
    expect(body.error).toBe('rate_limited')
  })
})

describe('POST /oauth/token (S10-005 authorization code hashing)', () => {
  const realHashToken = authModule.hashToken

  beforeEach(() => {
    vi.clearAllMocks()
    vi.mocked(getOAuthClientByClientId).mockResolvedValue(confidentialClient as never)
    vi.spyOn(authModule, 'hashToken').mockImplementation(async (plain: string) => {
      if (plain === CLIENT_SECRET) return confidentialClient.clientSecretHash
      return realHashToken(plain)
    })
    vi.spyOn(authModule, 'timingSafeEqual').mockReturnValue(true)
  })

  it('looks up authorization codes by SHA-256 hash, not plaintext', async () => {
    const plainCode = 'auth-code-plaintext-value'
    const codeHash = await authModule.hashToken(plainCode)

    vi.mocked(lookupAuthorizationCode).mockImplementation(async (_db, hash) => {
      if (hash === codeHash) {
        return {
          id: 'code-id',
          oauthClientId: confidentialClient.id,
          redirectUri: confidentialClient.redirectUris[0],
          usedAt: null,
          expiresAt: new Date(Date.now() + 60_000),
          codeChallenge: null,
          tenantId: 'tenant-id',
          userId: 'user-id',
          scope: 'read:customers',
        } as never
      }
      return null
    })
    vi.mocked(markAuthorizationCodeUsed).mockResolvedValue(true)
    vi.mocked(insertTokenPair).mockResolvedValue(undefined)

    const res = await postToken(makeEnv(), {
      grant_type: 'authorization_code',
      client_id: confidentialClient.clientId,
      client_secret: CLIENT_SECRET,
      redirect_uri: confidentialClient.redirectUris[0]!,
      code: plainCode,
    })

    expect(lookupAuthorizationCode).toHaveBeenCalledWith(expect.anything(), codeHash)
    expect(res.status).toBe(200)
  })
})

describe('POST /oauth/token (auth code single-use)', () => {
  const realHashToken = authModule.hashToken

  beforeEach(() => {
    vi.clearAllMocks()
    vi.mocked(getOAuthClientByClientId).mockResolvedValue(confidentialClient as never)
    vi.spyOn(authModule, 'hashToken').mockImplementation(async (plain: string) => {
      if (plain === CLIENT_SECRET) return confidentialClient.clientSecretHash
      return realHashToken(plain)
    })
    vi.spyOn(authModule, 'timingSafeEqual').mockReturnValue(true)
    vi.mocked(insertTokenPair).mockResolvedValue(undefined)
  })

  it('rejects second consume of the same authorization code', async () => {
    const plainCode = 'single-use-auth-code'
    vi.mocked(lookupAuthorizationCode).mockResolvedValue({
      id: 'code-id',
      oauthClientId: confidentialClient.id,
      redirectUri: confidentialClient.redirectUris[0],
      usedAt: null,
      expiresAt: new Date(Date.now() + 60_000),
      codeChallenge: null,
      tenantId: 'tenant-id',
      userId: 'user-id',
      scope: 'read:customers',
    } as never)

    let consumeCount = 0
    vi.mocked(markAuthorizationCodeUsed).mockImplementation(async () => {
      consumeCount++
      return consumeCount === 1
    })

    const body = {
      grant_type: 'authorization_code',
      client_id: confidentialClient.clientId,
      client_secret: CLIENT_SECRET,
      redirect_uri: confidentialClient.redirectUris[0]!,
      code: plainCode,
    }

    const res1 = await postToken(makeEnv(), body)
    expect(res1.status).toBe(200)
    expect(insertTokenPair).toHaveBeenCalledTimes(1)

    const res2 = await postToken(makeEnv(), body)
    expect(res2.status).toBe(400)
    const json = await res2.json() as { error: string; error_description: string }
    expect(json.error).toBe('invalid_grant')
    expect(json.error_description).toBe('Authorization code already used')
    expect(insertTokenPair).toHaveBeenCalledTimes(1)
  })
})
