import bboxTurf from '@turf/bbox'
import { lineString } from '@turf/helpers'
import centroid from '@turf/centroid'
import olMap from 'ol/Map'
import { fromLonLat } from 'ol/proj'
import GeoJSON from 'ol/format/GeoJSON'
import olLayerVector from 'ol/layer/Vector'
import olVectorTile from 'ol/layer/VectorTile'
import olSourceCluster from 'ol/source/Cluster'
import olFeature from 'ol/Feature'
import olGeomPoint from 'ol/geom/Point'
import debounce from 'lodash.debounce'
import ugh from 'ugh'

/**
 * Creates a new feature with point geometry at the location of the pixel and attributes describing the pixel's value
 * @function
 * @category Popup
 * @since 1.17.0
 * @param {ol.Layer} layer - The openlayers layer
 * @param {Object} event - An object with a `map` and `pixel` property
 * @returns {ol.Feature} An openlayers feature with point geometry at the location of the pixel and attributes describing the pixel's value
 */
export const getPixelValue = (layer, event) => {
  const { map, pixel } = event
  const clickCoordinate = map.getCoordinateFromPixel(pixel)

  let renderedLayer = layer

  if (layer.isGeoserverLayer) renderedLayer = layer.getWMSLayer()

  const renderContext = renderedLayer?.getRenderer()?.context

  if (!renderContext) return null

  const pixelImageData = renderContext.getImageData(pixel[0], pixel[1], 1, 1)
  const [red, green, blue, alpha] = pixelImageData.data

  return new olFeature({
    geometry: new olGeomPoint(clickCoordinate),
    title: layer.getProperties().title || 'Raster Pixel',
    red,
    green,
    blue,
    alpha
  })
}

/**
 * Bind multiple move listeners with the same callback
 * @function
 * @category Popup
 * @since 0.2.0
 * @param {ol.Map} map - The openlayers map to which the events are bound
 * @param {Function} callback - The callback invoked when a `change:size`, `change:resolution` or a `change:center` event was fired
 * @returns {ol.EventsKey[]} Array of openlayers event keys for unsetting listener events (use with removeMovementListener)
 */
export const addMovementListener = (map, callback) => {
  if (typeof callback !== 'function') return ugh.error('\'addMovementListener\' requires a valid openlayers map & callback function') // eslint-disable-line

  // If performance becomes an issue with catalog layers & far zoom level, these debounce levels can be adjusted
  const slowDebounce = debounce(callback, 0)
  const fastDebounce = debounce(callback, 0)

  const keys = [
    map.on('change:size', slowDebounce),
    map.getView().on('change:resolution', slowDebounce),
    map.getView().on('change:center', fastDebounce)
  ]

  return keys
}

/**
 * Remove list of event keys
 * @function
 * @category Popup
 * @since 0.2.0
 * @param {ol.Map} map - The openlayers map to which the events are bound
 * @param {Array} keys - remove the listeners via` an array of event keys
 */
export const removeMovementListener = (keys = []) => {
  keys.forEach(({ target, type, listener }) => target.un(type, listener))
}

const setParentLayer = ({ features, layer }) => {
  return new Promise(resolve => {
    const parentLayer = layer.get('_ol_kit_parent')
    const parent = parentLayer || layer

    features.forEach((feature, i) => {
      // true makes this performant with a silent trigger: https://openlayers.org/en/latest/apidoc/module-ol_Feature.html#set
      feature.set('_ol_kit_parent', parent, true)

      return feature
    })

    resolve({ features, layer })
  })
}

