import {createPropertyExpression, Feature, GlobalProperties, StylePropertyExpression} from '../expression';
import {expressions} from './definitions';
import v8 from '../reference/v8.json' with {type: 'json'};
import {Color, createExpression, ICanonicalTileID, StyleExpression, StylePropertySpecification} from '..';
import {ExpressionParsingError} from './parsing_error';
import {getGeometry} from '../../test/lib/geometry';
import {VariableAnchorOffsetCollection} from './types/variable_anchor_offset_collection';
import {expectToMatchColor} from '../../test/lib/util';
import {describe, test, expect, vi, beforeEach} from 'vitest';

// filter out internal "error" and "filter-*" expressions from definition list
const filterExpressionRegex = /filter-/;
const definitionList = Object.keys(expressions).filter((expression) => {
    return expression !== 'error' && !filterExpressionRegex.exec(expression);
}).sort();

test('v8.json includes all definitions from style-spec', () => {
    const v8List = Object.keys(v8.expression_name.values);
    const v8SupportedList = v8List.filter((expression) => {
        //filter out expressions that are not supported in GL-JS
        return !!v8.expression_name.values[expression]['sdk-support']['basic functionality']['js'];
    });
    expect(definitionList).toEqual(v8SupportedList.sort());
});

describe('createPropertyExpression', () => {
    test('prohibits non-interpolable properties from using an "interpolate" expression', () => {
        const {result, value} = createPropertyExpression([
            'interpolate', ['linear'], ['zoom'], 0, 0, 10, 10
        ], {
            type: 'number',
            'property-type': 'data-constant',
            expression: {
                'interpolated': false,
                'parameters': ['zoom']
            }
        } as StylePropertySpecification);
        expect(result).toBe('error');
        expect((value as ExpressionParsingError[])).toHaveLength(1);
        expect(value[0].message).toBe('"interpolate" expressions cannot be used with this property');
    });

    test('sets globalStateRefs', () => {
        const {value} = createPropertyExpression(['case', ['>', ['global-state','stateKey'], 0], 100, ['global-state', 'anotherStateKey']], {
            type: 'number',
            'property-type': 'data-driven',
            expression: {
                'interpolated': false,
                'parameters': ['zoom', 'feature']
            }
        } as any as StylePropertySpecification) as {value: StylePropertyExpression};

        expect(value.globalStateRefs).toEqual(new Set(['stateKey', 'anotherStateKey']));
    });
});

describe('evaluate expression', () => {
    test('warns and falls back to default for invalid enum values', () => {
        const {value} = createPropertyExpression(['get', 'x'], {
            type: 'enum',
            values: {a: {}, b: {}, c: {}},
            default: 'a',
            'property-type': 'data-driven',
            expression: {
                'interpolated': false,
                'parameters': ['zoom', 'feature']
            }
        } as any as StylePropertySpecification) as {value: StylePropertyExpression};

        vi.spyOn(console, 'warn').mockImplementation(() => { });

        expect(value.kind).toBe('source');

        expect(value.evaluate({} as GlobalProperties, {properties: {x: 'b'}} as any as Feature)).toBe('b');
        expect(value.evaluate({} as GlobalProperties, {properties: {x: 'invalid'}} as any as Feature)).toBe('a');
        expect(console.warn).toHaveBeenCalledWith('Expected value to be one of "a", "b", "c", but found "invalid" instead.');
    });

    test('warns for invalid variableAnchorOffsetCollection values', () => {
        const {value} = createPropertyExpression(['get', 'x'], {
            type: 'variableAnchorOffsetCollection',
            'property-type': 'data-driven',
            transition: false,
            expression: {
                'interpolated': false,
                'parameters': ['zoom', 'feature']
            }
        }) as {value: StylePropertyExpression};

        const warnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});

        expect(value.kind).toBe('source');

        expect(value.evaluate({} as GlobalProperties, {properties: {x: 'invalid'}} as any as Feature)).toBeNull();
        expect(console.warn).toHaveBeenCalledTimes(1);
        expect(console.warn).toHaveBeenCalledWith('Could not parse variableAnchorOffsetCollection from value \'invalid\'');

        warnMock.mockClear();
        expect(value.evaluate({} as GlobalProperties, {properties: {x: ['top', [2, 2]]}} as any as Feature)).toEqual(new VariableAnchorOffsetCollection(['top', [2, 2]]));
        expect(console.warn).not.toHaveBeenCalled();
    });
});

