import type {SignalRef} from 'vega';
import {isArray, isNumber} from 'vega-util';
import {isBinned, isBinning, isBinParams} from '../../../bin.js';
import {
  getOffsetChannel,
  getSecondaryRangeChannel,
  getSizeChannel,
  getVgPositionChannel,
  isPolarPositionChannel,
  isXorY,
  PolarPositionChannel,
  PositionChannel,
} from '../../../channel.js';
import {
  getBandPosition,
  getBandSize,
  isFieldDef,
  isFieldOrDatumDef,
  TypedFieldDef,
  vgField,
} from '../../../channeldef.js';
import {Config, getViewConfigDiscreteStep} from '../../../config.js';
import {Encoding} from '../../../encoding.js';
import * as log from '../../../log/index.js';
import {BandSize, isRelativeBandSize} from '../../../mark.js';
import {hasDiscreteDomain} from '../../../scale.js';
import {isSignalRef, isVgRangeStep, VgEncodeEntry, VgValueRef} from '../../../vega.schema.js';
import {getMarkConfig, getMarkPropOrConfig, signalOrStringValue, signalOrValueRef} from '../../common.js';
import {ScaleComponent} from '../../scale/component.js';
import {UnitModel} from '../../unit.js';
import {nonPosition} from './nonposition.js';
import {positionOffset} from './offset.js';
import {vgAlignedPositionChannel} from './position-align.js';
import {pointPositionDefaultRef} from './position-point.js';
import {rangePosition} from './position-range.js';
import * as ref from './valueref.js';
import {getOffsetScaleChannel} from '../../../channel.js';
import {getFirstDefined} from '../../../util.js';
import {Mark} from '../../../mark.js';
import {OFFSETTED_RECT_END_SUFFIX, OFFSETTED_RECT_START_SUFFIX} from '../../data/timeunit.js';

export function rectPosition(model: UnitModel, channel: 'x' | 'y' | 'theta' | 'radius'): VgEncodeEntry {
  const {config, encoding, markDef} = model;
  const mark = markDef.type;

  const channel2 = getSecondaryRangeChannel(channel);
  const sizeChannel = getSizeChannel(channel);
  const channelDef = encoding[channel];
  const channelDef2 = encoding[channel2];

  const scale = model.getScaleComponent(channel);
  const scaleType = scale ? scale.get('type') : undefined;

  const orient = markDef.orient;
  const hasSizeDef =
    (encoding as any)[sizeChannel] ??
    encoding.size ??
    getMarkPropOrConfig('size', markDef, config, {vgChannel: sizeChannel});

  const offsetScaleChannel = getOffsetChannel(channel);

  const isBarOrTickBand =
    (mark === 'bar' && (channel === 'x' ? orient === 'vertical' : orient === 'horizontal')) ||
    (mark === 'tick' && (channel === 'y' ? orient === 'vertical' : orient === 'horizontal'));

  // x, x2, and width -- we must specify two of these in all conditions
  if (
    isFieldDef(channelDef) &&
    (isBinning(channelDef.bin) || isBinned(channelDef.bin) || (channelDef.timeUnit && !channelDef2)) &&
    !(hasSizeDef && !isRelativeBandSize(hasSizeDef)) &&
    !(encoding as any)[offsetScaleChannel] &&
    !hasDiscreteDomain(scaleType)
  ) {
    return rectBinPosition({
      fieldDef: channelDef,
      fieldDef2: channelDef2,
      channel,
      model,
    });
  } else if (((isFieldOrDatumDef(channelDef) && hasDiscreteDomain(scaleType)) || isBarOrTickBand) && !channelDef2) {
    return positionAndSize(channelDef, channel, model);
  } else {
    return rangePosition(channel, model, {defaultPos: 'zeroOrMax', defaultPos2: 'zeroOrMin'});
  }
}

