import type { BuiltInProviderType, RedirectableProviderType } from '@auth/core/providers'
import type { LoggerInstance, Session } from '@auth/core/types'
import * as React from 'react'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { ClientSessionError, fetchData, now, parseUrl, useOnline } from './client'
import type {
  WindowProps,
  AuthState,
  AuthClientConfig,
  SessionContextValue,
  SessionProviderProps,
  GetSessionParams,
  UseSessionOptions,
  LiteralUnion,
  SignInOptions,
  SignInAuthorizationParams,
  SignInResponse,
  ClientSafeProvider,
  SignOutParams,
  SignOutResponse,
} from './client'

const logger: LoggerInstance = {
  debug: console.debug,
  error: console.error,
  warn: console.warn,
}

class AuthConfigManager {
  private static instance: AuthConfigManager | null = null
  private config: AuthClientConfig

  private constructor() {
    this.config = this.createDefaultConfig()
  }

  private createDefaultConfig(): AuthClientConfig {
    return {
      baseUrl: typeof window !== 'undefined' ? parseUrl(window.location.origin).origin : '',
      basePath: typeof window !== 'undefined' ? parseUrl(window.location.origin).path : '/api/auth',
      credentials: 'same-origin',
      lastSync: 0,
      session: null,
      fetchSession: async () => void 0,
    }
  }

  static getInstance(): AuthConfigManager {
    if (!AuthConfigManager.instance) {
      AuthConfigManager.instance = new AuthConfigManager()
    }
    return AuthConfigManager.instance
  }

  setConfig(userConfig: Partial<AuthClientConfig>): void {
    this.config = { ...this.config, ...userConfig }
  }

  getConfig(): AuthClientConfig {
    return this.config
  }

  initializeConfig(hasInitialSession: boolean): void {
    this.config.lastSync = hasInitialSession ? now() : 0
  }
}

export const authConfigManager = AuthConfigManager.getInstance()

export const SessionContext = React.createContext<SessionContextValue | undefined>(undefined)

function useInitializeSession(hasInitialSession: boolean, initialSession: Session | null) {
  const authConfig = authConfigManager.getConfig()
  const [session, setSession] = React.useState<Session | null>(initialSession)
  const [loading, setLoading] = React.useState(!hasInitialSession)

  useEffect(() => {
    authConfig.fetchSession = async ({ event } = {}) => {
      try {
        const isStorageEvent = event === 'storage'

        if (isStorageEvent || !authConfig.session) {
          authConfig.lastSync = now()
          authConfig.session = await getSession()
          setSession(authConfig.session)
          return
        }

        if (!event || !authConfig.session || now() < authConfig.lastSync) {
          return
        }

        authConfig.lastSync = now()
        authConfig.session = await getSession()
        setSession(authConfig.session)
      } catch (error) {
        logger.error(new ClientSessionError((error as Error).message, error as any))
      } finally {
        setLoading(false)
      }
    }

    authConfig.fetchSession()

    return () => {
      authConfig.lastSync = 0
      authConfig.session = null
      authConfig.fetchSession = async () => void 0
    }
  }, [])

  return { session, setSession, loading, setLoading }
}

function useVisibilityChangeEventListener(
  authConfig: AuthClientConfig,
  refetchOnWindowFocus: boolean
) {
  useEffect(() => {
    const abortController = new AbortController()
    const handleVisibilityChange = () => {
      if (refetchOnWindowFocus && document.visibilityState === 'visible') {
        authConfig.fetchSession({ event: 'visibilitychange' })
      }
    }
    document.addEventListener('visibilitychange', handleVisibilityChange, {
      signal: abortController.signal,
    })

    return () => abortController.abort()
  }, [refetchOnWindowFocus])
}

function useRefetchInterval(
  authConfig: AuthClientConfig,
  refetchInterval?: number,
  shouldRefetch?: boolean
) {
  useEffect(() => {
    if (refetchInterval && shouldRefetch) {
      const intervalId = setInterval(() => {
        if (authConfig.session) {
          authConfig.fetchSession({ event: 'poll' })
        }
      }, refetchInterval * 1000)
      return () => clearInterval(intervalId)
    }
  }, [refetchInterval, shouldRefetch])
}

