import {ExpressionParsingError} from './parsing_error';
import {ParsingContext} from './parsing_context';
import {EvaluationContext} from './evaluation_context';
import {
    CompoundExpression,
    isFeatureConstant,
    isGlobalPropertyConstant,
    isStateConstant,
    isExpressionConstant
} from './compound_expression';
import {Step} from './definitions/step';
import {Interpolate} from './definitions/interpolate';
import {Coalesce} from './definitions/coalesce';
import {Let} from './definitions/let';
import {expressions} from './definitions';
import {Color} from './types/color';
import {Padding} from './types/padding';
import {NumberArray} from './types/number_array';
import {ColorArray} from './types/color_array';
import {VariableAnchorOffsetCollection} from './types/variable_anchor_offset_collection';
import {ProjectionDefinition} from './types/projection_definition';
import {GlobalState} from './definitions/global_state';
import {RuntimeError} from './runtime_error';
import {success, error} from '../util/result';
import {
    supportsPropertyExpression,
    supportsZoomExpression,
    supportsInterpolation
} from '../util/properties';
import {
    ColorType,
    StringType,
    NumberType,
    BooleanType,
    ValueType,
    FormattedType,
    PaddingType,
    ResolvedImageType,
    VariableAnchorOffsetCollectionType,
    array,
    type Type,
    type EvaluationKind,
    ProjectionDefinitionType,
    NumberArrayType,
    ColorArrayType
} from './types';
import {ICanonicalTileID} from '../tiles_and_coordinates';
import {isFunction, createFunction} from '../function';

import type {Value} from './values';
import type {Expression} from './expression';
import type {StylePropertySpecification} from '..';
import type {Result} from '../util/result';
import type {InterpolationType} from './definitions/interpolate';
import type {FormattedSection} from './types/formatted';
import type {Point2D} from '../point2d';
import type {StyleFunction} from '../function';
import type {
    PaddingSpecification,
    NumberArraySpecification,
    ColorArraySpecification,
    PropertyValueSpecification,
    VariableAnchorOffsetCollectionSpecification
} from '../types.g';

export type Feature = {
    readonly type:
        | 0
        | 1
        | 2
        | 3
        | 'Unknown'
        | 'Point'
        | 'MultiPoint'
        | 'LineString'
        | 'MultiLineString'
        | 'Polygon'
        | 'MultiPolygon';
    readonly id?: any;
    readonly properties: {[_: string]: any};
    readonly patterns?: {
        [_: string]: {
            min: string;
            mid: string;
            max: string;
        };
    };
    readonly dashes?: {
        [_: string]: {
            min: string;
            mid: string;
            max: string;
        };
    };
    readonly geometry?: Array<Array<Point2D>>;
};

export type FeatureState = {[_: string]: any};

export type GlobalProperties = Readonly<{
    zoom: number;
    heatmapDensity?: number;
    elevation?: number;
    lineProgress?: number;
    isSupportedScript?: (_: string) => boolean;
    accumulated?: Value;
    globalState?: Record<string, any>;
}>;

export class StyleExpression {
    expression: Expression;

    _evaluator: EvaluationContext;
    _defaultValue: Value;
    _warningHistory: {[key: string]: boolean};
    _enumValues: {[_: string]: any};
    readonly _globalState: Record<string, any>;
    readonly _rootKey: string;

    constructor(
        expression: Expression,
        rootKey: string,
        propertySpec?: StylePropertySpecification | null,
        globalState?: Record<string, any>
    ) {
        this.expression = expression;
        this._warningHistory = {};
        this._evaluator = new EvaluationContext();
        this._defaultValue = propertySpec ? getDefaultValue(propertySpec) : null;
        this._enumValues =
            propertySpec && propertySpec.type === 'enum' ? propertySpec.values : null;
        this._globalState = globalState;
        this._rootKey = rootKey;
    }

    evaluateWithoutErrorHandling(
        globals: GlobalProperties,
        feature?: Feature,
        featureState?: FeatureState,
        canonical?: ICanonicalTileID,
        availableImages?: Array<string>,
        formattedSection?: FormattedSection
    ): any {
        if (this._globalState) {
            globals = addGlobalState(globals, this._globalState);
        }
        this._evaluator.globals = globals;
        this._evaluator.feature = feature;
        this._evaluator.featureState = featureState;
        this._evaluator.canonical = canonical;
        this._evaluator.availableImages = availableImages || null;
        this._evaluator.formattedSection = formattedSection;

        return this.expression.evaluate(this._evaluator);
    }

