import {contourDensity, geoPath} from "d3";
import {create} from "../context.js";
import {Mark} from "../mark.js";
import {TypedArray, coerceNumbers, maybeTuple, maybeZ} from "../options.js";
import {applyPosition} from "../projection.js";
import {
  applyChannelStyles,
  applyDirectStyles,
  applyFrameAnchor,
  applyIndirectStyles,
  applyTransform,
  groupZ
} from "../style.js";
import {initializer} from "../transforms/basic.js";

const defaults = {
  ariaLabel: "density",
  fill: "none",
  stroke: "currentColor",
  strokeMiterlimit: 1
};

export class Density extends Mark {
  constructor(data, {x, y, z, weight, fill, stroke, ...options} = {}) {
    // If fill or stroke is specified as “density”, then temporarily treat these
    // as a literal color when computing defaults and maybeZ; below, we’ll unset
    // these constant colors back to undefined since they will instead be
    // populated by a channel generated by the initializer.
    const fillDensity = isDensity(fill) && ((fill = "currentColor"), true);
    const strokeDensity = isDensity(stroke) && ((stroke = "currentColor"), true);
    super(
      data,
      {
        x: {value: x, scale: "x", optional: true},
        y: {value: y, scale: "y", optional: true},
        z: {value: maybeZ({z, fill, stroke}), optional: true},
        weight: {value: weight, optional: true}
      },
      densityInitializer({...options, fill, stroke}, fillDensity, strokeDensity),
      defaults
    );
    if (fillDensity) this.fill = undefined;
    if (strokeDensity) this.stroke = undefined;
    this.z = z;
  }
  filter(index) {
    return index; // don’t filter contours constructed by initializer
  }
  render(index, scales, channels, dimensions, context) {
    const {contours} = channels;
    const path = geoPath();
    return create("svg:g", context)
      .call(applyIndirectStyles, this, dimensions, context)
      .call(applyTransform, this, {})
      .call((g) =>
        g
          .selectAll()
          .data(index)
          .enter()
          .append("path")
          .call(applyDirectStyles, this)
          .call(applyChannelStyles, this, channels)
          .attr("d", (i) => path(contours[i]))
      )
      .node();
  }
}

export function density(data, {x, y, ...options} = {}) {
  [x, y] = maybeTuple(x, y);
  return new Density(data, {...options, x, y});
}

const dropChannels = new Set(["x", "y", "z", "weight"]);

function densityInitializer(options, fillDensity, strokeDensity) {
  const k = 100; // arbitrary scale factor for readability
  let {bandwidth, thresholds} = options;
  bandwidth = bandwidth === undefined ? 20 : +bandwidth;
  thresholds =
    thresholds === undefined
      ? 20
      : typeof thresholds?.[Symbol.iterator] === "function"
      ? coerceNumbers(thresholds)
      : +thresholds;
  return initializer(options, function (data, facets, channels, scales, dimensions, context) {
    const W = channels.weight ? coerceNumbers(channels.weight.value) : null;
    const Z = channels.z?.value;
    const {z} = this;
    const [cx, cy] = applyFrameAnchor(this, dimensions);
    const {width, height} = dimensions;

    // Get the (either scaled or projected) xy channels.
    const {x: X, y: Y} = applyPosition(channels, scales, context);

    // Group any of the input channels according to the first index associated
    // with each z-series or facet. Drop any channels not be needed for
    // rendering after the contours are computed.
    const newChannels = Object.fromEntries(
      Object.entries(channels)
        .filter(([key]) => !dropChannels.has(key))
        .map(([key, channel]) => [key, {...channel, value: []}])
    );

    // If the fill or stroke encodes density, construct new output channels.
    const FD = fillDensity && [];
    const SD = strokeDensity && [];

    const density = contourDensity()
      .x(X ? (i) => X[i] : cx)
      .y(Y ? (i) => Y[i] : cy)
      .weight(W ? (i) => W[i] : 1)
      .size([width, height])
      .bandwidth(bandwidth);

    // Compute the grid for each facet-series.
    const facetsContours = [];
    for (const facet of facets) {
      const facetContours = [];
      facetsContours.push(facetContours);
      for (const index of Z ? groupZ(facet, Z, z) : [facet]) {
        const contour = density.contours(index);
        facetContours.push([index, contour]);
      }
    }

    // If explicit thresholds were not specified, find the maximum density of
    // all grids and use this to compute thresholds.
    let T = thresholds;
    if (!(T instanceof TypedArray)) {
      let maxValue = 0;
      for (const facetContours of facetsContours) {
        for (const [, contour] of facetContours) {
          const max = contour.max;
          if (max > maxValue) maxValue = max;
        }
      }
      T = Float64Array.from({length: thresholds - 1}, (_, i) => (maxValue * k * (i + 1)) / thresholds);
    }

    // Generate contours for each facet-series.
    const newFacets = [];
    const contours = [];
    for (const facetContours of facetsContours) {
      const newFacet = [];
      newFacets.push(newFacet);
      for (const [index, contour] of facetContours) {
        for (const t of T) {
          newFacet.push(contours.length);
          contours.push(contour(t / k));
          if (FD) FD.push(t);
          if (SD) SD.push(t);
          for (const key in newChannels) {
            newChannels[key].value.push(channels[key].value[index[0]]);
          }
        }
      }
    }

    // If the fill or stroke encodes density, ensure that a zero value is
    // included so that the default color scale domain starts at zero. Otherwise
    // if the starting range value is the same as the background color, the
    // first contour might not be visible.
    if (FD) FD.push(0);
    if (SD) SD.push(0);

    return {
      data,
      facets: newFacets,
      channels: {
        ...newChannels,
        ...(FD && {fill: {value: FD, scale: "color"}}),
        ...(SD && {stroke: {value: SD, scale: "color"}}),
        contours: {value: contours}
      }
    };
  });
}

function isDensity(value) {
  return /^density$/i.test(value);
}
