import * as warning from 'warning'
import * as invariant from 'invariant'
import * as React from 'react'
import * as PropTypes from 'prop-types'
import * as ReactDOM from 'react-dom'
import { matchPath } from 'react-router'
import { isValidElementType } from 'react-is'

const isEmptyChildren = children => React.Children.count(children) === 0

enum LiveState {
  NORMAL_RENDER_MATCHED = 'normal matched render',
  NORMAL_RENDER_UNMATCHED = 'normal unmatched render (unmount)',
  NORMAL_RENDER_ON_INIT = 'normal render (matched or unmatched)',
  HIDE_RENDER = 'hide route when livePath matched'
}

type CacheDom = HTMLElement | null

interface IProps {
  computedMatch: any // private, from <Switch>
  path: string
  exact?: boolean
  strict?: boolean
  sensitive?: boolean
  component?: PropTypes.ReactComponentLike
  render?: React.StatelessComponent
  location: string
  livePath?: string
  alwaysLive: boolean
  onHide?: Function
  onReappear?: Function
  name?: string
}

const debugLog = (message: any) => {
  // console.log(message)
}

/**
 * The public API for matching a single path and rendering.
 */
class LiveRoute extends React.Component<IProps, any> {
  static propTypes = {
    computedMatch: PropTypes.object, // private, from <Switch>
    path: PropTypes.string,
    exact: PropTypes.bool,
    strict: PropTypes.bool,
    sensitive: PropTypes.bool,
    component: (props, propName): any => {
      if (props[propName] && !isValidElementType(props[propName])) {
        return new Error(`Invalid prop 'component' supplied to 'Route': the prop is not a valid React component`)
      }
    },
    render: PropTypes.func,
    children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
    location: PropTypes.object,
    onHide: PropTypes.func,
    livePath: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
    alwaysLive: PropTypes.bool,
    name: PropTypes.string // for LiveRoute debug
  }

  static defaultProps = {
    alwaysLive: false
  }

  static contextTypes = {
    router: PropTypes.shape({
      history: PropTypes.object.isRequired,
      route: PropTypes.object.isRequired,
      staticContext: PropTypes.object
    })
  }

  static childContextTypes = {
    router: PropTypes.object.isRequired
  }

  _latestMatchedRouter: any
  routeDom: CacheDom = null

  getChildContext() {
    return {
      router: {
        ...this.context.router,
        route: {
          location: this.props.location || this.context.router.route.location,
          match: this.state.match
        }
      }
    }
  }

  state = {
    match: this.computeMatch(this.props as any, this.context.router)
  }

  liveState: LiveState = LiveState.NORMAL_RENDER_ON_INIT
  scrollPosBackup: { left: number; top: number } | null = null
  previousDisplayStyle: string | null = null

  componentWillMount() {
    warning(
      !(this.props.component && this.props.render),
      'You should not use <Route component> and <Route render> in the same route; <Route render> will be ignored'
    )

    warning(
      !(this.props.component && this.props.children && !isEmptyChildren(this.props.children)),
      'You should not use <Route component> and <Route children> in the same route; <Route children> will be ignored'
    )

    warning(
      !(this.props.render && this.props.children && !isEmptyChildren(this.props.children)),
      'You should not use <Route render> and <Route children> in the same route; <Route children> will be ignored'
    )
  }

  componentDidMount() {
    // backup router and get DOM when mounting
    if (this.doesRouteEnableLive() && this.state.match) {
      this._latestMatchedRouter = this.context.router
      this.getRouteDom()
    }
  }

  componentWillReceiveProps(nextProps, nextContext) {
    warning(
      !(nextProps.location && !this.props.location),
      '<Route> elements should not change from uncontrolled to controlled (or vice versa). You initially used no "location" prop and then provided one on a subsequent render.'
    )

    warning(
      !(!nextProps.location && this.props.location),
      '<Route> elements should not change from controlled to uncontrolled (or vice versa). You provided a "location" prop initially but omitted it on a subsequent render.'
    )

    let match = this.computeMatch(nextProps, nextContext.router)
    let computedMatch = match

    // recompute match if enable live
    if (this.doesRouteEnableLive()) {
      computedMatch = this.computeMatchWithLive(this.props, nextProps, nextContext, match)
    }

    this.setState({
      match: computedMatch
    })
  }

  // get route of DOM
  componentDidUpdate(prevProps, prevState) {
    if (!this.doesRouteEnableLive()) {
      return
    }

    // restore display when matched normally
    debugLog(this.liveState)
    if (this.liveState === LiveState.NORMAL_RENDER_MATCHED) {
      this.showRoute()
      this.restoreScrollPosition()
      this.clearScroll()
    }

    // get DOM if match and render
    if (this.state.match) {
      this.getRouteDom()
    }
  }

  // clear on unmounting
  componentWillUnmount() {
    this.clearScroll()
  }

  doesRouteEnableLive() {
    return this.props.livePath || this.props.alwaysLive
  }

