///////////////////////////////////////////////////////////
// ReflexContainer
// By Philippe Leefsma
// December 2016
//
///////////////////////////////////////////////////////////
import ReflexSplitter from './ReflexSplitter'
import ReflexEvents from './ReflexEvents'
import {getDataProps} from './utilities'
import PropTypes from 'prop-types'
import React from 'react'
import './Polyfills'

export default class ReflexContainer extends React.Component {

  /////////////////////////////////////////////////////////
  // orientation: Orientation of the layout container
  //              valid values are ['horizontal', 'vertical'] 
  // maxRecDepth: Maximun recursion depth to solve initial flex
  //              of layout elements based on user provided values
  // className: Space separated classnames to apply custom styles 
  //            to the layout container  
  // style: allows passing inline style to the container
  /////////////////////////////////////////////////////////
  static propTypes = {
    windowResizeAware: PropTypes.bool,
    orientation: PropTypes.oneOf([
      'horizontal', 'vertical'
    ]),
    maxRecDepth: PropTypes.number,
    className: PropTypes.string,
    style: PropTypes.object
  }

  static defaultProps = {
    orientation: 'horizontal',
    windowResizeAware: false,
    maxRecDepth: 100,
    className: '',
    style: {}
  }

  constructor (props) {
    super (props)
    this.events = new ReflexEvents()
    this.children = []
    this.state = {
      flexData: []
    }
    this.ref = React.createRef()
  }
  
  componentDidMount () {

    const flexData = this.computeFlexData()

    const {windowResizeAware} = this.props

    if (windowResizeAware) {
      window.addEventListener(
        'resize', this.onWindowResize)
    }

    this.setState ({
      windowResizeAware,
      flexData
    })

    this.events.on(
      'element.size', this.onElementSize)

    this.events.on(
      'startResize', this.onStartResize)

    this.events.on(
      'stopResize', this.onStopResize)

    this.events.on(
      'resize', this.onResize)
  }

  componentWillUnmount () {

    this.events.off()

    window.removeEventListener(
      'resize', this.onWindowResize)
  }

  getValidChildren (props = this.props) {
    return this.toArray(props.children).filter((child) => {
      return !!child
    })
  }

  componentDidUpdate (prevProps, prevState) {

    const children = this.getValidChildren(this.props)

    if ((children.length !== this.state.flexData.length) ||
        (prevProps.orientation !== this.props.orientation) || 
        this.flexHasChanged(prevProps)) {

      const flexData = this.computeFlexData(
        children, this.props)

      this.setState({
        flexData
      })
    }

    if (this.props.windowResizeAware !== this.state.windowResizeAware) {
      !this.props.windowResizeAware
        ?  window.removeEventListener('resize', this.onWindowResize)
        : window.addEventListener('resize', this.onWindowResize)
      this.setState({
        windowResizeAware: this.props.windowResizeAware
      })
    }
  }

  // UNSAFE_componentWillReceiveProps(props) {

  //   const children = this.getValidChildren(props)

  //   if (children.length !== this.state.flexData.length || 
  //     props.orientation !== this.props.orientation || 
  //     this.flexHasChanged(props)) 
  //   {
  //     const flexData = this.computeFlexData(
  //       children, props)

  //     this.setState({
  //       flexData
  //     });
  //   }

  //   if (props.windowResizeAware !== this.state.windowResizeAware) {
  //     !props.windowResizeAware
  //       ? window.removeEventListener('resize', this.onWindowResize)
  //       : window.addEventListener('resize', this.onWindowResize)
  //     this.setState({
  //       windowResizeAware: props.windowResizeAware
  //     })
  //   }
  // } 

  /////////////////////////////////////////////////////////
  // attempts to preserve current flex on window resize
  //
  /////////////////////////////////////////////////////////
  onWindowResize = () => {

    this.setState({
      flexData: this.computeFlexData()
    })
  }

