/*
* 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 {
    Point,
    Path,
    Polyline
} from '../util/graphic';
import PathProxy from 'zrender/src/core/PathProxy';
import { RectLike } from 'zrender/src/core/BoundingRect';
import { normalizeRadian } from 'zrender/src/contain/util';
import { cubicProjectPoint, quadraticProjectPoint } from 'zrender/src/core/curve';
import Element from 'zrender/src/Element';
import { defaults, retrieve2 } from 'zrender/src/core/util';
import { LabelLineOption, DisplayState, StatesOptionMixin } from '../util/types';
import Model from '../model/Model';
import { invert } from 'zrender/src/core/matrix';
import * as vector from 'zrender/src/core/vector';
import { DISPLAY_STATES, SPECIAL_STATES } from '../util/states';

const PI2 = Math.PI * 2;
const CMD = PathProxy.CMD;

const DEFAULT_SEARCH_SPACE = ['top', 'right', 'bottom', 'left'] as const;

type CandidatePosition = typeof DEFAULT_SEARCH_SPACE[number];

function getCandidateAnchor(
    pos: CandidatePosition,
    distance: number,
    rect: RectLike,
    outPt: Point,
    outDir: Point
) {
    const width = rect.width;
    const height = rect.height;
    switch (pos) {
        case 'top':
            outPt.set(
                rect.x + width / 2,
                rect.y - distance
            );
            outDir.set(0, -1);
            break;
        case 'bottom':
            outPt.set(
                rect.x + width / 2,
                rect.y + height + distance
            );
            outDir.set(0, 1);
            break;
        case 'left':
            outPt.set(
                rect.x - distance,
                rect.y + height / 2
            );
            outDir.set(-1, 0);
            break;
        case 'right':
            outPt.set(
                rect.x + width + distance,
                rect.y + height / 2
            );
            outDir.set(1, 0);
            break;
    }
}


function projectPointToArc(
    cx: number, cy: number, r: number, startAngle: number, endAngle: number, anticlockwise: boolean,
    x: number, y: number, out: number[]
): number {
    x -= cx;
    y -= cy;
    const d = Math.sqrt(x * x + y * y);
    x /= d;
    y /= d;

    // Intersect point.
    const ox = x * r + cx;
    const oy = y * r + cy;

    if (Math.abs(startAngle - endAngle) % PI2 < 1e-4) {
        // Is a circle
        out[0] = ox;
        out[1] = oy;
        return d - r;
    }

    if (anticlockwise) {
        const tmp = startAngle;
        startAngle = normalizeRadian(endAngle);
        endAngle = normalizeRadian(tmp);
    }
    else {
        startAngle = normalizeRadian(startAngle);
        endAngle = normalizeRadian(endAngle);
    }
    if (startAngle > endAngle) {
        endAngle += PI2;
    }

    let angle = Math.atan2(y, x);
    if (angle < 0) {
        angle += PI2;
    }
    if ((angle >= startAngle && angle <= endAngle)
        || (angle + PI2 >= startAngle && angle + PI2 <= endAngle)) {
        // Project point is on the arc.
        out[0] = ox;
        out[1] = oy;
        return d - r;
    }

    const x1 = r * Math.cos(startAngle) + cx;
    const y1 = r * Math.sin(startAngle) + cy;

    const x2 = r * Math.cos(endAngle) + cx;
    const y2 = r * Math.sin(endAngle) + cy;

    const d1 = (x1 - x) * (x1 - x) + (y1 - y) * (y1 - y);
    const d2 = (x2 - x) * (x2 - x) + (y2 - y) * (y2 - y);

    if (d1 < d2) {
        out[0] = x1;
        out[1] = y1;
        return Math.sqrt(d1);
    }
    else {
        out[0] = x2;
        out[1] = y2;
        return Math.sqrt(d2);
    }
}

function projectPointToLine(
    x1: number, y1: number, x2: number, y2: number, x: number, y: number, out: number[], limitToEnds: boolean
) {
    const dx = x - x1;
    const dy = y - y1;

    let dx1 = x2 - x1;
    let dy1 = y2 - y1;

    const lineLen = Math.sqrt(dx1 * dx1 + dy1 * dy1);
    dx1 /= lineLen;
    dy1 /= lineLen;

    // dot product
    const projectedLen = dx * dx1 + dy * dy1;
    let t = projectedLen / lineLen;
    if (limitToEnds) {
        t = Math.min(Math.max(t, 0), 1);
    }
    t *= lineLen;
    const ox = out[0] = x1 + t * dx1;
    const oy = out[1] = y1 + t * dy1;

    return Math.sqrt((ox - x) * (ox - x) + (oy - y) * (oy - y));
}

function projectPointToRect(
    x1: number, y1: number, width: number, height: number, x: number, y: number, out: number[]
): number {
    if (width < 0) {
        x1 = x1 + width;
        width = -width;
    }
    if (height < 0) {
        y1 = y1 + height;
        height = -height;
    }
    const x2 = x1 + width;
    const y2 = y1 + height;

    const ox = out[0] = Math.min(Math.max(x, x1), x2);
    const oy = out[1] = Math.min(Math.max(y, y1), y2);

    return Math.sqrt((ox - x) * (ox - x) + (oy - y) * (oy - y));
}

const tmpPt: number[] = [];

function nearestPointOnRect(pt: Point, rect: RectLike, out: Point) {
    const dist = projectPointToRect(
        rect.x, rect.y, rect.width, rect.height,
        pt.x, pt.y, tmpPt
    );
    out.set(tmpPt[0], tmpPt[1]);
    return dist;
}
/**
 * Calculate min distance corresponding point.
 * This method won't evaluate if point is in the path.
 */