    evaluate(
        globals: GlobalProperties,
        feature?: Feature,
        featureState?: FeatureState,
        canonical?: ICanonicalTileID,
        availableImages?: Array<string>,
        formattedSection?: FormattedSection
    ): any {
        if (this._globalState) {
            globals = addGlobalState(globals, this._globalState);
        }
        this._evaluator.globals = globals;
        this._evaluator.feature = feature || null;
        this._evaluator.featureState = featureState || null;
        this._evaluator.canonical = canonical;
        this._evaluator.availableImages = availableImages || null;
        this._evaluator.formattedSection = formattedSection || null;

        try {
            const val = this.expression.evaluate(this._evaluator);
            if (val === null || val === undefined || (typeof val === 'number' && val !== val)) {
                return this._defaultValue;
            }
            if (this._enumValues && !(val in this._enumValues)) {
                throw new RuntimeError(
                    `Expected value to be one of ${Object.keys(this._enumValues)
                        .map((v) => JSON.stringify(v))
                        .join(', ')}, but found ${JSON.stringify(val)} instead.`,
                    ''
                );
            }
            return val;
        } catch (e) {
            // A non-RuntimeError throw has no location, so treat it as the root ('').
            const path = e instanceof RuntimeError ? e.path : '';
            const dedupKey = `${path}|${e.message}`;
            if (!this._warningHistory[dedupKey]) {
                this._warningHistory[dedupKey] = true;
                if (typeof console !== 'undefined') {
                    console.warn(
                        formatRuntimeWarning(this._rootKey, path, e.message, this._defaultValue)
                    );
                }
            }
            return this._defaultValue;
        }
    }
}

/**
 * Builds the warning logged when an expression or legacy function fails at
 * evaluation: a `rootKey + index path` location prefix, plus the fallback
 * value being used.
 * @param rootKey Caller-supplied location of the expression in the style JSON
 * @param path Index path of the throwing sub-expression ('' for the root)
 * @param message The error message from the failed evaluation
 * @param defaultValue The value being fallen back to
 * @returns The formatted warning string
 */
function formatRuntimeWarning(
    rootKey: string,
    path: string,
    message: string,
    defaultValue: Value
): string {
    const fallback = defaultValue == null ? '' : ` Falling back to ${String(defaultValue)}.`;
    return `${rootKey}${path}: ${message}${fallback}`;
}

/**
 * Rejects a missing or empty root key. The location prefix is what makes
 * runtime warnings actionable, so callers must always supply one; failing
 * here surfaces the programmer error at style load instead of producing
 * unattributable warnings at render time.
 * @param rootKey The root key to check
 */
function assertRootKey(rootKey: string): void {
    if (!rootKey) {
        throw new Error(
            'rootKey must identify the location of the expression in the style JSON, e.g. "layers[3].paint.line-width".'
        );
    }
}

export function isExpression(expression: unknown) {
    return (
        Array.isArray(expression) &&
        expression.length > 0 &&
        typeof expression[0] === 'string' &&
        expression[0] in expressions
    );
}

/**
 * Parse and typecheck the given style spec JSON expression.  If
 * options.defaultValue is provided, then the resulting StyleExpression's
 * `evaluate()` method will handle errors by logging a warning (once per
 * message) and returning the default value.  Otherwise, it will throw
 * evaluation errors.
 *
 * @private
 */
export function createExpression(
    expression: unknown,
    rootKey: string,
    propertySpec?: StylePropertySpecification | null,
    globalState?: Record<string, any>
): Result<StyleExpression, Array<ExpressionParsingError>> {
    assertRootKey(rootKey);
    const parser = new ParsingContext(
        expressions,
        isExpressionConstant,
        [],
        propertySpec ? getExpectedType(propertySpec) : undefined
    );

    // For string-valued properties, coerce to string at the top level rather than asserting.
    const parsed = parser.parse(
        expression,
        undefined,
        undefined,
        undefined,
        propertySpec && propertySpec.type === 'string' ? {typeAnnotation: 'coerce'} : undefined
    );

    if (!parsed) {
        return error(parser.errors);
    }

    return success(new StyleExpression(parsed, rootKey, propertySpec, globalState));
}

