import isPlainObject from './isPlainObject'
import type { Middleware } from 'redux'
import { getTimeMeasureUtils } from './utils'

/**
 * Returns true if the passed value is "plain", i.e. a value that is either
 * directly JSON-serializable (boolean, number, string, array, plain object)
 * or `undefined`.
 *
 * @param val The value to check.
 *
 * @public
 */
export function isPlain(val: any) {
  const type = typeof val
  return (
    val == null ||
    type === 'string' ||
    type === 'boolean' ||
    type === 'number' ||
    Array.isArray(val) ||
    isPlainObject(val)
  )
}

interface NonSerializableValue {
  keyPath: string
  value: unknown
}

type IgnorePaths = readonly (string | RegExp)[]

/**
 * @public
 */
export function findNonSerializableValue(
  value: unknown,
  path: string = '',
  isSerializable: (value: unknown) => boolean = isPlain,
  getEntries?: (value: unknown) => [string, any][],
  ignoredPaths: IgnorePaths = [],
  cache?: WeakSet<object>
): NonSerializableValue | false {
  let foundNestedSerializable: NonSerializableValue | false

  if (!isSerializable(value)) {
    return {
      keyPath: path || '<root>',
      value: value,
    }
  }

  if (typeof value !== 'object' || value === null) {
    return false
  }

  if (cache?.has(value)) return false

  const entries = getEntries != null ? getEntries(value) : Object.entries(value)

  const hasIgnoredPaths = ignoredPaths.length > 0

  for (const [key, nestedValue] of entries) {
    const nestedPath = path ? path + '.' + key : key

    if (hasIgnoredPaths) {
      const hasMatches = ignoredPaths.some((ignored) => {
        if (ignored instanceof RegExp) {
          return ignored.test(nestedPath)
        }
        return nestedPath === ignored
      })
      if (hasMatches) {
        continue
      }
    }

    if (!isSerializable(nestedValue)) {
      return {
        keyPath: nestedPath,
        value: nestedValue,
      }
    }

    if (typeof nestedValue === 'object') {
      foundNestedSerializable = findNonSerializableValue(
        nestedValue,
        nestedPath,
        isSerializable,
        getEntries,
        ignoredPaths,
        cache
      )

      if (foundNestedSerializable) {
        return foundNestedSerializable
      }
    }
  }

  if (cache && isNestedFrozen(value)) cache.add(value)

  return false
}

export function isNestedFrozen(value: object) {
  if (!Object.isFrozen(value)) return false

  for (const nestedValue of Object.values(value)) {
    if (typeof nestedValue !== 'object' || nestedValue === null) continue

    if (!isNestedFrozen(nestedValue)) return false
  }

  return true
}

/**
 * Options for `createSerializableStateInvariantMiddleware()`.
 *
 * @public
 */
export interface SerializableStateInvariantMiddlewareOptions {
  /**
   * The function to check if a value is considered serializable. This
   * function is applied recursively to every value contained in the
   * state. Defaults to `isPlain()`.
   */
  isSerializable?: (value: any) => boolean
  /**
   * The function that will be used to retrieve entries from each
   * value.  If unspecified, `Object.entries` will be used. Defaults
   * to `undefined`.
   */
  getEntries?: (value: any) => [string, any][]

  /**
   * An array of action types to ignore when checking for serializability.
   * Defaults to []
   */
  ignoredActions?: string[]

  /**
   * An array of dot-separated path strings or regular expressions to ignore
   * when checking for serializability, Defaults to
   * ['meta.arg', 'meta.baseQueryMeta']
   */
  ignoredActionPaths?: (string | RegExp)[]

  /**
   * An array of dot-separated path strings or regular expressions to ignore
   * when checking for serializability, Defaults to []
   */
  ignoredPaths?: (string | RegExp)[]
  /**
   * Execution time warning threshold. If the middleware takes longer
   * than `warnAfter` ms, a warning will be displayed in the console.
   * Defaults to 32ms.
   */
  warnAfter?: number

  /**
   * Opt out of checking state. When set to `true`, other state-related params will be ignored.
   */
  ignoreState?: boolean

  /**
   * Opt out of checking actions. When set to `true`, other action-related params will be ignored.
   */
  ignoreActions?: boolean

  /**
   * Opt out of caching the results. The cache uses a WeakSet and speeds up repeated checking processes.
   * The cache is automatically disabled if no browser support for WeakSet is present.
   */
  disableCache?: boolean
}

/**
 * Creates a middleware that, after every state change, checks if the new
 * state is serializable. If a non-serializable value is found within the
 * state, an error is printed to the console.
 *
 * @param options Middleware options.
 *
 * @public
 */
export function createSerializableStateInvariantMiddleware(
  options: SerializableStateInvariantMiddlewareOptions = {}
): Middleware {
  if (process.env.NODE_ENV === 'production') {
    return () => (next) => (action) => next(action)
  }
  const {
    isSerializable = isPlain,
    getEntries,
    ignoredActions = [],
    ignoredActionPaths = ['meta.arg', 'meta.baseQueryMeta'],
    ignoredPaths = [],
    warnAfter = 32,
    ignoreState = false,
    ignoreActions = false,
    disableCache = false,
  } = options

  const cache: WeakSet<object> | undefined =
    !disableCache && WeakSet ? new WeakSet() : undefined

  return (storeAPI) => (next) => (action) => {
    const result = next(action)

    const measureUtils = getTimeMeasureUtils(
      warnAfter,
      'SerializableStateInvariantMiddleware'
    )

    if (
      !ignoreActions &&
      !(ignoredActions.length && ignoredActions.indexOf(action.type) !== -1)
    ) {
      measureUtils.measureTime(() => {
        const foundActionNonSerializableValue = findNonSerializableValue(
          action,
          '',
          isSerializable,
          getEntries,
          ignoredActionPaths,
          cache
        )

        if (foundActionNonSerializableValue) {
          const { keyPath, value } = foundActionNonSerializableValue

          console.error(
            `A non-serializable value was detected in an action, in the path: \`${keyPath}\`. Value:`,
            value,
            '\nTake a look at the logic that dispatched this action: ',
            action,
            '\n(See https://redux.js.org/faq/actions#why-should-type-be-a-string-or-at-least-serializable-why-should-my-action-types-be-constants)',
            '\n(To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)'
          )
        }
      })
    }

    if (!ignoreState) {
      measureUtils.measureTime(() => {
        const state = storeAPI.getState()

        const foundStateNonSerializableValue = findNonSerializableValue(
          state,
          '',
          isSerializable,
          getEntries,
          ignoredPaths,
          cache
        )

        if (foundStateNonSerializableValue) {
          const { keyPath, value } = foundStateNonSerializableValue

          console.error(
            `A non-serializable value was detected in the state, in the path: \`${keyPath}\`. Value:`,
            value,
            `
Take a look at the reducer(s) handling this action type: ${action.type}.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)`
          )
        }
      })

      measureUtils.warnIfExceeded()
    }

    return result
  }
}
