// @flow

import assert from 'assert';

import type {StylePropertySpecification} from '../style-spec.js';
import type {ExpressionSpecification} from '../types.js';

function convertLiteral(value) {
    return typeof value === 'object' ? ['literal', value] : value;
}

export default function convertFunction(parameters: any, propertySpec: StylePropertySpecification): ExpressionSpecification {
    let stops = parameters.stops;
    if (!stops) {
        // identity function
        return convertIdentityFunction(parameters, propertySpec);
    }

    const zoomAndFeatureDependent = stops && typeof stops[0][0] === 'object';
    const featureDependent = zoomAndFeatureDependent || parameters.property !== undefined;
    const zoomDependent = zoomAndFeatureDependent || !featureDependent;

    stops = stops.map((stop) => {
        if (!featureDependent && propertySpec.tokens && typeof stop[1] === 'string') {
            return [stop[0], convertTokenString(stop[1])];
        }
        return [stop[0], convertLiteral(stop[1])];
    });

    if (zoomAndFeatureDependent) {
        return convertZoomAndPropertyFunction(parameters, propertySpec, stops);
    } else if (zoomDependent) {
        return convertZoomFunction(parameters, propertySpec, stops);
    } else {
        return convertPropertyFunction(parameters, propertySpec, stops);
    }
}

function convertIdentityFunction(parameters, propertySpec): Array<mixed> {
    const get = ['get', parameters.property];

    if (parameters.default === undefined) {
        // By default, expressions for string-valued properties get coerced. To preserve
        // legacy function semantics, insert an explicit assertion instead.
        return propertySpec.type === 'string' ? ['string', get] : get;
    } else if (propertySpec.type === 'enum') {
        return [
            'match',
            get,
            Object.keys(propertySpec.values),
            get,
            parameters.default
        ];
    } else {
        const expression = [propertySpec.type === 'color' ? 'to-color' : propertySpec.type, get, convertLiteral(parameters.default)];
        if (propertySpec.type === 'array') {
            expression.splice(1, 0, propertySpec.value, propertySpec.length || null);
        }
        return expression;
    }
}

function getInterpolateOperator(parameters) {
    switch (parameters.colorSpace) {
    case 'hcl': return 'interpolate-hcl';
    case 'lab': return 'interpolate-lab';
    default: return 'interpolate';
    }
}

function convertZoomAndPropertyFunction(parameters, propertySpec, stops) {
    const featureFunctionParameters = {};
    const featureFunctionStops = {};
    const zoomStops = [];
    for (let s = 0; s < stops.length; s++) {
        const stop = stops[s];
        const zoom = stop[0].zoom;
        if (featureFunctionParameters[zoom] === undefined) {
            featureFunctionParameters[zoom] = {
                zoom,
                type: parameters.type,
                property: parameters.property,
                default: parameters.default,
            };
            featureFunctionStops[zoom] = [];
            zoomStops.push(zoom);
        }
        featureFunctionStops[zoom].push([stop[0].value, stop[1]]);
    }

    // the interpolation type for the zoom dimension of a zoom-and-property
    // function is determined directly from the style property specification
    // for which it's being used: linear for interpolatable properties, step
    // otherwise.
    const functionType = getFunctionType({}, propertySpec);
    if (functionType === 'exponential') {
        const expression = [getInterpolateOperator(parameters), ['linear'], ['zoom']];

        for (const z of zoomStops) {
            const output = convertPropertyFunction(featureFunctionParameters[z], propertySpec, featureFunctionStops[z]);
            appendStopPair(expression, z, output, false);
        }

        return expression;
    } else {
        const expression = ['step', ['zoom']];

        for (const z of zoomStops) {
            const output = convertPropertyFunction(featureFunctionParameters[z], propertySpec, featureFunctionStops[z]);
            appendStopPair(expression, z, output, true);
        }

        fixupDegenerateStepCurve(expression);

        return expression;
    }
}

function coalesce(a, b) {
    if (a !== undefined) return a;
    if (b !== undefined) return b;
}

function getFallback(parameters, propertySpec) {
    const defaultValue = convertLiteral(coalesce(parameters.default, propertySpec.default));

    /*
     * Some fields with type: resolvedImage have an undefined default.
     * Because undefined is an invalid value for resolvedImage, set fallback to
     * an empty string instead of undefined to ensure output
     * passes validation.
     */
    if (defaultValue === undefined && propertySpec.type === 'resolvedImage') {
        return '';
    }
    return defaultValue;
}