const wfsSelector = (layer, event, opts) => {
  let features = []
  const source = layer.getSource()
  const { map, pixel } = event
  const clickCoordinate = map.getCoordinateFromPixel(pixel)
  // check for featuresAtPixel to account for hitTolerance
  const featuresAtPixel = map.getFeaturesAtPixel(pixel, {
    layerFilter: () => true,
    hitTolerance: opts.hitTolerance ? opts.hitTolerance : 3,
    checkWrapped: true
  })

  if (source instanceof olSourceCluster) {
    // support for clustered feature clicks
    const clusteredFeatures = source.getClosestFeatureToCoordinate(clickCoordinate).get('features')

    features = clusteredFeatures
  } else {
    const sourceFeatures = source.getFeatures()

    sourceFeatures.forEach(sourceFeature => {
      // check if any feature on layer source is also at click location
      const isAtPixel = featuresAtPixel ? featuresAtPixel.find(f => f === sourceFeature) : null

      if (isAtPixel) features.push(sourceFeature)
    })
  }

  if (features.length) return setParentLayer({ features, layer })
}

const vectorTileSelector = (layer, event, opts) => {
  const { map, pixel } = event
  // check for featuresAtPixel to account for hitTolerance
  const featuresAtPixel = map.getFeaturesAtPixel(pixel, {
    layerFilter: () => true,
    hitTolerance: opts.hitTolerance ? opts.hitTolerance : 3,
    checkWrapped: true
  })

  return new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
    const vectorTileSourceFeatures = await layer.getSource()
      .getFeaturesInExtent(map.getView().calculateExtent(map.getSize()))
    const matchingFeaturesAtPixel = vectorTileSourceFeatures.filter(sourceFeature => {
      const { ol_uid } = sourceFeature // eslint-disable-line camelcase

      let isFeatureAtClick = false

      featuresAtPixel.forEach(feat => {
        if (feat?.ol_uid === ol_uid) isFeatureAtClick = true // eslint-disable-line camelcase
      })

      return isFeatureAtClick
    })

    const { features } = await setParentLayer({ features: matchingFeaturesAtPixel, layer })

    resolve({ features, layer })
  })
}

/**
 * Get all features for a given click event
 * @function
 * @category Popup
 * @since 0.2.0
 * @param {Object} event - An object with a `map` and `pixel` property
 * @param {ol.Map} event.map - The openlayers map where the layer exists
 * @param {Number[]} event.pixel - An array consisting of `x` and `y` pixel locations
 * @param {Object} [opts] - Object of optional params
 * @param {Number} [opts.hitTolerance = 3] - Additional area around features that is clickable to select them
 * @returns {Promise[]} An array of promises, each of which resolve to an object `{ layer, features }`
 */
export const getLayersAndFeaturesForEvent = (event, opts = {}) => {
  if (typeof event !== 'object') return ugh.error('getLayersAndFeaturesForEvent first arg must be an object') // eslint-disable-line
  const { map, pixel } = event

  if (!(map instanceof olMap) || !Array.isArray(pixel)) return ugh.error('getLayersAndFeaturesForEvent requires a valid openlayers map & pixel location (as an array)') // eslint-disable-line

  const promises = map.getLayers().getArray().map(layer => {
    if (layer instanceof olVectorTile) {
      // handle vector tile features
      return vectorTileSelector(layer, event, opts)
    } else if (layer.isVectorLayer || layer instanceof olLayerVector || !layer.getLayerState().managed) { // layer.getLayerState().managed is an undocumented ol prop that lets us ignore select's vector layer
      // handle non vector tile wfs layers
      return wfsSelector(layer, event, opts)
    }
  }).filter(Boolean)

  const wmsSelector = layer => {
    if (layer.get('_ol_kit_parent')?.isGeoserverLayer) {
      // this logic handles clicks on GeoserverLayers
      const geoserverLayer = layer.get('_ol_kit_parent')
      const coords = map.getCoordinateFromPixel(pixel)
      const wmsPromise = new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
        const rawFeatures = await geoserverLayer.fetchFeaturesAtClick(coords, map)
        const { features } = await setParentLayer({ features: rawFeatures, layer })

        resolve({ features, layer })
      })

      promises.push(wmsPromise)
    }
  }

  // We have to look for ImageLayers with a parent GeoserverLayer this way, as the ImageLayer doesn't show up in map.getLayers()
  map.forEachLayerAtPixel(pixel, wmsSelector)

  return promises
}

