import chroma from 'chroma-js';
import turfBbox from '@turf/bbox';
import maplibregl, { ExpressionInputType, ExpressionSpecification } from 'maplibre-gl';
import { viewport } from '@placemarkio/geo-viewport';
import type { Feature, FeatureCollection, Position, BBox } from 'geojson';
import type { Scale } from 'chroma-js';
import { assertUnreachable } from 'components/utils';
import { Color, ColorScale, DataBounds, isGroupByForMatchExpression, ColorScaleTypes } from 'types';
import { DEFAULT_COLORS } from './constants';

import type {
    ChoroplethDataValue,
    ChoroplethFixedTooltipDescription,
    MapFilter,
    ChoroplethTooltipFormatter,
    MapRenderTooltipFunction,
    ComputeTooltipFunction,
    ChoroplethLayer,
} from './types';
import { ChoroplethTooltipMatcherTypes, TooltipParams } from './types';

export const LIGHT_GREY: Color = '#CBD2DB';
export const DARK_GREY: Color = '#515457';

export function getDataBounds(values: ChoroplethDataValue[]): DataBounds {
    const rawValues = values.map((v) => v.y);
    const min = Math.min(...rawValues);
    const max = Math.max(...rawValues);
    return { min, max };
}

export const colorShapes = ({
    featureCollection,
    colorMapping,
    emptyValueColor,
}: {
    featureCollection: FeatureCollection;
    colorMapping: { [key: string]: Color };
    emptyValueColor: Color;
}) => {
    const coloredFeatures = featureCollection.features.map((feature: Feature) => {
        const color = colorMapping[feature?.properties?.key] || emptyValueColor;
        return {
            ...feature,
            properties: {
                ...feature.properties,
                color,
            },
        };
    });
    const coloredShapes: FeatureCollection = {
        type: 'FeatureCollection',
        features: coloredFeatures,
    };
    return coloredShapes;
};

export const mapKeyToColor = (
    values: ChoroplethDataValue[],
    dataBounds: DataBounds,
    colorScale: ColorScale,
    emptyValueColor: Color = DEFAULT_COLORS.Default
): { [s: string]: string } => {
    const { min, max } = dataBounds;
    let colorMin: Color;
    let colorMax: Color;
    let scale: Scale;
    // This is an exhaustive check, function must handle all color scale types
    switch (colorScale.type) {
        case ColorScaleTypes.Palette: {
            const thresholdArray: number[] = [];
            colorScale.colors.forEach((_color: Color, i: number) => {
                if (i === 0) {
                    thresholdArray.push(min);
                    thresholdArray.push(min + (max - min) / colorScale.colors.length);
                } else if (i === colorScale.colors.length - 1) {
                    thresholdArray.push(max);
                } else {
                    thresholdArray.push(min + ((max - min) / colorScale.colors.length) * (i + 1));
                }
            });
            scale = chroma.scale(colorScale.colors).classes(thresholdArray);
            break;
        }
        case ColorScaleTypes.Gradient:
            colorMin = chroma(colorScale.colors.start).hex();
            colorMax = chroma(colorScale.colors.end).hex();
            scale = chroma.scale([colorMin, colorMax]).domain([min, max]);
            break;
        default: {
            // This function should never be reached because of the exhaustive check (will throw a compilation error)
            const exhaustiveCheck: never = colorScale;
            assertUnreachable(exhaustiveCheck);
        }
    }

    const dataMapping: { [s: ChoroplethDataValue['x']]: Color } = {};
    values.forEach(({ x, y }) => {
        dataMapping[x] = Number.isFinite(y) ? scale(y).hex() : emptyValueColor;
    });
    return dataMapping;
};

// This is a default bound that will be extended
export const VOID_BOUNDS: BBox = [180, 90, -180, -90];

type CoordsPath = Position[];

function computeBboxFromCoords(coordsPath: CoordsPath, bbox: BBox): BBox {
    return coordsPath.reduce<BBox>(
        (current: BBox, coords: Position) => [
            Math.min(coords[0], current[0]),
            Math.min(coords[1], current[1]),
            Math.max(coords[0], current[2]),
            Math.max(coords[1], current[3]),
        ],
        bbox
    );
}

// The features given by querySourceFeatures are cut based on a tile representation
// but we need the bounding box of the features themselves, so we need to build them again
function mergeBboxFromFeaturesWithSameKey(features: Feature[], matchKey: string) {
    const mergedBboxes: {
        [key: string]: {
            bbox: BBox;
        };
    } = {};
    features.forEach((feature) => {
        // FIXME: supports only shapes for now
        if (feature.geometry.type === 'Polygon') {
            // Compute extent first
            let bbox = VOID_BOUNDS;
            feature.geometry.coordinates.forEach((coordsPath) => {
                bbox = computeBboxFromCoords(coordsPath, bbox);
            });
            const id: string = feature.properties?.[matchKey];
            if (!mergedBboxes[id]) {
                mergedBboxes[id] = { bbox };
            } else {
                const storedBbox = mergedBboxes[id].bbox;
                const mergedBbox: BBox = [
                    Math.min(bbox[0], storedBbox[0]),
                    Math.min(bbox[1], storedBbox[1]),
                    Math.max(bbox[2], storedBbox[2]),
                    Math.max(bbox[3], storedBbox[3]),
                ];
                // Replace the Features at the right id by the merged bbox
                mergedBboxes[id] = {
                    bbox: mergedBbox,
                };
            }
        }
    });
    return mergedBboxes;
}

