import axios from 'axios'

import { upperCaseFirst } from '@baserow/modules/core/utils/string'
import { makeRefreshAuthInterceptor } from '@baserow/modules/core/plugins/clientAuthRefresh'

export class ResponseErrorMessage {
  constructor(title, message) {
    this.title = title
    this.message = message
  }
}

/**
 * This class holds all the default error messages and offers the ability to
 * register new ones. This is stored in a separate class because need to inject
 * this, so it can be used by other modules.
 */
export class ClientErrorMap {
  constructor(app) {
    // Declare the default error messages.
    this.errorMap = {
      ERROR_USER_NOT_IN_GROUP: new ResponseErrorMessage(
        app.i18n.t('clientHandler.userNotInWorkspaceTitle'),
        app.i18n.t('clientHandler.userNotInWorkspaceDescription')
      ),
      ERROR_USER_INVALID_GROUP_PERMISSIONS: new ResponseErrorMessage(
        app.i18n.t('clientHandler.invalidWorkspacePermissionsTitle'),
        app.i18n.t('clientHandler.invalidWorkspacePermissionsDescription')
      ),
      // @TODO move these errors to the module.
      ERROR_TABLE_DOES_NOT_EXIST: new ResponseErrorMessage(
        app.i18n.t('clientHandler.tableDoesNotExistTitle'),
        app.i18n.t('clientHandler.tableDoesNotExistDescription')
      ),
      ERROR_ROW_DOES_NOT_EXIST: new ResponseErrorMessage(
        app.i18n.t('clientHandler.rowDoesNotExistTitle'),
        app.i18n.t('clientHandler.rowDoesNotExistDescription')
      ),
      ERROR_CANNOT_CREATE_FIELD_TYPE: new ResponseErrorMessage(
        app.i18n.t('clientHandler.cannotCreateFieldTypeTitle'),
        app.i18n.t('clientHandler.cannotCreateFieldTypeDescription')
      ),
      ERROR_NOTIFICATION_DOES_NOT_EXIST: new ResponseErrorMessage(
        app.i18n.t('clientHandler.notificationDoesNotExistTitle'),
        app.i18n.t('clientHandler.notificationDoesNotExistDescription')
      ),
      ERROR_FILE_SIZE_TOO_LARGE: new ResponseErrorMessage(
        app.i18n.t('clientHandler.fileSizeTooLargeTitle'),
        app.i18n.t('clientHandler.fileSizeTooLargeDescription')
      ),
      ERROR_INVALID_FILE: new ResponseErrorMessage(
        app.i18n.t('clientHandler.invalidFileTitle'),
        app.i18n.t('clientHandler.invalidFileDescription')
      ),
      ERROR_FILE_URL_COULD_NOT_BE_REACHED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.fileUrlCouldNotBeReachedTitle'),
        app.i18n.t('clientHandler.fileUrlCouldNotBeReachedDescription')
      ),
      ERROR_INVALID_FILE_URL: new ResponseErrorMessage(
        app.i18n.t('clientHandler.invalidFileUrlTitle'),
        app.i18n.t('clientHandler.invalidFileUrlDescription')
      ),
      USER_ADMIN_CANNOT_DEACTIVATE_SELF: new ResponseErrorMessage(
        app.i18n.t('clientHandler.adminCannotDeactivateSelfTitle'),
        app.i18n.t('clientHandler.adminCannotDeactivateSelfDescription')
      ),
      USER_ADMIN_CANNOT_DELETE_SELF: new ResponseErrorMessage(
        app.i18n.t('clientHandler.adminCannotDeleteSelfTitle'),
        app.i18n.t('clientHandler.adminCannotDeleteSelfDescription')
      ),
      USER_ADMIN_ALREADY_EXISTS: new ResponseErrorMessage(
        app.i18n.t('clientHandler.adminAlreadyExistsTitle'),
        app.i18n.t('clientHandler.adminAlreadyExistsDescription')
      ),
      ERROR_MAX_FIELD_COUNT_EXCEEDED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.maxFieldCountExceededTitle'),
        app.i18n.t('clientHandler.maxFieldCountExceededDescription')
      ),
      ERROR_CANNOT_RESTORE_PARENT_BEFORE_CHILD: new ResponseErrorMessage(
        app.i18n.t('clientHandler.cannotRestoreParentBeforeChildTitle'),
        app.i18n.t('clientHandler.cannotRestoreParentBeforeChildDescription')
      ),
      ERROR_CANT_RESTORE_AS_RELATED_TABLE_TRASHED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.cannotRestoreAsRelatedTableTrashedTitle'),
        app.i18n.t(
          'clientHandler.cannotRestoreAsRelatedTableTrashedDescription'
        )
      ),
      ERROR_GROUP_USER_IS_LAST_ADMIN: new ResponseErrorMessage(
        app.i18n.t('clientHandler.workspaceUserIsLastAdminTitle'),
        app.i18n.t('clientHandler.workspaceUserIsLastAdminDescription')
      ),
      ERROR_MAX_JOB_COUNT_EXCEEDED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.errorMaxJobCountExceededTitle'),
        app.i18n.t('clientHandler.errorMaxJobCountExceededDescription')
      ),
      ERROR_FAILED_TO_LOCK_FIELD_DUE_TO_CONFLICT: new ResponseErrorMessage(
        app.i18n.t('clientHandler.failedToLockFieldDueToConflictTitle'),
        app.i18n.t('clientHandler.failedToLockFieldDueToConflictDescription')
      ),
      ERROR_FAILED_TO_LOCK_TABLE_DUE_TO_CONFLICT: new ResponseErrorMessage(
        app.i18n.t('clientHandler.failedToLockTableDueToConflictTitle'),
        app.i18n.t('clientHandler.failedToLockTableDueToConflictDescription')
      ),
      ERROR_UNDO_REDO_LOCK_CONFLICT: new ResponseErrorMessage(
        app.i18n.t('clientHandler.failedToUndoRedoDueToConflictTitle'),
        app.i18n.t('clientHandler.failedToUndoRedoDueToConflictDescription')
      ),
      ERROR_MAXIMUM_SNAPSHOTS_REACHED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.maximumSnapshotsReachedTitle'),
        app.i18n.t('clientHandler.maximumSnapshotsReachedDescription')
      ),
      ERROR_SNAPSHOT_IS_BEING_CREATED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.snapshotBeingCreatedTitle'),
        app.i18n.t('clientHandler.snapshotBeingCreatedDescription')
      ),
      ERROR_SNAPSHOT_IS_BEING_RESTORED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.snapshotBeingRestoredTitle'),
        app.i18n.t('clientHandler.snapshotBeingRestoredDescription')
      ),
      ERROR_SNAPSHOT_IS_BEING_DELETED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.snapshotBeingDeletedTitle'),
        app.i18n.t('clientHandler.snapshotBeingDeletedDescription')
      ),
      ERROR_SNAPSHOT_NAME_NOT_UNIQUE: new ResponseErrorMessage(
        app.i18n.t('clientHandler.snapshotNameNotUniqueTitle'),
        app.i18n.t('clientHandler.snapshotNameNotUniqueDescription')
      ),
      ERROR_SNAPSHOT_OPERATION_LIMIT_EXCEEDED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.snapshotOperationLimitExceededTitle'),
        app.i18n.t('clientHandler.snapshotOperationLimitExceededDescription')
      ),
      ERROR_AUTH_PROVIDER_DISABLED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.disabledPasswordProviderTitle'),
        app.i18n.t('clientHandler.disabledPasswordProviderMessage')
      ),
      ERROR_OUTPUT_PARSER: new ResponseErrorMessage(
        app.i18n.t('clientHandler.outputParserTitle'),
        app.i18n.t('clientHandler.outputParserDescription')
      ),
      ERROR_GENERATIVE_AI_PROMPT: new ResponseErrorMessage(
        app.i18n.t('clientHandler.generateAIPromptTitle'),
        app.i18n.t('clientHandler.generateAIPromptDescription')
      ),
      // TODO: Move to enterprise module if possible
      ERROR_CANNOT_DISABLE_ALL_AUTH_PROVIDERS: new ResponseErrorMessage(
        app.i18n.t('clientHandler.cannotDisableAllAuthProvidersTitle'),
        app.i18n.t('clientHandler.cannotDisableAllAuthProvidersDescription')
      ),
      ERROR_MAX_LOCKS_PER_TRANSACTION_EXCEEDED: new ResponseErrorMessage(
        app.i18n.t('clientHandler.maxLocksPerTransactionExceededTitle'),
        app.i18n.t('clientHandler.maxLocksPerTransactionExceededDescription')
      ),
      ERROR_LAST_ADMIN_OF_GROUP: new ResponseErrorMessage(
        app.i18n.t('clientHandler.lastAdminTitle'),
        app.i18n.t('clientHandler.lastAdminMessage')
      ),
      ERROR_GENERATIVE_AI_DOES_NOT_EXIST: new ResponseErrorMessage(
        app.i18n.t('clientHandler.generativeAIDoesNotExistTitle'),
        app.i18n.t('clientHandler.generativeAIDoesNotExistDescription')
      ),
      ERROR_MODEL_DOES_NOT_BELONG_TO_TYPE: new ResponseErrorMessage(
        app.i18n.t('clientHandler.modelDoesNotBelongToTypeTitle'),
        app.i18n.t('clientHandler.modelDoesNotBelongToTypeDescription')
      ),
      ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED:
        new ResponseErrorMessage(
          app.i18n.t(
            'clientHandler.maxNumberOfPendingWorkspaceInvitesReachedTitle'
          ),
          app.i18n.t(
            'clientHandler.maxNumberOfPendingWorkspaceInvitesReachedDescription'
          )
        ),
      ERROR_FIELD_IS_ALREADY_PRIMARY: new ResponseErrorMessage(
        app.i18n.t('clientHandler.fieldIsAlreadyPrimaryTitle'),
        app.i18n.t('clientHandler.fieldIsAlreadyPrimaryDescription')
      ),
      ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE: new ResponseErrorMessage(
        app.i18n.t('clientHandler.incompatiblePrimaryFieldTypeTitle'),
        app.i18n.t('clientHandler.incompatiblePrimaryFieldTypeDescription')
      ),
    }
  }

  setError(code, title, description) {
    this.errorMap[code] = new ResponseErrorMessage(title, description)
  }
}

