<script setup lang="ts">
import clamp from 'lodash/clamp'
import debounce from 'lodash/debounce'
import get from 'lodash/get'
import kebabCase from 'lodash/kebabCase'
import keys from 'lodash/keys'
import pickBy from 'lodash/pickBy'

import { drag } from 'd3-drag'
import { geoRobinson } from 'd3-geo-projection'
import type { GeoProjection } from 'd3-geo'
import { json } from 'd3-fetch'
import { pointer as d3Pointer, select } from 'd3-selection'
import type { Selection } from 'd3-selection'
import { zoom as d3Zoom, zoomIdentity as d3ZoomIdentity } from 'd3-zoom'
import 'd3-transition'
import { feature } from 'topojson-client'
import type { GeometryCollection, Topology } from 'topojson-specification'
import type { Feature, Geometry } from 'geojson'

import {
  ComponentPublicInstance,
  computed,
  provide,
  ref,
  toRef,
  watch
} from 'vue'

import { ParentKey } from '@/keys'
import { MapTransform, ParentMap } from '@/types'
import config from '@/config'
import configDefaults from '@/config.default'
import { getChartProps, useChart } from '@/composables/useChart'
import { useChoropleth } from '@/composables/useChoropleth'
import { useMapProjection } from '@/composables/useMapProjection'
import ScaleLegend from '@/components/Legend/LegendScale.vue'

defineOptions({
  name: 'ChoroplethMap'
})

export interface ChoroplethMapProps {
  /**
   * Covers the empty values with a hatched pattern.
   */
  hatchEmpty?: boolean
  /**
   * Hide the legend of the map.
   */
  hideLegend?: boolean
  /**
   * Change the scale function used to get calculate a feature color.
   */
  featureColorScale?: ((v: any) => string) | null
  /**
   * Change the color of the outline.
   */
  outlineColor?: string
  /**
   * Change the color of the graticule.
   */
  graticuleColor?: string
  /**
   * Maximum value to use in the color scale.
   */
  max?: number | null
  /**
   * Minimum value to use in the color scale.
   */
  min?: number | null
  /**
   * If true the map should be clickable (and zoom on a given feature).
   */
  clickable?: boolean
  /**
   * Field in the topojson containing all the feature objects.
   */
  topojsonObjects?: string
  /**
   * Field in the topojson objects containing the id of a feature. This field supports dot notation for nested values.
   */
  topojsonObjectsPath?: string | string[]
  /**
   * URL of the topojson.
   */
  topojsonUrl?: string
  /**
   * Duration of the transitions.
   */
  transitionDuration?: number
  /**
   * If true the user will be able to navigate in the map with drag and mouse wheel.
   */
  zoomable?: boolean
  /**
   * Set to true if your projection is spherical.
   */
  spherical?: boolean
  /**
   * Minimum zoom value.
   */
  zoomMin?: number
  /**
   * Maximum zoom value.
   */
  zoomMax?: number
  /**
   * Initial zoom value.
   */
  zoom?: number | null
  /**
   * Initial center of the map.
   */
  center?: number[] | null
  /**
   * Projection object from d3 to draw the features.
   * @see https://d3js.org/d3-geo/projection
   */
  projection?: () => GeoProjection
  /**
   * If true the map will display a sphere outline around the world.
   */
  outline?: boolean
  /**
   * If true the map will display a graticule grid (representing parallels and meridians).
   */
  graticule?: boolean
  /**
   * Maximum height used by the map.
   */
  height?: string
  /**
   * Neutral color of the map's features.
   */
  color?: string
  /**
   * Neutral color of the map s features in social mode.
   */
  socialColor?: string
  data?: string | Record<string, number> | null
  dataUrlType?: 'json' | 'csv' | 'tsv'
  chartHeightRatio?: number
  socialMode?: boolean
  socialModeRatio?: number
}

