import {isExpressionFilter} from './index';

import type {FilterSpecification} from '../types';

type ExpectedTypes = {
    [_: string]: 'string' | 'number' | 'boolean';
};

/**
 * Convert the given legacy filter to (the JSON representation of) an
 * equivalent expression
 * @private
 */
export default function convertFilter(filter: FilterSpecification): unknown {
    return _convertFilter(filter, {});
}

/*
 * Convert the given filter to an expression, storing the expected types for
 * any feature properties referenced in expectedTypes.
 *
 * These expected types are needed in order to construct preflight type checks
 * needed for handling 'any' filters. A preflight type check is necessary in
 * order to mimic legacy filters' semantics around expected type mismatches.
 * For example, consider the legacy filter:
 *
 *     ["any", ["all", [">", "y", 0], [">", "y", 0]], [">", "x", 0]]
 *
 * Naively, we might convert this to the expression:
 *
 *     ["any", ["all", [">", ["get", "y"], 0], [">", ["get", "z"], 0]], [">", ["get", "x"], 0]]
 *
 * But if we tried to evaluate this against, say `{x: 1, y: null, z: 0}`, the
 * [">", ["get", "y"], 0] would cause an evaluation error, leading to the
 * entire filter returning false. Legacy filter semantics, though, ask for
 * [">", "y", 0] to simply return `false` when `y` is of the wrong type,
 * allowing the subsequent terms of the outer "any" expression to be evaluated
 * (resulting, in this case, in a `true` value, because x > 0).
 *
 * We account for this by inserting a preflight type-checking expression before
 * each "any" term, allowing us to avoid evaluating the actual converted filter
 * if any type mismatches would cause it to produce an evalaution error:
 *
 *     ["any",
 *       ["case",
 *         ["all", ["==", ["typeof", ["get", "y"]], "number"], ["==", ["typeof", ["get", "z"], "number]],
 *         ["all", [">", ["get", "y"], 0], [">", ["get", "z"], 0]],
 *         false
 *       ],
 *       ["case",
 *         ["==", ["typeof", ["get", "x"], "number"]],
 *         [">", ["get", "x"], 0],
 *         false
 *       ]
 *     ]
 *
 * An alternative, possibly more direct approach would be to use type checks
 * in the conversion of each comparison operator, so that the converted version
 * of each individual ==, >=, etc. would mimic the legacy filter semantics. The
 * downside of this approach is that it can lead to many more type checks than
 * would otherwise be necessary: outside the context of an "any" expression,
 * bailing out due to a runtime type error (expression semantics) and returning
 * false (legacy filter semantics) are equivalent: they cause the filter to
 * produce a `false` result.
 */
function _convertFilter(filter: FilterSpecification, expectedTypes: ExpectedTypes): unknown {
    if (isExpressionFilter(filter)) { return filter; }

    if (!filter) return true;
    const op = filter[0];
    if (filter.length <= 1) return (op !== 'any');

    let converted;

    if (
        op === '==' ||
        op === '!=' ||
        op === '<' ||
        op === '>' ||
        op === '<=' ||
        op === '>='
    ) {
        const [, property, value] = (filter as any);
        converted = convertComparisonOp(property, value, op, expectedTypes);
    } else if (op === 'any') {
        const children = (filter as any).slice(1).map(f => {
            const types: Record<string, any> = {};
            const child = _convertFilter(f, types);
            const typechecks = runtimeTypeChecks(types);
            return typechecks === true ? child : ['case', typechecks, child, false];
        });
        return ['any'].concat(children);
    } else if (op === 'all') {
        const children: any[] = (filter).slice(1).map(f => _convertFilter(f, expectedTypes));
        return children.length > 1 ? ['all'].concat(children) : [].concat(...children);
    } else if (op === 'none') {
        return ['!', _convertFilter(['any'].concat((filter).slice(1)), {})];
    } else if (op === 'in') {
        converted = convertInOp((filter[1]), filter.slice(2));
    } else if (op === '!in') {
        converted = convertInOp((filter[1]), filter.slice(2), true);
    } else if (op === 'has') {
        converted = convertHasOp((filter[1]));
    } else if (op === '!has') {
        converted = ['!', convertHasOp((filter[1]))];
    } else {
        converted = true;
    }

    return converted;
}

// Given a set of feature properties and an expected type for each one,
// construct an boolean expression that tests whether each property has the
// right type.
// E.g.: for {name: 'string', population: 'number'}, return
// [ 'all',
//   ['==', ['typeof', ['get', 'name'], 'string']],
//   ['==', ['typeof', ['get', 'population'], 'number]]
// ]
function runtimeTypeChecks(expectedTypes: ExpectedTypes) {
    const conditions = [];
    for (const property in expectedTypes) {
        const get = property === '$id' ? ['id'] : ['get', property];
        conditions.push(['==', ['typeof', get], expectedTypes[property]]);
    }
    if (conditions.length === 0) return true;
    if (conditions.length === 1) return conditions[0];
    return ['all'].concat(conditions);
}

function convertComparisonOp(property: string, value: any, op: string, expectedTypes?: ExpectedTypes | null) {
    let get;
    if (property === '$type') {
        return [op, ['geometry-type'], value];
    } else if (property === '$id') {
        get = ['id'];
    } else {
        get = ['get', property];
    }

    if (expectedTypes && value !== null) {
        const type = ((typeof value) as any);
        expectedTypes[property] = type;
    }

    if (op === '==' && property !== '$id' && value === null) {
        return [
            'all',
            ['has', property], // missing property != null for legacy filters
            ['==', get, null]
        ];
    } else if (op === '!=' && property !== '$id' && value === null) {
        return [
            'any',
            ['!', ['has', property]], // missing property != null for legacy filters
            ['!=', get, null]
        ];
    }

    return [op, get, value];
}

function convertInOp(property: string, values: Array<any>, negate: boolean = false) {
    if (values.length === 0) return negate;

    let get: string[];
    if (property === '$type') {
        get = ['geometry-type'];
    } else if (property === '$id') {
        get = ['id'];
    } else {
        get = ['get', property];
    }

    // Determine if the list of values to be searched is homogenously typed.
    // If so (and if the type is string or number), then we can use a
    // [match, input, [...values], true, false] construction rather than a
    // bunch of `==` tests.
    let uniformTypes = true;
    const type = typeof values[0];
    for (const value of values) {
        if (typeof value !== type) {
            uniformTypes = false;
            break;
        }
    }

    if (uniformTypes && (type === 'string' || type === 'number')) {
        // Match expressions must have unique values.
        const uniqueValues = values.sort().filter((v, i) => i === 0 || values[i - 1] !== v);
        return ['match', get, uniqueValues, !negate, negate];
    }

    return [negate ? 'all' : 'any'].concat(
        values.map(v => [negate ? '!=' : '==', get, v]) as any[]
    );
}

function convertHasOp(property: string) {
    if (property === '$type') {
        return true;
    } else if (property === '$id') {
        return ['!=', ['id'], null];
    } else {
        return ['has', property];
    }
}