function convertPropertyFunction(parameters, propertySpec, stops) {
    const type = getFunctionType(parameters, propertySpec);
    const get = ['get', parameters.property];
    if (type === 'categorical' && typeof stops[0][0] === 'boolean') {
        assert(parameters.stops.length > 0 && parameters.stops.length <= 2);
        const expression = ['case'];
        for (const stop of stops) {
            expression.push(['==', get, stop[0]], stop[1]);
        }

        expression.push(getFallback(parameters, propertySpec));
        return expression;
    } else if (type === 'categorical') {
        const expression = ['match', get];
        for (const stop of stops) {
            appendStopPair(expression, stop[0], stop[1], false);
        }
        expression.push(getFallback(parameters, propertySpec));
        return expression;
    } else if (type === 'interval') {
        const expression = ['step', ['number', get]];
        for (const stop of stops) {
            appendStopPair(expression, stop[0], stop[1], true);
        }
        fixupDegenerateStepCurve(expression);
        return parameters.default === undefined ? expression : [
            'case',
            ['==', ['typeof', get], 'number'],
            expression,
            convertLiteral(parameters.default)
        ];
    } else if (type === 'exponential') {
        const base = parameters.base !== undefined ? parameters.base : 1;
        const expression = [
            getInterpolateOperator(parameters),
            base === 1 ? ["linear"] : ["exponential", base],
            ["number", get]
        ];

        for (const stop of stops) {
            appendStopPair(expression, stop[0], stop[1], false);
        }
        return parameters.default === undefined ? expression : [
            'case',
            ['==', ['typeof', get], 'number'],
            expression,
            convertLiteral(parameters.default)
        ];
    } else {
        throw new Error(`Unknown property function type ${type}`);
    }
}

function convertZoomFunction(parameters, propertySpec, stops, input = ['zoom']) {
    const type = getFunctionType(parameters, propertySpec);
    let expression;
    let isStep = false;
    if (type === 'interval') {
        expression = ['step', input];
        isStep = true;
    } else if (type === 'exponential') {
        const base = parameters.base !== undefined ? parameters.base : 1;
        expression = [getInterpolateOperator(parameters), base === 1 ? ["linear"] : ["exponential", base], input];

    } else {
        throw new Error(`Unknown zoom function type "${type}"`);
    }

    for (const stop of stops) {
        appendStopPair(expression, stop[0], stop[1], isStep);
    }

    fixupDegenerateStepCurve(expression);

    return expression;
}

function fixupDegenerateStepCurve(expression) {
    // degenerate step curve (i.e. a constant function): add a noop stop
    if (expression[0] === 'step' && expression.length === 3) {
        expression.push(0);
        expression.push(expression[3]);
    }
}

function appendStopPair(curve, input, output, isStep) {
    // Skip duplicate stop values. They were not validated for functions, but they are for expressions.
    // https://github.com/mapbox/mapbox-gl-js/issues/4107
    if (curve.length > 3 && input === curve[curve.length - 2]) {
        return;
    }
    // step curves don't get the first input value, as it is redundant.
    if (!(isStep && curve.length === 2)) {
        curve.push(input);
    }
    curve.push(output);
}

function getFunctionType(parameters, propertySpec) {
    if (parameters.type) {
        return parameters.type;
    } else {
        assert(propertySpec.expression);
        return (propertySpec.expression: any).interpolated ? 'exponential' : 'interval';
    }
}

// "String with {name} token" => ["concat", "String with ", ["get", "name"], " token"]
export function convertTokenString(s: string): string | ExpressionSpecification {
    const result = ['concat'];
    const re = /{([^{}]+)}/g;
    let pos = 0;
    for (let match = re.exec(s); match !== null; match = re.exec(s)) {
        const literal = s.slice(pos, re.lastIndex - match[0].length);
        pos = re.lastIndex;
        if (literal.length > 0) result.push(literal);
        result.push(['get', match[1]]);
    }

    if (result.length === 1) {
        return s;
    }

    if (pos < s.length) {
        result.push(s.slice(pos));
    } else if (result.length === 2) {
        return ['to-string', result[1]];
    }

    return result;
}