function defaultSizeRef(
  sizeChannel: 'width' | 'height',
  scaleName: string,
  scale: ScaleComponent,
  config: Config<SignalRef>,
  bandSize: BandSize,
  hasFieldDef: boolean,
  mark: Mark,
): VgValueRef {
  if (isRelativeBandSize(bandSize)) {
    if (scale) {
      const scaleType = scale.get('type');
      if (scaleType === 'band') {
        let bandWidth = `bandwidth('${scaleName}')`;
        if (bandSize.band !== 1) {
          bandWidth = `${bandSize.band} * ${bandWidth}`;
        }
        const minBandSize = getMarkConfig('minBandSize', {type: mark}, config);
        return {signal: minBandSize ? `max(${signalOrStringValue(minBandSize)}, ${bandWidth})` : bandWidth};
      } else if (bandSize.band !== 1) {
        log.warn(log.message.cannotUseRelativeBandSizeWithNonBandScale(scaleType));
      }
    } else {
      return {
        mult: bandSize.band,
        field: {group: sizeChannel},
      };
    }
  } else if (isSignalRef(bandSize)) {
    return bandSize;
  } else if (bandSize) {
    return {value: bandSize};
  }

  // no valid band size
  if (scale) {
    const scaleRange = scale.get('range');
    if (isVgRangeStep(scaleRange) && isNumber(scaleRange.step)) {
      return {value: scaleRange.step - 2};
    }
  }
  if (!hasFieldDef) {
    const {bandPaddingInner, barBandPaddingInner, rectBandPaddingInner, tickBandPaddingInner} = config.scale;
    const padding = getFirstDefined(
      bandPaddingInner,
      mark === 'tick' ? tickBandPaddingInner : mark === 'bar' ? barBandPaddingInner : rectBandPaddingInner,
    ); // this part is like paddingInner in scale.ts
    if (isSignalRef(padding)) {
      return {signal: `(1 - (${padding.signal})) * ${sizeChannel}`};
    } else if (isNumber(padding)) {
      return {signal: `${1 - padding} * ${sizeChannel}`};
    }
  }
  const defaultStep = getViewConfigDiscreteStep(config.view, sizeChannel);
  return {value: defaultStep - 2};
}

/**
 * Output position encoding and its size encoding for continuous, point, and band scales.
 */
function positionAndSize(
  fieldDef: Encoding<string>['x' | 'y' | 'theta' | 'radius'],
  channel: 'x' | 'y' | 'theta' | 'radius',
  model: UnitModel,
) {
  const {markDef, encoding, config, stack} = model;
  const orient = markDef.orient;

  const scaleName = model.scaleName(channel);
  const scale = model.getScaleComponent(channel);
  const vgSizeChannel = getSizeChannel(channel);
  const channel2 = getSecondaryRangeChannel(channel);

  const offsetScaleChannel = getOffsetChannel(channel);
  const offsetScaleName = model.scaleName(offsetScaleChannel);
  const offsetScale = model.getScaleComponent(getOffsetScaleChannel(channel));

  const useVlSizeChannel =
    // Always uses size channel for ticks, because tick only calls rectPosition() for the size channel
    markDef.type === 'tick' ||
    // use "size" channel for bars, if there is orient and the channel matches the right orientation
    (orient === 'horizontal' && channel === 'y') ||
    (orient === 'vertical' && channel === 'x');

  // Use size encoding / mark property / config if it exists
  let sizeMixins;
  if (encoding.size || markDef.size) {
    if (useVlSizeChannel) {
      sizeMixins = nonPosition('size', model, {
        vgChannel: vgSizeChannel,
        defaultRef: signalOrValueRef(markDef.size),
      });
    } else {
      log.warn(log.message.cannotApplySizeToNonOrientedMark(markDef.type));
    }
  }
  const hasSizeFromMarkOrEncoding = !!sizeMixins;

  // Otherwise, apply default value
  const bandSize = getBandSize({
    channel,
    fieldDef,
    markDef,
    config,
    scaleType: (scale || offsetScale)?.get('type'),
    useVlSizeChannel,
  });

  sizeMixins = sizeMixins || {
    [vgSizeChannel]: defaultSizeRef(
      vgSizeChannel,
      offsetScaleName || scaleName,
      offsetScale || scale,
      config,
      bandSize,
      !!fieldDef,
      markDef.type,
    ),
  };

  /*
    Band scales with size value and all point scales, use xc/yc + band=0.5

    Otherwise (band scales that has size based on a band ref), use x/y with position band = (1 - size_band) / 2.
    In this case, size_band is the band specified in the x/y-encoding.
    By default band is 1, so `(1 - band) / 2` = 0.
    If band is 0.6, the the x/y position in such case should be `(1 - band) / 2` = 0.2
   */

  const defaultBandAlign =
    (scale || offsetScale)?.get('type') === 'band' && isRelativeBandSize(bandSize) && !hasSizeFromMarkOrEncoding
      ? 'top'
      : 'middle';

  const vgChannel = vgAlignedPositionChannel(channel, markDef, config, defaultBandAlign);
  const center = vgChannel === 'xc' || vgChannel === 'yc';
  const {offset, offsetType} = positionOffset({channel, markDef, encoding, model, bandPosition: center ? 0.5 : 0});

  // When a centered rect-based mark (e.g., a tick with an explicit size) is
  // placed on a timeUnit-binned field, honor `timeUnitBandPosition` so this
  // path stays aligned with the `rectBinPosition` path used by bars without an
  // explicit size. Skip when:
  // - the mark is not centered (bars spanning a full band need `bandPosition = 0`), or
  // - an encoding-driven offset is in play (e.g., `xOffset`), which already
  //   positions the mark at the band's leading edge.
  // See https://github.com/vega/vega-lite/issues/9836.
  const timeUnitBandPosition =
    center && offsetType !== 'encoding' && isFieldDef(fieldDef) && fieldDef.timeUnit && !encoding[channel2]
      ? getBandPosition({fieldDef, markDef, config})
      : undefined;

  const bandPosition =
    timeUnitBandPosition ??
    (center
      ? offsetType === 'encoding'
        ? 0
        : 0.5
      : isSignalRef(bandSize)
        ? {signal: `(1-${bandSize})/2`}
        : isRelativeBandSize(bandSize)
          ? (1 - bandSize.band) / 2
          : 0);

  const posRef = ref.midPointRefWithPositionInvalidTest({
    channel,
    channelDef: fieldDef,
    markDef,
    config,
    scaleName,
    scale,
    stack,
    offset,
    defaultRef: pointPositionDefaultRef({model, defaultPos: 'mid', channel, scaleName, scale}),
    bandPosition,
  });

  if (vgSizeChannel) {
    return {[vgChannel]: posRef, ...sizeMixins};
  } else {
    // otherwise, we must simulate size by setting position2 = position + size
    // (for theta/radius since Vega doesn't have thetaWidth/radiusWidth)
    const vgChannel2 = getVgPositionChannel(channel2);
    const sizeRef = sizeMixins[vgSizeChannel];
    const sizeOffset = offset ? {...sizeRef, offset} : sizeRef;
    return {
      [vgChannel]: posRef,

      // posRef might be an array that wraps position invalid test
      [vgChannel2]: isArray(posRef)
        ? [posRef[0], {...posRef[1], offset: sizeOffset}]
        : {
            ...posRef,
            offset: sizeOffset,
          },
    };
  }
}