  /////////////////////////////////////////////////////////
  // Check if flex has changed: this allows updating the
  // component when different flex is passed as property
  // to one or several children
  //
  /////////////////////////////////////////////////////////
  flexHasChanged (prevProps) {

    const prevChildrenFlex =
      this.getValidChildren(prevProps).map((child) => {
        return child.props.flex || 0
      })

    const childrenFlex =
      this.getValidChildren().map((child) => {
        return child.props.flex || 0
      })

    return !childrenFlex.every((flex, idx) => {
      return flex === prevChildrenFlex[idx]
    })
  }

  /////////////////////////////////////////////////////////
  // Returns size of a ReflexElement
  //
  /////////////////////////////////////////////////////////
  getSize (element) { 

    const ref = element?.props.ref || element?.ref

    const domElement = ref?.current

    switch (this.props.orientation) {
      case 'horizontal':
        return domElement?.offsetHeight ?? 0
      case 'vertical':
      default:
        return domElement?.offsetWidth ?? 0
    }
  }

  /////////////////////////////////////////////////////////
  // Computes offset from pointer position
  //
  /////////////////////////////////////////////////////////
  getOffset (pos, domElement) {

    const {
      top, bottom,
      left, right
    } = domElement.getBoundingClientRect()

    switch (this.props.orientation) {
      case 'horizontal': {
        const offset = pos.clientY - this.previousPos
        if (offset > 0) {
          if (pos.clientY >= top) {
            return offset
          }
        } else {
          if (pos.clientY <= bottom) {
            return offset
          }
        }
        break
      }
      case 'vertical': 
      default: {
        const offset = pos.clientX - this.previousPos
        if (offset > 0) {
          if (pos.clientX > left) {
            return offset
          }
        } else {
          if (pos.clientX < right) {
            return offset
          }
        }
      }
      break
    }
    return 0
  }

  /////////////////////////////////////////////////////////
  // Handles startResize event
  //
  /////////////////////////////////////////////////////////
  onStartResize = (data) => {

    const pos = data.event.changedTouches 
      ? data.event.changedTouches[0]
      : data.event

    switch (this.props.orientation) {

      case 'horizontal':
        document.body.classList.add('reflex-row-resize')
        this.previousPos = pos.clientY
        break

      case 'vertical':
      default:
        document.body.classList.add('reflex-col-resize')
        this.previousPos = pos.clientX
        break
    }

    this.elements = [
      this.children[data.index - 1],
      this.children[data.index + 1]
    ]

    this.emitElementsEvent(
      this.elements, 
      'onStartResize')
  }

  /////////////////////////////////////////////////////////
  // Handles splitter resize event
  //
  /////////////////////////////////////////////////////////
  onResize = (data) => {

    const pos = data.event.changedTouches 
      ? data.event.changedTouches[0] 
      : data.event

    const offset = this.getOffset(
      pos, data.domElement)

    switch (this.props.orientation) {
      case 'horizontal':
        this.previousPos = pos.clientY
        break
      case 'vertical':
      default:
        this.previousPos = pos.clientX
        break
    }

    if (offset) {
     
      const availableOffset =
        this.computeAvailableOffset(
          data.index, offset)

      if (availableOffset) {

        this.elements = this.dispatchOffset(
          data.index, availableOffset)

        this.adjustFlex(this.elements)

        this.setState({
          resizing: true
        }, () => {
          this.emitElementsEvent(
            this.elements, 'onResize')
        })
      }
    }
  }

  /////////////////////////////////////////////////////////
  // Handles stopResize event
  //
  /////////////////////////////////////////////////////////
  onStopResize = (data) => {

    document.body.classList.remove('reflex-row-resize')
    document.body.classList.remove('reflex-col-resize')

    const resizedRefs = this.elements ? this.elements.map(element => {
      return element.props.ref || element.ref
    }) : [];

    const elements = this.children.filter(child => {
      return !ReflexSplitter.isA(child) &&
        resizedRefs.includes(child.props.ref || child.ref)
    })

    this.emitElementsEvent(
      elements, 'onStopResize')

    this.setState({
      resizing: false
    })
  }

