import { createEnvMock, createMockFetch } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/core/config/env', () =>
  createEnvMock({
    GOOGLE_CLIENT_ID: 'google_client_id',
    GOOGLE_CLIENT_SECRET: 'google_client_secret',
    GITHUB_CLIENT_ID: 'github_client_id',
    GITHUB_CLIENT_SECRET: 'github_client_secret',
    X_CLIENT_ID: 'x_client_id',
    X_CLIENT_SECRET: 'x_client_secret',
    CONFLUENCE_CLIENT_ID: 'confluence_client_id',
    CONFLUENCE_CLIENT_SECRET: 'confluence_client_secret',
    JIRA_CLIENT_ID: 'jira_client_id',
    JIRA_CLIENT_SECRET: 'jira_client_secret',
    AIRTABLE_CLIENT_ID: 'airtable_client_id',
    AIRTABLE_CLIENT_SECRET: 'airtable_client_secret',
    NOTION_CLIENT_ID: 'notion_client_id',
    NOTION_CLIENT_SECRET: 'notion_client_secret',
    MICROSOFT_CLIENT_ID: 'microsoft_client_id',
    MICROSOFT_CLIENT_SECRET: 'microsoft_client_secret',
    LINEAR_CLIENT_ID: 'linear_client_id',
    LINEAR_CLIENT_SECRET: 'linear_client_secret',
    SLACK_CLIENT_ID: 'slack_client_id',
    SLACK_CLIENT_SECRET: 'slack_client_secret',
    REDDIT_CLIENT_ID: 'reddit_client_id',
    REDDIT_CLIENT_SECRET: 'reddit_client_secret',
    DROPBOX_CLIENT_ID: 'dropbox_client_id',
    DROPBOX_CLIENT_SECRET: 'dropbox_client_secret',
    WEALTHBOX_CLIENT_ID: 'wealthbox_client_id',
    WEALTHBOX_CLIENT_SECRET: 'wealthbox_client_secret',
    WEBFLOW_CLIENT_ID: 'webflow_client_id',
    WEBFLOW_CLIENT_SECRET: 'webflow_client_secret',
    ASANA_CLIENT_ID: 'asana_client_id',
    ASANA_CLIENT_SECRET: 'asana_client_secret',
    PIPEDRIVE_CLIENT_ID: 'pipedrive_client_id',
    PIPEDRIVE_CLIENT_SECRET: 'pipedrive_client_secret',
    HUBSPOT_CLIENT_ID: 'hubspot_client_id',
    HUBSPOT_CLIENT_SECRET: 'hubspot_client_secret',
    LINKEDIN_CLIENT_ID: 'linkedin_client_id',
    LINKEDIN_CLIENT_SECRET: 'linkedin_client_secret',
    SALESFORCE_CLIENT_ID: 'salesforce_client_id',
    SALESFORCE_CLIENT_SECRET: 'salesforce_client_secret',
    SHOPIFY_CLIENT_ID: 'shopify_client_id',
    SHOPIFY_CLIENT_SECRET: 'shopify_client_secret',
    ZOOM_CLIENT_ID: 'zoom_client_id',
    ZOOM_CLIENT_SECRET: 'zoom_client_secret',
    WORDPRESS_CLIENT_ID: 'wordpress_client_id',
    WORDPRESS_CLIENT_SECRET: 'wordpress_client_secret',
    SPOTIFY_CLIENT_ID: 'spotify_client_id',
    SPOTIFY_CLIENT_SECRET: 'spotify_client_secret',
  })
)

import { refreshOAuthToken } from '@/lib/oauth'

/**
 * Default OAuth token response for successful requests.
 */
const defaultOAuthResponse = {
  ok: true,
  json: {
    access_token: 'new_access_token',
    expires_in: 3600,
    refresh_token: 'new_refresh_token',
  },
}

/**
 * Helper to run a function with a mocked global fetch.
 */
function withMockFetch<T>(mockFetch: ReturnType<typeof vi.fn>, fn: () => Promise<T>): Promise<T> {
  const originalFetch = global.fetch
  global.fetch = mockFetch
  return fn().finally(() => {
    global.fetch = originalFetch
  })
}