function nearestPointOnPath(pt: Point, path: PathProxy, out: Point) {
    let xi = 0;
    let yi = 0;
    let x0 = 0;
    let y0 = 0;
    let x1;
    let y1;

    let minDist = Infinity;

    const data = path.data;
    const x = pt.x;
    const y = pt.y;

    for (let i = 0; i < data.length;) {
        const cmd = data[i++];

        if (i === 1) {
            xi = data[i];
            yi = data[i + 1];
            x0 = xi;
            y0 = yi;
        }

        let d = minDist;

        switch (cmd) {
            case CMD.M:
                // moveTo 命令重新创建一个新的 subpath, 并且更新新的起点
                // 在 closePath 的时候使用
                x0 = data[i++];
                y0 = data[i++];
                xi = x0;
                yi = y0;
                break;
            case CMD.L:
                d = projectPointToLine(xi, yi, data[i], data[i + 1], x, y, tmpPt, true);
                xi = data[i++];
                yi = data[i++];
                break;
            case CMD.C:
                d = cubicProjectPoint(
                    xi, yi,
                    data[i++], data[i++], data[i++], data[i++], data[i], data[i + 1],
                    x, y, tmpPt
                );

                xi = data[i++];
                yi = data[i++];
                break;
            case CMD.Q:
                d = quadraticProjectPoint(
                    xi, yi,
                    data[i++], data[i++], data[i], data[i + 1],
                    x, y, tmpPt
                );
                xi = data[i++];
                yi = data[i++];
                break;
            case CMD.A:
                // TODO Arc 判断的开销比较大
                const cx = data[i++];
                const cy = data[i++];
                const rx = data[i++];
                const ry = data[i++];
                const theta = data[i++];
                const dTheta = data[i++];
                // TODO Arc 旋转
                i += 1;
                const anticlockwise = !!(1 - data[i++]);
                x1 = Math.cos(theta) * rx + cx;
                y1 = Math.sin(theta) * ry + cy;
                // 不是直接使用 arc 命令
                if (i <= 1) {
                    // 第一个命令起点还未定义
                    x0 = x1;
                    y0 = y1;
                }
                // zr 使用scale来模拟椭圆, 这里也对x做一定的缩放
                const _x = (x - cx) * ry / rx + cx;
                d = projectPointToArc(
                    cx, cy, ry, theta, theta + dTheta, anticlockwise,
                    _x, y, tmpPt
                );
                xi = Math.cos(theta + dTheta) * rx + cx;
                yi = Math.sin(theta + dTheta) * ry + cy;
                break;
            case CMD.R:
                x0 = xi = data[i++];
                y0 = yi = data[i++];
                const width = data[i++];
                const height = data[i++];
                d = projectPointToRect(x0, y0, width, height, x, y, tmpPt);
                break;
            case CMD.Z:
                d = projectPointToLine(xi, yi, x0, y0, x, y, tmpPt, true);

                xi = x0;
                yi = y0;
                break;
        }

        if (d < minDist) {
            minDist = d;
            out.set(tmpPt[0], tmpPt[1]);
        }
    }

    return minDist;
}