const props = withDefaults(defineProps<ChoroplethMapProps>(), {
  hatchEmpty: false,
  hideLegend: false,
  featureColorScale: null,
  outlineColor: 'currentColor',
  graticuleColor: 'currentColor',
  max: null,
  min: null,
  clickable: false,
  topojsonObjects: 'countries1',
  topojsonObjectsPath: 'id',
  topojsonUrl: () =>
    config.get(
      'map.topojson.world-countries-sans-antarctica',
      configDefaults['map.topojson.world-countries-sans-antarctica']
    ),
  transitionDuration: 750,
  zoomable: false,
  spherical: false,
  zoomMin: 1,
  zoomMax: 8,
  zoom: null,
  center: null,
  projection: geoRobinson,
  outline: false,
  graticule: false,
  height: '300px',
  color: '#fff',
  socialColor: '#000',
  data: null,
  dataUrlType: 'json',
  chartHeightRatio: undefined,
  socialMode: false,
  socialModeRatio: 5 / 4
})

const emit = defineEmits<{
  click: [d: any]
  reset: []
  zoomed: [d: any]
  loaded: [data: Record<string, number> | null]
  resized: []
}>()

const resizable = ref<ComponentPublicInstance<HTMLElement> | null>(null)
const topojson = ref<Topology | null>(null)
const topojsonPromise = ref<Promise<Topology> | null>(null)
const mapRect = ref<DOMRect>(new DOMRect(0, 0, 0, 0))
const featureCursor = ref<string | null>(null)
const featureZoom = ref<string | null>(null)
const isLoaded = ref<boolean>(false)
const mapTransform = ref<MapTransform>({
  k: 1,
  x: 0,
  y: 0,
  rotateX: 0,
  rotateY: 0
})

const debouncedDraw = debounce(function () {
  draw()
}, 10)

const { loadedData } = useChart<Record<string, number>>(
  resizable,
  getChartProps(props),
  { emit },
  isLoaded,
  debouncedDraw,
  afterLoaded
)

async function afterLoaded() {
  return new Promise<void>((resolve) => {
    return loadTopojson().then(() => {
      draw()
      resolve()
      return
    })
  })
}

// The two endpoint colors are read from the rendered SVG's computed style, so
// they stay in the component (the projection/color composables are DOM-free).
// `socialMode` is always different from null but accessing it makes this
// computed property reactive to social-mode toggles.
const featureColorScaleStart = computed(() => {
  const defaultColor = '#fff'
  const node = map.value?.node()
  if (isLoaded.value && props.socialMode !== null && node) {
    const computedStyle = window.getComputedStyle(node)
    return computedStyle.getPropertyValue('color') || defaultColor
  }
  return defaultColor
})

const featureColorScaleEnd = computed(() => {
  const defaultColor = '#7a0177'
  const node = map.value?.node()
  if (isLoaded.value && node) {
    const computedStyle = window.getComputedStyle(node)
    return computedStyle.getPropertyValue('--bs-primary') || defaultColor
  }
  return defaultColor
})

const {
  maxValue,
  minValue,
  featureColorScaleFunction,
  featureColor
} = useChoropleth({
  loadedData,
  topojsonObjectsPath: toRef(() => props.topojsonObjectsPath),
  max: toRef(() => props.max),
  min: toRef(() => props.min),
  featureColorScale: toRef(() => props.featureColorScale),
  colorScaleStart: featureColorScaleStart,
  colorScaleEnd: featureColorScaleEnd
})

const hasCursor = computed(() => {
  return !!featureCursor.value
})

const hasZoom = computed(() => {
  return !!featureZoom.value
})

const geojson = computed(() => {
  if (!topojson.value) {
    return { type: 'FeatureCollection', features: [] } as const
  }
  const object = get(
    topojson.value,
    ['objects', props.topojsonObjects],
    null
  )
  return feature(topojson.value, object as GeometryCollection)
})

const mapClass = computed(() => {
  return {
    'choropleth-map--has-cursor': hasCursor.value,
    'choropleth-map--has-zoom': hasZoom.value,
    'choropleth-map--hatch-empty': props.hatchEmpty
  }
})