describe('OAuth Token Refresh', () => {
  describe('Basic Auth Providers', () => {
    const basicAuthProviders = [
      {
        name: 'Airtable',
        providerId: 'airtable',
        endpoint: 'https://airtable.com/oauth2/v1/token',
      },
      { name: 'X (Twitter)', providerId: 'x', endpoint: 'https://api.x.com/2/oauth2/token' },
      {
        name: 'Confluence',
        providerId: 'confluence',
        endpoint: 'https://auth.atlassian.com/oauth/token',
      },
      { name: 'Jira', providerId: 'jira', endpoint: 'https://auth.atlassian.com/oauth/token' },
      { name: 'Linear', providerId: 'linear', endpoint: 'https://api.linear.app/oauth/token' },
      {
        name: 'Reddit',
        providerId: 'reddit',
        endpoint: 'https://www.reddit.com/api/v1/access_token',
      },
      {
        name: 'Asana',
        providerId: 'asana',
        endpoint: 'https://app.asana.com/-/oauth_token',
      },
      {
        name: 'Zoom',
        providerId: 'zoom',
        endpoint: 'https://zoom.us/oauth/token',
      },
      {
        name: 'Spotify',
        providerId: 'spotify',
        endpoint: 'https://accounts.spotify.com/api/token',
      },
    ]

    basicAuthProviders.forEach(({ name, providerId, endpoint }) => {
      it.concurrent(
        `should send ${name} request with Basic Auth header and no credentials in body`,
        async () => {
          const mockFetch = createMockFetch(defaultOAuthResponse)
          const refreshToken = 'test_refresh_token'

          await withMockFetch(mockFetch, () => refreshOAuthToken(providerId, refreshToken))

          expect(mockFetch).toHaveBeenCalledWith(
            endpoint,
            expect.objectContaining({
              method: 'POST',
              headers: expect.objectContaining({
                'Content-Type': 'application/x-www-form-urlencoded',
                Authorization: expect.stringMatching(/^Basic /),
              }),
              body: expect.any(String),
            })
          )

          const [, requestOptions] = mockFetch.mock.calls[0] as [
            string,
            { headers: Record<string, string>; body: string },
          ]

          const authHeader = requestOptions.headers.Authorization
          expect(authHeader).toMatch(/^Basic /)

          const base64Credentials = authHeader.replace('Basic ', '')
          const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8')
          const [clientId, clientSecret] = credentials.split(':')

          expect(clientId).toBe(`${providerId}_client_id`)
          expect(clientSecret).toBe(`${providerId}_client_secret`)

          const bodyParams = new URLSearchParams(requestOptions.body)
          const bodyKeys = Array.from(bodyParams.keys())

          expect(bodyKeys).toEqual(['grant_type', 'refresh_token'])
          expect(bodyParams.get('grant_type')).toBe('refresh_token')
          expect(bodyParams.get('refresh_token')).toBe(refreshToken)

          expect(bodyParams.get('client_id')).toBeNull()
          expect(bodyParams.get('client_secret')).toBeNull()
        }
      )
    })
  })

  describe('Body Credential Providers', () => {
    const bodyCredentialProviders = [
      { name: 'Google', providerId: 'google', endpoint: 'https://oauth2.googleapis.com/token' },
      {
        name: 'Microsoft',
        providerId: 'microsoft',
        endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
      },
      {
        name: 'Outlook',
        providerId: 'outlook',
        endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
      },
      { name: 'Slack', providerId: 'slack', endpoint: 'https://slack.com/api/oauth.v2.access' },
      {
        name: 'Dropbox',
        providerId: 'dropbox',
        endpoint: 'https://api.dropboxapi.com/oauth2/token',
      },
      {
        name: 'Wealthbox',
        providerId: 'wealthbox',
        endpoint: 'https://app.crmworkspace.com/oauth/token',
      },
      {
        name: 'Webflow',
        providerId: 'webflow',
        endpoint: 'https://api.webflow.com/oauth/access_token',
      },
      {
        name: 'Pipedrive',
        providerId: 'pipedrive',
        endpoint: 'https://oauth.pipedrive.com/oauth/token',
      },
      {
        name: 'HubSpot',
        providerId: 'hubspot',
        endpoint: 'https://api.hubapi.com/oauth/v1/token',
      },
      {
        name: 'LinkedIn',
        providerId: 'linkedin',
        endpoint: 'https://www.linkedin.com/oauth/v2/accessToken',
      },
      {
        name: 'Salesforce',
        providerId: 'salesforce',
        endpoint: 'https://login.salesforce.com/services/oauth2/token',
      },
      {
        name: 'Shopify',
        providerId: 'shopify',
        endpoint: 'https://accounts.shopify.com/oauth/token',
      },
      {
        name: 'WordPress',
        providerId: 'wordpress',
        endpoint: 'https://public-api.wordpress.com/oauth2/token',
      },
    ]

    bodyCredentialProviders.forEach(({ name, providerId, endpoint }) => {
      it.concurrent(
        `should send ${name} request with credentials in body and no Basic Auth`,
        async () => {
          const mockFetch = createMockFetch(defaultOAuthResponse)
          const refreshToken = 'test_refresh_token'

          await withMockFetch(mockFetch, () => refreshOAuthToken(providerId, refreshToken))

          expect(mockFetch).toHaveBeenCalledWith(
            endpoint,
            expect.objectContaining({
              method: 'POST',
              headers: expect.objectContaining({
                'Content-Type': 'application/x-www-form-urlencoded',
              }),
              body: expect.any(String),
            })
          )

          const [, requestOptions] = mockFetch.mock.calls[0] as [
            string,
            { headers: Record<string, string>; body: string },
          ]

          expect(requestOptions.headers.Authorization).toBeUndefined()

          const bodyParams = new URLSearchParams(requestOptions.body)
          const bodyKeys = Array.from(bodyParams.keys()).sort()

          expect(bodyKeys).toEqual(['client_id', 'client_secret', 'grant_type', 'refresh_token'])
          expect(bodyParams.get('grant_type')).toBe('refresh_token')
          expect(bodyParams.get('refresh_token')).toBe(refreshToken)

          const expectedClientId =
            providerId === 'outlook' ? 'microsoft_client_id' : `${providerId}_client_id`
          const expectedClientSecret =
            providerId === 'outlook' ? 'microsoft_client_secret' : `${providerId}_client_secret`

          expect(bodyParams.get('client_id')).toBe(expectedClientId)
          expect(bodyParams.get('client_secret')).toBe(expectedClientSecret)
        }
      )
    })

    it.concurrent('should send Notion request with Basic Auth header and JSON body', async () => {
      const mockFetch = createMockFetch(defaultOAuthResponse)
      const refreshToken = 'test_refresh_token'

      await withMockFetch(mockFetch, () => refreshOAuthToken('notion', refreshToken))

      expect(mockFetch).toHaveBeenCalledWith(
        'https://api.notion.com/v1/oauth/token',
        expect.objectContaining({
          method: 'POST',
          headers: expect.objectContaining({
            'Content-Type': 'application/json',
            Authorization: expect.stringMatching(/^Basic /),
          }),
          body: expect.any(String),
        })
      )

      const [, requestOptions] = mockFetch.mock.calls[0] as [
        string,
        { headers: Record<string, string>; body: string },
      ]

      const authHeader = requestOptions.headers.Authorization
      const base64Credentials = authHeader.replace('Basic ', '')
      const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8')
      const [clientId, clientSecret] = credentials.split(':')

      expect(clientId).toBe('notion_client_id')
      expect(clientSecret).toBe('notion_client_secret')

      const bodyParams = JSON.parse(requestOptions.body)
      expect(bodyParams).toEqual({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
      })
    })

    it.concurrent('should include User-Agent header for Reddit requests', async () => {
      const mockFetch = createMockFetch(defaultOAuthResponse)
      const refreshToken = 'test_refresh_token'

      await withMockFetch(mockFetch, () => refreshOAuthToken('reddit', refreshToken))

      const [, requestOptions] = mockFetch.mock.calls[0] as [
        string,
        { headers: Record<string, string>; body: string },
      ]
      expect(requestOptions.headers['User-Agent']).toBe(
        'sim-studio/1.0 (https://github.com/simstudioai/sim)'
      )
    })
  })

  describe('Error Handling', () => {
    it.concurrent('should return failure for unsupported provider', async () => {
      const mockFetch = createMockFetch(defaultOAuthResponse)
      const refreshToken = 'test_refresh_token'

      const result = await withMockFetch(mockFetch, () =>
        refreshOAuthToken('unsupported', refreshToken)
      )

      expect(result.ok).toBe(false)
    })

    it.concurrent('should return failure with errorCode for HTTP error responses', async () => {
      const mockFetch = vi.fn().mockResolvedValue({
        ok: false,
        status: 400,
        text: async () =>
          JSON.stringify({
            error: 'invalid_request',
            error_description: 'Invalid refresh token',
          }),
      })
      const refreshToken = 'test_refresh_token'

      const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken))

      expect(result.ok).toBe(false)
      if (!result.ok) {
        expect(result.errorCode).toBe('invalid_request')
      }
    })

    it.concurrent('should return failure for Slack-style body errors', async () => {
      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        status: 200,
        statusText: 'OK',
        json: async () => ({ ok: false, error: 'invalid_refresh_token' }),
      })
      const refreshToken = 'test_refresh_token'

      const result = await withMockFetch(mockFetch, () => refreshOAuthToken('slack', refreshToken))

      expect(result.ok).toBe(false)
      if (!result.ok) {
        expect(result.errorCode).toBe('invalid_refresh_token')
      }
    })

    it.concurrent('should return failure for network errors', async () => {
      const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
      const refreshToken = 'test_refresh_token'

      const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken))

      expect(result.ok).toBe(false)
    })
  })

  describe('Token Response Handling', () => {
    it.concurrent('should handle providers that return new refresh tokens', async () => {
      const refreshToken = 'old_refresh_token'
      const newRefreshToken = 'new_refresh_token'

      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new_access_token',
          expires_in: 3600,
          refresh_token: newRefreshToken,
        }),
      })

      const result = await withMockFetch(mockFetch, () =>
        refreshOAuthToken('airtable', refreshToken)
      )

      expect(result).toEqual({
        ok: true,
        accessToken: 'new_access_token',
        expiresIn: 3600,
        refreshToken: newRefreshToken,
      })
    })

    it.concurrent(
      'should rotate refresh token for Microsoft providers (microsoft, outlook, onedrive, sharepoint)',
      async () => {
        const microsoftProviders = [
          'microsoft',
          'outlook',
          'onedrive',
          'sharepoint',
          'microsoft-excel',
          'microsoft-teams',
          'microsoft-planner',
          'microsoft-ad',
          'microsoft-dataverse',
        ]
        const oldRefreshToken = 'old_microsoft_refresh_token'
        const rotatedRefreshToken = 'rotated_microsoft_refresh_token'

        for (const providerId of microsoftProviders) {
          const mockFetch = vi.fn().mockResolvedValue({
            ok: true,
            json: async () => ({
              access_token: 'new_access_token',
              expires_in: 3600,
              refresh_token: rotatedRefreshToken,
            }),
          })

          const result = await withMockFetch(mockFetch, () =>
            refreshOAuthToken(providerId, oldRefreshToken)
          )

          expect(result).toEqual({
            ok: true,
            accessToken: 'new_access_token',
            expiresIn: 3600,
            refreshToken: rotatedRefreshToken,
          })
        }
      }
    )

    it.concurrent('should use original refresh token when new one is not provided', async () => {
      const refreshToken = 'original_refresh_token'

      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new_access_token',
          expires_in: 3600,
        }),
      })

      const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken))

      expect(result).toEqual({
        ok: true,
        accessToken: 'new_access_token',
        expiresIn: 3600,
        refreshToken: refreshToken,
      })
    })

    it.concurrent('should return failure when access token is missing', async () => {
      const refreshToken = 'test_refresh_token'

      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          expires_in: 3600,
        }),
      })

      const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken))

      expect(result.ok).toBe(false)
    })

    it.concurrent('should use default expiration when not provided', async () => {
      const refreshToken = 'test_refresh_token'

      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          access_token: 'new_access_token',
        }),
      })

      const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken))

      expect(result).toEqual({
        ok: true,
        accessToken: 'new_access_token',
        expiresIn: 3600,
        refreshToken: refreshToken,
      })
    })
  })
})