// Temporal variable for intermediate usage.
const pt0 = new Point();
const pt1 = new Point();
const pt2 = new Point();
const dir = new Point();
const dir2 = new Point();

/**
 * Calculate a proper guide line based on the label position and graphic element definition
 * @param label
 * @param labelRect
 * @param target
 * @param targetRect
 */
export function updateLabelLinePoints(
    target: Element,
    labelLineModel: Model<LabelLineOption>
) {
    if (!target) {
        return;
    }

    const labelLine = target.getTextGuideLine();
    const label = target.getTextContent();
    // Needs to create text guide in each charts.
    if (!(label && labelLine)) {
        return;
    }

    const labelGuideConfig = target.textGuideLineConfig || {};

    const points = [[0, 0], [0, 0], [0, 0]];

    const searchSpace = labelGuideConfig.candidates || DEFAULT_SEARCH_SPACE;
    const labelRect = label.getBoundingRect().clone();
    labelRect.applyTransform(label.getComputedTransform());

    let minDist = Infinity;
    const anchorPoint = labelGuideConfig.anchor;
    const targetTransform = target.getComputedTransform();
    const targetInversedTransform = targetTransform && invert([], targetTransform);
    const len = labelLineModel.get('length2') || 0;

    if (anchorPoint) {
        pt2.copy(anchorPoint);
    }
    for (let i = 0; i < searchSpace.length; i++) {
        const candidate = searchSpace[i];
        getCandidateAnchor(candidate, 0, labelRect, pt0, dir);
        Point.scaleAndAdd(pt1, pt0, dir, len);

        // Transform to target coord space.
        pt1.transform(targetInversedTransform);

        // Note: getBoundingRect will ensure the `path` being created.
        const boundingRect = target.getBoundingRect();
        const dist = anchorPoint ? anchorPoint.distance(pt1)
            : (target instanceof Path
                ? nearestPointOnPath(pt1, target.path, pt2)
                : nearestPointOnRect(pt1, boundingRect, pt2));

        // TODO pt2 is in the path
        if (dist < minDist) {
            minDist = dist;
            // Transform back to global space.
            pt1.transform(targetTransform);
            pt2.transform(targetTransform);

            pt2.toArray(points[0]);
            pt1.toArray(points[1]);
            pt0.toArray(points[2]);
        }
    }

    limitTurnAngle(points, labelLineModel.get('minTurnAngle'));

    labelLine.setShape({ points });
}

// Temporal variable for the limitTurnAngle function
const tmpArr: number[] = [];
const tmpProjPoint = new Point();
/**
 * Reduce the line segment attached to the label to limit the turn angle between two segments.
 * @param linePoints
 * @param minTurnAngle Radian of minimum turn angle. 0 - 180
 */
export function limitTurnAngle(linePoints: number[][], minTurnAngle: number) {
    if (!(minTurnAngle <= 180 && minTurnAngle > 0)) {
        return;
    }
    minTurnAngle = minTurnAngle / 180 * Math.PI;
    // The line points can be
    //      /pt1----pt2 (label)
    //     /
    // pt0/
    pt0.fromArray(linePoints[0]);
    pt1.fromArray(linePoints[1]);
    pt2.fromArray(linePoints[2]);

    Point.sub(dir, pt0, pt1);
    Point.sub(dir2, pt2, pt1);

    const len1 = dir.len();
    const len2 = dir2.len();
    if (len1 < 1e-3 || len2 < 1e-3) {
        return;
    }

    dir.scale(1 / len1);
    dir2.scale(1 / len2);

    const angleCos = dir.dot(dir2);
    const minTurnAngleCos = Math.cos(minTurnAngle);
    if (minTurnAngleCos < angleCos) {    // Smaller than minTurnAngle
        // Calculate project point of pt0 on pt1-pt2
        const d = projectPointToLine(pt1.x, pt1.y, pt2.x, pt2.y, pt0.x, pt0.y, tmpArr, false);
        tmpProjPoint.fromArray(tmpArr);
        // Calculate new projected length with limited minTurnAngle and get the new connect point
        tmpProjPoint.scaleAndAdd(dir2, d / Math.tan(Math.PI - minTurnAngle));
        // Limit the new calculated connect point between pt1 and pt2.
        const t = pt2.x !== pt1.x
            ? (tmpProjPoint.x - pt1.x) / (pt2.x - pt1.x)
            : (tmpProjPoint.y - pt1.y) / (pt2.y - pt1.y);
        if (isNaN(t)) {
            return;
        }

        if (t < 0) {
            Point.copy(tmpProjPoint, pt1);
        }
        else if (t > 1) {
            Point.copy(tmpProjPoint, pt2);
        }

        tmpProjPoint.toArray(linePoints[1]);
    }
}