export class ErrorHandler {
  constructor(
    store,
    app,
    clientErrorMap,
    response,
    code = null,
    detail = null
  ) {
    this.isHandled = false
    this.store = store
    this.app = app
    this.response = response
    this.setError(code, detail)
    this.errorMap = clientErrorMap.errorMap

    // A temporary notFoundMap containing the error messages for when the
    // response contains a 404 error based on the provided context name. Note
    // that an entry is not found a default message will be generated.
    this.notFoundMap = {}
  }

  /**
   * Changes the error code and details.
   */
  setError(code, detail) {
    this.code = code
    this.detail = detail
  }

  hasBaserowAPIError() {
    return this.response !== undefined && this.code != null
  }

  hasRequestBodyValidationError() {
    return (
      this.response !== undefined &&
      this.response?.data?.error === 'ERROR_REQUEST_BODY_VALIDATION'
    )
  }

  /**
   * Returns true is the response status code is equal to not found (404).
   * @return {boolean}
   */
  isNotFound() {
    return this.response !== undefined && this.response.status === 404
  }

  /**
   * Returns true if the response status code is equal to not found (429) which
   * means that the user is sending too much requests to the server.
   * @return {boolean}
   */
  isTooManyRequests() {
    return this.response !== undefined && this.response.status === 429
  }