export class ZoomConstantExpression<Kind extends EvaluationKind> {
    kind: Kind;
    isStateDependent: boolean;
    globalStateRefs: Set<string>;
    _styleExpression: StyleExpression;
    readonly _globalState: Record<string, any>;

    constructor(kind: Kind, expression: StyleExpression, globalState?: Record<string, any>) {
        this.kind = kind;
        this._styleExpression = expression;
        this.isStateDependent =
            kind !== ('constant' as EvaluationKind) && !isStateConstant(expression.expression);
        this.globalStateRefs = findGlobalStateRefs(expression.expression);
        this._globalState = globalState;
    }

    evaluateWithoutErrorHandling(
        globals: GlobalProperties,
        feature?: Feature,
        featureState?: FeatureState,
        canonical?: ICanonicalTileID,
        availableImages?: Array<string>,
        formattedSection?: FormattedSection
    ): any {
        if (this._globalState) {
            globals = addGlobalState(globals, this._globalState);
        }
        return this._styleExpression.evaluateWithoutErrorHandling(
            globals,
            feature,
            featureState,
            canonical,
            availableImages,
            formattedSection
        );
    }

    evaluate(
        globals: GlobalProperties,
        feature?: Feature,
        featureState?: FeatureState,
        canonical?: ICanonicalTileID,
        availableImages?: Array<string>,
        formattedSection?: FormattedSection
    ): any {
        if (this._globalState) {
            globals = addGlobalState(globals, this._globalState);
        }
        return this._styleExpression.evaluate(
            globals,
            feature,
            featureState,
            canonical,
            availableImages,
            formattedSection
        );
    }
}

export class ZoomDependentExpression<Kind extends EvaluationKind> {
    kind: Kind;
    zoomStops: Array<number>;
    isStateDependent: boolean;
    globalStateRefs: Set<string>;
    _styleExpression: StyleExpression;
    interpolationType: InterpolationType;
    readonly _globalState: Record<string, any>;

    constructor(
        kind: Kind,
        expression: StyleExpression,
        zoomStops: Array<number>,
        interpolationType?: InterpolationType,
        globalState?: Record<string, any>
    ) {
        this.kind = kind;
        this.zoomStops = zoomStops;
        this._styleExpression = expression;
        this.isStateDependent =
            kind !== ('camera' as EvaluationKind) && !isStateConstant(expression.expression);
        this.globalStateRefs = findGlobalStateRefs(expression.expression);
        this.interpolationType = interpolationType;
        this._globalState = globalState;
    }

    evaluateWithoutErrorHandling(
        globals: GlobalProperties,
        feature?: Feature,
        featureState?: FeatureState,
        canonical?: ICanonicalTileID,
        availableImages?: Array<string>,
        formattedSection?: FormattedSection
    ): any {
        if (this._globalState) {
            globals = addGlobalState(globals, this._globalState);
        }
        return this._styleExpression.evaluateWithoutErrorHandling(
            globals,
            feature,
            featureState,
            canonical,
            availableImages,
            formattedSection
        );
    }

    evaluate(
        globals: GlobalProperties,
        feature?: Feature,
        featureState?: FeatureState,
        canonical?: ICanonicalTileID,
        availableImages?: Array<string>,
        formattedSection?: FormattedSection
    ): any {
        if (this._globalState) {
            globals = addGlobalState(globals, this._globalState);
        }
        return this._styleExpression.evaluate(
            globals,
            feature,
            featureState,
            canonical,
            availableImages,
            formattedSection
        );
    }

    interpolationFactor(input: number, lower: number, upper: number): number {
        if (this.interpolationType) {
            return Interpolate.interpolationFactor(this.interpolationType, input, lower, upper);
        } else {
            return 0;
        }
    }
}

export function isZoomExpression(
    expression: any
): expression is ZoomConstantExpression<'source'> | ZoomDependentExpression<'source'> {
    return (expression as ZoomConstantExpression<'source'>)._styleExpression !== undefined;
}