const mapZoom = computed(() => {
  return d3Zoom()
    .scaleExtent([props.zoomMin, props.zoomMax])
    .translateExtent([
      [0, 0],
      [mapWidth.value, mapHeight.value]
    ])
    .on('zoom', mapZoomed)
})

const mapSphericalZoom = computed(() => {
  return d3Zoom()
    .scaleExtent([props.zoomMin, props.zoomMax])
    .on('zoom', mapSphericalZoomed)
})

const mapRotate = computed(() => {
  return drag().on('drag', mapRotated)
})

// The zoom behavior actually bound to the selection (see prepareZoom): spherical
// maps scale via mapSphericalZoom, planar maps via mapZoom. Programmatic zoom
// must dispatch through this one so the matching handler runs.
const activeZoomBehavior = computed(() => {
  return props.spherical ? mapSphericalZoom.value : mapZoom.value
})

const mapHeight = computed(() => {
  return mapRect.value.height
})

const mapWidth = computed(() => {
  return mapRect.value.width
})

const {
  mapProjection,
  featurePath,
  initialFeaturePath,
  initialGraticulePath,
  rotatingMapProjection,
  graticuleLines,
  mapCenter
} = useMapProjection({
  projection: toRef(() => props.projection),
  geojson,
  width: mapWidth,
  height: mapHeight,
  spherical: toRef(() => props.spherical),
  center: toRef(() => props.center),
  transform: mapTransform
})

const mapStyle = computed(() => {
  const {
    k = 0,
    x = 0,
    y = 0,
    rotateX = 0,
    rotateY = 0
  } = mapTransform.value
  return {
    '--map-height': props.height,
    '--map-color': props.color,
    '--map-social-color': props.socialColor,
    '--map-scale': k,
    '--map-translate-x': x,
    '--map-translate-y': y,
    '--map-rotate-x': rotateX,
    '--map-rotate-y': rotateY
  }
})

const map = computed(
  (): Selection<SVGElement, unknown, null, undefined> | null => {
    const selection = select(resizable.value).select<SVGElement>('svg')
    if (!selection) {
      throw new Error('Empty SVG selection')
    }
    return selection
  }
)

const transformOrigin = computed(() => {
  return props.spherical ? '50% 50%' : '0 0'
})

function setMapNodeSize({ width, height }: { width: number, height: number }) {
  const node = map.value?.node()
  if (node) {
    (node as any)['width'] = width;
    (node as any)['height'] = height
  }
}

const cursorValue = computed(() => {
  if (featureCursor.value && loadedData.value) {
    return loadedData.value[featureCursor.value] ?? null
  }
  return null
})

const isReady = computed(() => {
  return isLoaded.value && topojson.value
})

function prepare() {
  if (!map.value) {
    throw new Error('Map is null')
  }
  // Set the map sizes
  mapRect.value = map.value.node()?.getBoundingClientRect() as DOMRect
  // Remove any existing country
  map.value.selectAll('.choropleth-map__main__outline > *').remove()
  map.value.selectAll('.choropleth-map__main__graticule > *').remove()
  map.value.selectAll('.choropleth-map__main__features > *').remove()
  // Return the map to allow chaining
  return map.value
}

function prepareZoom() {
  // User can zoom on the map. A spherical map rotates and scales through its
  // dedicated behaviors; a planar map uses mapZoom. Binding both (or binding
  // mapZoom twice) would install conflicting zoom handlers on the selection.
  if (props.zoomable && props.spherical) {
    map.value?.call(mapRotate.value as any).call(mapSphericalZoom.value as any)
  }
  else if (props.zoomable) {
    map.value?.call(mapZoom.value as any)
  }
  // An initial zoom value is given
  if (props.zoom || props.spherical) {
    applyZoom(props.zoom ?? props.zoomMin, 0)
  }
}

function draw() {
  prepare()
  drawOutline()
  drawGraticule()
  drawFeatures()
  prepareZoom()
}