  /**
   * Return true if there is a network error.
   * @return {boolean}
   */
  hasNetworkError() {
    return this.response === undefined
  }

  /**
   * Finds a message in the global errors or in the provided specific error map.
   */
  getErrorMessage(specificErrorMap = null) {
    if (
      specificErrorMap !== null &&
      Object.prototype.hasOwnProperty.call(specificErrorMap, this.code)
    ) {
      return specificErrorMap[this.code]
    }

    if (Object.prototype.hasOwnProperty.call(this.errorMap, this.code)) {
      return this.errorMap[this.code]
    }

    return this.genericDefaultError()
  }

  searchForMatchingFieldException(
    listOfDetailErrors,
    mapOfDetailCodeToResponseError
  ) {
    for (const detailError of listOfDetailErrors) {
      if (
        detailError &&
        typeof detailError === 'object' &&
        typeof detailError.code === 'string'
      ) {
        const handledError = mapOfDetailCodeToResponseError[detailError.code]
        if (handledError) {
          return handledError
        }
      }
    }
    return null
  }

  /**
   * Given a "ERROR_REQUEST_BODY_VALIDATION" error has occurred this function matches
   * a provided error map against the machine readable error codes in the "detail"
   * key in the response.
   *
   * For example if the response contains an error looking like:
   *
   * {
   *   "error": "ERROR_REQUEST_BODY_VALIDATION",
   *   "detail": {
   *     "url": [
   *       {
   *         "error": "Enter a valid URL.",
   *         "code": "invalid"
   *       }
   *     ]
   *   }
   * }
   *
   * Then you would call this function like so to match the above error and get your
   * ResponseErrorMessage returned:
   *
   * getRequestBodyErrorMessage({"url":{"invalid": new ResponseErrorMessage('a','b')}})
   *
   * @param requestBodyErrorMap An object where it's keys are the names of the
   * request body attribute that can fail with a value being another sub object. This
   * sub object should be keyed by the "code" returned in the error detail with the
   * value being a ResponseErrorMessage that should be returned if the API returned an
   * error for that attribute and code.
   * @return Any The first ResponseErrorMessage which is found in the error map that
   * matches an error in the response body, or null if no match is found.
   */
  getRequestBodyErrorMessage(requestBodyErrorMap) {
    const detail = this.response?.data?.detail

    if (requestBodyErrorMap && detail && typeof detail === 'object') {
      for (const fieldName of Object.keys(detail)) {
        const errorsForField = detail[fieldName]
        const supportedExceptionsForField = requestBodyErrorMap[fieldName]

        if (
          errorsForField != null &&
          Array.isArray(errorsForField) &&
          supportedExceptionsForField
        ) {
          const matchingException = this.searchForMatchingFieldException(
            errorsForField,
            supportedExceptionsForField
          )
          if (matchingException) {
            return matchingException
          }
        }
      }
    }

    return null
  }