export async function getSession(params?: GetSessionParams) {
  const { baseUrl, basePath, credentials } = authConfigManager.getConfig()
  const session = await fetchData<Session>(
    'session',
    {
      baseUrl,
      basePath,
      credentials,
    },
    logger,
    params
  )
  return session
}

export async function getCsrfToken() {
  const { baseUrl, basePath, credentials } = authConfigManager.getConfig()
  const response = await fetchData<{ csrfToken: string }>(
    'csrf',
    {
      baseUrl,
      basePath,
      credentials,
    },
    logger
  )
  return response?.csrfToken ?? ''
}

export function SessionProvider(props: SessionProviderProps) {
  if (!SessionContext) {
    throw new Error('React Context is unavailable in Server Components')
  }

  const { children, refetchInterval, refetchWhenOffline = true } = props

  const authConfig = authConfigManager.getConfig()

  const hasInitialSession = !!props.session

  authConfigManager.initializeConfig(hasInitialSession)

  const { session, setSession, loading, setLoading } = useInitializeSession(
    hasInitialSession,
    props.session ?? null
  )

  useVisibilityChangeEventListener(authConfig, props.refetchOnWindowFocus ?? true)

  const isOnline = useOnline()

  const shouldRefetch = refetchWhenOffline || isOnline

  useRefetchInterval(authConfig, refetchInterval, shouldRefetch)

  const contextValue: SessionContextValue = useMemo(
    () =>
      ({
        data: session,
        status: loading ? 'loading' : session ? 'authenticated' : 'unauthenticated',
        update: async (data) => {
          if (loading || !session) {
            return
          }
          setLoading(true)
          const updatedSession = await fetchData<Session>(
            'session',
            authConfig,
            logger,
            data ? { body: { csrfToken: await getCsrfToken(), data } } : undefined
          )
          setLoading(false)
          if (updatedSession) {
            setSession(updatedSession)
          }
          return updatedSession
        },
      } as SessionContextValue),
    [session, loading, setSession]
  )

  return <SessionContext.Provider value={contextValue}>{children}</SessionContext.Provider>
}

export function useSession<R extends boolean>(
  options?: UseSessionOptions<R>
): SessionContextValue<R> {
  if (!SessionContext) {
    throw new Error('React Context is unavailable in Server Components')
  }

  const config = authConfigManager.getConfig()

  const session = useContext(SessionContext)

  const { required, onUnauthenticated } = options ?? {}

  const requiredAndNotLoading = required && session?.status === 'unauthenticated'

  useEffect(() => {
    if (requiredAndNotLoading) {
      const url = `${config.baseUrl}${config.basePath}/signin?${new URLSearchParams({
        error: 'SessionRequired',
        callbackUrl: window.location.href,
      })}`
      if (onUnauthenticated) {
        onUnauthenticated()
      } else {
        window.location.href = url
      }
    }
  }, [requiredAndNotLoading, onUnauthenticated])

  if (requiredAndNotLoading) {
    return {
      data: session?.data,
      update: session?.update,
      status: 'loading',
    }
  }

  return session as SessionContextValue<R>
}

type ProvidersType = Record<LiteralUnion<BuiltInProviderType>, ClientSafeProvider>

export async function getProviders() {
  return fetchData<ProvidersType>('providers', authConfigManager.getConfig(), logger)
}