export type ConstantExpression = {
    kind: 'constant';
    globalStateRefs: Set<string>;
    readonly _globalState: Record<string, any>;
    readonly evaluate: (
        globals: GlobalProperties,
        feature?: Feature,
        featureState?: FeatureState,
        canonical?: ICanonicalTileID,
        availableImages?: Array<string>
    ) => any;
};

export type SourceExpression = {
    kind: 'source';
    isStateDependent: boolean;
    globalStateRefs: Set<string>;
    readonly _globalState: Record<string, any>;
    readonly evaluate: (
        globals: GlobalProperties,
        feature?: Feature,
        featureState?: FeatureState,
        canonical?: ICanonicalTileID,
        availableImages?: Array<string>,
        formattedSection?: FormattedSection
    ) => any;
};

export type CameraExpression = {
    kind: 'camera';
    globalStateRefs: Set<string>;
    readonly _globalState: Record<string, any>;
    readonly evaluate: (
        globals: GlobalProperties,
        feature?: Feature,
        featureState?: FeatureState,
        canonical?: ICanonicalTileID,
        availableImages?: Array<string>
    ) => any;
    readonly interpolationFactor: (input: number, lower: number, upper: number) => number;
    zoomStops: Array<number>;
    interpolationType: InterpolationType;
};

export type CompositeExpression = {
    kind: 'composite';
    isStateDependent: boolean;
    globalStateRefs: Set<string>;
    readonly _globalState: Record<string, any>;
    readonly evaluate: (
        globals: GlobalProperties,
        feature?: Feature,
        featureState?: FeatureState,
        canonical?: ICanonicalTileID,
        availableImages?: Array<string>,
        formattedSection?: FormattedSection
    ) => any;
    readonly interpolationFactor: (input: number, lower: number, upper: number) => number;
    zoomStops: Array<number>;
    interpolationType: InterpolationType;
};

export type StylePropertyExpression =
    | ConstantExpression
    | SourceExpression
    | CameraExpression
    | CompositeExpression;

export function createPropertyExpression(
    expressionInput: unknown,
    rootKey: string,
    propertySpec: StylePropertySpecification,
    globalState?: Record<string, any>
): Result<StylePropertyExpression, Array<ExpressionParsingError>> {
    const expression = createExpression(expressionInput, rootKey, propertySpec, globalState);
    if (expression.result === 'error') {
        return expression;
    }

    const parsed = expression.value.expression;

    const isFeatureConstantResult = isFeatureConstant(parsed);
    if (!isFeatureConstantResult && !supportsPropertyExpression(propertySpec)) {
        return error([new ExpressionParsingError('', 'data expressions not supported')]);
    }

    const isZoomConstant = isGlobalPropertyConstant(parsed, ['zoom']);
    if (!isZoomConstant && !supportsZoomExpression(propertySpec)) {
        return error([new ExpressionParsingError('', 'zoom expressions not supported')]);
    }

    const zoomCurve = findZoomCurve(parsed);
    if (!zoomCurve && !isZoomConstant) {
        return error([
            new ExpressionParsingError(
                '',
                '"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.'
            )
        ]);
    } else if (zoomCurve instanceof ExpressionParsingError) {
        return error([zoomCurve]);
    } else if (zoomCurve instanceof Interpolate && !supportsInterpolation(propertySpec)) {
        return error([
            new ExpressionParsingError(
                '',
                '"interpolate" expressions cannot be used with this property'
            )
        ]);
    }

    if (!zoomCurve) {
        return success(
            isFeatureConstantResult
                ? (new ZoomConstantExpression(
                      'constant',
                      expression.value,
                      globalState
                  ) as ConstantExpression)
                : (new ZoomConstantExpression(
                      'source',
                      expression.value,
                      globalState
                  ) as SourceExpression)
        );
    }

    const interpolationType =
        zoomCurve instanceof Interpolate ? zoomCurve.interpolation : undefined;

    return success(
        isFeatureConstantResult
            ? (new ZoomDependentExpression(
                  'camera',
                  expression.value,
                  zoomCurve.labels,
                  interpolationType,
                  globalState
              ) as CameraExpression)
            : (new ZoomDependentExpression(
                  'composite',
                  expression.value,
                  zoomCurve.labels,
                  interpolationType,
                  globalState
              ) as CompositeExpression)
    );
}

