import {
    typeToString,
    NumberType,
    StringType,
    BooleanType,
    ColorType,
    ObjectType,
    ValueType,
    ErrorType,
    CollatorType,
    array
} from './types';

import {ParsingContext} from './parsing_context';
import {EvaluationContext} from './evaluation_context';

import {expressions} from './definitions';
import {CollatorExpression} from './definitions/collator';
import {Within} from './definitions/within';
import {Literal} from './definitions/literal';
import {Assertion} from './definitions/assertion';
import {Coercion} from './definitions/coercion';
import {Var} from './definitions/var';
import {Distance} from './definitions/distance';
import {GlobalState} from './definitions/global_state';

import type {Expression, ExpressionRegistry} from './expression';
import type {Value} from './values';
import type {Type} from './types';

import {typeOf, validateRGBA, valueToString} from './values';
import {RuntimeError} from './runtime_error';
import {Color} from './types/color';

export type Varargs = {
    type: Type;
};
type Signature = Array<Type> | Varargs;
type Evaluate = (b: EvaluationContext, a: Array<Expression>) => Value;

type Definition =
    | [Type, Signature, Evaluate]
    | {
          type: Type;
          overloads: Array<[Signature, Evaluate]>;
      };

export class CompoundExpression implements Expression {
    name: string;
    type: Type;
    _evaluate: Evaluate;
    args: Array<Expression>;

    static definitions: {[_: string]: Definition};

    constructor(name: string, type: Type, evaluate: Evaluate, args: Array<Expression>) {
        this.name = name;
        this.type = type;
        this._evaluate = evaluate;
        this.args = args;
    }

    evaluate(ctx: EvaluationContext) {
        return this._evaluate(ctx, this.args);
    }

    eachChild(fn: (_: Expression) => void) {
        this.args.forEach(fn);
    }

    outputDefined() {
        return false;
    }

    static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Expression {
        const op: string = args[0] as any;
        const definition = CompoundExpression.definitions[op];
        if (!definition) {
            return context.error(
                `Unknown expression "${op}". If you wanted a literal array, use ["literal", [...]].`,
                0
            ) as null;
        }

        // Now check argument types against each signature
        const type = Array.isArray(definition) ? definition[0] : definition.type;

        const availableOverloads = Array.isArray(definition)
            ? [[definition[1], definition[2]]]
            : definition.overloads;

        const overloads = availableOverloads.filter(
            ([signature]) =>
                !Array.isArray(signature) || // varags
                signature.length === args.length - 1 // correct param count
        );

        let signatureContext: ParsingContext = null;

        for (const [params, evaluate] of overloads) {
            // Use a fresh context for each attempted signature so that, if
            // we eventually succeed, we haven't polluted `context.errors`.
            signatureContext = new ParsingContext(
                context.registry,
                isExpressionConstant,
                context.path,
                null,
                context.scope
            );

            // First parse all the args, potentially coercing to the
            // types expected by this overload.
            const parsedArgs: Array<Expression> = [];
            let argParseFailed = false;
            for (let i = 1; i < args.length; i++) {
                const arg = args[i];
                const expectedType = Array.isArray(params)
                    ? params[i - 1]
                    : (params as Varargs).type;

                const parsed = signatureContext.parse(arg, 1 + parsedArgs.length, expectedType);
                if (!parsed) {
                    argParseFailed = true;
                    break;
                }
                parsedArgs.push(parsed);
            }
            if (argParseFailed) {
                // Couldn't coerce args of this overload to expected type, move
                // on to next one.
                continue;
            }

            if (Array.isArray(params)) {
                if (params.length !== parsedArgs.length) {
                    signatureContext.error(
                        `Expected ${params.length} arguments, but found ${parsedArgs.length} instead.`
                    );
                    continue;
                }
            }

            for (let i = 0; i < parsedArgs.length; i++) {
                const expected = Array.isArray(params) ? params[i] : (params as Varargs).type;
                const arg = parsedArgs[i];
                signatureContext.concat(i + 1).checkSubtype(expected, arg.type);
            }

            if (signatureContext.errors.length === 0) {
                return new CompoundExpression(op, type, evaluate as Evaluate, parsedArgs);
            }
        }

        if (overloads.length === 1) {
            context.errors.push(...signatureContext.errors);
        } else {
            const expected = overloads.length ? overloads : availableOverloads;
            const signatures = expected
                .map(([params]) => stringifySignature(params as Signature))
                .join(' | ');

            const actualTypes = [];
            // For error message, re-parse arguments without trying to
            // apply any coercions
            for (let i = 1; i < args.length; i++) {
                const parsed = context.parse(args[i], 1 + actualTypes.length);
                if (!parsed) return null;
                actualTypes.push(typeToString(parsed.type));
            }
            context.error(
                `Expected arguments of type ${signatures}, but found (${actualTypes.join(', ')}) instead.`
            );
        }

        return null;
    }