function drawOutline() {
  map.value
    ?.select('.choropleth-map__main__outline')
    .append('path')
    .attr('d', initialFeaturePath.value({ type: 'Sphere' }))
    .attr('stroke', props.outlineColor)
}

function drawGraticule() {
  map.value
    ?.select('.choropleth-map__main__graticule')
    .append('path')
    .attr('d', initialGraticulePath.value)
    .attr('stroke', props.graticuleColor)
}

function drawFeatures() {
  const features = map.value
    ?.select('.choropleth-map__main__features')
    .selectAll('.choropleth-map__main__features__item')
    .data(geojson.value.features)
    .enter()
    .append('path')
  if (!features) {
    throw new Error('features is undefined')
  }
  features
    .attr('class', featureClass)
    .attr('d', initialFeaturePath.value)
    .on('mouseover', featureMouseOver)
    .on('mouseleave', featureMouseLeave)
    .on('click', mapClicked)
    .style('color', featureColor.value)
}

function update() {
  // Bind geojson features to path
  if (!map.value) {
    return
  }
  map.value
    .selectAll('.choropleth-map__main__features__item')
    .data(geojson.value.features)
    .attr('class', featureClass)
    .style('color', featureColor.value)
}

function featureClass(d: Feature<Geometry>) {
  return keys(pickBy(featureClassObject(d), value => value)).join(' ')
}

function featureClassObject(d: Feature<Geometry>) {
  const pathClass = 'choropleth-map__main__features__item'
  const id = get(d, props.topojsonObjectsPath)
  return {
    [pathClass]: true,
    [`${pathClass}--identifier-${kebabCase(id)}`]: true,
    [`${pathClass}--empty`]: loadedData.value && !(id in loadedData.value),
    [`${pathClass}--zoomed`]: featureZoom.value === id,
    [`${pathClass}--cursored`]: featureCursor.value === id
  }
}

function featureMouseLeave() {
  featureCursor.value = null
}

function featureMouseOver(_event: MouseEvent, d: Feature<Geometry>) {
  const id = get(d, props.topojsonObjectsPath)
  const cursorId = loadedData.value && (id in loadedData.value) ? id : null
  updateFeatureCursor(cursorId)
}

function updateFeatureCursor(id: any | null) {
  featureCursor.value = id
}

async function loadTopojson() {
  if (!topojsonPromise.value) {
    if (!props.topojsonUrl?.length) {
      throw new Error('Empty topojsonUrl')
    }
    // json()'s generic pins the parsed shape instead of leaving it unconstrained;
    // its signature still allows `undefined` for a failed fetch, but a rejected
    // fetch rejects the promise rather than resolving to it, so this endpoint's
    // response is trusted to always be a parsed Topology.
    topojsonPromise.value = json<Topology>(props.topojsonUrl) as Promise<Topology>
    topojson.value = await topojsonPromise.value
  }
  return topojsonPromise.value
}

async function mapClicked(event: MouseEvent, d: Feature) {
  /**
   * A click on a feature
   * @event click
   * @param Clicked feature
   */
  emit('click', d)
  // Don't zoom on the map feature
  if (!props.clickable) {
    return
  }
  if (featureZoom.value === get(d, props.topojsonObjectsPath)) {
    return resetZoom(event, d)
  }
  // TODO CD: it was a promise, should it be one?
  setFeatureZoom(d, d3Pointer(event, map.value?.node()))
  /**
   * A zoom on a feature ended
   * @event zoomed
   * @param Zoomed feature
   */
  emit('zoomed', d)
}

function mapSphericalZoomed({
  transform: { k }
}: {
  transform: MapTransform
}) {
  const transform = `scale(${k})`
  mapTransform.value = { ...mapTransform.value, k }
  applyTransformToTrackedElements(transform)
}

function mapZoomed({ transform }: { transform: MapTransform }) {
  mapTransform.value = transform
  applyTransformToTrackedElements(transform)
}