// serialization wrapper for old-style stop functions normalized to the
// expression interface
export class StylePropertyFunction<T> {
    _parameters: PropertyValueSpecification<T>;
    _specification: StylePropertySpecification;
    readonly _rootKey: string;
    readonly _defaultValue: Value;
    _warningHistory: {[key: string]: boolean};
    _innerEvaluate: (globals: GlobalProperties, feature?: Feature) => any;

    kind: StyleFunction['kind'];
    interpolationFactor: (input: number, lower: number, upper: number) => number;
    zoomStops: Array<number>;
    interpolationType: InterpolationType | null;
    isStateDependent: boolean = false; // Legacy functions cannot reference feature state or global state, so these are constant.
    globalStateRefs: Set<string> = new Set<string>();
    readonly _globalState: Record<string, any> = null;

    constructor(
        parameters: PropertyValueSpecification<T>,
        rootKey: string,
        specification: StylePropertySpecification
    ) {
        assertRootKey(rootKey);
        this._parameters = parameters;
        this._specification = specification;
        this._rootKey = rootKey;
        this._defaultValue = getDefaultValue(specification);
        this._warningHistory = {};

        const fn = createFunction(this._parameters, this._specification);
        this.kind = fn.kind;
        this.interpolationFactor = fn.interpolationFactor;
        this.zoomStops = fn.zoomStops;
        this.interpolationType = fn.interpolationType;
        this._innerEvaluate = fn.evaluate;
    }

    /**
     * Evaluates the legacy function, handling a runtime throw (e.g. interpolating
     * mismatched value types) by warning with the property location and falling
     * back to the spec default, mirroring {@link StyleExpression.evaluate}.
     * @param globals Global evaluation properties (e.g. zoom)
     * @param feature The feature being evaluated, if any
     * @returns The function result, or the spec default if evaluation throws
     */
    evaluate(globals: GlobalProperties, feature?: Feature): any {
        try {
            return this._innerEvaluate(globals, feature);
        } catch (e) {
            const message = e instanceof Error ? e.message : String(e);
            // Legacy functions have no parser index path, so the location is '' (root).
            const dedupKey = `|${message}`;
            if (!this._warningHistory[dedupKey]) {
                this._warningHistory[dedupKey] = true;
                if (typeof console !== 'undefined') {
                    console.warn(
                        formatRuntimeWarning(this._rootKey, '', message, this._defaultValue)
                    );
                }
            }
            return this._defaultValue;
        }
    }

    static deserialize<T>(serialized: {
        _parameters: PropertyValueSpecification<T>;
        _specification: StylePropertySpecification;
        _rootKey: string;
    }) {
        return new StylePropertyFunction(
            serialized._parameters,
            serialized._rootKey,
            serialized._specification
        ) as StylePropertyFunction<T>;
    }

    static serialize<T>(input: StylePropertyFunction<T>) {
        return {
            _parameters: input._parameters,
            _specification: input._specification,
            _rootKey: input._rootKey
        };
    }
}

export function normalizePropertyExpression<T>(
    value: PropertyValueSpecification<T>,
    rootKey: string,
    specification: StylePropertySpecification,
    globalState?: Record<string, any>
): StylePropertyExpression {
    if (isFunction(value)) {
        return new StylePropertyFunction(value, rootKey, specification);
    } else if (isExpression(value)) {
        const expression = createPropertyExpression(value, rootKey, specification, globalState);
        if (expression.result === 'error') {
            // this should have been caught in validation
            throw new Error(expression.value.map((err) => `${err.key}: ${err.message}`).join(', '));
        }
        return expression.value;
    } else {
        let constant: any = value;
        if (specification.type === 'color' && typeof value === 'string') {
            constant = Color.parse(value);
        } else if (
            specification.type === 'padding' &&
            (typeof value === 'number' || Array.isArray(value))
        ) {
            constant = Padding.parse(value as PaddingSpecification);
        } else if (
            specification.type === 'numberArray' &&
            (typeof value === 'number' || Array.isArray(value))
        ) {
            constant = NumberArray.parse(value as NumberArraySpecification);
        } else if (
            specification.type === 'colorArray' &&
            (typeof value === 'string' || Array.isArray(value))
        ) {
            constant = ColorArray.parse(value as ColorArraySpecification);
        } else if (
            specification.type === 'variableAnchorOffsetCollection' &&
            Array.isArray(value)
        ) {
            constant = VariableAnchorOffsetCollection.parse(
                value as VariableAnchorOffsetCollectionSpecification
            );
        } else if (specification.type === 'projectionDefinition' && typeof value === 'string') {
            constant = ProjectionDefinition.parse(value);
        }
        return {
            globalStateRefs: new Set<string>(),
            _globalState: null,
            kind: 'constant',
            evaluate: () => constant
        };
    }
}