    static register(registry: ExpressionRegistry, definitions: {[_: string]: Definition}) {
        CompoundExpression.definitions = definitions;
        for (const name in definitions) {
            registry[name] = CompoundExpression;
        }
    }
}

function rgba(ctx, [r, g, b, a]) {
    r = r.evaluate(ctx);
    g = g.evaluate(ctx);
    b = b.evaluate(ctx);
    const alpha = a ? a.evaluate(ctx) : 1;
    const error = validateRGBA(r, g, b, alpha);
    if (error) throw new RuntimeError(error);
    return new Color(r / 255, g / 255, b / 255, alpha, false);
}

function has(key, obj) {
    return key in obj;
}

function get(key, obj) {
    const v = obj[key];
    return typeof v === 'undefined' ? null : v;
}

function binarySearch(v, a, i, j) {
    while (i <= j) {
        const m = (i + j) >> 1;
        if (a[m] === v) return true;
        if (a[m] > v) j = m - 1;
        else i = m + 1;
    }
    return false;
}

function varargs(type: Type): Varargs {
    return {type};
}

CompoundExpression.register(expressions, {
    error: [
        ErrorType,
        [StringType],
        (ctx, [v]) => {
            throw new RuntimeError(v.evaluate(ctx));
        }
    ],
    typeof: [StringType, [ValueType], (ctx, [v]) => typeToString(typeOf(v.evaluate(ctx)))],
    'to-rgba': [
        array(NumberType, 4),
        [ColorType],
        (ctx, [v]) => {
            const [r, g, b, a] = v.evaluate(ctx).rgb;
            return [r * 255, g * 255, b * 255, a];
        }
    ],
    rgb: [ColorType, [NumberType, NumberType, NumberType], rgba],
    rgba: [ColorType, [NumberType, NumberType, NumberType, NumberType], rgba],
    has: {
        type: BooleanType,
        overloads: [
            [[StringType], (ctx, [key]) => has(key.evaluate(ctx), ctx.properties())],
            [
                [StringType, ObjectType],
                (ctx, [key, obj]) => has(key.evaluate(ctx), obj.evaluate(ctx))
            ]
        ]
    },
    get: {
        type: ValueType,
        overloads: [
            [[StringType], (ctx, [key]) => get(key.evaluate(ctx), ctx.properties())],
            [
                [StringType, ObjectType],
                (ctx, [key, obj]) => get(key.evaluate(ctx), obj.evaluate(ctx))
            ]
        ]
    },
    'feature-state': [
        ValueType,
        [StringType],
        (ctx, [key]) => get(key.evaluate(ctx), ctx.featureState || {})
    ],
    properties: [ObjectType, [], (ctx) => ctx.properties()],
    'geometry-type': [StringType, [], (ctx) => ctx.geometryType()],
    id: [ValueType, [], (ctx) => ctx.id()],
    zoom: [NumberType, [], (ctx) => ctx.globals.zoom],
    'heatmap-density': [NumberType, [], (ctx) => ctx.globals.heatmapDensity || 0],
    elevation: [NumberType, [], (ctx) => ctx.globals.elevation || 0],
    'line-progress': [NumberType, [], (ctx) => ctx.globals.lineProgress || 0],
    accumulated: [
        ValueType,
        [],
        (ctx) => (ctx.globals.accumulated === undefined ? null : ctx.globals.accumulated)
    ],
    '+': [
        NumberType,
        varargs(NumberType),
        (ctx, args) => {
            let result = 0;
            for (const arg of args) {
                result += arg.evaluate(ctx);
            }
            return result;
        }
    ],
    '*': [
        NumberType,
        varargs(NumberType),
        (ctx, args) => {
            let result = 1;
            for (const arg of args) {
                result *= arg.evaluate(ctx);
            }
            return result;
        }
    ],
    '-': {
        type: NumberType,
        overloads: [
            [[NumberType, NumberType], (ctx, [a, b]) => a.evaluate(ctx) - b.evaluate(ctx)],
            [[NumberType], (ctx, [a]) => -a.evaluate(ctx)]
        ]
    },
    '/': [NumberType, [NumberType, NumberType], (ctx, [a, b]) => a.evaluate(ctx) / b.evaluate(ctx)],
    '%': [NumberType, [NumberType, NumberType], (ctx, [a, b]) => a.evaluate(ctx) % b.evaluate(ctx)],
    ln2: [NumberType, [], () => Math.LN2],
    pi: [NumberType, [], () => Math.PI],
    e: [NumberType, [], () => Math.E],
    '^': [
        NumberType,
        [NumberType, NumberType],
        (ctx, [b, e]) => Math.pow(b.evaluate(ctx), e.evaluate(ctx))
    ],
    sqrt: [NumberType, [NumberType], (ctx, [x]) => Math.sqrt(x.evaluate(ctx))],
    log10: [NumberType, [NumberType], (ctx, [n]) => Math.log(n.evaluate(ctx)) / Math.LN10],
    ln: [NumberType, [NumberType], (ctx, [n]) => Math.log(n.evaluate(ctx))],
    log2: [NumberType, [NumberType], (ctx, [n]) => Math.log(n.evaluate(ctx)) / Math.LN2],
    sin: [NumberType, [NumberType], (ctx, [n]) => Math.sin(n.evaluate(ctx))],
    cos: [NumberType, [NumberType], (ctx, [n]) => Math.cos(n.evaluate(ctx))],
    tan: [NumberType, [NumberType], (ctx, [n]) => Math.tan(n.evaluate(ctx))],
    asin: [NumberType, [NumberType], (ctx, [n]) => Math.asin(n.evaluate(ctx))],
    acos: [NumberType, [NumberType], (ctx, [n]) => Math.acos(n.evaluate(ctx))],
    atan: [NumberType, [NumberType], (ctx, [n]) => Math.atan(n.evaluate(ctx))],
    min: [
        NumberType,
        varargs(NumberType),
        (ctx, args) => Math.min(...args.map((arg) => arg.evaluate(ctx)))
    ],
    max: [
        NumberType,
        varargs(NumberType),
        (ctx, args) => Math.max(...args.map((arg) => arg.evaluate(ctx)))
    ],
    abs: [NumberType, [NumberType], (ctx, [n]) => Math.abs(n.evaluate(ctx))],
    round: [
        NumberType,
        [NumberType],
        (ctx, [n]) => {
            const v = n.evaluate(ctx);
            // Javascript's Math.round() rounds towards +Infinity for halfway
            // values, even when they're negative. It's more common to round
            // away from 0 (e.g., this is what python and C++ do)
            return v < 0 ? -Math.round(-v) : Math.round(v);
        }
    ],
    floor: [NumberType, [NumberType], (ctx, [n]) => Math.floor(n.evaluate(ctx))],
    ceil: [NumberType, [NumberType], (ctx, [n]) => Math.ceil(n.evaluate(ctx))],
    'filter-==': [
        BooleanType,
        [StringType, ValueType],
        (ctx, [k, v]) => ctx.properties()[(k as any).value] === (v as any).value
    ],
    'filter-id-==': [BooleanType, [ValueType], (ctx, [v]) => ctx.id() === (v as any).value],
    'filter-type-==': [
        BooleanType,
        [StringType],
        (ctx, [v]) => ctx.geometryType() === (v as any).value
    ],
    'filter-<': [
        BooleanType,
        [StringType, ValueType],
        (ctx, [k, v]) => {
            const a = ctx.properties()[(k as any).value];
            const b = (v as any).value;
            return typeof a === typeof b && a < b;
        }
    ],
    'filter-id-<': [
        BooleanType,
        [ValueType],
        (ctx, [v]) => {
            const a = ctx.id();
            const b = (v as any).value;
            return typeof a === typeof b && a < b;
        }
    ],
    'filter->': [
        BooleanType,
        [StringType, ValueType],
        (ctx, [k, v]) => {
            const a = ctx.properties()[(k as any).value];
            const b = (v as any).value;
            return typeof a === typeof b && a > b;
        }
    ],
    'filter-id->': [
        BooleanType,
        [ValueType],
        (ctx, [v]) => {
            const a = ctx.id();
            const b = (v as any).value;
            return typeof a === typeof b && a > b;
        }
    ],
    'filter-<=': [
        BooleanType,
        [StringType, ValueType],
        (ctx, [k, v]) => {
            const a = ctx.properties()[(k as any).value];
            const b = (v as any).value;
            return typeof a === typeof b && a <= b;
        }
    ],
    'filter-id-<=': [
        BooleanType,
        [ValueType],
        (ctx, [v]) => {
            const a = ctx.id();
            const b = (v as any).value;
            return typeof a === typeof b && a <= b;
        }
    ],
    'filter->=': [
        BooleanType,
        [StringType, ValueType],
        (ctx, [k, v]) => {
            const a = ctx.properties()[(k as any).value];
            const b = (v as any).value;
            return typeof a === typeof b && a >= b;
        }
    ],
    'filter-id->=': [
        BooleanType,
        [ValueType],
        (ctx, [v]) => {
            const a = ctx.id();
            const b = (v as any).value;
            return typeof a === typeof b && a >= b;
        }
    ],
    'filter-has': [BooleanType, [ValueType], (ctx, [k]) => (k as any).value in ctx.properties()],
    'filter-has-id': [BooleanType, [], (ctx) => ctx.id() !== null && ctx.id() !== undefined],
    'filter-type-in': [
        BooleanType,
        [array(StringType)],
        (ctx, [v]) => (v as any).value.indexOf(ctx.geometryType()) >= 0
    ],
    'filter-id-in': [
        BooleanType,
        [array(ValueType)],
        (ctx, [v]) => (v as any).value.indexOf(ctx.id()) >= 0
    ],
    'filter-in-small': [
        BooleanType,
        [StringType, array(ValueType)],
        // assumes v is an array literal
        (ctx, [k, v]) => (v as any).value.indexOf(ctx.properties()[(k as any).value]) >= 0
    ],
    'filter-in-large': [
        BooleanType,
        [StringType, array(ValueType)],
        // assumes v is a array literal with values sorted in ascending order and of a single type
        (ctx, [k, v]) =>
            binarySearch(
                ctx.properties()[(k as any).value],
                (v as any).value,
                0,
                (v as any).value.length - 1
            )
    ],
    all: {
        type: BooleanType,
        overloads: [
            [[BooleanType, BooleanType], (ctx, [a, b]) => a.evaluate(ctx) && b.evaluate(ctx)],
            [
                varargs(BooleanType),
                (ctx, args) => {
                    for (const arg of args) {
                        if (!arg.evaluate(ctx)) return false;
                    }
                    return true;
                }
            ]
        ]
    },
    any: {
        type: BooleanType,
        overloads: [
            [[BooleanType, BooleanType], (ctx, [a, b]) => a.evaluate(ctx) || b.evaluate(ctx)],
            [
                varargs(BooleanType),
                (ctx, args) => {
                    for (const arg of args) {
                        if (arg.evaluate(ctx)) return true;
                    }
                    return false;
                }
            ]
        ]
    },
    '!': [BooleanType, [BooleanType], (ctx, [b]) => !b.evaluate(ctx)],
    'is-supported-script': [
        BooleanType,
        [StringType],
        // At parse time this will always return true, so we need to exclude this expression with isGlobalPropertyConstant
        (ctx, [s]) => {
            const isSupportedScript = ctx.globals && ctx.globals.isSupportedScript;
            if (isSupportedScript) {
                return isSupportedScript(s.evaluate(ctx));
            }
            return true;
        }
    ],
    upcase: [StringType, [StringType], (ctx, [s]) => s.evaluate(ctx).toUpperCase()],
    downcase: [StringType, [StringType], (ctx, [s]) => s.evaluate(ctx).toLowerCase()],
    concat: [
        StringType,
        varargs(ValueType),
        (ctx, args) => args.map((arg) => valueToString(arg.evaluate(ctx))).join('')
    ],
    split: [
        array(StringType),
        [StringType, StringType],
        (ctx, [s, delim]) => s.evaluate(ctx).split(delim.evaluate(ctx))
    ],
    join: [
        StringType,
        [array(StringType), StringType],
        (ctx, [arr, delim]) => arr.evaluate(ctx).join(delim.evaluate(ctx))
    ],
    'resolved-locale': [
        StringType,
        [CollatorType],
        (ctx, [collator]) => collator.evaluate(ctx).resolvedLocale()
    ]
});

