import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { refreshOAuthTokenIfExpired } from '../refresh'
import type { OAuthToken } from '../../types'

describe('refreshOAuthTokenIfExpired', () => {
  // Save original fetch
  const originalFetch = global.fetch

  beforeEach(() => {
    // Mock environment variables
    process.env.DROPBOX_APP_KEY = 'test-dropbox-app-key'
    process.env.DROPBOX_APP_SECRET = 'test-dropbox-app-secret'
    process.env.GOOGLE_OAUTH_CLIENT_ID = 'test-google-client-id'
    process.env.GOOGLE_OAUTH_CLIENT_SECRET = 'test-google-client-secret'
  })

  afterEach(() => {
    // Restore original fetch
    global.fetch = originalFetch
    vi.restoreAllMocks()
  })

  describe('token expiry check', () => {
    it('should return token unchanged if still valid (more than 5 min remaining)', async () => {
      const validToken: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'valid-access-token',
        refreshToken: 'valid-refresh-token',
        expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 min in future
        tokenType: 'Bearer',
        scope: 'read write'
      }

      const result = await refreshOAuthTokenIfExpired(validToken)
      expect(result).toBe(validToken) // Should return same object reference
    })

    it('should refresh token if expires within 5 minutes', async () => {
      const expiringSoonToken: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'expiring-access-token',
        refreshToken: 'valid-refresh-token',
        expiresAt: new Date(Date.now() + 4 * 60 * 1000).toISOString(), // 4 min in future
        tokenType: 'Bearer'
      }

      // Mock successful refresh
      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new-access-token',
          refresh_token: 'new-refresh-token',
          expires_in: 3600
        })
      })

      const result = await refreshOAuthTokenIfExpired(expiringSoonToken)
      expect(result.accessToken).toBe('new-access-token')
      expect(global.fetch).toHaveBeenCalledTimes(1)
    })

    it('should refresh token if already expired', async () => {
      const expiredToken: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'expired-access-token',
        refreshToken: 'valid-refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 min in past
        tokenType: 'Bearer'
      }

      // Mock successful refresh
      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'refreshed-access-token',
          refresh_token: 'refreshed-refresh-token',
          expires_in: 3600
        })
      })

      const result = await refreshOAuthTokenIfExpired(expiredToken)
      expect(result.accessToken).toBe('refreshed-access-token')
    })
  })

  describe('validation', () => {
    it('should throw if token missing provider field', async () => {
      const tokenWithoutProvider = {
        accessToken: 'test-access-token',
        refreshToken: 'test-refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      } as OAuthToken

      await expect(refreshOAuthTokenIfExpired(tokenWithoutProvider)).rejects.toThrow(
        'Token missing provider field - cannot refresh'
      )
    })

    it('should throw if token missing refresh token', async () => {
      const tokenWithoutRefreshToken: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'test-access-token',
        refreshToken: '', // Empty refresh token
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      await expect(refreshOAuthTokenIfExpired(tokenWithoutRefreshToken)).rejects.toThrow(
        'Token cannot be refreshed (no refresh_token). User must re-authorize.'
      )
    })

    it('should throw for unknown provider', async () => {
      const tokenWithUnknownProvider: OAuthToken = {
        provider: 'unknown-provider',
        accessToken: 'test-access-token',
        refreshToken: 'test-refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      await expect(refreshOAuthTokenIfExpired(tokenWithUnknownProvider)).rejects.toThrow(
        'Unknown OAuth provider: unknown-provider'
      )
    })
  })

  describe('exchange method: form-encoded (Dropbox)', () => {
    it('should use form-encoded body for Dropbox', async () => {
      const dropboxToken: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'dropbox-refresh-123',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new-dropbox-token',
          refresh_token: 'new-refresh-token',
          expires_in: 3600
        })
      })
      global.fetch = mockFetch

      await refreshOAuthTokenIfExpired(dropboxToken)

      expect(mockFetch).toHaveBeenCalledTimes(1)
      const [url, options] = mockFetch.mock.calls[0]

      // Verify URL
      expect(url).toBe('https://api.dropboxapi.com/oauth2/token')

      // Verify Content-Type
      expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded')

      // Verify body is URLSearchParams format
      const params = new URLSearchParams(options.body)
      expect(params.get('grant_type')).toBe('refresh_token')
      expect(params.get('refresh_token')).toBe('dropbox-refresh-123')
      expect(params.get('client_id')).toBe('test-dropbox-app-key')
      expect(params.get('client_secret')).toBe('test-dropbox-app-secret')
    })

    it('should handle Dropbox successful refresh', async () => {
      const dropboxToken: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token-123',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer',
        scope: 'files.content.read'
      }

      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new-dropbox-token',
          refresh_token: 'new-dropbox-refresh',
          expires_in: 7200
        })
      })

      const result = await refreshOAuthTokenIfExpired(dropboxToken)

      expect(result.provider).toBe('dropbox')
      expect(result.accessToken).toBe('new-dropbox-token')
      expect(result.refreshToken).toBe('new-dropbox-refresh')
      expect(result.tokenType).toBe('Bearer')
      expect(result.scope).toBe('files.content.read') // Preserved from original
    })
  })

  describe('exchange method: form-encoded (Google Sheets)', () => {
    it('should use form-encoded body for Google Sheets', async () => {
      const googleToken: OAuthToken = {
        provider: 'google-sheets',
        accessToken: 'old-google-token',
        refreshToken: 'google-refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new-google-token',
          refresh_token: 'new-google-refresh',
          expires_in: 3600
        })
      })
      global.fetch = mockFetch

      await refreshOAuthTokenIfExpired(googleToken)

      expect(mockFetch).toHaveBeenCalledTimes(1)
      const [url, options] = mockFetch.mock.calls[0]

      // Verify URL
      expect(url).toBe('https://oauth2.googleapis.com/token')

      // Verify Content-Type
      expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded')

      // Verify body is URLSearchParams format
      const params = new URLSearchParams(options.body)
      expect(params.get('grant_type')).toBe('refresh_token')
      expect(params.get('refresh_token')).toBe('google-refresh-token')
      expect(params.get('client_id')).toBe('test-google-client-id')
      expect(params.get('client_secret')).toBe('test-google-client-secret')
    })
  })

  describe('defensive normalization', () => {
    it('should handle RFC 6749-compliant snake_case response', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new-token-snake',
          refresh_token: 'new-refresh-snake',
          expires_in: 3600
        })
      })

      const result = await refreshOAuthTokenIfExpired(token)

      expect(result.accessToken).toBe('new-token-snake')
      expect(result.refreshToken).toBe('new-refresh-snake')
    })

    it('should handle non-compliant camelCase response', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          accessToken: 'new-token-camel',
          refreshToken: 'new-refresh-camel',
          expiresIn: 7200
        })
      })

      const result = await refreshOAuthTokenIfExpired(token)

      expect(result.accessToken).toBe('new-token-camel')
      expect(result.refreshToken).toBe('new-refresh-camel')
    })

    it('should prefer snake_case over camelCase when both present', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'snake-case-token',
          accessToken: 'camel-case-token',
          refresh_token: 'snake-case-refresh',
          refreshToken: 'camel-case-refresh',
          expires_in: 3600
        })
      })

      const result = await refreshOAuthTokenIfExpired(token)

      // Should prefer snake_case (RFC 6749 standard)
      expect(result.accessToken).toBe('snake-case-token')
      expect(result.refreshToken).toBe('snake-case-refresh')
    })

    it('should throw if access_token missing in response', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          // Missing access_token field
          refresh_token: 'new-refresh',
          expires_in: 3600
        })
      })

      await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
        'Invalid token refresh response: missing access_token'
      )
    })

    it('should use default expires_in if missing', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      const beforeRefresh = Date.now()

      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new-token',
          refresh_token: 'new-refresh'
          // Missing expires_in - should default to 3600
        })
      })

      const result = await refreshOAuthTokenIfExpired(token)

      const afterRefresh = Date.now()
      const expiresAt = new Date(result.expiresAt).getTime()

      // Should default to 3600 seconds (1 hour)
      const expectedExpiry = beforeRefresh + 3600 * 1000
      expect(expiresAt).toBeGreaterThanOrEqual(expectedExpiry - 100) // Allow 100ms tolerance
      expect(expiresAt).toBeLessThanOrEqual(afterRefresh + 3600 * 1000)
    })

    it('should reuse existing refresh_token if provider does not return new one', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'original-refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new-token',
          // No refresh_token in response - some providers don't return new one
          expires_in: 3600
        })
      })

      const result = await refreshOAuthTokenIfExpired(token)

      expect(result.accessToken).toBe('new-token')
      expect(result.refreshToken).toBe('original-refresh-token') // Reused
    })
  })

  describe('error handling', () => {
    it('should parse OAuth error response with error and error_description', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'invalid-refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      global.fetch = vi.fn().mockResolvedValue({
        ok: false,
        status: 400,
        json: async () => ({
          error: 'invalid_request',
          error_description: 'The refresh token is invalid or expired'
        })
      })

      await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
        'Failed to refresh OAuth token for dropbox: 400 - invalid_request: The refresh token is invalid or expired'
      )
    })

    it('should identify invalid_grant errors', async () => {
      const token: OAuthToken = {
        provider: 'google-sheets',
        accessToken: 'old-token',
        refreshToken: 'revoked-refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      global.fetch = vi.fn().mockResolvedValue({
        ok: false,
        status: 400,
        json: async () => ({
          error: 'invalid_grant',
          error_description: 'The refresh token was revoked'
        })
      })

      await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
        'invalid_grant: The refresh token was revoked (User must re-authorize)'
      )
    })

    it('should handle non-JSON error responses', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      global.fetch = vi.fn().mockResolvedValue({
        ok: false,
        status: 503,
        statusText: 'Service Unavailable',
        json: async () => {
          throw new Error('Not JSON')
        }
      })

      await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
        'Failed to refresh OAuth token for dropbox: 503 - Service Unavailable'
      )
    })

    it('should handle network errors', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))

      await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow('Network error')
    })
  })

  describe('token field preservation', () => {
    it('should preserve all original token fields', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer',
        scope: 'read write',
        // Custom fields
        customField: 'custom-value',
        metadata: { userId: '12345' }
      }

      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new-token',
          refresh_token: 'new-refresh',
          expires_in: 3600
        })
      })

      const result = await refreshOAuthTokenIfExpired(token)

      // Updated fields
      expect(result.accessToken).toBe('new-token')
      expect(result.refreshToken).toBe('new-refresh')

      // Preserved fields
      expect(result.provider).toBe('dropbox')
      expect(result.tokenType).toBe('Bearer')
      expect(result.scope).toBe('read write')
      expect(result.customField).toBe('custom-value')
      expect(result.metadata).toEqual({ userId: '12345' })
    })

    it('should update expiresAt correctly', async () => {
      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      const beforeRefresh = Date.now()

      global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new-token',
          refresh_token: 'new-refresh',
          expires_in: 7200 // 2 hours
        })
      })

      const result = await refreshOAuthTokenIfExpired(token)

      const afterRefresh = Date.now()
      const expiresAt = new Date(result.expiresAt).getTime()

      // Should be approximately 2 hours from now
      const expectedExpiry = beforeRefresh + 7200 * 1000
      expect(expiresAt).toBeGreaterThanOrEqual(expectedExpiry - 100) // Allow 100ms tolerance
      expect(expiresAt).toBeLessThanOrEqual(afterRefresh + 7200 * 1000)
    })
  })

  describe('environment variable errors', () => {
    it('should throw if CLIENT_ID missing', async () => {
      delete process.env.DROPBOX_APP_KEY

      const token: OAuthToken = {
        provider: 'dropbox',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow('Missing environment variable: DROPBOX_APP_KEY')
    })

    it('should throw if CLIENT_SECRET missing', async () => {
      delete process.env.GOOGLE_OAUTH_CLIENT_SECRET

      const token: OAuthToken = {
        provider: 'google-sheets',
        accessToken: 'old-token',
        refreshToken: 'refresh-token',
        expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
        tokenType: 'Bearer'
      }

      await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
        'Missing environment variable: GOOGLE_OAUTH_CLIENT_SECRET'
      )
    })
  })
})