  /////////////////////////////////////////////////////////
  // Handles element size modified event
  //
  /////////////////////////////////////////////////////////
  onElementSize = (data) => {

    return new Promise((resolve) => {

      try {

        const idx = data.index

        const size = this.getSize(this.children[idx])

        const offset = data.size - size

        const dir = data.direction

        const splitterIdx = idx + dir

        const availableOffset =
          this.computeAvailableOffset(
            splitterIdx, dir * offset)

        this.elements = null

        if (availableOffset) {

          this.elements = this.dispatchOffset(
            splitterIdx, availableOffset)

          this.adjustFlex(this.elements)
        }

        this.setState(this.state, () => {
          this.emitElementsEvent(
            this.elements, 'onResize')
          resolve()
        })

      } catch (ex) {

        // TODO handle exception ...
        console.log(ex)
      }
    })
  }

  /////////////////////////////////////////////////////////
  // Adjusts flex after a dispatch to make sure
  // total flex of modified elements remains the same
  //
  /////////////////////////////////////////////////////////
  adjustFlex (elements) {

    const diffFlex = elements.reduce((sum, element) => {

      const idx = element.props.index

      const previousFlex = element.props.flex

      const nextFlex = this.state.flexData[idx].flex

      return sum +
        (previousFlex - nextFlex) / elements.length

    }, 0)

    elements.forEach((element) => {
      this.state.flexData[element.props.index].flex
        += diffFlex
    })
  }

  /////////////////////////////////////////////////////////
  // Returns available offset for a given raw offset value
  // This checks how much the panes can be stretched and
  // shrink, then returns the min
  //
  /////////////////////////////////////////////////////////
  computeAvailableOffset (idx, offset) {

    const stretch = this.computeAvailableStretch(
      idx, offset)

    const shrink = this.computeAvailableShrink(
      idx, offset)

    const availableOffset =
      Math.min(stretch, shrink) *
      Math.sign(offset)

    return availableOffset
  }

  /////////////////////////////////////////////////////////
  // Returns true if the next splitter than the one at idx
  // can propagate the drag. This can happen if that
  // next element is actually a splitter and it has
  // propagate=true property set
  //
  /////////////////////////////////////////////////////////
  checkPropagate (idx, direction) {

    if (direction > 0) {

      if (idx < this.children.length - 2) {

        const child = this.children[idx + 2]

        const typeCheck = ReflexSplitter.isA(child)

        return typeCheck && child.props.propagate
      }

    } else {

      if (idx > 2) {

        const child = this.children[idx - 2]

        const typeCheck = ReflexSplitter.isA(child)

        return typeCheck && child.props.propagate
      }
    }

    return false
  }

  /////////////////////////////////////////////////////////
  // Recursively computes available stretch at splitter
  // idx for given raw offset
  //
  /////////////////////////////////////////////////////////
  computeAvailableStretch (idx, offset) {

    const childIdx = offset < 0 ? idx + 1 : idx - 1

    const child = this.children[childIdx]

    const size = this.getSize(child)

    const maxSize = child?.props.maxSize ?? 0

    const availableStretch = maxSize - size

    if (availableStretch < Math.abs(offset)) {

      if (this.checkPropagate(idx, -1 * offset)) {

        const nextOffset = Math.sign(offset) *
          (Math.abs(offset) - availableStretch)

        return availableStretch +
          this.computeAvailableStretch(
            offset < 0 ? idx + 2 : idx - 2,
            nextOffset)
      }
    }

    return Math.min(availableStretch, Math.abs(offset))
  }

  /////////////////////////////////////////////////////////
  // Recursively computes available shrink at splitter
  // idx for given raw offset
  //
  /////////////////////////////////////////////////////////
  computeAvailableShrink (idx, offset) {

    const childIdx = offset > 0 ? idx + 1 : idx -1

    const child = this.children[childIdx]

    const size = this.getSize(child)

    const minSize = Math.max(
      child?.props.minSize ?? 0, 0)

    const availableShrink = size - minSize

    if (availableShrink < Math.abs(offset)) {

      if (this.checkPropagate(idx, offset)) {

        const nextOffset = Math.sign(offset) *
          (Math.abs(offset) - availableShrink)

        return availableShrink +
          this.computeAvailableShrink(
            offset > 0 ? idx + 2 : idx - 2,
            nextOffset)
      }
    }

    return Math.min(availableShrink, Math.abs(offset))
  }