  /**
   * Finds a not found message for a given context.
   */
  getNotFoundMessage(name) {
    if (!Object.prototype.hasOwnProperty.call(this.notFoundMap, name)) {
      return new ResponseErrorMessage(
        this.app.i18n.t('clientHandler.notFoundTitle', {
          name: upperCaseFirst(name),
        }),
        this.app.i18n.t('clientHandler.notFoundDescription', {
          name: name.toLowerCase(),
        })
      )
    }
    return this.notFoundMap[name]
  }

  /**
   * Returns a standard network error message. For example if the API server
   * could not be reached.
   */
  getNetworkErrorMessage() {
    return new ResponseErrorMessage(
      this.app.i18n.t('clientHandler.networkErrorTitle'),
      this.app.i18n.t('clientHandler.networkErrorDescription')
    )
  }

  /**
   * Returns a standard network error message. For example if the API server
   * could not be reached.
   */
  getTooManyRequestsError() {
    return new ResponseErrorMessage(
      this.app.i18n.t('clientHandler.tooManyRequestsTitle'),
      this.app.i18n.t('clientHandler.tooManyRequestsDescription')
    )
  }

  /**
   * If there is an error or the requested detail is not found an error
   * message related to the problem is returned.
   */
  getMessage(name = null, specificErrorMap = null, requestBodyErrorMap = null) {
    if (this.isTooManyRequests()) {
      return this.getTooManyRequestsError()
    }
    if (this.hasNetworkError()) {
      return this.getNetworkErrorMessage()
    }
    if (this.hasBaserowAPIError()) {
      if (this.hasRequestBodyValidationError()) {
        const matchingRequestBodyError =
          this.getRequestBodyErrorMessage(requestBodyErrorMap)
        if (matchingRequestBodyError) {
          return matchingRequestBodyError
        }
      }
      return this.getErrorMessage(specificErrorMap)
    }
    if (this.isNotFound()) {
      return this.getNotFoundMessage(name)
    }
    return this.genericDefaultError()
  }

  genericDefaultError() {
    return new ResponseErrorMessage(
      this.app.i18n.t('clientHandler.notCompletedTitle'),
      this.app.i18n.t('clientHandler.notCompletedDescription')
    )
  }

  /**
   * If there is an error or the requested detail is not found we will try to
   * get find an existing message of one is not provided and notify the user
   * about what went wrong. After that the error is marked as handled.
   */
  notifyIf(name = null, message = null) {
    if (
      !(
        this.hasBaserowAPIError() ||
        this.hasNetworkError() ||
        this.isNotFound()
      ) ||
      this.isHandled
    ) {
      return
    }

    if (message === null) {
      message = this.getMessage(name)
    }

    this.store.dispatch(
      'toast/error',
      {
        title: message.title,
        message: message.message,
      },
      { root: true }
    )

    this.handled()
  }

  /**
   * Will mark the error as handled so that the same error message isn't shown
   * twice.
   */
  handled() {
    this.isHandled = true
  }
}