/**
 * Get the best position for the popup to be displayed given features
 * @function
 * @category Popup
 * @param {Object} event - An object with an `event` and `pixel` property
 * @param {ol.Map} event.map - The openlayers map where the layer exists
 * @param {Number[]} event.pixel - An array consisting of `x` and `y` pixel locations
 * @param {ol.Feature[]} features - An array of features around which the popup should position
 * @param {Object} [opts]
 * @param {Number} [opts.popupHeight = 280] - The height of the popup
 * @param {Number} [opts.popupWidth = 280] - The width of the popup
 * @param {Number} [opts.arrowHeight = 16] - The height of the popup's arrow/pointer
 * @param {Number} [opts.navbarOffset = 55] - The height of the navbar
 * @param {Number[]} [opts.viewPadding = [0, 0, 0, 0]] - An array of padding to apply to the best fit logic in top, right, bottom, left order
 * @returns {Object} An object containing the arrow/pointer position, pixel location & if the popup will fit properly within the viewport
 */
export const getPopupPositionFromFeatures = (event, features = [], opts = {}) => {
  if (typeof event !== 'object' || !Array.isArray(features)) return ugh.error('getPopupPositionFromFeatures first arg must be an object & second arg array of features')
  const { map, pixel = [0, 0] } = event

  if (!(map instanceof olMap)) return ugh.error('getPopupPositionFromFeatures requires a valid openlayers map as a property of the first arg')
  if (!features.length) return { arrow: 'none', pixel, fits: false }
  const arrowHeight = opts.arrowHeight || 16
  const geoJSON = new GeoJSON({ featureProjection: 'EPSG:3857' })
  const height = opts.popupHeight || 280
  const width = opts.popupWidth || 280
  const fullHeight = height + arrowHeight
  const fullWidth = width + arrowHeight
  const [mapX, mapY] = map.getSize()

  const getPadding = (idx) => opts.viewPadding ? opts.viewPadding[idx] : calculateViewPadding(map)[idx]
  const padding = {
    top: getPadding(0),
    right: getPadding(1),
    bottom: getPadding(2),
    left: getPadding(3)
  }

  // find bbox for passed features
  const getFitsForFeatures = rawFeatures => {
    // create a new array so original features are not mutated when _ol_kit_parent is nullified
    const features = rawFeatures.map(feature => {
      const clone = feature.clone()

      // this removes a ref to _ol_kit_parent to solve circularJSON bug
      clone.set('_ol_kit_parent', null)
      clone.set('features', null)

      return clone
    })
    const jsonFeatures = geoJSON.writeFeatures(features)
    const [minX, minY, maxX, maxY] = bboxTurf(JSON.parse(jsonFeatures))

    return {
      top: getMidPixel([[minX, maxY], [maxX, maxY]]),
      right: getMidPixel([[maxX, maxY], [maxX, minY]]),
      bottom: getMidPixel([[minX, minY], [maxX, minY]]),
      left: getMidPixel([[minX, minY], [minX, maxY]])
    }
  }

  const getMidPixel = lineCoords => {
    const centerFeature = centroid(lineString(lineCoords))
    const coords = fromLonLat(centerFeature.geometry.coordinates)

    return map.getPixelFromCoordinate(coords)
  }

  const fitsRight = ([x, y]) => x + fullWidth <= mapX - padding.right && y >= (height / 2) + padding.top && y + (height / 2) <= mapY - padding.bottom // eslint-disable-line
  const fitsBelow = ([x, y]) => y + fullHeight - padding.bottom <= mapY && (width / 2) + padding.left <= x && x + (width / 2) - padding.right <= mapX // eslint-disable-line
  const fitsAbove = ([x, y]) => y - padding.top >= fullHeight && (width / 2) + padding.left <= x && x + (width / 2) - padding.right <= mapX // eslint-disable-line
  const fitsLeft = ([x, y]) => x + padding.left >= fullWidth && y >= (height / 2) + padding.top && y + (height / 2) <= mapY - padding.bottom // eslint-disable-line

  // the order of these checks determine which side is tried first (right, left, top, and then bottom)
  const getPosition = bbox => {
    if (fitsRight(bbox.right)) return { arrow: 'left', pixel: bbox.right, fits: true }
    if (fitsLeft(bbox.left)) return { arrow: 'right', pixel: bbox.left, fits: true }
    if (fitsAbove(bbox.top)) return { arrow: 'bottom', pixel: bbox.top, fits: true }
    if (fitsBelow(bbox.bottom)) return { arrow: 'top', pixel: bbox.bottom, fits: true }

    if (opts.lastPosition) {
      if (opts.lastPosition.arrow === 'left') return { arrow: 'left', pixel: bbox.right, fits: false }
      if (opts.lastPosition.arrow === 'top') return { arrow: 'top', pixel: bbox.bottom, fits: false }
      if (opts.lastPosition.arrow === 'bottom') return { arrow: 'bottom', pixel: bbox.top, fits: false }
      if (opts.lastPosition.arrow === 'right') return { arrow: 'right', pixel: bbox.left, fits: false }
      if (opts.lastPosition.arrow === 'none') return { arrow: 'none', pixel: opts.lastPosition.pixel, fits: false }
    }

    // if none of the above return, it doesn't fit on any side (it's on top of or within)
    return { arrow: 'none', pixel, fits: false }
  }

  const fitsForFeature = getFitsForFeatures(features)

  return getPosition(fitsForFeature)
}