  /////////////////////////////////////////////////////////
  // Returns flex value for unit pixel
  //
  /////////////////////////////////////////////////////////
  computePixelFlex (orientation = this.props.orientation) {
    if (!this.ref.current) {
      console.warn('Unable to locate ReflexContainer dom node');
      return 0.0;
    }

    switch (orientation) {

      case 'horizontal':

        if (this.ref.current.offsetHeight === 0.0) {
          console.warn(
            'Found ReflexContainer with height=0, ' +
            'this will cause invalid behavior...')
          console.warn(this.ref.current)
          return 0.0
        }

        return 1.0 / this.ref.current.offsetHeight

      case 'vertical':
      default:

        if (this.ref.current.offsetWidth === 0.0) {
          console.warn(
            'Found ReflexContainer with width=0, ' +
            'this will cause invalid behavior...')
          console.warn(this.ref.current)
          return 0.0
        }

        return 1.0 / this.ref.current.offsetWidth
    }
  }

  /////////////////////////////////////////////////////////
  // Adds offset to a given ReflexElement
  //
  /////////////////////////////////////////////////////////
  addOffset (element, offset) {

    const size = this.getSize(element)

    const idx = element.props.index

    const newSize = Math.max(size + offset, 0)

    const currentFlex = this.state.flexData[idx].flex

    const newFlex = (currentFlex > 0)
        ? currentFlex * newSize / size
        : this.computePixelFlex () * newSize

    this.state.flexData[idx].flex =
      (!isFinite(newFlex) || isNaN(newFlex))
        ? 0 : newFlex
  }

  /////////////////////////////////////////////////////////
  // Recursively dispatches stretch offset across
  // children elements starting at splitter idx
  //
  /////////////////////////////////////////////////////////
  dispatchStretch (idx, offset) {

    const childIdx = offset < 0 ? idx + 1 : idx - 1

    if (childIdx < 0 || childIdx > this.children.length-1) {

      return []
    }

    const child = this.children[childIdx]

    const size = this.getSize(child)

    const newSize = Math.min(
      child.props.maxSize,
      size + Math.abs(offset))

    const dispatchedStretch = newSize - size

    this.addOffset(child, dispatchedStretch)

    if (dispatchedStretch < Math.abs(offset)) {

      const nextIdx = idx - Math.sign(offset) * 2

      const nextOffset = Math.sign(offset) *
        (Math.abs(offset) - dispatchedStretch)

      return [
        child,
        ...this.dispatchStretch(nextIdx, nextOffset)
      ]
    }

    return [child]
  }

  /////////////////////////////////////////////////////////
  // Recursively dispatches shrink offset across
  // children elements starting at splitter idx
  //
  /////////////////////////////////////////////////////////
  dispatchShrink (idx, offset) {

    const childIdx = offset > 0 ? idx + 1 : idx - 1

    if (childIdx < 0 || childIdx > this.children.length-1) {

      return []
    }

    const child = this.children[childIdx]

    const size = this.getSize(child)

    const newSize = Math.max(
      child.props.minSize,
      size - Math.abs(offset))

    const dispatchedShrink = newSize - size

    this.addOffset(child, dispatchedShrink)

    if (Math.abs(dispatchedShrink) < Math.abs(offset)) {

      const nextIdx = idx + Math.sign(offset) * 2

      const nextOffset = Math.sign(offset) *
        (Math.abs(offset) + dispatchedShrink)

      return [
        child,
        ...this.dispatchShrink(nextIdx, nextOffset)
      ]
    }

    return [child]
  }

  /////////////////////////////////////////////////////////
  // Dispatch offset at splitter idx
  //
  /////////////////////////////////////////////////////////
  dispatchOffset (idx, offset) {
    return [
      ...this.dispatchStretch(idx, offset),
      ...this.dispatchShrink(idx, offset)
    ]
  }