function getBinSpacing(
  channel: PositionChannel | PolarPositionChannel,
  spacing: number,
  reverse: boolean | SignalRef,
  axisTranslate: number | SignalRef,
  offset: number | VgValueRef,
  minBandSize: number | SignalRef,
  bandSizeExpr: string,
) {
  if (isPolarPositionChannel(channel)) {
    return 0;
  }

  const isEnd = channel === 'x' || channel === 'y2';

  const spacingOffset = isEnd ? -spacing / 2 : spacing / 2;

  if (isSignalRef(reverse) || isSignalRef(offset) || isSignalRef(axisTranslate) || minBandSize) {
    const reverseExpr = signalOrStringValue(reverse);
    const offsetExpr = signalOrStringValue(offset);
    const axisTranslateExpr = signalOrStringValue(axisTranslate);
    const minBandSizeExpr = signalOrStringValue(minBandSize);

    const sign = isEnd ? '' : '-';

    const spacingAndSizeOffset = minBandSize
      ? `(${bandSizeExpr} < ${minBandSizeExpr} ? ${sign}0.5 * (${minBandSizeExpr} - (${bandSizeExpr})) : ${spacingOffset})`
      : spacingOffset;

    const t = axisTranslateExpr ? `${axisTranslateExpr} + ` : '';
    const r = reverseExpr ? `(${reverseExpr} ? -1 : 1) * ` : '';
    const o = offsetExpr ? `(${offsetExpr} + ${spacingAndSizeOffset})` : spacingAndSizeOffset;

    return {
      signal: t + r + o,
    };
  } else {
    offset = offset || 0;
    // FIXME: remove this override
    // eslint-disable-next-line @typescript-eslint/no-unsafe-unary-minus
    return axisTranslate + (reverse ? -offset - spacingOffset : +offset + spacingOffset);
  }
}

