import unitBezier from '@mapbox/unitbezier';

import {
    array,
    ArrayType,
    ColorType,
    ColorTypeT,
    NumberType,
    NumberTypeT,
    PaddingType,
    PaddingTypeT,
    NumberArrayTypeT,
    ColorArrayTypeT,
    VariableAnchorOffsetCollectionType,
    VariableAnchorOffsetCollectionTypeT,
    typeToString,
    verifyType,
    ProjectionDefinitionType,
    ColorArrayType,
    NumberArrayType
} from '../types';
import {findStopLessThanOrEqualTo} from '../stops';
import {Color} from '../types/color';
import {interpolateArray, interpolateNumber} from '../../util/interpolate-primitives';
import {Padding} from '../types/padding';
import {ColorArray} from '../types/color_array';
import {NumberArray} from '../types/number_array';
import {VariableAnchorOffsetCollection} from '../types/variable_anchor_offset_collection';
import {ProjectionDefinition} from '../types/projection_definition';

import type {Stops} from '../stops';
import type {Expression} from '../expression';
import type {ParsingContext} from '../parsing_context';
import type {EvaluationContext} from '../evaluation_context';
import type {ProjectionDefinitionTypeT, Type} from '../types';

export type InterpolationType =
    | {
          name: 'linear';
      }
    | {
          name: 'exponential';
          base: number;
      }
    | {
          name: 'cubic-bezier';
          controlPoints: [number, number, number, number];
      };
type InterpolatedValueType =
    | NumberTypeT
    | ColorTypeT
    | ProjectionDefinitionTypeT
    | PaddingTypeT
    | NumberArrayTypeT
    | ColorArrayTypeT
    | VariableAnchorOffsetCollectionTypeT
    | ArrayType<NumberTypeT>;
export class Interpolate implements Expression {
    labels: Array<number> = [];
    outputs: Array<Expression> = [];

    constructor(
        public type: InterpolatedValueType,
        public operator: 'interpolate' | 'interpolate-hcl' | 'interpolate-lab',
        public interpolation: InterpolationType,
        public input: Expression,
        stops: Stops,
        public readonly key: string
    ) {
        for (const [label, expression] of stops) {
            this.labels.push(label);
            this.outputs.push(expression);
        }
    }

    static interpolationFactor(
        interpolation: InterpolationType,
        input: number,
        lower: number,
        upper: number
    ) {
        let t = 0;
        if (interpolation.name === 'exponential') {
            t = exponentialInterpolation(input, interpolation.base, lower, upper);
        } else if (interpolation.name === 'linear') {
            t = exponentialInterpolation(input, 1, lower, upper);
        } else if (interpolation.name === 'cubic-bezier') {
            const c = interpolation.controlPoints;
            const ub = unitBezier(c[0], c[1], c[2], c[3]);
            t = ub(exponentialInterpolation(input, 1, lower, upper));
        }
        return t;
    }

    static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
        let [operator, interpolation, input, ...rest] = args;

        if (!Array.isArray(interpolation) || interpolation.length === 0) {
            return context.error('Expected an interpolation type expression.', 1) as null;
        }

        if (interpolation[0] === 'linear') {
            interpolation = {name: 'linear'};
        } else if (interpolation[0] === 'exponential') {
            const base = interpolation[1];
            if (typeof base !== 'number')
                return context.error(
                    'Exponential interpolation requires a numeric base.',
                    1,
                    1
                ) as null;
            interpolation = {
                name: 'exponential',
                base
            };
        } else if (interpolation[0] === 'cubic-bezier') {
            const controlPoints = interpolation.slice(1);
            if (
                controlPoints.length !== 4 ||
                controlPoints.some((t) => typeof t !== 'number' || t < 0 || t > 1)
            ) {
                return context.error(
                    'Cubic bezier interpolation requires four numeric arguments with values between 0 and 1.',
                    1
                ) as null;
            }

            interpolation = {
                name: 'cubic-bezier',
                controlPoints: controlPoints as any
            };
        } else {
            return context.error(
                `Unknown interpolation type ${String(interpolation[0])}`,
                1,
                0
            ) as null;
        }

        if (args.length - 1 < 4) {
            return context.error(
                `Expected at least 4 arguments, but found only ${args.length - 1}.`
            ) as null;
        }

        if ((args.length - 1) % 2 !== 0) {
            return context.error('Expected an even number of arguments.') as null;
        }

        input = context.parse(input, 2, NumberType);
        if (!input) return null;

        const stops: Stops = [];

        let outputType: Type = null;
        if (
            (operator === 'interpolate-hcl' || operator === 'interpolate-lab') &&
            context.expectedType != ColorArrayType
        ) {
            outputType = ColorType;
        } else if (context.expectedType && context.expectedType.kind !== 'value') {
            outputType = context.expectedType;
        }

        for (let i = 0; i < rest.length; i += 2) {
            const label = rest[i];
            const value = rest[i + 1];

            const labelKey = i + 3;
            const valueKey = i + 4;

            if (typeof label !== 'number') {
                return context.error(
                    'Input/output pairs for "interpolate" expressions must be defined using literal numeric values (not computed expressions) for the input values.',
                    labelKey
                ) as null;
            }

            if (stops.length && stops[stops.length - 1][0] >= label) {
                return context.error(
                    'Input/output pairs for "interpolate" expressions must be arranged with input values in strictly ascending order.',
                    labelKey
                ) as null;
            }
            const parsed = context.parse(value, valueKey, outputType);
            if (!parsed) return null;
            outputType = outputType || parsed.type;
            stops.push([label, parsed]);
        }