describe('Distance expression', () => {
    describe('Invalid expression', () => {
        test('missing geometry', () => {
            const response = createExpression(['distance']);
            expect(response.result).toBe('error');
        });
        test('invalid geometry', () => {
            const response = createExpression(['distance', {type: 'Nope!'}]);
            expect(response.result).toBe('error');
        });
    });

    describe('valid expression', () => {
        test('multi point geometry', () => {
            const response = createExpression(['distance', {type: 'MultiPoint', coordinates: [[3, 3], [3, 4]]}]);
            expect(response.result).toBe('success');
        });
        test('multi line geometry', () => {
            const response = createExpression(['distance', {type: 'MultiLineString', coordinates: [[[3, 3], [3, 4]]]}]);
            expect(response.result).toBe('success');
        });
        test('multi polygon geometry', () => {
            const response = createExpression(['distance', {type: 'MultiPolygon', coordinates: [[[[3, 3], [3, 4], [4, 4], [4, 3], [3, 3]]]]}]);
            expect(response.result).toBe('success');
        });
    });

    describe('Distance from point', () => {
        const featureInTile = {} as Feature;
        const canonical = {z: 20, x: 3, y: 3} as ICanonicalTileID;
        let value: StyleExpression;

        beforeEach(() => {
            const response = createExpression(
                ['distance', {type: 'Point', coordinates: [3, 3]}],
                {
                    type: 'number',
                    'property-type': 'data-constant',
                    expression: {
                        interpolated: false,
                        parameters: ['zoom']
                    }
                } as StylePropertySpecification);
            value = response.value as StyleExpression;
        });
        test('point to point in the same location', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2);
        });
        test('point to point in different location', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3.001]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0);
        });
        test('point to line', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [3.001, 3]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0);
        });
        test('point to line that passes through the point', () => {
            getGeometry(featureInTile, {type: 'LineString', coordinates: [[2, 3], [4, 3]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2);
        });
        test('point to line that pass through the point vertically', () => {
            getGeometry(featureInTile, {type: 'LineString', coordinates: [[3, 2], [3, 4]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2);
        });
        test('point to containing polygon', () => {
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0);
        });
        test('point to near by polygon', () => {
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 2.999], [3, 2.999], [3, 0], [0, 0]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0);
        });
        test('point to polygon with hole around the point', () => {
            // polygon inner ring direction is important!
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]], [[2.999, 2.999], [3.001, 2.999], [3.001, 3.001], [2.999, 3.001], [2.999, 2.999]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0);
        });
    });
    describe('Distance from line', () => {
        const canonical = {z: 20, x: 3, y: 3} as ICanonicalTileID;
        const featureInTile = {} as Feature;
        let value: StyleExpression;

        beforeEach(() => {
            const response = createExpression(
                ['distance', {type: 'LineString', coordinates: [[3, 3], [3, 4]]}],
                {
                    type: 'number',
                    'property-type': 'data-constant',
                    expression: {
                        interpolated: false,
                        parameters: ['zoom']
                    }
                } as StylePropertySpecification);

            value = response.value as StyleExpression;
        });
        test('line to point that is on the line', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2);
        });
        test('line to point that is on the middle of the line', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3.5]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2);
        });
        test('line to point that is close by horizontally', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [3, 2.999]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0);
        });
        test('line to point that is close by vertically', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [3.001, 3]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0);
        });
        test('line to line that are parallel', () => {
            getGeometry(featureInTile, {type: 'LineString', coordinates: [[3.001, 3], [3.001, 4]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0);
        });
        test('line to line that are perpendicular', () => {
            getGeometry(featureInTile, {type: 'LineString', coordinates: [[2.5, 3.5], [3.5, 3.5]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0);
        });
        test('line to polygon that contains the line', () => {
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0);
        });
        test('line to polygon that is close by', () => {
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 2.999], [3, 2.999], [3, 0], [0, 0]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0);
        });
        test('line to polygon with hole around the line', () => {
            // polygon inner ring direction is important!
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]], [[2.999, 2.999], [3.001, 2.999], [3.001, 4.001], [2.999, 4.001], [2.999, 2.999]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0);
        });
    });
    describe('Distance from long line', () => {
        const canonical = {z: 20, x: 3, y: 3} as ICanonicalTileID;
        const featureInTile = {} as Feature;
        let value: StyleExpression;

        beforeEach(() => {
            const coordinates = [];
            for (let i = 0; i < 200; i++) {
                coordinates.push([3, 3 + i / 200.0]);
            }
            const response = createExpression(
                ['distance', {
                    type: 'FeatureCollection',
                    features: [{
                        type: 'Feature',
                        geometry: {
                            type: 'LineString', coordinates
                        }
                    }]
                }],
                {
                    type: 'number',
                    'property-type': 'data-constant',
                    expression: {
                        interpolated: false,
                        parameters: ['zoom']
                    }
                } as StylePropertySpecification);

            value = response.value as StyleExpression;
        });
        test('long line to multiple points close by', () => {
            const coordinates = [];
            for (let i = 0; i < 200; i++) {
                coordinates.push([2.999, 3 + i / 200.0]);
            }
            getGeometry(featureInTile, {type: 'MultiPoint', coordinates}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0);
        });
    });

    describe('Distance from simple polygon', () => {
        const canonical = {z: 20, x: 3, y: 3} as ICanonicalTileID;
        const featureInTile = {} as Feature;
        let value: StyleExpression;
        let response: any;

        beforeEach(() => {
            response = createExpression(
                ['distance', {
                    type: 'Feature',
                    geometry: {
                        type: 'Polygon',
                        coordinates: [[[3, 3], [3, 4], [4, 4], [4, 3], [3, 3]]]
                    },
                }],
                {
                    type: 'number',
                    'property-type': 'data-constant',
                    expression: {
                        interpolated: false,
                        parameters: ['zoom']
                    }
                } as StylePropertySpecification);
            value = response.value as StyleExpression;
        });

        test('polygon to point that is on the edge of the the polygon', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2);
        });
        test('polygon to point that is inside the polygon', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [3.001, 3.001]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0);
        });
        test('polygon to point that is outside the polygon', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [2.999, 3]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0);
        });
        test('polygon to multiple points outside the polygon that require a better algorithm', () => {
            const coordinates = [];
            for (let i = 0; i < 200; i++) {
                coordinates.push([2.999, 3 - i / 1000.0]);
            }
            getGeometry(featureInTile, {type: 'MultiPoint', coordinates}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0);
        });

        test('polygon to multiple points outside and inside the polygon that require a better algorithm', () => {
            const coordinates = [];
            for (let i = 0; i < 200; i++) {
                coordinates.push([2.999, 3 - i / 1000.0]);
            }
            coordinates.push([3.001, 3.001]);
            getGeometry(featureInTile, {type: 'MultiPoint', coordinates}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0);
        });

        test('polygon to line that is outside the polygon', () => {
            getGeometry(featureInTile, {type: 'LineString', coordinates: [[2.999, 3], [2.999, 4]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0);
        });
        test('polygon to line that is passes through the polygon', () => {
            getGeometry(featureInTile, {type: 'LineString', coordinates: [[2.5, 3.5], [3.5, 3.5]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0);
        });
        test('polygon to polygon that is inside the polygon', () => {
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0);
        });
        test('polygon to polygon that is outside the polygon', () => {
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 2.999], [3, 2.999], [3, 0], [0, 0]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0);
        });
        test('polygon to polygon that contains the polygon', () => {
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[3.5, 3.5], [3.5, 3.6], [3.6, 3.6], [3.6, 3.5], [3.5, 3.5]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0);
        });
        test('polygon to polygon with hole around the polygon', () => {
            // polygon with hole - direction is important!
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]], [[2.999, 2.999], [4.001, 2.999,], [4.001, 4.001], [2.999, 4.001], [2.999, 2.999]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0);
        });
    });
    describe('Distance from polygon with hole', () => {
        const canonical = {z: 20, x: 3, y: 3} as ICanonicalTileID;
        const featureInTile = {} as Feature;
        let value: StyleExpression;

        beforeEach(() => {
            const response = createExpression(
                ['distance', {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]], [[2.999, 2.999], [2.999, 4.001], [4.001, 4.001], [4.001, 2.999], [2.999, 2.999]]]}],
                {
                    type: 'number',
                    'property-type': 'data-constant',
                    expression: {
                        interpolated: false,
                        parameters: ['zoom']
                    }
                } as StylePropertySpecification);
            value = response.value as StyleExpression;
        });

        test('polygon to point inside the hole', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0);
        });
        test('polygon to point inside the polygon', () => {
            getGeometry(featureInTile, {type: 'Point', coordinates: [2.999, 2.999]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0);
        });
        test('polygon to line inside the hole', () => {
            getGeometry(featureInTile, {type: 'LineString', coordinates: [[3, 3], [3, 4]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0);
        });
        test('polygon to line inside the polygon', () => {
            getGeometry(featureInTile, {type: 'LineString', coordinates: [[2.5, 3.5], [3.5, 3.5]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0);
        });
        test('polygon to polygon containing the polygon with the hole', () => {
            getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]]]}, canonical);
            expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0);
        });
    });
});