function stringifySignature(signature: Signature): string {
    if (Array.isArray(signature)) {
        return `(${signature.map(typeToString).join(', ')})`;
    } else {
        return `(${typeToString(signature.type)}...)`;
    }
}

function isExpressionConstant(expression: Expression) {
    if (expression instanceof Var) {
        return isExpressionConstant(expression.boundExpression);
    } else if (expression instanceof CompoundExpression && expression.name === 'error') {
        return false;
    } else if (expression instanceof CollatorExpression) {
        // Although the results of a Collator expression with fixed arguments
        // generally shouldn't change between executions, we can't serialize them
        // as constant expressions because results change based on environment.
        return false;
    } else if (expression instanceof Within) {
        return false;
    } else if (expression instanceof Distance) {
        return false;
    } else if (expression instanceof GlobalState) {
        return false;
    }

    const isTypeAnnotation = expression instanceof Coercion || expression instanceof Assertion;

    let childrenConstant = true;
    expression.eachChild((child) => {
        // We can _almost_ assume that if `expressions` children are constant,
        // they would already have been evaluated to Literal values when they
        // were parsed.  Type annotations are the exception, because they might
        // have been inferred and added after a child was parsed.

        // So we recurse into isConstant() for the children of type annotations,
        // but otherwise simply check whether they are Literals.
        if (isTypeAnnotation) {
            childrenConstant = childrenConstant && isExpressionConstant(child);
        } else {
            childrenConstant = childrenConstant && child instanceof Literal;
        }
    });
    if (!childrenConstant) {
        return false;
    }

    return (
        isFeatureConstant(expression) &&
        isGlobalPropertyConstant(expression, [
            'zoom',
            'heatmap-density',
            'elevation',
            'line-progress',
            'accumulated',
            'is-supported-script'
        ])
    );
}