function rectBinPosition({
  fieldDef,
  fieldDef2,
  channel,
  model,
}: {
  fieldDef: TypedFieldDef<string>;
  fieldDef2?: Encoding<string>['x2' | 'y2'];
  channel: 'x' | 'y' | 'theta' | 'radius';
  model: UnitModel;
}) {
  const {config, markDef, encoding} = model;

  const scale = model.getScaleComponent(channel);
  const scaleName = model.scaleName(channel);
  const scaleType = scale ? scale.get('type') : undefined;
  const reverse = scale.get('reverse');

  const bandSize = getBandSize({channel, fieldDef, markDef, config, scaleType});

  const axis = (model.component.axes as any)[channel]?.[0];
  const axisTranslate = axis?.get('translate') ?? 0.5; // vega default is 0.5

  const spacing = isXorY(channel) ? (getMarkPropOrConfig('binSpacing', markDef, config) ?? 0) : 0;

  const channel2 = getSecondaryRangeChannel(channel);
  const vgChannel = getVgPositionChannel(channel);
  const vgChannel2 = getVgPositionChannel(channel2);
  const minBandSize = getMarkConfig('minBandSize', markDef, config);

  const {offset} = positionOffset({channel, markDef, encoding, model, bandPosition: 0});
  const {offset: offset2} = positionOffset({channel: channel2, markDef, encoding, model, bandPosition: 0});

  const bandSizeExpr = ref.binSizeExpr({fieldDef, scaleName});
  const binSpacingOffset = getBinSpacing(channel, spacing, reverse, axisTranslate, offset, minBandSize, bandSizeExpr);
  const binSpacingOffset2 = getBinSpacing(
    channel2,
    spacing,
    reverse,
    axisTranslate,
    offset2 ?? offset,
    minBandSize,
    bandSizeExpr,
  );

  const bandPositionForBandSize = isSignalRef(bandSize)
    ? {signal: `(1-${bandSize.signal})/2`}
    : isRelativeBandSize(bandSize)
      ? (1 - bandSize.band) / 2
      : 0.5;

  const bandPosition = getBandPosition({fieldDef, fieldDef2, markDef, config});

  if (isBinning(fieldDef.bin) || fieldDef.timeUnit) {
    const useRectOffsetField = fieldDef.timeUnit && bandPosition !== 0.5;

    return {
      [vgChannel2]: rectBinRef({
        fieldDef,
        scaleName,
        bandPosition: bandPositionForBandSize,
        offset: binSpacingOffset2,
        useRectOffsetField,
      }),
      [vgChannel]: rectBinRef({
        fieldDef,
        scaleName,
        bandPosition: isSignalRef(bandPositionForBandSize)
          ? {signal: `1-${bandPositionForBandSize.signal}`}
          : 1 - bandPositionForBandSize,
        offset: binSpacingOffset,
        useRectOffsetField,
      }),
    };
  } else if (isBinned(fieldDef.bin)) {
    const startRef = ref.valueRefForFieldOrDatumDef(fieldDef, scaleName, {}, {offset: binSpacingOffset2});

    if (isFieldDef(fieldDef2)) {
      return {
        [vgChannel2]: startRef,
        [vgChannel]: ref.valueRefForFieldOrDatumDef(fieldDef2, scaleName, {}, {offset: binSpacingOffset}),
      };
    } else if (isBinParams(fieldDef.bin) && fieldDef.bin.step) {
      return {
        [vgChannel2]: startRef,
        [vgChannel]: {
          signal: `scale("${scaleName}", ${vgField(fieldDef, {expr: 'datum'})} + ${fieldDef.bin.step})`,
          offset: binSpacingOffset,
        },
      };
    }
  }
  log.warn(log.message.channelRequiredForBinned(channel2));
  return undefined;
}

/**
 * Value Ref for binned fields
 */
function rectBinRef({
  fieldDef,
  scaleName,
  bandPosition,
  offset,
  useRectOffsetField,
}: {
  fieldDef: TypedFieldDef<string>;
  scaleName: string;
  bandPosition: number | SignalRef;
  offset?: number | SignalRef;
  useRectOffsetField: boolean;
}) {
  return ref.interpolatedSignalRef({
    scaleName,
    fieldOrDatumDef: fieldDef,
    bandPosition,
    offset,
    ...(useRectOffsetField
      ? {
          startSuffix: OFFSETTED_RECT_START_SUFFIX,
          endSuffix: OFFSETTED_RECT_END_SUFFIX,
        }
      : {}),
  });
}