// We're calculating the maximum zoom required to fit the smallest feature we're displaying, to prevent people from zooming "too far" by accident
export const computeMaxZoomFromGeoJsonFeatures = (
    mapContainer: HTMLElement,
    features: Feature[],
    matchKey: string
): number => {
    let maxZoom = 0; // maxZoom lowest value possible
    const filteredBboxes = mergeBboxFromFeaturesWithSameKey(features, matchKey);
    // FIXME: any
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    Object.values(filteredBboxes).forEach((value: any) => {
        // Vtiles = 512 tile size
        maxZoom = Math.max(
            viewport(value.bbox, [mapContainer.clientWidth, mapContainer.clientHeight], {
                tileSize: 512,
            }).zoom,
            maxZoom
        );
    });
    return maxZoom;
};

const getShapeCenter = (feature: Feature) => {
    const featureBbox = turfBbox(feature.geometry);
    const centerLatitude = (featureBbox[1] + featureBbox[3]) / 2;
    const centerLongitude = (featureBbox[0] + featureBbox[2]) / 2;
    return [centerLongitude, centerLatitude];
};

export const getFixedTooltips = (
    shapeKeys: string[],
    features: Feature[],
    renderTooltip: MapRenderTooltipFunction,
    matchKey: string
): ChoroplethFixedTooltipDescription[] => {
    const popups = shapeKeys.map((shapeKey) => {
        const matchedFeature = features.find(
            (feature) => feature.properties?.[matchKey] === shapeKey
        );
        if (matchedFeature) {
            const center = getShapeCenter(matchedFeature);
            const description = renderTooltip(matchedFeature);
            // Cancel the debounce on activeShapes
            renderTooltip.cancel();
            const popup = new maplibregl.Popup({
                closeOnClick: false,
                closeButton: false,
                className: 'tooltip-on-hover',
            });
            return { center, description, popup };
        }
        return null;
    });

    return popups.filter((item): item is NonNullable<ChoroplethFixedTooltipDescription> =>
        Boolean(item)
    ) as ChoroplethFixedTooltipDescription[];
};

/** Transform a filter object from the options into a Maplibre filter expression */
export const computeFilterExpression = (filterConfig: MapFilter): ExpressionSpecification => {
    const { key, value } = filterConfig;
    const filterMatchExpression: ExpressionSpecification = [
        'in',
        ['downcase', ['get', key]],
        ['literal', []],
    ];

    const matchingValues: string[] = Array.isArray(value)
        ? value.map((v) => v.toString().toLowerCase())
        : [value.toString().toLowerCase()];

    filterMatchExpression[2] = ['literal', matchingValues];
    return filterMatchExpression;
};

export const defaultTooltipFormat: ChoroplethTooltipFormatter = ({ value, label }) =>
    value ? `${label} &mdash; ${value}` : label;

export const computeTooltip: ComputeTooltipFunction = (
    hoveredFeature,
    dataValues,
    options,
    matchKey
) => {
    const values = dataValues || [];
    const matchedFeature = values.find(
        (item: ChoroplethDataValue) => String(item.x) === hoveredFeature.properties?.[matchKey]
    );

    let tooltipLabel = hoveredFeature.properties?.label || hoveredFeature.properties?.[matchKey];
    const labelMatcher = options?.tooltip?.labelMatcher;

    if (labelMatcher) {
        const { type } = labelMatcher;
        if (type === ChoroplethTooltipMatcherTypes.KeyProperty) {
            const { key } = labelMatcher;
            tooltipLabel = hoveredFeature.properties?.[key];
        } else if (type === ChoroplethTooltipMatcherTypes.KeyMap && matchedFeature) {
            const { mapping } = labelMatcher;
            tooltipLabel = mapping[matchedFeature?.x];
        }
    }

    const tooltipRawValues: TooltipParams = {
        value: matchedFeature?.y,
        label: tooltipLabel,
        key: hoveredFeature.properties?.[matchKey], // === matchedFeature.x
    };
    const format = options?.tooltip?.formatter;

    return format ? format(tooltipRawValues) : defaultTooltipFormat(tooltipRawValues);
};

export const computeBaseLayer = (
    fillColor: string | ExpressionSpecification,
    DefaultColor: Color
): ChoroplethLayer => ({
    type: 'fill',
    layout: {},
    paint: {
        'fill-color': fillColor,
        'fill-opacity': 1,
        'fill-outline-color': DefaultColor,
    },
});

export const computeMatchExpression = (
    colors: { [s: string]: string },
    matchKey: string,
    emptyValueColor: Color
): ExpressionSpecification => {
    const matchExpression: ['match', ExpressionSpecification] = ['match', ['get', matchKey]];
    const groupByColors: ExpressionInputType[] = [];
    Object.entries(colors).forEach((e) => groupByColors.push(...e));
    groupByColors.push(emptyValueColor); // Default fallback color
    if (!isGroupByForMatchExpression(groupByColors)) {
        throw new Error('Not the expected type for complete match expression');
    }
    return [...matchExpression, ...groupByColors];
};