/**
 * Limit the angle of line and the surface
 * @param maxSurfaceAngle Radian of minimum turn angle. 0 - 180. 0 is same direction to normal. 180 is opposite
 */
export function limitSurfaceAngle(linePoints: vector.VectorArray[], surfaceNormal: Point, maxSurfaceAngle: number) {
    if (!(maxSurfaceAngle <= 180 && maxSurfaceAngle > 0)) {
        return;
    }
    maxSurfaceAngle = maxSurfaceAngle / 180 * Math.PI;

    pt0.fromArray(linePoints[0]);
    pt1.fromArray(linePoints[1]);
    pt2.fromArray(linePoints[2]);

    Point.sub(dir, pt1, pt0);
    Point.sub(dir2, pt2, pt1);

    const len1 = dir.len();
    const len2 = dir2.len();

    if (len1 < 1e-3 || len2 < 1e-3) {
        return;
    }

    dir.scale(1 / len1);
    dir2.scale(1 / len2);

    const angleCos = dir.dot(surfaceNormal);
    const maxSurfaceAngleCos = Math.cos(maxSurfaceAngle);

    if (angleCos < maxSurfaceAngleCos) {
        // Calculate project point of pt0 on pt1-pt2
        const d = projectPointToLine(pt1.x, pt1.y, pt2.x, pt2.y, pt0.x, pt0.y, tmpArr, false);
        tmpProjPoint.fromArray(tmpArr);

        const HALF_PI = Math.PI / 2;
        const angle2 = Math.acos(dir2.dot(surfaceNormal));
        const newAngle = HALF_PI + angle2 - maxSurfaceAngle;
        if (newAngle >= HALF_PI) {
            // parallel
            Point.copy(tmpProjPoint, pt2);
        }
        else {
            // Calculate new projected length with limited minTurnAngle and get the new connect point
            tmpProjPoint.scaleAndAdd(dir2, d / Math.tan(Math.PI / 2 - newAngle));
            // Limit the new calculated connect point between pt1 and pt2.
            const t = pt2.x !== pt1.x
                ? (tmpProjPoint.x - pt1.x) / (pt2.x - pt1.x)
                : (tmpProjPoint.y - pt1.y) / (pt2.y - pt1.y);
            if (isNaN(t)) {
                return;
            }

            if (t < 0) {
                Point.copy(tmpProjPoint, pt1);
            }
            else if (t > 1) {
                Point.copy(tmpProjPoint, pt2);
            }
        }

        tmpProjPoint.toArray(linePoints[1]);
    }
}


type LabelLineModel = Model<LabelLineOption>;

function setLabelLineState(
    labelLine: Polyline,
    ignore: boolean,
    stateName: string,
    stateModel: Model
) {
    const isNormal = stateName === 'normal';
    const stateObj = isNormal ? labelLine : labelLine.ensureState(stateName);
    // Make sure display.
    stateObj.ignore = ignore;
    // Set smooth
    let smooth = stateModel.get('smooth');
    if (smooth && smooth === true) {
        smooth = 0.3;
    }
    stateObj.shape = stateObj.shape || {};
    if (smooth > 0) {
        (stateObj.shape as Polyline['shape']).smooth = smooth as number;
    }
    const styleObj = stateModel.getModel('lineStyle').getLineStyle();
    isNormal ? labelLine.useStyle(styleObj) : stateObj.style = styleObj;
}