export async function signIn<P extends RedirectableProviderType | undefined = undefined>(
  provider?: LiteralUnion<
    P extends RedirectableProviderType ? P | BuiltInProviderType : BuiltInProviderType
  >,
  options: SignInOptions = {},
  authorizationParams: SignInAuthorizationParams = {}
): Promise<P extends RedirectableProviderType ? SignInResponse | undefined : undefined> {
  const { callbackUrl = window.location.href, redirect = true, ...opts } = options

  const config = authConfigManager.getConfig()

  const href = `${config.baseUrl}${config.basePath}`

  const providers = await getProviders()
  if (!providers) {
    window.location.href = `${href}/error`
    return
  }

  if (!provider || !(provider in providers)) {
    window.location.href = `${href}/signin?${new URLSearchParams({ callbackUrl })}`
    return
  }

  const isCredentials = providers[provider].type === 'credentials'
  const isEmail = providers[provider].type === 'email'

  const signInUrl = `${href}/${isCredentials ? 'callback' : 'signin'}/${provider}`

  const csrfToken = await getCsrfToken()
  const res = await fetch(`${signInUrl}?${new URLSearchParams(authorizationParams)}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'X-Auth-Return-Redirect': '1',
    },
    body: new URLSearchParams({ ...opts, csrfToken, callbackUrl }),
    credentials: config.credentials,
  })

  const data = (await res.json()) as { url: string }

  if (redirect) {
    const url = data.url ?? callbackUrl
    window.location.href = url

    if (url.includes('#')) {
      window.location.reload()
    }
    return
  }

  const error = new URL(data.url).searchParams.get('error')

  if (res.ok) {
    await config.fetchSession?.({ event: 'storage' })
  }

  return {
    error,
    status: res.status,
    ok: res.ok,
    url: error ? null : data.url,
  } as P extends RedirectableProviderType ? SignInResponse : undefined
}

export async function signOut<R extends boolean = true>(
  options?: SignOutParams<R>
): Promise<R extends true ? undefined : SignOutResponse> {
  const { callbackUrl = window.location.href, redirect = true } = options ?? {}
  const config = authConfigManager.getConfig()

  const csrfToken = await getCsrfToken()
  const res = await fetch(`${config.baseUrl}${config.basePath}/signout`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'X-Auth-Return-Redirect': '1',
    },
    body: new URLSearchParams({ csrfToken, callbackUrl }),
    credentials: config.credentials,
  })

  const data = (await res.json()) as { url: string }

  if (redirect) {
    const url = data.url ?? callbackUrl
    window.location.href = url

    if (url.includes('#')) {
      window.location.reload()
    }

    return undefined as R extends true ? undefined : SignOutResponse
  }

  await config.fetchSession?.({ event: 'storage' })

  return data as R extends true ? undefined : SignOutResponse
}

const createPopup = ({ url, title, height, width }: WindowProps) => {
  const left = window.screenX + (window.outerWidth - width) / 2
  const top = window.screenY + (window.outerHeight - height) / 2.5
  const externalPopup = window.open(
    url,
    title,
    `width=${width},height=${height},left=${left},top=${top}`
  )
  return externalPopup
}

interface PopupLoginOptions extends Partial<Omit<WindowProps, 'url'>> {
  onSuccess?: () => void
  callbackUrl?: string
}

export const useOauthPopupLogin = (
  provider: Parameters<typeof signIn>[0],
  options: PopupLoginOptions = {}
) => {
  const { width = 500, height = 500, title = 'Signin', onSuccess, callbackUrl = '/' } = options

  const [externalWindow, setExternalWindow] = useState<Window | null>()

  const [state, setState] = useState<AuthState>({ status: 'loading' })

  const popUpSignin = useCallback(async () => {
    const res = await signIn(provider, {
      redirect: false,
      callbackUrl,
    })

    if (res?.error) {
      setState({ status: 'errored', error: res.error })
      return
    }
    setExternalWindow(
      createPopup({
        url: res?.url as string,
        title,
        width,
        height,
      })
    )
  }, [])

  useEffect(() => {
    const handleMessage = (event: MessageEvent<AuthState>) => {
      if (event.origin !== window.location.origin) {
        return
      }
      if (event.data.status) {
        setState(event.data)
        if (event.data.status === 'success') {
          onSuccess?.()
        }
        externalWindow?.close()
      }
    }

    window.addEventListener('message', handleMessage)

    return () => {
      window.removeEventListener('message', handleMessage)
      externalWindow?.close()
    }
  }, [externalWindow])

  return { popUpSignin, ...state }
}
