import { useRouter } from 'next/router'
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import Button from 'react-bootstrap/Button'
import Toast from 'react-bootstrap/Toast'
import ToastBody from 'react-bootstrap/ToastBody'
import ToastContainer from 'react-bootstrap/ToastContainer'
import styles from './toast.module.css'

const ToastContext = createContext(() => {})

export const TOAST_DEFAULT_DELAY_MS = 5000

const ensureFlow = (toasts, newToast) => {
  const { flowId } = newToast
  if (flowId) {
    // replace previous toast with same flow id
    const idx = toasts.findIndex(toast => toast.flowId === flowId)
    if (idx === -1) return [...toasts, newToast]
    return [
      ...toasts.slice(0, idx),
      newToast,
      ...toasts.slice(idx + 1)
    ]
  }
  return [...toasts, newToast]
}

const mapHidden = ({ id, tag }) => toast => {
  // mark every previous toast with same tag as hidden
  if (toast.tag === tag && toast.id !== id) return { ...toast, hidden: true }
  return toast
}

export const ToastProvider = ({ children }) => {
  const router = useRouter()
  const [toasts, setToasts] = useState([])
  const toastId = useRef(0)

  const removeToast = useCallback(({ id, onCancel, tag }) => {
    setToasts(toasts => toasts.filter(toast => {
      if (toast.id === id) {
        // remove the toast with the passed id with no exceptions
        return false
      }
      const sameTag = tag && tag === toast.tag
      if (!sameTag) {
        // don't touch toasts with different tags
        return true
      }
      const toRemoveHasCancel = !!toast.onCancel || !!toast.onUndo
      if (toRemoveHasCancel) {
        // don't remove this toast so the user can decide to cancel this toast now
        return true
      }
      // remove toasts with same tag if they are not cancelable
      return false
    }))
  }, [setToasts])

  const dispatchToast = useCallback((toast) => {
    toast = {
      ...toast,
      createdAt: +new Date(),
      id: toastId.current++
    }
    setToasts(toasts => ensureFlow(toasts, toast).map(mapHidden(toast)))
    return () => removeToast(toast)
  }, [setToasts, removeToast])

  const endFlow = useCallback((flowId) => {
    setToasts(toasts => toasts.filter(toast => toast.flowId !== flowId))
  }, [setToasts])

  const toaster = useMemo(() => ({
    success: (body, options) => {
      const toast = {
        body,
        variant: 'success',
        autohide: true,
        delay: TOAST_DEFAULT_DELAY_MS,
        tag: options?.tag || body,
        ...options
      }
      return dispatchToast(toast)
    },
    warning: (body, options) => {
      const toast = {
        body,
        variant: 'warning',
        autohide: true,
        delay: TOAST_DEFAULT_DELAY_MS,
        tag: options?.tag || body,
        ...options
      }
      return dispatchToast(toast)
    },
    danger: (body, options) => {
      const toast = {
        body,
        variant: 'danger',
        autohide: false,
        tag: options?.tag || body,
        ...options
      }
      return dispatchToast(toast)
    },
    endFlow
  }), [dispatchToast, removeToast, endFlow])

  // Only clear toasts with no cancel function on page navigation
  // since navigation should not interfere with being able to cancel an action.
  useEffect(() => {
    const handleRouteChangeStart = () => setToasts(toasts => toasts.length > 0 ? toasts.filter(({ onCancel, onUndo, persistOnNavigate }) => onCancel || onUndo || persistOnNavigate) : toasts)
    router.events.on('routeChangeStart', handleRouteChangeStart)

    return () => {
      router.events.off('routeChangeStart', handleRouteChangeStart)
    }
  }, [router.events, setToasts])

  // this function merges toasts with the same tag into one toast.
  // for example: 3x 'zap pending' -> '(3) zap pending'
  const tagReducer = (toasts, toast) => {
    const { tag } = toast

    // has tag?
    if (!tag) return [...toasts, toast]

    // existing tag?
    const idx = toasts.findIndex(toast => toast.tag === tag)
    if (idx === -1) return [...toasts, toast]

    // merge toasts with same tag
    const prevToast = toasts[idx]
    let { amount } = prevToast
    amount = amount ? amount + 1 : 2
    const body = `(${amount}) ${toast.body}`
    return [
      ...toasts.slice(0, idx),
      { ...toast, amount, body },
      ...toasts.slice(idx + 1)
    ]
  }

  // only show toast with highest ID of each tag
  const visibleToasts = toasts.reduce(tagReducer, [])

  return (
    <ToastContext.Provider value={toaster}>
      <ToastContainer className={`pb-3 pe-3 ${styles.toastContainer}`} position='bottom-end' containerPosition='fixed'>
        {visibleToasts.map(toast => {
          const textStyle = toast.variant === 'warning' ? 'text-dark' : ''
          const onClose = () => {
            toast.onUndo?.()
            toast.onCancel?.()
            toast.onClose?.()
            removeToast(toast)
          }
          const buttonElement = toast.onUndo
            ? <div className={`${styles.toastUndo} ${textStyle}`}>undo</div>
            : toast.onCancel
              ? <div className={`${styles.toastCancel} ${textStyle}`}>cancel</div>
              : <div className={`${styles.toastClose} ${textStyle}`}>X</div>
          // a toast is unhidden if it was hidden before since it now gets rendered
          const unhidden = toast.hidden
          // we only need to start the animation at a different timing when it was hidden by another toast before.
          // if we don't do this, the animation for rerendered toasts skips ahead and toast delay and animation get out of sync.
          const elapsed = (+new Date() - toast.createdAt)
          const animationDelay = unhidden ? `-${elapsed}ms` : undefined
          const animationDuration = `${toast.delay}ms`
          return (
            <Toast
              key={toast.id} bg={toast.variant} show autohide={toast.autohide}
              delay={toast.delay} className={`${styles.toast} ${styles[toast.variant]} ${textStyle}`} onClose={() => removeToast(toast)}
            >
              <ToastBody>
                <div className='d-flex align-items-center'>
                  <div className='flex-grow-1'>{toast.body}</div>
                  <Button
                    variant={null}
                    className='p-0 ps-2'
                    aria-label='close'
                    onClick={onClose}
                  >{buttonElement}
                  </Button>
                </div>
              </ToastBody>
              {toast.progressBar && <div className={`${styles.progressBar} ${styles[toast.variant]}`} style={{ animationDuration, animationDelay }} />}
            </Toast>
          )
        })}
      </ToastContainer>
      {children}
    </ToastContext.Provider>
  )
}