  /////////////////////////////////////////////////////////
  // Emits given if event for each given element
  // if present in the component props
  //
  /////////////////////////////////////////////////////////
  emitElementsEvent (elements, event) {
    this.toArray(elements).forEach(component => {
      if (component.props[event]) {
        const compRef = component.props.ref || component.ref
        component.props[event]({
          domElement: compRef?.current,
          component
        })
      }
    })
  }

  /////////////////////////////////////////////////////////
  // Computes initial flex data based on provided flex
  // properties. By default each ReflexElement gets
  // evenly arranged within its container
  //
  /////////////////////////////////////////////////////////
  computeFlexData (
    children = this.getValidChildren(), 
    props = this.props) {

    const pixelFlex = this.computePixelFlex(props.orientation)

    const computeFreeFlex = (flexData) => {
      return flexData.reduce((sum, entry) => {
        if (!ReflexSplitter.isA(entry)
          && entry.constrained) {
          return sum - entry.flex
        }
        return sum
      }, 1.0)
    }

    const computeFreeElements = (flexData) => {
      return flexData.reduce((sum, entry) => {
        if (!ReflexSplitter.isA(entry)
          && !entry.constrained) {
          return sum + 1
        }
        return sum
      }, 0.0)
    }

    const flexDataInit = children.map((child) => {
      const props = child.props
      return {
        maxFlex: (props.maxSize || Number.MAX_VALUE) * pixelFlex,
        sizeFlex: (props.size || Number.MAX_VALUE) * pixelFlex,
        minFlex: (props.minSize || 1) * pixelFlex,
        constrained: props.flex !== undefined,
        flex: props.flex || 0,
        type: child.type
      }
    })

    const computeFlexDataRec = (flexDataIn, depth=0) => {

      let hasContrain = false

      const freeElements = computeFreeElements(flexDataIn)

      const freeFlex = computeFreeFlex(flexDataIn)

      const flexDataOut = flexDataIn.map(entry => {

        if (ReflexSplitter.isA(entry)) {
          return entry
        }

        const proposedFlex = !entry.constrained
          ? freeFlex/freeElements
          : entry.flex

        const constrainedFlex =
          Math.min(entry.sizeFlex,
            Math.min(entry.maxFlex,
              Math.max(entry.minFlex,
                proposedFlex)))

        const constrained = entry.constrained ||
          (constrainedFlex !== proposedFlex)

        hasContrain = hasContrain || constrained

        return { 
          ...entry,
          flex: constrainedFlex,
          constrained
        }
      })

      return (hasContrain && depth < this.props.maxRecDepth)
        ? computeFlexDataRec(flexDataOut, depth+1)
        : flexDataOut
    }

    const flexData = computeFlexDataRec(flexDataInit)

    return flexData.map(entry => {
      return {
          flex: !ReflexSplitter.isA(entry)
            ? entry.flex 
            : 0.0,
          ref: React.createRef()
       }
    })
  }

  /////////////////////////////////////////////////////////
  // Utility method to ensure given argument is
  // returned as an array
  //
  /////////////////////////////////////////////////////////
  toArray (obj) {
    return obj ? (Array.isArray(obj) ? obj : [obj]) : []
  }

  /////////////////////////////////////////////////////////
  // Render container. This will clone all original child
  // components in order to pass some internal properties
  // used to handle resizing logic
  //
  /////////////////////////////////////////////////////////
  render () {

    const className = [
      this.state.resizing ? 'reflex-resizing':'',
      ...this.props.className.split(' '),
      this.props.orientation,
      'reflex-container'
    ].join(' ').trim()

    this.children = React.Children.map(
      this.getValidChildren(), (child, index) => {

        if (index > this.state.flexData.length - 1) {
          return <div/>
        }

        const flexData = this.state.flexData[index]

        const newProps = {
          ...child.props,
          maxSize: child.props.maxSize || Number.MAX_VALUE,
          orientation: this.props.orientation,
          minSize: child.props.minSize || 1,
          events: this.events,
          flex: flexData.flex,
          ref: flexData.ref,
          index
        }

        return React.cloneElement(child, newProps)
      })

    return (
      <div
        {...getDataProps(this.props)}
        style={this.props.style}
        className={className}
        ref={this.ref}>
        { this.children }
      </div>
    )
  }
}