export function makeErrorResponseInterceptor(
  store,
  app,
  clientErrorMap,
  nuxtErrorHandler
) {
  return (error) => {
    const rspData = error.response?.data

    // user session expired. Redirect to login page to start a new session.
    if (rspData?.error === 'ERROR_INVALID_REFRESH_TOKEN') {
      nuxtErrorHandler({ statusCode: 401, message: 'User session expired' })
      return Promise.reject(error)
    }

    error.handler = new ErrorHandler(store, app, clientErrorMap, error.response)
    if (
      typeof rspData === 'object' &&
      'error' in rspData &&
      'detail' in rspData
    ) {
      error.handler.setError(rspData.error, rspData.detail)
    } else if (typeof rspData !== 'object') {
      error.handler.setError(500, null)
    }

    return Promise.reject(error)
  }
}

/**
 * Add the user related headers according to the current authentication status.
 */
const prepareRequestHeaders = (store) => (config) => {
  const application = store.getters['userSourceUser/getCurrentApplication']
  if (store.getters['auth/isAuthenticated']) {
    const token = store.getters['auth/token']
    config.headers.Authorization = `JWT ${token}`
    config.headers.ClientSessionId =
      store.getters['auth/getUntrustedClientSessionId']
    // If we are logged with Baserow user and with a user source user
    // so we also want to send this user token
    // to the backend through the custom `UserSourceAuthorization` header.
    // This enables the "double" authentication.
    // We access the data with the permission of the currently logged Baserow user
    // but we can see the data of the user source user.
    if (store.getters['userSourceUser/isAuthenticated'](application)) {
      const userSourceToken =
        store.getters['userSourceUser/accessToken'](application)
      config.headers.UserSourceAuthorization = `JWT ${userSourceToken}`
    }
  } else if (store.getters['userSourceUser/isAuthenticated'](application)) {
    // Here we are logged as a user source user
    const userSourceToken =
      store.getters['userSourceUser/accessToken'](application)
    config.headers.Authorization = `JWT ${userSourceToken}`
  }
  if (store.getters['auth/webSocketId'] !== null) {
    const webSocketId = store.getters['auth/webSocketId']
    config.headers.WebSocketId = webSocketId
  }

  return config
}

const createAxiosInstance = (app) => {
  const url =
    (process.client
      ? app.$config.PUBLIC_BACKEND_URL
      : app.$config.PRIVATE_BACKEND_URL) + '/api'
  return axios.create({
    baseURL: url,
    withCredentials: false,
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
  })
}

export default function ({ app, store, error }, inject) {
  const client = createAxiosInstance(app)
  // Create and inject the client error map, so that other modules can also register
  // default error messages.
  const clientErrorMap = new ClientErrorMap(app)
  inject('clientErrorMap', clientErrorMap)

  client.interceptors.request.use(prepareRequestHeaders(store))

  // Create a response interceptor to add more detail to the error message
  // and to create a toast when there is a network error.
  client.interceptors.response.use(
    null,
    makeErrorResponseInterceptor(store, app, clientErrorMap, error)
  )

  // Main auth refresh token
  const shouldInterceptRequest = () =>
    store.getters['auth/shouldRefreshToken']()

  const shouldInterceptResponse = (error) =>
    store.getters['auth/isAuthenticated'] &&
    error.response?.data?.error === 'ERROR_INVALID_ACCESS_TOKEN'

  const refreshToken = async () => await store.dispatch('auth/refresh')

  const refreshAuthInterceptor = makeRefreshAuthInterceptor(
    client,
    refreshToken,
    shouldInterceptRequest,
    shouldInterceptResponse
  )
  client.interceptors.response.use(null, refreshAuthInterceptor)

  // User source auth refresh token (only active if it's not a double authentication)
  const shouldInterceptUserSourceRequest = (req) => {
    const application = store.getters['userSourceUser/getCurrentApplication']
    return (
      !store.getters['auth/isAuthenticated'] &&
      store.getters['userSourceUser/shouldRefreshToken'](application)
    )
  }

  const shouldInterceptUserSourceResponse = (error) => {
    const application = store.getters['userSourceUser/getCurrentApplication']
    return (
      !store.getters['auth/isAuthenticated'] &&
      store.getters['userSourceUser/isAuthenticated'](application) &&
      error.response?.data?.error === 'ERROR_INVALID_ACCESS_TOKEN'
    )
  }
  const refreshUserSourceToken = async () =>
    await store.dispatch('userSourceUser/refreshAuth', {
      application: store.getters['userSourceUser/getCurrentApplication'],
    })

  const refreshUserSourceUserInterceptor = makeRefreshAuthInterceptor(
    client,
    refreshUserSourceToken,
    shouldInterceptUserSourceRequest,
    shouldInterceptUserSourceResponse
  )
  client.interceptors.response.use(null, refreshUserSourceUserInterceptor)

  inject('client', client)
}