        if (
            !verifyType(outputType, NumberType) &&
            !verifyType(outputType, ProjectionDefinitionType) &&
            !verifyType(outputType, ColorType) &&
            !verifyType(outputType, PaddingType) &&
            !verifyType(outputType, NumberArrayType) &&
            !verifyType(outputType, ColorArrayType) &&
            !verifyType(outputType, VariableAnchorOffsetCollectionType) &&
            !verifyType(outputType, array(NumberType))
        ) {
            return context.error(`Type ${typeToString(outputType)} is not interpolatable.`) as null;
        }

        return new Interpolate(
            outputType,
            operator as any,
            interpolation as InterpolationType,
            input as Expression,
            stops,
            context.key
        );
    }

    evaluate(ctx: EvaluationContext) {
        const labels = this.labels;
        const outputs = this.outputs;

        if (labels.length === 1) {
            return outputs[0].evaluate(ctx);
        }

        const value: number = this.input.evaluate(ctx);
        if (value <= labels[0]) {
            return outputs[0].evaluate(ctx);
        }

        const stopCount = labels.length;
        if (value >= labels[stopCount - 1]) {
            return outputs[stopCount - 1].evaluate(ctx);
        }

        const index = findStopLessThanOrEqualTo(labels, value, this.key);
        const lower = labels[index];
        const upper = labels[index + 1];
        const t = Interpolate.interpolationFactor(this.interpolation, value, lower, upper);

        const outputLower = outputs[index].evaluate(ctx);
        const outputUpper = outputs[index + 1].evaluate(ctx);

        switch (this.operator) {
            case 'interpolate':
                switch (this.type.kind) {
                    case 'number':
                        return interpolateNumber(outputLower, outputUpper, t);
                    case 'color':
                        return Color.interpolate(outputLower, outputUpper, t);
                    case 'padding':
                        return Padding.interpolate(outputLower, outputUpper, t);
                    case 'colorArray':
                        return ColorArray.interpolate(outputLower, outputUpper, t);
                    case 'numberArray':
                        return NumberArray.interpolate(outputLower, outputUpper, t);
                    case 'variableAnchorOffsetCollection':
                        return VariableAnchorOffsetCollection.interpolate(
                            outputLower,
                            outputUpper,
                            t,
                            this.key
                        );
                    case 'array':
                        return interpolateArray(outputLower, outputUpper, t);
                    case 'projectionDefinition':
                        return ProjectionDefinition.interpolate(outputLower, outputUpper, t);
                }
            case 'interpolate-hcl':
                switch (this.type.kind) {
                    case 'color':
                        return Color.interpolate(outputLower, outputUpper, t, 'hcl');
                    case 'colorArray':
                        return ColorArray.interpolate(outputLower, outputUpper, t, 'hcl');
                }
            case 'interpolate-lab':
                switch (this.type.kind) {
                    case 'color':
                        return Color.interpolate(outputLower, outputUpper, t, 'lab');
                    case 'colorArray':
                        return ColorArray.interpolate(outputLower, outputUpper, t, 'lab');
                }
        }
    }

    eachChild(fn: (_: Expression) => void) {
        fn(this.input);
        for (const expression of this.outputs) {
            fn(expression);
        }
    }

    outputDefined(): boolean {
        return this.outputs.every((out) => out.outputDefined());
    }
}

/**
 * Returns a ratio that can be used to interpolate between exponential function
 * stops.
 * How it works: Two consecutive stop values define a (scaled and shifted) exponential function `f(x) = a * base^x + b`, where `base` is the user-specified base,
 * and `a` and `b` are constants affording sufficient degrees of freedom to fit
 * the function to the given stops.
 *
 * Here's a bit of algebra that lets us compute `f(x)` directly from the stop
 * values without explicitly solving for `a` and `b`:
 *
 * First stop value: `f(x0) = y0 = a * base^x0 + b`
 * Second stop value: `f(x1) = y1 = a * base^x1 + b`
 * => `y1 - y0 = a(base^x1 - base^x0)`
 * => `a = (y1 - y0)/(base^x1 - base^x0)`
 *
 * Desired value: `f(x) = y = a * base^x + b`
 * => `f(x) = y0 + a * (base^x - base^x0)`
 *
 * From the above, we can replace the `a` in `a * (base^x - base^x0)` and do a
 * little algebra:
 * ```
 * a * (base^x - base^x0) = (y1 - y0)/(base^x1 - base^x0) * (base^x - base^x0)
 *                     = (y1 - y0) * (base^x - base^x0) / (base^x1 - base^x0)
 * ```
 *
 * If we let `(base^x - base^x0) / (base^x1 base^x0)`, then we have
 * `f(x) = y0 + (y1 - y0) * ratio`.  In other words, `ratio` may be treated as
 * an interpolation factor between the two stops' output values.
 *
 * (Note: a slightly different form for `ratio`,
 * `(base^(x-x0) - 1) / (base^(x1-x0) - 1) `, is equivalent, but requires fewer
 * expensive `Math.pow()` operations.)
 *
 * @private
 */
function exponentialInterpolation(input, base, lowerValue, upperValue) {
    const difference = upperValue - lowerValue;
    const progress = input - lowerValue;

    if (difference === 0) {
        return 0;
    } else if (base === 1) {
        return progress / difference;
    } else {
        return (Math.pow(base, progress) - 1) / (Math.pow(base, difference) - 1);
    }
}

export const interpolateFactory = {
    color: Color.interpolate,
    number: interpolateNumber,
    padding: Padding.interpolate,
    numberArray: NumberArray.interpolate,
    colorArray: ColorArray.interpolate,
    variableAnchorOffsetCollection: VariableAnchorOffsetCollection.interpolate,
    array: interpolateArray
};
