/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.  See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership.  The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License.  You may obtain a copy of the License at
*
*   http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied.  See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import type Axis from '../coord/Axis';
import { calcBandWidth } from '../coord/axisBand';
import type { AxisBaseModel } from '../coord/AxisBaseModel';
import Axis2D from '../coord/cartesian/Axis2D';
import { COORD_SYS_TYPE_CARTESIAN_2D } from '../coord/cartesian/GridModel';
import { COORD_SYS_TYPE_SINGLE } from '../coord/single/AxisModel';
import type SingleAxis from '../coord/single/SingleAxis';
import type SeriesModel from '../model/Series';
import { isOrdinalScale } from '../scale/helper';
import { makeInner } from './model';

export function needFixJitter(seriesModel: SeriesModel, axis: Axis): boolean {
    const coordinateSystem = seriesModel.coordinateSystem;
    const coordType = coordinateSystem && coordinateSystem.type;
    const baseAxis = coordinateSystem && coordinateSystem.getBaseAxis && coordinateSystem.getBaseAxis();
    const scaleType = baseAxis && baseAxis.scale && baseAxis.scale.type;
    const seriesValid = coordType === COORD_SYS_TYPE_CARTESIAN_2D && scaleType === 'ordinal'
        || coordType === COORD_SYS_TYPE_SINGLE;

    const axisValid = (axis.model as AxisBaseModel).get('jitter') > 0;
    return seriesValid && axisValid;
}

export type JitterData = {
    fixedCoord: number,
    floatCoord: number,
    r: number
};

const inner = makeInner<{ items: JitterData[] }, Axis2D | SingleAxis>();

/**
 * Fix jitter for overlapping data points.
 *
 * @param fixedAxis The axis whose coord doesn't change with jitter.
 * @param fixedCoord The coord of fixedAxis.
 * @param floatCoord The coord of the other axis, which should be changed with jittering.
 * @param radius The radius of the data point, considering the symbol is a circle.
 * @returns updated floatCoord.
 */
export function fixJitter(
    fixedAxis: Axis2D | SingleAxis,
    fixedCoord: number,
    floatCoord: number,
    radius: number
): number {
    if (fixedAxis instanceof Axis2D) {
        const scaleType = fixedAxis.scale.type;
        if (scaleType !== 'ordinal') {
            return floatCoord;
        }
    }
    const axisModel = fixedAxis.model as AxisBaseModel;
    const jitter = axisModel.get('jitter');
    if (!(jitter > 0)) {
        return floatCoord;
    }
    const jitterOverlap = axisModel.get('jitterOverlap');
    const jitterMargin = axisModel.get('jitterMargin') || 0;
    // Get band width to limit jitter range
    const bandWidth = isOrdinalScale(fixedAxis.scale)
        ? calcBandWidth(fixedAxis).w
        : null;
    if (jitterOverlap) {
        return fixJitterIgnoreOverlaps(floatCoord, jitter, bandWidth, radius);
    }
    return fixJitterAvoidOverlaps(fixedAxis, fixedCoord, floatCoord, radius, jitter, jitterMargin);
}

function fixJitterIgnoreOverlaps(
    floatCoord: number,
    jitter: number,
    bandWidth: number | null,
    radius: number
): number {
    // Don't clamp single axis
    if (bandWidth === null) {
        return floatCoord + (Math.random() - 0.5) * jitter;
    }
    const maxJitter = bandWidth - radius * 2;
    const actualJitter = Math.min(Math.max(0, jitter), maxJitter);
    return floatCoord + (Math.random() - 0.5) * actualJitter;
}

function fixJitterAvoidOverlaps(
    fixedAxis: Axis2D | SingleAxis,
    fixedCoord: number,
    floatCoord: number,
    radius: number,
    jitter: number,
    margin: number
): number {
    const store = inner(fixedAxis);
    if (!store.items) {
        store.items = [];
    }
    const items = store.items;

    // Try both positive and negative directions, choose the one with smaller movement
    const overlapA = placeJitterOnDirection(items, fixedCoord, floatCoord, radius, jitter, margin, 1);
    const overlapB = placeJitterOnDirection(items, fixedCoord, floatCoord, radius, jitter, margin, -1);
    const minFloat = Math.abs(overlapA - floatCoord) < Math.abs(overlapB - floatCoord) ? overlapA : overlapB;

    // Clamp only category axis
    const bandWidth = isOrdinalScale(fixedAxis.scale)
        ? calcBandWidth(fixedAxis).w
        : null;
    const distance = Math.abs(minFloat - floatCoord);

    if (distance > jitter / 2 || (bandWidth && distance > bandWidth / 2 - radius)) {
        // If the new item is moved too far, then give up.
        // Fall back to random jitter.
        return fixJitterIgnoreOverlaps(floatCoord, jitter, bandWidth, radius);
    }

    // Add new point to array
    items.push({
        fixedCoord: fixedCoord,
        floatCoord: minFloat,
        r: radius
    });

    return minFloat;
}

function placeJitterOnDirection(
    items: JitterData[],
    fixedCoord: number,
    floatCoord: number,
    radius: number,
    jitter: number,
    margin: number,
    direction: 1 | -1
): number {
    let y = floatCoord;

    // Check all existing items for overlap and find the maximum adjustment needed
    for (let i = 0; i < items.length; i++) {
        const item = items[i];
        const dx = fixedCoord - item.fixedCoord;
        const dy = y - item.floatCoord;
        const d2 = dx * dx + dy * dy;
        const r = radius + item.r + margin;

        if (d2 < r * r) {
            // Has overlap, calculate required adjustment
            const requiredY = item.floatCoord + Math.sqrt(r * r - dx * dx) * direction;

            // Check if this adjustment would move too far
            if (Math.abs(requiredY - floatCoord) > jitter / 2) {
                return Number.MAX_VALUE; // Give up
            }

            // Update y only when it's larger to the center
            if (direction === 1 && requiredY > y || direction === -1 && requiredY < y) {
                y = requiredY;
                // Loop from the start again
                i = -1; // Reset index to recheck all items
                continue; // Recalculate with the new y position
            }
        }
    }

    return y;
}
