import {RangeScheme, SignalRef} from 'vega';
import {isArray, isNumber, isObject} from 'vega-util';
import {isBinning} from '../../bin';
import {
  ANGLE,
  COLOR,
  FILL,
  FILLOPACITY,
  isXorY,
  OPACITY,
  RADIUS,
  ScaleChannel,
  SCALE_CHANNELS,
  SHAPE,
  SIZE,
  STROKE,
  STROKEDASH,
  STROKEOPACITY,
  STROKEWIDTH,
  THETA,
  X,
  Y
} from '../../channel';
import {getFieldOrDatumDef, ScaleDatumDef, ScaleFieldDef} from '../../channeldef';
import {Config, getViewConfigDiscreteSize, getViewConfigDiscreteStep, ViewConfig} from '../../config';
import {DataSourceType} from '../../data';
import * as log from '../../log';
import {Mark} from '../../mark';
import {
  channelScalePropertyIncompatability,
  Domain,
  hasContinuousDomain,
  hasDiscreteDomain,
  isContinuousToDiscrete,
  isExtendedScheme,
  Scale,
  scaleTypeSupportProperty,
  Scheme
} from '../../scale';
import {isStep, LayoutSizeMixins} from '../../spec/base';
import * as util from '../../util';
import {isSignalRef, VgRange} from '../../vega.schema';
import {signalOrStringValue} from '../common';
import {getBinSignalName} from '../data/bin';
import {SignalRefWrapper} from '../signal';
import {Explicit, makeExplicit, makeImplicit} from '../split';
import {UnitModel} from '../unit';
import {ScaleComponentIndex} from './component';

export const RANGE_PROPERTIES: (keyof Scale)[] = ['range', 'scheme'];

function getSizeChannel(channel: ScaleChannel) {
  return channel === 'x' ? 'width' : channel === 'y' ? 'height' : undefined;
}

export function parseUnitScaleRange(model: UnitModel) {
  const localScaleComponents: ScaleComponentIndex = model.component.scales;

  // use SCALE_CHANNELS instead of scales[channel] to ensure that x, y come first!
  for (const channel of SCALE_CHANNELS) {
    const localScaleCmpt = localScaleComponents[channel];
    if (!localScaleCmpt) {
      continue;
    }

    const rangeWithExplicit = parseRangeForChannel(channel, model);

    localScaleCmpt.setWithExplicit('range', rangeWithExplicit);
  }
}

function getBinStepSignal(model: UnitModel, channel: 'x' | 'y'): SignalRefWrapper {
  const fieldDef = model.fieldDef(channel);

  if (fieldDef && fieldDef.bin && isBinning(fieldDef.bin)) {
    const binSignal = getBinSignalName(model, fieldDef.field, fieldDef.bin);

    // TODO: extract this to be range step signal
    const sizeType = getSizeChannel(channel);
    const sizeSignal = model.getName(sizeType);
    return new SignalRefWrapper(() => {
      const updatedName = model.getSignalName(binSignal);
      const binCount = `(${updatedName}.stop - ${updatedName}.start) / ${updatedName}.step`;
      return `${model.getSignalName(sizeSignal)} / (${binCount})`;
    });
  }
  return undefined;
}

/**
 * Return mixins that includes one of the Vega range types (explicit range, range.step, range.scheme).
 */
export function parseRangeForChannel(channel: ScaleChannel, model: UnitModel): Explicit<VgRange> {
  const specifiedScale = model.specifiedScales[channel];
  const {size} = model;

  const mergedScaleCmpt = model.getScaleComponent(channel);
  const scaleType = mergedScaleCmpt.get('type');

  // Check if any of the range properties is specified.
  // If so, check if it is compatible and make sure that we only output one of the properties
  for (const property of RANGE_PROPERTIES) {
    if (specifiedScale[property] !== undefined) {
      const supportedByScaleType = scaleTypeSupportProperty(scaleType, property);
      const channelIncompatability = channelScalePropertyIncompatability(channel, property);
      if (!supportedByScaleType) {
        log.warn(log.message.scalePropertyNotWorkWithScaleType(scaleType, property, channel));
      } else if (channelIncompatability) {
        // channel
        log.warn(channelIncompatability);
      } else {
        switch (property) {
          case 'range': {
            const range = specifiedScale.range;
            if (isArray(range)) {
              if (isXorY(channel)) {
                return makeExplicit(
                  range.map(v => {
                    if (v === 'width' || v === 'height') {
                      // get signal for width/height

                      // Just like default range logic below, we use SignalRefWrapper to account for potential merges and renames.

                      const sizeSignal = model.getName(v);
                      const getSignalName = model.getSignalName.bind(model);
                      return SignalRefWrapper.fromName(getSignalName, sizeSignal);
                    }
                    return v;
                  })
                );
              }
            } else if (isObject(range)) {
              return makeExplicit({
                data: model.requestDataName(DataSourceType.Main),
                field: range.field,
                sort: {op: 'min', field: model.vgField(channel)}
              });
            }

            return makeExplicit(range);
          }
          case 'scheme':
            return makeExplicit(parseScheme(specifiedScale[property]));
        }
      }
    }
  }

  if (channel === X || channel === Y) {
    const sizeChannel = channel === X ? 'width' : 'height';
    const sizeValue = size[sizeChannel];
    if (isStep(sizeValue)) {
      if (hasDiscreteDomain(scaleType)) {
        return makeExplicit({step: sizeValue.step});
      } else {
        log.warn(log.message.stepDropped(sizeChannel));
      }
    }
  }

  const {rangeMin, rangeMax} = specifiedScale;
  const d = defaultRange(channel, model);

  if (
    (rangeMin !== undefined || rangeMax !== undefined) &&
    // it's ok to check just rangeMin's compatibility since rangeMin/rangeMax are the same
    scaleTypeSupportProperty(scaleType, 'rangeMin') &&
    isArray(d) &&
    d.length === 2
  ) {
    return makeExplicit([rangeMin ?? d[0], rangeMax ?? d[1]]);
  }

  return makeImplicit(d);
}