function mapRotated(event: any) {
  const { yaw, pitch } = calculateRotation(event)
  applyRotation(yaw, pitch)
}

function calculateRotation(event: any) {
  const sensitivity = 75
  const k = sensitivity / mapProjection.value.scale()
  const [rotateX, rotateY] = mapProjection.value.rotate()
  const yaw = rotateX + event.dx * k
  const pitch = rotateY - event.dy * k
  return { yaw, pitch }
}

function applyTransformToTrackedElements(transform: any) {
  map.value
    ?.selectAll('.choropleth-map__main__tracked')
    .attr('transform', transform)
}

function applyRotation(rotateX: number, rotateY: number) {
  mapTransform.value = { ...mapTransform.value, rotateX, rotateY }
  const featuresPaths = initialFeaturePath.value.projection(
    rotatingMapProjection.value
  )
  const graticulePaths = featuresPaths(graticuleLines.value)
  map.value
    ?.selectAll('g.choropleth-map__main__features path')
    .attr('d', featuresPaths as any)
  map.value
    ?.selectAll('g.choropleth-map__main__graticule path')
    .attr('d', graticulePaths)
}

function applyZoomIdentity(
  zoomIdentity: any,
  pointer: number[] | null = null,
  transitionDuration = props.transitionDuration
) {
  // Dispatch through the behavior actually bound to the selection so the
  // matching handler runs (spherical applies scale only and preserves
  // rotation; planar applies the full transform).
  return map.value
    ?.transition()
    .duration(transitionDuration)
    .call(activeZoomBehavior.value.transform as any, zoomIdentity, pointer)
    .end()
}

function resetZoom(_event: MouseEvent, _d: Feature<Geometry>) {
  map.value
    ?.style('--map-scale', 1)
    .transition()
    .duration(props.transitionDuration)
    .call((activeZoomBehavior.value as any)?.transform, d3ZoomIdentity)
  featureZoom.value = null
  emitResetEvent()
}

function emitResetEvent() {
  /**
   * The zoom on the map was reset to its initial <slot ate></slot>
   * @event reset
   */
  emit('reset')
}

function setFeaturesClasses() {
  map.value
    ?.selectAll<SVGPathElement, Feature<Geometry>>('.choropleth-map__main__features__item')
    .attr('class', featureClass)
}

function setFeatureZoom(d: Feature<Geometry>, pointer = [0, 0]) {
  featureZoom.value = get(d, props.topojsonObjectsPath)
  const [[x0, y0], [x1, y1]] = featurePath.value.bounds(d)
  const scale = Math.min(
    8,
    0.9 / Math.max((x1 - x0) / mapWidth.value, (y1 - y0) / mapHeight.value)
  )
  const zoomIdentity = d3ZoomIdentity
    .translate(mapWidth.value / 2, mapHeight.value / 2)
    .scale(scale)
    .translate(-(x0 + x1) / 2, -(y0 + y1) / 2)
  return map.value
    ?.style('--map-scale', scale)
    .transition()
    .duration(props.transitionDuration)
    .call((activeZoomBehavior.value as any)?.transform, zoomIdentity, pointer)
    .end()
}

function applyZoom(
  zoom: number,
  transitionDuration = props.transitionDuration
) {
  const zoomScale = clamp(zoom, props.zoomMin, props.zoomMax)
  if (props.spherical) {
    return setSphericalZoom(zoomScale, transitionDuration)
  }
  else {
    return setPlanarZoom(zoomScale, transitionDuration)
  }
}

function setSphericalZoom(zoomScale: number, transitionDuration: number) {
  const zoomIdentity = d3ZoomIdentity.scale(zoomScale)
  mapTransform.value = { ...mapTransform.value, k: zoomScale }
  return applyZoomIdentity(zoomIdentity, null, transitionDuration)
}