/**
 * Calculate bounding box of elements on page with _popup_boundary class and returns padding array excluding area of these elements
 * @function
 * @category Popup
 * @param {olMap} map - An instance of an openlayers map
 * @param {Object} opts
 * @returns {Array} - Array of view padding pixel numbers: [top, right, bottom, left]
 */
export const calculateViewPadding = (map, opts = {}) => {
  if (!(map instanceof olMap)) return ugh.error('calculateViewPadding requires a valid openlayers map as arg')
  const viewPadding = [0, 0, 0, 0]
  const navbarOffset = opts.navbarOffset || 55
  const boundaryElements = Array.from(document.getElementsByClassName('_popup_boundary'))
  const [mapX, mapY] = map.getSize()

  boundaryElements.forEach(elem => {
    const bbox = elem.getBoundingClientRect()
    const isOffScreen = bbox.x < 0 || bbox.x >= mapX || bbox.y < navbarOffset || bbox.y >= mapY

    if (!isOffScreen) {
      if (bbox.right < mapX / 2) {
        // set left padding for elements on left half of map
        const newPadding = bbox.right

        if (viewPadding[3] < newPadding) viewPadding[3] = newPadding
      } else if (bbox.left > mapX / 2) {
        // set right padding for elements on right half of map
        const newPadding = mapX - bbox.left

        if (viewPadding[1] < newPadding) viewPadding[1] = newPadding
      } else if (bbox.top > mapY / 2) {
        // set bottom padding for elements on bottom half of map
        const newPadding = (mapY + navbarOffset) - bbox.top

        if (viewPadding[2] < newPadding) viewPadding[2] = newPadding
      } else if (bbox.y > navbarOffset) {
        // set top padding for elements lower than navbar
        const newPadding = bbox.bottom

        if (viewPadding[0] < newPadding) viewPadding[0] = newPadding
      }
    }
  })

  return viewPadding
}

/**
 * Remove blacklisted attributes (geom & geometry & _ol_kit*) from an object
 * @function
 * @category Popup
 * @since 0.11.0
 * @param {Object} properties - A feature attribute object
 * @returns {Object} A filtered attribute object
 */
export const sanitizeProperties = properties => {
  const blacklist = ['geom', 'geometry']
  const sanitized = {}

  for (const key in properties) {
    const keyLower = key.toLowerCase()

    if (!blacklist.includes(keyLower) && !keyLower.includes('_ol_kit')) {
      sanitized[key] = properties[key]
    }
  }

  return sanitized
}