function parseScheme(scheme: Scheme | SignalRef): RangeScheme {
  if (isExtendedScheme(scheme)) {
    return {
      scheme: scheme.name,
      ...util.omit(scheme, ['name'])
    };
  }
  return {scheme: scheme};
}

function defaultRange(channel: ScaleChannel, model: UnitModel): VgRange {
  const {size, config, mark, encoding} = model;

  const getSignalName = model.getSignalName.bind(model);

  const {type} = getFieldOrDatumDef(encoding[channel]) as ScaleFieldDef<string> | ScaleDatumDef;

  const mergedScaleCmpt = model.getScaleComponent(channel);
  const scaleType = mergedScaleCmpt.get('type');

  const {domain, domainMid} = model.specifiedScales[channel];

  switch (channel) {
    case X:
    case Y: {
      // If there is no explicit width/height for discrete x/y scales
      if (util.contains(['point', 'band'], scaleType)) {
        if (channel === X && !size.width) {
          const w = getViewConfigDiscreteSize(config.view, 'width');
          if (isStep(w)) {
            return w;
          }
        } else if (channel === Y && !size.height) {
          const h = getViewConfigDiscreteSize(config.view, 'height');
          if (isStep(h)) {
            return h;
          }
        }
      }

      // If step is null, use zero to width or height.
      // Note that we use SignalRefWrapper to account for potential merges and renames.

      const sizeType = getSizeChannel(channel);
      const sizeSignal = model.getName(sizeType);

      if (channel === Y && hasContinuousDomain(scaleType)) {
        // For y continuous scale, we have to start from the height as the bottom part has the max value.
        return [SignalRefWrapper.fromName(getSignalName, sizeSignal), 0];
      } else {
        return [0, SignalRefWrapper.fromName(getSignalName, sizeSignal)];
      }
    }

    case SIZE: {
      // TODO: support custom rangeMin, rangeMax
      const zero = model.component.scales[channel].get('zero');
      const rangeMin = sizeRangeMin(mark, zero, config);
      const rangeMax = sizeRangeMax(mark, size, model, config);
      if (isContinuousToDiscrete(scaleType)) {
        return interpolateRange(
          rangeMin,
          rangeMax,
          defaultContinuousToDiscreteCount(scaleType, config, domain, channel)
        );
      } else {
        return [rangeMin, rangeMax];
      }
    }

    case THETA:
      return [0, Math.PI * 2];

    case ANGLE:
      // TODO: add config.scale.min/maxAngleDegree (for point and text) and config.scale.min/maxAngleRadian (for arc) once we add arc marks.
      // (It's weird to add just config.scale.min/maxAngleDegree for now)
      return [0, 360];

    case RADIUS: {
      // max radius = half od min(width,height)
      return [
        0,
        new SignalRefWrapper(() => {
          const w = model.getSignalName('width');
          const h = model.getSignalName('height');
          return `min(${w},${h})/2`;
        })
      ];
    }

    case STROKEWIDTH:
      // TODO: support custom rangeMin, rangeMax
      return [config.scale.minStrokeWidth, config.scale.maxStrokeWidth];
    case STROKEDASH:
      return [
        // TODO: add this to Vega's config.range?
        [1, 0],
        [4, 2],
        [2, 1],
        [1, 1],
        [1, 2, 4, 2]
      ];
    case SHAPE:
      return 'symbol';
    case COLOR:
    case FILL:
    case STROKE:
      if (scaleType === 'ordinal') {
        // Only nominal data uses ordinal scale by default
        return type === 'nominal' ? 'category' : 'ordinal';
      } else {
        if (domainMid !== undefined) {
          return 'diverging';
        } else {
          return mark === 'rect' || mark === 'geoshape' ? 'heatmap' : 'ramp';
        }
      }
    case OPACITY:
    case FILLOPACITY:
    case STROKEOPACITY:
      // TODO: support custom rangeMin, rangeMax
      return [config.scale.minOpacity, config.scale.maxOpacity];
  }
  /* istanbul ignore next: should never reach here */
  throw new Error(`Scale range undefined for channel ${channel}`);
}