function isFeatureConstant(e: Expression) {
    if (e instanceof CompoundExpression) {
        if (e.name === 'get' && e.args.length === 1) {
            return false;
        } else if (e.name === 'feature-state') {
            return false;
        } else if (e.name === 'has' && e.args.length === 1) {
            return false;
        } else if (e.name === 'properties' || e.name === 'geometry-type' || e.name === 'id') {
            return false;
        } else if (/^filter-/.test(e.name)) {
            return false;
        }
    }

    if (e instanceof Within) {
        return false;
    }
    if (e instanceof Distance) {
        return false;
    }

    let result = true;
    e.eachChild((arg) => {
        if (result && !isFeatureConstant(arg)) {
            result = false;
        }
    });
    return result;
}

function isStateConstant(e: Expression) {
    if (e instanceof CompoundExpression) {
        if (e.name === 'feature-state') {
            return false;
        }
    }
    let result = true;
    e.eachChild((arg) => {
        if (result && !isStateConstant(arg)) {
            result = false;
        }
    });
    return result;
}

function isGlobalPropertyConstant(e: Expression, properties: Array<string>) {
    if (e instanceof CompoundExpression && properties.indexOf(e.name) >= 0) {
        return false;
    }
    let result = true;
    e.eachChild((arg) => {
        if (result && !isGlobalPropertyConstant(arg, properties)) {
            result = false;
        }
    });
    return result;
}

export {isFeatureConstant, isGlobalPropertyConstant, isStateConstant, isExpressionConstant};