describe('index-of expression', () => {
    test('requires a needle', () => {
        const response = createExpression(['index-of']);
        expect(response.result).toBe('error');
    });
    test('requires a haystack', () => {
        const response = createExpression(['index-of', 'a']);
        expect(response.result).toBe('error');
    });
    test('rejects a fourth argument', () => {
        const response = createExpression(['index-of', 'a', 'abc', 1, 8]);
        expect(response.result).toBe('error');
    });
    test('requires a primitive as the needle', () => {
        const response = createExpression(['index-of', ['literal', ['a']], ['a', 'b', 'c']]);
        expect(response.result).toBe('error');
    });
    test('requires a string or array as the haystack', () => {
        const response = createExpression(['index-of', 't', true]);
        expect(response.result).toBe('error');
    });
    test('finds an empty substring in an empty string', () => {
        const response = createExpression(['index-of', '', '']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(0);
    });
    test('finds an empty substring in a non-empty string', () => {
        const response = createExpression(['index-of', '', 'abc']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(0);
    });
    test('cannot find a non-empty substring in an empty string', () => {
        const response = createExpression(['index-of', 'abc', '']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(-1);
    });
    test('finds a non-empty substring in a non-empty string', () => {
        const response = createExpression(['index-of', 'b', 'abc']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(1);
    });
    test('only finds the first occurrence in a string', () => {
        const response = createExpression(['index-of', 'b', 'abbc']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(1);
    });
    test('starts looking for the substring at a positive start index', () => {
        const response = createExpression(['index-of', 'a', 'abc', 1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(-1);
    });
    test('starts looking for the substring at a negative start index', () => {
        const response = createExpression(['index-of', 'c', 'abc', -1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(2);
    });
    test('counts a non-ASCII character as a single character', () => {
        const response = createExpression(['index-of', '镇', '市镇']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(1);
    });
    test('counts a surrogate pair as a single character', () => {
        const response = createExpression(['index-of', '市镇', '丐𦨭市镇']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(2);
    });
    test('cannot find an element in an empty array', () => {
        const response = createExpression(['index-of', 1, ['literal', []]]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(-1);
    });
    test('finds an element in a non-empty array', () => {
        const response = createExpression(['index-of', 2, ['literal', [1, 2, 3]]]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(1);
    });
    test('only finds the first occurrence in an array', () => {
        const response = createExpression(['index-of', 2, ['literal', [1, 2, 2, 3]]]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(1);
    });
    test('starts looking for the element at a positive start index', () => {
        const response = createExpression(['index-of', 1, ['literal', [1, 2, 3]], 1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(-1);
    });
    test('starts looking for the element at a negative start index', () => {
        const response = createExpression(['index-of', 3, ['literal', [1, 2, 3]], -1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(2);
    });
});

describe('length expression', () => {
    test('requires an argument', () => {
        const response = createExpression(['length']);
        expect(response.result).toBe('error');
    });
    test('requires a string or array as the argument', () => {
        const response = createExpression(['length', true]);
        expect(response.result).toBe('error');
    });
    test('rejects a second argument', () => {
        const response = createExpression(['length', 'abc', 'def']);
        expect(response.result).toBe('error');
    });
    test('measures an empty string', () => {
        const response = createExpression(['length', '']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(0);
    });
    test('measures a non-empty string', () => {
        const response = createExpression(['length', 'abc']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(3);
    });
    test('counts a non-ASCII character as a single character', () => {
        const response = createExpression(['length', '市镇']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(2);
    });
    test('counts a surrogate pair as a single character', () => {
        const response = createExpression(['length', '丐𦨭市镇']);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(4);
    });
    test('measures an empty array', () => {
        const response = createExpression(['length', ['literal', []]]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(0);
    });
    test('measures a non-empty array', () => {
        const response = createExpression(['length', ['literal', [1, 2, 3]]]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe(3);
    });
});

describe('slice expression', () => {
    test('requires an input argument', () => {
        const response = createExpression(['slice']);
        expect(response.result).toBe('error');
    });
    test('requires a start index argument', () => {
        const response = createExpression(['slice', 'abc']);
        expect(response.result).toBe('error');
    });
    test('rejects a fourth argument', () => {
        const response = createExpression(['slice', 'abc', 0, 1, 8]);
        expect(response.result).toBe('error');
    });
    test('requires a string or array as the input argument', () => {
        const response = createExpression(['slice', true, 0]);
        expect(response.result).toBe('error');
    });
    test('requires a number as the start index argument', () => {
        const response = createExpression(['slice', 'abc', true]);
        expect(response.result).toBe('error');
    });
    test('slices an empty string', () => {
        const response = createExpression(['slice', '', 0]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe('');
    });
    test('slices a string starting at the beginning', () => {
        const response = createExpression(['slice', 'abc', 0]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe('abc');
    });
    test('slices a string starting at the middle', () => {
        const response = createExpression(['slice', 'abc', 1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe('bc');
    });
    test('slices a string starting at the end', () => {
        const response = createExpression(['slice', 'abc', 3]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe('');
    });
    test('slices a string backwards from the end', () => {
        const response = createExpression(['slice', 'abc', -2]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe('bc');
    });
    test('slices a string by a zero-length range', () => {
        const response = createExpression(['slice', 'abc', 1, 1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe('');
    });
    test('slices a string by a negative-length range', () => {
        const response = createExpression(['slice', 'abc', 2, 1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe('');
    });
    test('avoids splitting a non-ASCII character', () => {
        const response = createExpression(['slice', '市镇', 1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe('镇');
    });
    test('avoids splitting a surrogate pair', () => {
        const response = createExpression(['slice', '丐𦨭市镇', 2]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toBe('市镇');
    });
    test('slices an empty array', () => {
        const response = createExpression(['slice', ['literal', []], 0]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toEqual([]);
    });
    test('slices an array starting at the beginning', () => {
        const response = createExpression(['slice', ['literal', [1, 2, 3]], 0]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toEqual([1, 2, 3]);
    });
    test('slices an array starting at the middle', () => {
        const response = createExpression(['slice', ['literal', [1, 2, 3]], 1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toEqual([2, 3]);
    });
    test('slices an array starting at the end', () => {
        const response = createExpression(['slice', ['literal', [1, 2, 3]], 3]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toEqual([]);
    });
    test('slices an array backwards from the end', () => {
        const response = createExpression(['slice', ['literal', [1, 2, 3]], -2]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toEqual([2, 3]);
    });
    test('slices an array by a zero-length range', () => {
        const response = createExpression(['slice', ['literal', [1, 2, 3]], 1, 1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toEqual([]);
    });
    test('slices an array by a negative-length range', () => {
        const response = createExpression(['slice', ['literal', [1, 2, 3]], 2, 1]);
        expect(response.result).toBe('success');
        expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toEqual([]);
    });
});

describe('projection expression', () => {
    test('step', () => {
        const response = createExpression(['step', ['zoom'], 'vertical-perspective', 10, 'mercator']);

        if (response.result === 'success') {
            expect(response.value.evaluate({zoom: 5})).toBe('vertical-perspective');
            expect(response.value.evaluate({zoom: 10})).toBe('mercator');
            expect(response.value.evaluate({zoom: 11})).toBe('mercator');
        } else {
            throw new Error('Failed to parse Step expression');
        }
    });

    test('step array', () => {
        const response = createExpression(['step', ['zoom'], ['literal', ['vertical-perspective', 'mercator', 0.5]], 10, 'mercator'], v8.projection.type as StylePropertySpecification);

        if (response.result === 'success') {
            expect(response.value.evaluate({zoom: 5})).toStrictEqual(['vertical-perspective', 'mercator', 0.5]);
            expect(response.value.evaluate({zoom: 10})).toBe('mercator');
            expect(response.value.evaluate({zoom: 11})).toBe('mercator');
        } else {
            throw new Error('Failed to parse Step expression');
        }
    });

    test('interpolate', () => {
        const response = createExpression(['interpolate', ['linear'], ['zoom'], 8, 'vertical-perspective', 10, 'mercator'], v8.projection.type as StylePropertySpecification);

        if (response.result === 'success') {
            expect(response.value.evaluate({zoom: 5})).toBe('vertical-perspective');
            expect(response.value.evaluate({zoom: 9})).toEqual({from: 'vertical-perspective', to: 'mercator', transition: 0.5});
            expect(response.value.evaluate({zoom: 11})).toBe('mercator');
        } else {
            throw new Error('Failed to parse Interpolate expression');
        }
    });

    test('interpolate numberArray', () => {
        const response = createExpression(['interpolate', ['linear'], ['zoom'], 8, ['literal', [2,3]], 10, ['literal', [4,5]]],  {
            type: 'numberArray',
            'property-type': 'data-constant',
            expression: {
                interpolated: true,
                parameters: ['zoom']
            },
            transition: false
        });

        if (response.result === 'success') {
            expect(response.value.evaluate({zoom: 8}).values).toEqual([2,3]);
            expect(response.value.evaluate({zoom: 9}).values).toEqual([3,4]);
            expect(response.value.evaluate({zoom: 10}).values).toEqual([4,5]);
        } else {
            throw new Error('Failed to parse Interpolate expression');
        }
    })

    test('interpolate ColorArray', () => {
        const response = createExpression(['interpolate', ['linear'], ['zoom'], 8, ['literal', ['white','black']], 10, ['literal', ['black','white']]],  {
            type: 'colorArray',
            'property-type': 'data-constant',
            expression: {
                interpolated: true,
                parameters: ['zoom']
            },
            transition: false
        });

        if (response.result === 'success') {
            expect(response.value.evaluate({zoom: 8}).values).toEqual([Color.parse('white'), Color.parse('black')]);
            expect(response.value.evaluate({zoom: 9}).values).toEqual([new Color(0.5, 0.5, 0.5), new Color(0.5, 0.5, 0.5)]);
            expect(response.value.evaluate({zoom: 10}).values).toEqual([Color.parse('black'), Color.parse('white')]);
        } else {
            throw new Error('Failed to parse Interpolate expression');
        }
    })

    test('interpolate-hcl ColorArray', () => {
        const response = createExpression(['interpolate-hcl', ['linear'], ['zoom'], 8, ['literal', ['white','black']], 10, ['literal', ['black','white']]],  {
            type: 'colorArray',
            'property-type': 'data-constant',
            expression: {
                interpolated: true,
                parameters: ['zoom']
            },
            transition: false
        });

        if (response.result === 'success') {
            expect(response.value.evaluate({zoom: 8}).values).toEqual([Color.parse('white'), Color.parse('black')]);
            expect(response.value.evaluate({zoom: 9}).values).toHaveLength(2);
            for (let i = 0; i < 2; i++) {
                expectToMatchColor(response.value.evaluate({zoom: 9}).values[i], 'rgb(46.63266% 46.63266% 46.63266% / 1)');
            }
            expect(response.value.evaluate({zoom: 10}).values).toEqual([Color.parse('black'), Color.parse('white')]);
        } else {
            throw new Error('Failed to parse Interpolate expression');
        }
    })

    test('interpolate-lab ColorArray', () => {
        const response = createExpression(['interpolate-lab', ['linear'], ['zoom'], 8, ['literal', ['white','black']], 10, ['literal', ['black','white']]],  {
            type: 'colorArray',
            'property-type': 'data-constant',
            expression: {
                interpolated: true,
                parameters: ['zoom']
            },
            transition: false
        });

        if (response.result === 'success') {
            expect(response.value.evaluate({zoom: 8}).values).toEqual([Color.parse('white'), Color.parse('black')]);
            expect(response.value.evaluate({zoom: 9}).values).toHaveLength(2);
            for (let i = 0; i < 2; i++) {
                expectToMatchColor(response.value.evaluate({zoom: 9}).values[i], 'rgb(46.63266% 46.63266% 46.63266% / 1)');
            }
            expect(response.value.evaluate({zoom: 10}).values).toEqual([Color.parse('black'), Color.parse('white')]);
        } else {
            throw new Error('Failed to parse Interpolate expression');
        }
    });
});

describe('global-state expression', () => {
    test('requires a property argument', () => {
        const response = createExpression(['global-state']);
        expect(response.result).toBe('error');
        expect(response.value[0]).toBeInstanceOf(ExpressionParsingError);
        expect((response.value[0] as ExpressionParsingError).message).toBe('Expected 1 argument, but found 0 instead.');
    });
    test('requires a string as the property argument', () => {
        const response = createExpression(['global-state', true]);
        expect(response.result).toBe('error');
        expect(response.value[0]).toBeInstanceOf(ExpressionParsingError);
        expect((response.value[0] as ExpressionParsingError).message).toBe('Global state property must be string, but found boolean instead.');
    });
    test('rejects a second argument', () => {
        const response = createExpression(['global-state', 'foo', 'bar']);
        expect(response.result).toBe('error');
        expect(response.value[0]).toBeInstanceOf(ExpressionParsingError);
        expect((response.value[0] as ExpressionParsingError).message).toBe('Expected 1 argument, but found 2 instead.');
    });
    test('evaluates a global state property', () => {
        const response = createExpression(['global-state', 'foo']);
        if (response.result === 'success') {
            expect(response.value.evaluate({globalState: {foo: 'bar'}, zoom: 0}, {} as Feature)).toBe('bar');
        } else {
            throw new Error('Failed to parse GlobalState expression');
        }
    });
});