function setPlanarZoom(zoomScale: number, transitionDuration: number) {
  const [x, y] = mapProjection.value(mapCenter.value as [number, number])!
  const [translateX, translateY] = [
    mapWidth.value / 2 - zoomScale * x,
    mapHeight.value / 2 - zoomScale * y
  ]
  const zoomIdentity = d3ZoomIdentity
    .translate(translateX, translateY)
    .scale(zoomScale)
  mapTransform.value = {
    k: zoomScale,
    x: translateX,
    y: translateY,
    rotateX: 0,
    rotateY: 0
  }
  return applyZoomIdentity(zoomIdentity, null, transitionDuration)
}

watch(
  () => props.socialMode,
  () => {
    draw()
  }
)

watch(
  () => props.data,
  () => {
    update()
  }
)

watch(
  () => featureZoom.value,
  () => {
    setFeaturesClasses()
  }
)

watch(
  () => featureCursor.value,
  () => {
    setFeaturesClasses()
  }
)

provide<ParentMap>(ParentKey, {
  mapRect,
  mapTransform,
  rotatingMapProjection
})

defineExpose({
  featureCursor,
  loadTopojson,
  updateFeatureCursor,
  setMapNodeSize,
  draw,
  resizable
})
</script>

<template>
  <div
    ref="resizable"
    :class="mapClass"
    :style="mapStyle"
    class="choropleth-map"
  >
    <svg
      :viewbox="`0 0 ${mapRect.width} ${mapRect.height}`"
      class="choropleth-map__main"
    >
      <pattern
        id="diagonalHatch"
        height="1"
        patternTransform="rotate(45 0 0)"
        patternUnits="userSpaceOnUse"
        width="1"
      >
        <rect
          :fill="featureColorScaleEnd"
          height="1"
          width="1"
        />
        <line
          :style="{ stroke: featureColorScaleStart, strokeWidth: 1 }"
          x1="0"
          x2="0"
          y1="0"
          y2="1"
        />
      </pattern>
      <g
        :transform-origin="transformOrigin"
        class="choropleth-map__main__tracked"
      >
        <g
          v-if="graticule"
          class="choropleth-map__main__graticule"
        />
        <g class="choropleth-map__main__features" />
        <g
          v-if="outline"
          class="choropleth-map__main__outline"
        />
        <slot v-if="isReady" />
      </g>
    </svg>
    <scale-legend
      v-if="!hideLegend && isReady"
      :color-scale="featureColorScaleFunction"
      :color-scale-end="featureColorScaleEnd"
      :color-scale-start="featureColorScaleStart"
      :cursor-value="cursorValue"
      :max="maxValue"
      :min="minValue"
      class="choropleth-map__legend"
    >
      <template #cursor="{ value }">
        <slot
          name="legend-cursor"
          v-bind="{ value, identifier: featureCursor }"
        />
      </template>
    </scale-legend>
  </div>
</template>

<style lang="scss" scoped>

.choropleth-map {
  --map-scale: 1;
  --map-color: #fff;
  --map-social-color: #000;

  position: relative;

  &__main {
    min-height: var(--map-height, 300px);
    height: 100%;
    width: 100%;
    color: var(--map-color);

    .chart--social-mode & {
      color: var(--map-social-color);
    }

    &:deep(.choropleth-map__main__outline),
    &:deep(.choropleth-map__main__graticule) {
      fill: transparent;
      pointer-events: none;
      stroke-width: calc(1px / var(--map-scale, 1));
    }

    &:deep(.choropleth-map__main__features__item) {
      stroke: currentColor;
      stroke-width: calc(1px / var(--map-scale, 1));
      fill: currentColor;
      transition:
        opacity 750ms,
        filter 750ms,
        fill 750ms;

      .choropleth-map__main__features__item--empty {
        opacity: 0.8;

        .choropleth-map--hatch-empty & {
          opacity: 0.3;
          fill: url('#diagonalHatch');
        }
      }

      .choropleth-map--has-zoom
        &:not(.choropleth-map__main__features__item--zoomed) {
        filter: grayscale(90%);
      }
    }
  }

  &__legend {
    position: absolute;
    left: 0;
    bottom: 0;
  }
}
</style>