export const useToast = () => useContext(ToastContext)

export const withToastFlow = (toaster) => flowFn => {
  const wrapper = async (...args) => {
    const {
      flowId,
      type: t,
      onPending,
      pendingMessage,
      onSuccess,
      onCancel,
      onError,
      onUndo,
      hideError,
      hideSuccess,
      skipToastFlow,
      timeout,
      ...toastProps
    } = flowFn(...args)
    let canceled

    if (skipToastFlow) return onPending()

    toaster.warning(pendingMessage || `${t} pending`, {
      progressBar: !!timeout,
      delay: timeout || TOAST_DEFAULT_DELAY_MS,
      onCancel: onCancel
        ? async () => {
          try {
            await onCancel()
            canceled = true
            toaster.warning(`${t} canceled`, { ...toastProps, flowId })
          } catch (err) {
            toaster.danger(`failed to cancel ${t}`, { ...toastProps, flowId })
          }
        }
        : undefined,
      onUndo: onUndo
        ? async () => {
          try {
            await onUndo()
            canceled = true
          } catch (err) {
            toaster.danger(`failed to undo ${t}`, { ...toastProps, flowId })
          }
        }
        : undefined,
      flowId,
      ...toastProps
    })
    try {
      const ret = await onPending()
      if (!canceled) {
        if (hideSuccess) {
          toaster.endFlow(flowId)
        } else {
          toaster.success(`${t} successful`, { ...toastProps, flowId })
        }
        await onSuccess?.()
      }
      return ret
    } catch (err) {
      // ignore errors if canceled since they might be caused by cancellation
      if (canceled) return
      const reason = err?.message?.toString().toLowerCase() || 'unknown reason'
      if (hideError) {
        toaster.endFlow(flowId)
      } else {
        toaster.danger(`${t} failed: ${reason}`, { ...toastProps, flowId })
      }
      await onError?.()
      throw err
    }
  }
  return wrapper
}