import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'

import { useAuthStore } from '@/stores/auth'

// Create mock router instance
const mockRouter = {
  push: vi.fn(),
  currentRoute: { value: { name: 'AuthCallback', path: '/auth/callback' } },
}

// Mock vue-router (hoisted)
vi.mock('vue-router', () => ({
  useRouter: () => mockRouter,
  useRoute: () => ({ query: {} }),
}))

// Mock the API client
vi.mock('@/api/client', () => ({
  apiClient: {
    get: vi.fn(),
  },
}))

// Mock the config
vi.mock('@/config', () => ({
  config: {
    apiBaseUrl: 'http://localhost:8000',
    wsUrl: 'http://localhost:8000',
    oauthRedirectUri: 'http://localhost:5173/auth/callback',
    isDev: true,
    isProd: false,
  },
}))

import { apiClient } from '@/api/client'
import { useAuth } from './useAuth'

describe('useAuth', () => {
  let mockLocation: { hash: string; pathname: string; search: string; href: string }

  beforeEach(() => {
    setActivePinia(createPinia())

    // Reset mock router
    mockRouter.push.mockReset()

    // Mock window.location with a property descriptor that allows href assignment
    mockLocation = {
      hash: '',
      pathname: '/auth/callback',
      search: '',
      href: 'http://localhost:5173/auth/callback',
    }

    // Delete and redefine to avoid conflicts
    delete (window as unknown as Record<string, unknown>).location
    Object.defineProperty(window, 'location', {
      value: mockLocation,
      writable: true,
      configurable: true,
    })

    // Mock history.replaceState
    vi.spyOn(window.history, 'replaceState').mockImplementation(() => {})

    // Mock fetch for logout calls
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({}),
    })

    // Reset mocks
    vi.mocked(apiClient.get).mockReset()
  })

  afterEach(() => {
    vi.restoreAllMocks()
  })

  describe('initial state', () => {
    it('starts with unauthenticated state', () => {
      /**
       * Test that useAuth starts in an unauthenticated state.
       *
       * Before OAuth flow or initialization completes, the composable
       * should report that the user is not authenticated.
       */
      const { isAuthenticated, isInitialized, user, error } = useAuth()

      expect(isAuthenticated.value).toBe(false)
      expect(isInitialized.value).toBe(false)
      expect(user.value).toBeNull()
      expect(error.value).toBeNull()
    })

    it('starts with isLoading as false', () => {
      /**
       * Test initial loading state.
       *
       * The composable should not be in a loading state until
       * an async operation is initiated.
       */
      const { isLoading } = useAuth()

      expect(isLoading.value).toBe(false)
    })
  })

  describe('initiateOAuth', () => {
    it('redirects to Google OAuth URL with redirect_uri', () => {
      /**
       * Test Google OAuth initiation.
       *
       * When initiating Google OAuth, the browser should be redirected
       * to the backend's Google OAuth endpoint with a redirect_uri param,
       * which will then redirect to Google's consent screen.
       */
      const { initiateOAuth } = useAuth()

      initiateOAuth('google')

      expect(mockLocation.href).toContain('/api/auth/google')
      expect(mockLocation.href).toContain('redirect_uri=')
    })

    it('redirects to Discord OAuth URL with redirect_uri', () => {
      /**
       * Test Discord OAuth initiation.
       *
       * When initiating Discord OAuth, the browser should be redirected
       * to the backend's Discord OAuth endpoint with a redirect_uri param,
       * which will then redirect to Discord's consent screen.
       */
      const { initiateOAuth } = useAuth()

      initiateOAuth('discord')

      expect(mockLocation.href).toContain('/api/auth/discord')
      expect(mockLocation.href).toContain('redirect_uri=')
    })

    it('clears any existing error', () => {
      /**
       * Test error clearing on OAuth initiation.
       *
       * Starting a new OAuth flow should clear any previous errors
       * so users don't see stale error messages.
       */
      const auth = useAuth()

      auth.initiateOAuth('google')

      expect(auth.error.value).toBeNull()
    })
  })

  describe('handleCallback', () => {
    it('successfully extracts tokens from URL hash', async () => {
      /**
       * Test token extraction from OAuth callback.
       *
       * The OAuth provider redirects back with tokens in the URL fragment.
       * handleCallback must parse these tokens and store them correctly.
       */
      mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'

      vi.mocked(apiClient.get).mockResolvedValue({
        id: 'user-1',
        display_name: 'Test User',
        avatar_url: null,
        has_starter_deck: true,
      })

      const { handleCallback } = useAuth()
      const result = await handleCallback()

      expect(result.success).toBe(true)
      expect(result.user?.id).toBe('user-1')
      expect(result.user?.displayName).toBe('Test User')
    })

    it('stores tokens in auth store', async () => {
      /**
       * Test that tokens are persisted in the auth store.
       *
       * After extracting tokens, they must be stored in the auth store
       * so they can be used for subsequent API requests.
       */
      mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'

      vi.mocked(apiClient.get).mockResolvedValue({
        id: 'user-1',
        display_name: 'Test User',
        avatar_url: null,
        has_starter_deck: true,
      })

      const { handleCallback } = useAuth()
      await handleCallback()

      const authStore = useAuthStore()
      expect(authStore.accessToken).toBe('abc123')
      expect(authStore.refreshToken).toBe('xyz789')
      expect(authStore.expiresAt).toBeGreaterThan(Date.now())
    })

    it('fetches user profile after storing tokens', async () => {
      /**
       * Test profile fetch after token storage.
       *
       * Once tokens are stored, we need to fetch the user's profile
       * to know their display name, avatar, and starter deck status.
       */
      mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'

      vi.mocked(apiClient.get).mockResolvedValue({
        id: 'user-1',
        display_name: 'Test User',
        avatar_url: 'https://example.com/avatar.png',
        has_starter_deck: true,
      })

      const { handleCallback } = useAuth()
      await handleCallback()

      expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')

      const authStore = useAuthStore()
      expect(authStore.user).toEqual({
        id: 'user-1',
        displayName: 'Test User',
        avatarUrl: 'https://example.com/avatar.png',
        hasStarterDeck: true,
      })
    })

    it('returns needsStarter=true for users without starter deck', async () => {
      /**
       * Test starter deck status in callback result.
       *
       * The callback result should indicate if the user needs to select
       * a starter deck, allowing the caller to redirect appropriately.
       */
      mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'

      vi.mocked(apiClient.get).mockResolvedValue({
        id: 'user-1',
        display_name: 'New User',
        avatar_url: null,
        has_starter_deck: false,
      })

      const { handleCallback } = useAuth()
      const result = await handleCallback()

      expect(result.success).toBe(true)
      expect(result.needsStarter).toBe(true)
    })

    it('returns needsStarter=false for users with starter deck', async () => {
      /**
       * Test starter deck status for existing users.
       *
       * Users who already have a starter deck should have needsStarter=false,
       * allowing them to proceed directly to the dashboard.
       */
      mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'

      vi.mocked(apiClient.get).mockResolvedValue({
        id: 'user-1',
        display_name: 'Existing User',
        avatar_url: null,
        has_starter_deck: true,
      })

      const { handleCallback } = useAuth()
      const result = await handleCallback()

      expect(result.success).toBe(true)
      expect(result.needsStarter).toBe(false)
    })

    it('returns error for missing tokens in hash', async () => {
      /**
       * Test error handling for malformed OAuth response.
       *
       * If the URL fragment is missing required tokens, handleCallback
       * should return an error result, not throw an exception.
       */
      mockLocation.hash = '#access_token=abc123' // Missing refresh_token and expires_in

      const { handleCallback } = useAuth()
      const result = await handleCallback()

      expect(result.success).toBe(false)
      expect(result.error).toContain('Missing tokens')
    })

    it('returns error for empty hash', async () => {
      /**
       * Test error handling for empty hash fragment.
       *
       * If the OAuth provider redirects without any tokens,
       * handleCallback should return an appropriate error.
       */
      mockLocation.hash = ''

      const { handleCallback } = useAuth()
      const result = await handleCallback()

      expect(result.success).toBe(false)
      expect(result.error).toBeDefined()
    })

    it('returns error from OAuth provider query params', async () => {
      /**
       * Test error forwarding from OAuth provider.
       *
       * When OAuth fails, the backend redirects with error info
       * in query params. handleCallback should extract and return this.
       */
      mockLocation.hash = ''
      mockLocation.search = '?error=access_denied&message=User%20cancelled'

      const { handleCallback } = useAuth()
      const result = await handleCallback()

      expect(result.success).toBe(false)
      expect(result.error).toBe('User cancelled')
    })

    it('uses default error message when message param is missing', async () => {
      /**
       * Test fallback error message.
       *
       * If the backend only provides an error code without a message,
       * we should use a sensible default.
       */
      mockLocation.hash = ''
      mockLocation.search = '?error=unknown_error'

      const { handleCallback } = useAuth()
      const result = await handleCallback()

      expect(result.success).toBe(false)
      expect(result.error).toBe('Authentication failed. Please try again.')
    })

    it('returns error when profile fetch fails', async () => {
      /**
       * Test error handling for profile fetch failures.
       *
       * If we successfully get tokens but fail to fetch the profile,
       * handleCallback should return an error result.
       */
      mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'

      vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'))

      const { handleCallback } = useAuth()
      const result = await handleCallback()

      expect(result.success).toBe(false)
      expect(result.error).toBe('Network error')
    })

    it('clears tokens from URL after parsing', async () => {
      /**
       * Test security cleanup of URL.
       *
       * Tokens in the URL should be cleared after parsing to prevent
       * them from appearing in browser history or being logged.
       */
      mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'

      vi.mocked(apiClient.get).mockResolvedValue({
        id: 'user-1',
        display_name: 'Test User',
        avatar_url: null,
        has_starter_deck: true,
      })

      const { handleCallback } = useAuth()
      await handleCallback()

      expect(window.history.replaceState).toHaveBeenCalledWith(
        null,
        '',
        '/auth/callback'
      )
    })
  })

  describe('logout', () => {
    it('calls auth store logout with server revocation', async () => {
      /**
       * Test logout with server-side token revocation.
       *
       * When logging out, we should revoke the refresh token on the
       * server to prevent it from being used if somehow compromised.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })

      const logoutSpy = vi.spyOn(authStore, 'logout')

      const { logout } = useAuth()
      await logout()

      expect(logoutSpy).toHaveBeenCalledWith(true)
    })

    it('clears auth state after logout', async () => {
      /**
       * Test state clearing after logout.
       *
       * All authentication state should be cleared so the user
       * is properly logged out and cannot access protected resources.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })
      authStore.setUser({
        id: 'user-1',
        displayName: 'Test User',
        avatarUrl: null,
        hasStarterDeck: true,
      })

      const { logout, isAuthenticated, user } = useAuth()
      await logout(false) // Don't redirect in test

      expect(isAuthenticated.value).toBe(false)
      expect(user.value).toBeNull()
    })

    it('redirects to login by default', async () => {
      /**
       * Test default redirect behavior after logout.
       *
       * After logging out, users should be redirected to the login page
       * by default so they can log in again if desired.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })

      const { logout } = useAuth()
      await logout()

      expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
    })

    it('skips redirect when redirectToLogin is false', async () => {
      /**
       * Test optional redirect suppression.
       *
       * Sometimes we want to log out without redirecting (e.g., when
       * the user is already on the login page or when handling errors).
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })

      const { logout } = useAuth()
      await logout(false)

      expect(mockRouter.push).not.toHaveBeenCalled()
    })
  })

  describe('logoutAll', () => {
    it('calls logout-all endpoint', async () => {
      /**
       * Test all-device logout API call.
       *
       * logoutAll should call the special endpoint that revokes
       * all refresh tokens for the user, logging them out everywhere.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })

      const { logoutAll } = useAuth()
      await logoutAll(false)

      expect(global.fetch).toHaveBeenCalledWith(
        'http://localhost:8000/api/auth/logout-all',
        expect.objectContaining({
          method: 'POST',
          headers: expect.objectContaining({
            'Authorization': 'Bearer test-token',
          }),
        })
      )
    })

    it('clears local state even if server call fails', async () => {
      /**
       * Test graceful handling of server errors.
       *
       * Even if the server call to revoke all tokens fails, we should
       * still clear local state so the user is logged out locally.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })

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

      const { logoutAll, isAuthenticated } = useAuth()
      await logoutAll(false)

      expect(isAuthenticated.value).toBe(false)
    })

    it('redirects to login by default', async () => {
      /**
       * Test default redirect after all-device logout.
       *
       * After logging out from all devices, users should be
       * redirected to the login page.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })

      const { logoutAll } = useAuth()
      await logoutAll()

      expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
    })
  })

  describe('initialize', () => {
    it('sets isInitialized to true after completion', async () => {
      /**
       * Test initialization completion flag.
       *
       * After initialize() completes, isInitialized should be true
       * so navigation guards know it's safe to check auth state.
       */
      const { initialize, isInitialized } = useAuth()

      expect(isInitialized.value).toBe(false)

      await initialize()

      expect(isInitialized.value).toBe(true)
    })

    it('returns true when authenticated with valid tokens', async () => {
      /**
       * Test initialization with existing valid session.
       *
       * If tokens are already stored and valid, initialization should
       * return true and keep the user logged in.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })
      authStore.setUser({
        id: 'user-1',
        displayName: 'Test User',
        avatarUrl: null,
        hasStarterDeck: true,
      })

      const { initialize } = useAuth()
      const result = await initialize()

      expect(result).toBe(true)
    })

    it('returns false when not authenticated', async () => {
      /**
       * Test initialization without existing session.
       *
       * If no tokens are stored, initialization should return false
       * indicating the user needs to log in.
       */
      const { initialize } = useAuth()
      const result = await initialize()

      expect(result).toBe(false)
    })

    it('fetches profile if tokens exist but user is null', async () => {
      /**
       * Test profile fetch during initialization.
       *
       * If we have tokens persisted but no user data (e.g., after
       * page reload), we should fetch the profile during init.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })
      // Note: user is NOT set

      vi.mocked(apiClient.get).mockResolvedValue({
        id: 'user-1',
        display_name: 'Test User',
        avatar_url: null,
        has_starter_deck: true,
      })

      const { initialize } = useAuth()
      await initialize()

      expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
      expect(authStore.user).toEqual({
        id: 'user-1',
        displayName: 'Test User',
        avatarUrl: null,
        hasStarterDeck: true,
      })
    })

    it('logs out if profile fetch fails during initialization', async () => {
      /**
       * Test handling of invalid tokens during initialization.
       *
       * If tokens exist but profile fetch fails (invalid tokens, etc.),
       * we should clear the invalid tokens and return false.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })

      vi.mocked(apiClient.get).mockRejectedValue(new Error('Unauthorized'))

      const { initialize, isAuthenticated } = useAuth()
      const result = await initialize()

      expect(result).toBe(false)
      expect(isAuthenticated.value).toBe(false)
    })

    it('only runs once (returns cached result on second call)', async () => {
      /**
       * Test initialization idempotency.
       *
       * Multiple calls to initialize() should only run the
       * initialization logic once. Subsequent calls should
       * return the same result immediately.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })
      authStore.setUser({
        id: 'user-1',
        displayName: 'Test User',
        avatarUrl: null,
        hasStarterDeck: true,
      })

      const { initialize, isInitialized } = useAuth()

      await initialize()
      expect(isInitialized.value).toBe(true)

      // Call again - should return immediately
      const result = await initialize()
      expect(result).toBe(true)
    })
  })

  describe('fetchProfile', () => {
    it('fetches and updates user profile', async () => {
      /**
       * Test manual profile refresh.
       *
       * Components may need to refresh the user profile (e.g., after
       * updating display name). fetchProfile should update the store.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })

      vi.mocked(apiClient.get).mockResolvedValue({
        id: 'user-1',
        display_name: 'Updated Name',
        avatar_url: 'https://example.com/new-avatar.png',
        has_starter_deck: true,
      })

      const { fetchProfile } = useAuth()
      const result = await fetchProfile()

      expect(result.displayName).toBe('Updated Name')
      expect(authStore.user?.displayName).toBe('Updated Name')
    })

    it('throws error when not authenticated', async () => {
      /**
       * Test unauthenticated profile fetch rejection.
       *
       * fetchProfile should throw an error if called when not
       * authenticated, rather than making a doomed API call.
       */
      const { fetchProfile } = useAuth()

      await expect(fetchProfile()).rejects.toThrow('Not authenticated')
    })
  })

  describe('clearError', () => {
    it('clears the error state', async () => {
      /**
       * Test error clearing.
       *
       * After displaying an error, components may want to clear it
       * (e.g., when user dismisses the error or tries again).
       */
      // First cause an error
      mockLocation.hash = ''

      const { handleCallback, clearError, error } = useAuth()
      await handleCallback()

      // Error should be set
      expect(error.value).toBeDefined()

      // Clear it
      clearError()

      expect(error.value).toBeNull()
    })
  })

  describe('loading states', () => {
    it('isLoading is true during handleCallback', async () => {
      /**
       * Test loading state during callback processing.
       *
       * Components should show loading indicators while the callback
       * is being processed (token storage, profile fetch).
       */
      mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'

      // Create a deferred promise to control when the API responds
      let resolveApi: (value: unknown) => void
      vi.mocked(apiClient.get).mockImplementation(
        () => new Promise((resolve) => { resolveApi = resolve })
      )

      const { handleCallback, isLoading } = useAuth()

      // Start callback (don't await)
      const promise = handleCallback()

      // Should be loading
      expect(isLoading.value).toBe(true)

      // Resolve the API call
      resolveApi!({
        id: 'user-1',
        display_name: 'Test User',
        avatar_url: null,
        has_starter_deck: true,
      })

      await promise

      // Should no longer be loading
      expect(isLoading.value).toBe(false)
    })

    it('isLoading is true during logout', async () => {
      /**
       * Test loading state during logout.
       *
       * Components should disable logout buttons and show feedback
       * while the logout operation is in progress.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })

      // Create a deferred promise
      let resolveFetch: () => void
      global.fetch = vi.fn().mockImplementation(
        () => new Promise((resolve) => {
          resolveFetch = () => resolve({ ok: true })
        })
      )

      const { logout, isLoading } = useAuth()

      // Start logout (don't await)
      const promise = logout(false)

      // Should be loading
      expect(isLoading.value).toBe(true)

      // Resolve the fetch
      resolveFetch!()

      await promise

      // Should no longer be loading
      expect(isLoading.value).toBe(false)
    })

    it('isLoading is true during initialize', async () => {
      /**
       * Test loading state during initialization.
       *
       * The app should show a loading screen while auth state
       * is being initialized on startup.
       */
      const authStore = useAuthStore()
      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })
      // Note: NOT setting user, so init will need to fetch profile

      // Create a deferred promise that we control
      let resolveApi: (value: unknown) => void = () => {}
      const apiPromise = new Promise((resolve) => {
        resolveApi = resolve
      })
      vi.mocked(apiClient.get).mockReturnValue(apiPromise as Promise<unknown>)

      const { initialize, isLoading } = useAuth()

      // Start init (don't await)
      const promise = initialize()

      // Wait a tick for async operations to start
      await new Promise((r) => setTimeout(r, 0))

      // Should be loading (needs to fetch profile)
      expect(isLoading.value).toBe(true)

      // Resolve the API call
      resolveApi({
        id: 'user-1',
        display_name: 'Test User',
        avatar_url: null,
        has_starter_deck: true,
      })

      await promise

      // Should no longer be loading
      expect(isLoading.value).toBe(false)
    })
  })

  describe('computed properties', () => {
    it('isAuthenticated reflects auth store state', () => {
      /**
       * Test isAuthenticated reactivity.
       *
       * The isAuthenticated computed should automatically update
       * when the auth store's authentication state changes.
       */
      const authStore = useAuthStore()
      const { isAuthenticated } = useAuth()

      expect(isAuthenticated.value).toBe(false)

      authStore.setTokens({
        accessToken: 'test-token',
        refreshToken: 'test-refresh',
        expiresAt: Date.now() + 3600000,
      })

      expect(isAuthenticated.value).toBe(true)
    })

    it('user reflects auth store state', () => {
      /**
       * Test user computed reactivity.
       *
       * The user computed should automatically update when the
       * auth store's user data changes.
       */
      const authStore = useAuthStore()
      const { user } = useAuth()

      expect(user.value).toBeNull()

      authStore.setUser({
        id: 'user-1',
        displayName: 'Test User',
        avatarUrl: null,
        hasStarterDeck: true,
      })

      expect(user.value?.displayName).toBe('Test User')
    })

    it('error combines local and store errors', () => {
      /**
       * Test error state composition.
       *
       * The error computed should show local errors (from composable
       * operations) or store errors (from token refresh, etc.).
       */
      const authStore = useAuthStore()
      const { error } = useAuth()

      expect(error.value).toBeNull()

      // Store error should be reflected
      authStore.error = 'Store error'
      expect(error.value).toBe('Store error')
    })
  })
})