function buildLabelLinePath(path: CanvasRenderingContext2D, shape: Polyline['shape']) {
    const smooth = shape.smooth as number;
    const points = shape.points;
    if (!points) {
        return;
    }
    path.moveTo(points[0][0], points[0][1]);
    if (smooth > 0 && points.length >= 3) {
        const len1 = vector.dist(points[0], points[1]);
        const len2 = vector.dist(points[1], points[2]);
        if (!len1 || !len2) {
            path.lineTo(points[1][0], points[1][1]);
            path.lineTo(points[2][0], points[2][1]);
            return;
        }

        const moveLen = Math.min(len1, len2) * smooth;

        const midPoint0 = vector.lerp([], points[1], points[0], moveLen / len1);
        const midPoint2 = vector.lerp([], points[1], points[2], moveLen / len2);

        const midPoint1 = vector.lerp([], midPoint0, midPoint2, 0.5);
        path.bezierCurveTo(midPoint0[0], midPoint0[1], midPoint0[0], midPoint0[1], midPoint1[0], midPoint1[1]);
        path.bezierCurveTo(midPoint2[0], midPoint2[1], midPoint2[0], midPoint2[1], points[2][0], points[2][1]);
    }
    else {
        for (let i = 1; i < points.length; i++) {
            path.lineTo(points[i][0], points[i][1]);
        }
    }
}

/**
 * Create a label line if necessary and set it's style.
 */
export function setLabelLineStyle(
    targetEl: Element,
    statesModels: Record<DisplayState, LabelLineModel>,
    defaultStyle?: Polyline['style']
) {
    let labelLine = targetEl.getTextGuideLine();
    const label = targetEl.getTextContent();
    if (!label) {
        // Not show label line if there is no label.
        if (labelLine) {
            targetEl.removeTextGuideLine();
        }
        return;
    }

    const normalModel = statesModels.normal;
    const showNormal = normalModel.get('show');
    const labelIgnoreNormal = label.ignore;

    for (let i = 0; i < DISPLAY_STATES.length; i++) {
        const stateName = DISPLAY_STATES[i];
        const stateModel = statesModels[stateName];
        const isNormal = stateName === 'normal';
        if (stateModel) {
            const stateShow = stateModel.get('show');
            const isLabelIgnored = isNormal
                ? labelIgnoreNormal
                : retrieve2(label.states[stateName] && label.states[stateName].ignore, labelIgnoreNormal);
            if (isLabelIgnored  // Not show when label is not shown in this state.
                || !retrieve2(stateShow, showNormal) // Use normal state by default if not set.
            ) {
                const stateObj = isNormal ? labelLine : (labelLine && labelLine.states[stateName]);
                if (stateObj) {
                    stateObj.ignore = true;
                }
                if (!!labelLine) {
                    setLabelLineState(labelLine, true, stateName, stateModel);
                }
                continue;
            }
            // Create labelLine if not exists
            if (!labelLine) {
                labelLine = new Polyline();
                targetEl.setTextGuideLine(labelLine);
                // Reset state of normal because it's new created.
                // NOTE: NORMAL should always been the first!
                if (!isNormal && (labelIgnoreNormal || !showNormal)) {
                    setLabelLineState(labelLine, true, 'normal', statesModels.normal);
                }

                // Use same state proxy.
                if (targetEl.stateProxy) {
                    labelLine.stateProxy = targetEl.stateProxy;
                }
            }

            setLabelLineState(labelLine, false, stateName, stateModel);
        }
    }

    if (labelLine) {
        defaults(labelLine.style, defaultStyle);
        // Not fill.
        labelLine.style.fill = null;

        const showAbove = normalModel.get('showAbove');

        const labelLineConfig = (targetEl.textGuideLineConfig = targetEl.textGuideLineConfig || {});
        labelLineConfig.showAbove = showAbove || false;

        // Custom the buildPath.
        labelLine.buildPath = buildLabelLinePath;
    }
}


export function getLabelLineStatesModels<LabelName extends string = 'labelLine'>(
    itemModel: Model<StatesOptionMixin<any, any> & Partial<Record<LabelName, any>>>,
    labelLineName?: LabelName
): Record<DisplayState, LabelLineModel> {
    labelLineName = (labelLineName || 'labelLine') as LabelName;
    const statesModels = {
        normal: itemModel.getModel(labelLineName) as LabelLineModel
    } as Record<DisplayState, LabelLineModel>;
    for (let i = 0; i < SPECIAL_STATES.length; i++) {
        const stateName = SPECIAL_STATES[i];
        statesModels[stateName] = itemModel.getModel([stateName, labelLineName]);
    }
    return statesModels;
}