  /**
   * @param {*} props: this.props
   * @param {*} nextProps: nextProps
   * @param {*} nextContext: nextContext
   * @param {*} match: computed `match` of current path
   * @returns
   * the returned object will be computed by following orders:
   * If path matched (no matter livePath matched or not), return normal computed `match`
   * If livePath matched, return latest normal render `match`
   * If livePath unmatched, return normal computed `match`
   * @memberof Route
   * Back up current router every time it is rendered normally, backing up to the next livePath rendering
   */
  computeMatchWithLive(props, nextProps, nextContext, match) {
    debugLog(`>>> ` + this.props.name + ` <<<`)
    // compute if livePath match
    const { livePath, alwaysLive } = nextProps
    const nextPropsWithLivePath = { ...nextProps, paths: livePath }
    const prevMatch = this.computeMatch(props, this.context.router)
    const livePathMatch = this.computePathsMatch(nextPropsWithLivePath, nextContext.router)

    // normal matched render
    if (match) {
      debugLog('--- NORMAL MATCH FLAG ---')
      if (this.liveState === LiveState.HIDE_RENDER && typeof this.props.onReappear === 'function') {
        this.props.onReappear({ location, livePath, alwaysLive })
      }
      this.liveState = LiveState.NORMAL_RENDER_MATCHED
      return match
    }

    // hide render
    if ((livePathMatch || props.alwaysLive) && this.routeDom) {
      // backup router when from normal match render to hide render
      if (prevMatch) {
        this._latestMatchedRouter = this.context.router
      }
      if (typeof this.props.onHide === 'function') {
        this.props.onHide({ location, livePath, alwaysLive })
      }
      debugLog('--- HIDE FLAG ---')
      this.liveState = LiveState.HIDE_RENDER
      this.saveScrollPosition()
      this.hideRoute()
      return prevMatch
    }

    // normal unmatched unmount
    debugLog('--- NORMAL UNMATCH FLAG ---')
    this.liveState = LiveState.NORMAL_RENDER_UNMATCHED
    this.clearScroll()
    this.clearDomData()
  }

  computePathsMatch({ computedMatch, location, paths, strict, exact, sensitive }, router) {
    invariant(router, 'You should not use <Route> or withRouter() outside a <Router>')
    const { route } = router
    const pathname = (location || route.location).pathname

    // livePath could accept a string or an array of string
    if (Array.isArray(paths)) {
      for (let path of paths) {
        if (typeof path !== 'string') {
          continue
        }
        const currPath = matchPath(pathname, { path, strict, exact, sensitive }, router.match)
        // return if one of the livePaths is matched
        if (currPath) {
          return currPath
        }
      }
      return null
    } else {
      return matchPath(pathname, { path: paths, strict, exact, sensitive }, router.match)
    }
  }

  computeMatch({ computedMatch, location, path, strict, exact, sensitive }, router) {
    // DO NOT use the computedMatch from Switch!
    // react-live-route: ignore match from <Switch>, actually LiveRoute should not be wrapped by <Switch>.
    // if (computedMatch) return computedMatch // <Switch> already computed the match for us
    invariant(router, 'You should not use <Route> or withRouter() outside a <Router>')

    const { route } = router
    const pathname = (location || route.location).pathname

    return matchPath(pathname, { path, strict, exact, sensitive }, route.match)
  }

  // get DOM of Route
  getRouteDom() {
    let routeDom = ReactDOM.findDOMNode(this)
    this.routeDom = routeDom as CacheDom
  }

  // backup scroll and hide DOM
  hideRoute() {
    if (this.routeDom && this.routeDom.style.display !== 'none') {
      debugLog('--- hide route ---')
      this.previousDisplayStyle = this.routeDom.style.display
      this.routeDom.style.display = 'none'
    }
  }

  // reveal DOM display
  showRoute() {
    if (this.routeDom && this.previousDisplayStyle !== null) {
      this.routeDom.style.display = this.previousDisplayStyle
    }
  }

  // save scroll position before hide DOM
  saveScrollPosition() {
    if (this.routeDom && this.scrollPosBackup === null) {
      const scrollTop = document.documentElement.scrollTop || document.body.scrollTop
      const scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft
      debugLog(`saved top = ${scrollTop}, left = ${scrollLeft}`)
      this.scrollPosBackup = { top: scrollTop, left: scrollLeft }
    }
  }

  // restore the scroll position before hide
  restoreScrollPosition() {
    const scroll = this.scrollPosBackup
    debugLog(scroll)
    if (scroll && this.routeDom) {
      window.scrollTo(scroll.left, scroll.top)
    }
  }

  // clear scroll position
  clearDomData() {
    if (this.doesRouteEnableLive()) {
      this.routeDom = null
      this.previousDisplayStyle = null
    }
  }

  // clear scroll position
  clearScroll() {
    if (this.doesRouteEnableLive()) {
      this.scrollPosBackup = null
    }
  }

  // normally render or unmount Route
  renderRoute(component, render, props, match) {
    debugLog(match)
    if (component) return match ? React.createElement(component, props) : null
    if (render) return match ? render(props) : null
  }

  render() {
    const { match } = this.state
    const { children, component, render, livePath, alwaysLive, onHide } = this.props
    const { history, route, staticContext } = this.context.router
    const location = this.props.location || route.location
    const props = { match, location, history, staticContext }

    // only affect LiveRoute
    if ((livePath || alwaysLive) && (component || render)) {
      debugLog('=== RENDER FLAG: ' + this.liveState + ' ===')
      if (
        this.liveState === LiveState.NORMAL_RENDER_MATCHED ||
        this.liveState === LiveState.NORMAL_RENDER_UNMATCHED ||
        this.liveState === LiveState.NORMAL_RENDER_ON_INIT
      ) {
        // normal render
        return this.renderRoute(component, render, props, match)
      } else if (this.liveState === LiveState.HIDE_RENDER) {
        // hide render
        const prevRouter = this._latestMatchedRouter
        const { history, route, staticContext } = prevRouter // load properties from prevRouter and fake props of latest normal render
        const liveProps = { match, location, history, staticContext }
        return this.renderRoute(component, render, liveProps, true)
      }
    }

    // the following is the same as Route of react-router, just render it normally
    if (component) return match ? React.createElement(component, props) : null

    if (render) return match ? render(props as any) : null

    if (typeof children === 'function') return children(props)

    if (children && !isEmptyChildren(children)) return React.Children.only(children)

    return null
  }
}

export { LiveRoute }