export function defaultContinuousToDiscreteCount(
  scaleType: 'quantile' | 'quantize' | 'threshold',
  config: Config,
  domain: Domain,
  channel: ScaleChannel
) {
  switch (scaleType) {
    case 'quantile':
      return config.scale.quantileCount;
    case 'quantize':
      return config.scale.quantizeCount;
    case 'threshold':
      if (domain !== undefined && isArray(domain)) {
        return domain.length + 1;
      } else {
        log.warn(log.message.domainRequiredForThresholdScale(channel));
        // default threshold boundaries for threshold scale since domain has cardinality of 2
        return 3;
      }
  }
}

/**
 * Returns the linear interpolation of the range according to the cardinality
 *
 * @param rangeMin start of the range
 * @param rangeMax end of the range
 * @param cardinality number of values in the output range
 */
export function interpolateRange(
  rangeMin: number | SignalRef,
  rangeMax: number | SignalRef,
  cardinality: number
): SignalRef {
  // always return a signal since it's better to compute the sequence in Vega later
  const f = () => {
    const rMax = signalOrStringValue(rangeMax);
    const rMin = signalOrStringValue(rangeMin);
    const step = `(${rMax} - ${rMin}) / (${cardinality} - 1)`;
    return `sequence(${rMin}, ${rMax} + ${step}, ${step})`;
  };
  if (isSignalRef(rangeMax)) {
    return new SignalRefWrapper(f);
  } else {
    return {signal: f()};
  }
}

function sizeRangeMin(mark: Mark, zero: boolean | SignalRef, config: Config): number | SignalRef {
  if (zero) {
    if (isSignalRef(zero)) {
      return {signal: `${zero.signal} ? 0 : ${sizeRangeMin(mark, false, config)}`};
    } else {
      return 0;
    }
  }
  switch (mark) {
    case 'bar':
    case 'tick':
      return config.scale.minBandSize;
    case 'line':
    case 'trail':
    case 'rule':
      return config.scale.minStrokeWidth;
    case 'text':
      return config.scale.minFontSize;
    case 'point':
    case 'square':
    case 'circle':
      return config.scale.minSize;
  }
  /* istanbul ignore next: should never reach here */
  // sizeRangeMin not implemented for the mark
  throw new Error(log.message.incompatibleChannel('size', mark));
}

export const MAX_SIZE_RANGE_STEP_RATIO = 0.95;

function sizeRangeMax(mark: Mark, size: LayoutSizeMixins, model: UnitModel, config: Config): number | SignalRef {
  const xyStepSignals = {
    x: getBinStepSignal(model, 'x'),
    y: getBinStepSignal(model, 'y')
  };

  switch (mark) {
    case 'bar':
    case 'tick': {
      if (config.scale.maxBandSize !== undefined) {
        return config.scale.maxBandSize;
      }
      const min = minXYStep(size, xyStepSignals, config.view);

      if (isNumber(min)) {
        return min - 1;
      } else {
        return new SignalRefWrapper(() => `${min.signal} - 1`);
      }
    }
    case 'line':
    case 'trail':
    case 'rule':
      return config.scale.maxStrokeWidth;
    case 'text':
      return config.scale.maxFontSize;
    case 'point':
    case 'square':
    case 'circle': {
      if (config.scale.maxSize) {
        return config.scale.maxSize;
      }

      const pointStep = minXYStep(size, xyStepSignals, config.view);
      if (isNumber(pointStep)) {
        return Math.pow(MAX_SIZE_RANGE_STEP_RATIO * pointStep, 2);
      } else {
        return new SignalRefWrapper(() => `pow(${MAX_SIZE_RANGE_STEP_RATIO} * ${pointStep.signal}, 2)`);
      }
    }
  }
  /* istanbul ignore next: should never reach here */
  // sizeRangeMax not implemented for the mark
  throw new Error(log.message.incompatibleChannel('size', mark));
}

/**
 * @returns {number} Range step of x or y or minimum between the two if both are ordinal scale.
 */
function minXYStep(
  size: LayoutSizeMixins,
  xyStepSignals: {x?: SignalRefWrapper; y?: SignalRefWrapper},
  viewConfig: ViewConfig
): number | SignalRef {
  const widthStep = isStep(size.width) ? size.width.step : getViewConfigDiscreteStep(viewConfig, 'width');
  const heightStep = isStep(size.height) ? size.height.step : getViewConfigDiscreteStep(viewConfig, 'height');

  if (xyStepSignals.x || xyStepSignals.y) {
    return new SignalRefWrapper(() => {
      const exprs = [
        xyStepSignals.x ? xyStepSignals.x.signal : widthStep,
        xyStepSignals.y ? xyStepSignals.y.signal : heightStep
      ];
      return `min(${exprs.join(', ')})`;
    });
  }

  return Math.min(widthStep, heightStep);
}