// Zoom-dependent expressions may only use ["zoom"] as the input to a top-level "step" or "interpolate"
// expression (collectively referred to as a "curve"). The curve may be wrapped in one or more "let" or
// "coalesce" expressions.
function findZoomCurve(expression: Expression): Step | Interpolate | ExpressionParsingError | null {
    let result = null;
    if (expression instanceof Let) {
        result = findZoomCurve(expression.result);
    } else if (expression instanceof Coalesce) {
        for (const arg of expression.args) {
            result = findZoomCurve(arg);
            if (result) {
                break;
            }
        }
    } else if (
        (expression instanceof Step || expression instanceof Interpolate) &&
        expression.input instanceof CompoundExpression &&
        expression.input.name === 'zoom'
    ) {
        result = expression;
    }

    if (result instanceof ExpressionParsingError) {
        return result;
    }

    expression.eachChild((child) => {
        const childResult = findZoomCurve(child);
        if (childResult instanceof ExpressionParsingError) {
            result = childResult;
        } else if (!result && childResult) {
            result = new ExpressionParsingError(
                '',
                '"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.'
            );
        } else if (result && childResult && result !== childResult) {
            result = new ExpressionParsingError(
                '',
                'Only one zoom-based "step" or "interpolate" subexpression may be used in an expression.'
            );
        }
    });

    return result;
}

export function findGlobalStateRefs(
    expression: Expression,
    results = new Set<string>()
): Set<string> {
    if (expression instanceof GlobalState) {
        results.add(expression.key);
    }

    expression.eachChild((childExpression) => {
        findGlobalStateRefs(childExpression, results);
    });
    return results;
}

function getExpectedType(spec: StylePropertySpecification): Type {
    const types = {
        color: ColorType,
        string: StringType,
        number: NumberType,
        enum: StringType,
        boolean: BooleanType,
        formatted: FormattedType,
        padding: PaddingType,
        numberArray: NumberArrayType,
        colorArray: ColorArrayType,
        projectionDefinition: ProjectionDefinitionType,
        resolvedImage: ResolvedImageType,
        variableAnchorOffsetCollection: VariableAnchorOffsetCollectionType
    };

    if (spec.type === 'array') {
        return array(types[spec.value] || ValueType, spec.length);
    }

    return types[spec.type];
}

function getDefaultValue(spec: StylePropertySpecification): Value {
    if (spec.type === 'color' && isFunction(spec.default)) {
        // Special case for heatmap-color: it uses the 'default:' to define a
        // default color ramp, but createExpression expects a simple value to fall
        // back to in case of runtime errors
        return new Color(0, 0, 0, 0);
    }
    switch (spec.type) {
        case 'color':
            return Color.parse(spec.default) || null;
        case 'padding':
            return Padding.parse(spec.default) || null;
        case 'numberArray':
            return NumberArray.parse(spec.default) || null;
        case 'colorArray':
            return ColorArray.parse(spec.default) || null;
        case 'variableAnchorOffsetCollection':
            return VariableAnchorOffsetCollection.parse(spec.default) || null;
        case 'projectionDefinition':
            return ProjectionDefinition.parse(spec.default) || null;
        default:
            return spec.default === undefined ? null : spec.default;
    }
}

function addGlobalState(
    globals: GlobalProperties,
    globalState: Record<string, any>
): GlobalProperties {
    const {zoom, heatmapDensity, elevation, lineProgress, isSupportedScript, accumulated} =
        globals ?? {};
    return {
        zoom,
        heatmapDensity,
        elevation,
        lineProgress,
        isSupportedScript,
        accumulated,
        globalState
    };
}
