/*
* 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.
*/

// Helpers for creating transitions in custom series and graphic components.
import Element, { ElementAnimateConfig, ElementProps } from 'zrender/src/Element';

import { makeInner, normalizeToArray } from '../util/model';
import { assert, bind, each, eqNaN, extend, hasOwn, indexOf, isArrayLike, keys, reduce } from 'zrender/src/core/util';
import { cloneValue } from 'zrender/src/animation/Animator';
import Displayable, { DisplayableProps } from 'zrender/src/graphic/Displayable';
import Model from '../model/Model';
import { getAnimationConfig } from './basicTransition';
import { Path } from '../util/graphic';
import { warn } from '../util/log';
import { AnimationOption, AnimationOptionMixin, ZRStyleProps } from '../util/types';
import { Dictionary } from 'zrender/src/core/types';
import { PathStyleProps } from 'zrender/src/graphic/Path';
import { TRANSFORMABLE_PROPS, TransformProp } from 'zrender/src/core/Transformable';

const LEGACY_TRANSFORM_PROPS_MAP = {
    position: ['x', 'y'],
    scale: ['scaleX', 'scaleY'],
    origin: ['originX', 'originY']
} as const;
const LEGACY_TRANSFORM_PROPS = keys(LEGACY_TRANSFORM_PROPS_MAP);

const TRANSFORM_PROPS_MAP = reduce(TRANSFORMABLE_PROPS, (obj, key) => {
    obj[key] = 1;
    return obj;
}, {} as Record<TransformProp, 1>);
const transformPropNamesStr = TRANSFORMABLE_PROPS.join(', ');

// '' means root
export const ELEMENT_ANIMATABLE_PROPS = ['', 'style', 'shape', 'extra'] as const;

export type TransitionProps = string | string[];
export type ElementRootTransitionProp = TransformProp | 'shape' | 'extra' | 'style';

export interface TransitionOptionMixin<T = Record<string, any>> {
    transition?: (keyof T & string) | ((keyof T & string)[]) | 'all'

    enterFrom?: T;
    leaveTo?: T;

    enterAnimation?: AnimationOption
    updateAnimation?: AnimationOption
    leaveAnimation?: AnimationOption
};

interface LooseElementProps extends ElementProps {
    style?: ZRStyleProps;
    shape?: Dictionary<unknown>;
}

type TransitionElementOption = Partial<Record<TransformProp, number>> & {
    shape?: Dictionary<any> & TransitionOptionMixin
    style?: PathStyleProps & TransitionOptionMixin
    extra?: Dictionary<any> & TransitionOptionMixin
    invisible?: boolean
    silent?: boolean
    autoBatch?: boolean
    ignore?: boolean

    during?: (params: TransitionDuringAPI) => void
} & TransitionOptionMixin;

const transitionInnerStore = makeInner<{
    leaveToProps: ElementProps;
    userDuring: (params: TransitionDuringAPI) => void;
}, Element>();

export interface TransitionBaseDuringAPI {
    // Usually other props do not need to be changed in animation during.
    setTransform(key: TransformProp, val: number): this
    getTransform(key: TransformProp): number;
    setExtra(key: string, val: unknown): this
    getExtra(key: string): unknown
}
export interface TransitionDuringAPI<
    StyleOpt extends any = any,
    ShapeOpt extends any = any
> extends TransitionBaseDuringAPI {
    setShape<T extends keyof ShapeOpt>(key: T, val: ShapeOpt[T]): this;
    getShape<T extends keyof ShapeOpt>(key: T): ShapeOpt[T];
    setStyle<T extends keyof StyleOpt>(key: T, val: StyleOpt[T]): this
    getStyle<T extends keyof StyleOpt>(key: T): StyleOpt[T];
};

function getElementAnimationConfig(
    animationType: 'enter' | 'update' | 'leave',
    el: Element,
    elOption: TransitionElementOption,
    parentModel: Model<AnimationOptionMixin>,
    dataIndex?: number
) {
    const animationProp = `${animationType}Animation` as const;
    const config: ElementAnimateConfig = getAnimationConfig(animationType, parentModel, dataIndex) || {};

    const userDuring = transitionInnerStore(el).userDuring;
    // Only set when duration is > 0 and it's need to be animated.
    if (config.duration > 0) {
        // For simplicity, if during not specified, the previous during will not work any more.
        config.during = userDuring ? bind(duringCall, { el: el, userDuring: userDuring }) : null;
        config.setToFinal = true;
        config.scope = animationType;
    }

    extend(config, elOption[animationProp]);
    return config;
}


export function applyUpdateTransition(
    el: Element,
    elOption: TransitionElementOption,
    animatableModel?: Model<AnimationOptionMixin>,
    opts?: {
        dataIndex?: number,
        isInit?: boolean,
        clearStyle?: boolean
    }
) {
    opts = opts || {};
    const {dataIndex, isInit, clearStyle} = opts;

    const hasAnimation = animatableModel.isAnimationEnabled();
    // Save the meta info for further morphing. Like apply on the sub morphing elements.
    const store = transitionInnerStore(el);
    const styleOpt = elOption.style;
    store.userDuring = elOption.during;

    const transFromProps = {} as ElementProps;
    const propsToSet = {} as ElementProps;

    prepareTransformAllPropsFinal(el, elOption, propsToSet);

    if (el.type === 'compound') {
        /**
         * We cannot directly clone shape for compoundPath,
         * because it makes the path to be an object instead of a Path instance,
         * and thus missing `buildPath` method.
         */
        const paths: Path[] = (el as Path).shape.paths;
        const optionPaths = elOption.shape.paths as TransitionElementOption['shape']['paths'];
        for (let i = 0; i < optionPaths.length; i++) {
            const path = optionPaths[i];
            prepareShapeOrExtraAllPropsFinal('shape', path, paths[i]);
        }
    }
    else {
        prepareShapeOrExtraAllPropsFinal('shape', elOption, propsToSet);
        prepareShapeOrExtraAllPropsFinal('extra', elOption, propsToSet);
    }

    if (!isInit && hasAnimation) {
        prepareTransformTransitionFrom(el, elOption, transFromProps);
        prepareShapeOrExtraTransitionFrom('shape', el, elOption, transFromProps);
        prepareShapeOrExtraTransitionFrom('extra', el, elOption, transFromProps);
        prepareStyleTransitionFrom(el, elOption, styleOpt, transFromProps);
    }

    (propsToSet as DisplayableProps).style = styleOpt;

    applyPropsDirectly(el, propsToSet, clearStyle);
    applyMiscProps(el, elOption);

    if (hasAnimation) {
        if (isInit) {
            const enterFromProps: ElementProps = {};
            each(ELEMENT_ANIMATABLE_PROPS, propName => {
                const prop: TransitionOptionMixin = propName ? elOption[propName] : elOption;
                if (prop && prop.enterFrom) {
                    if (propName) {
                        (enterFromProps as any)[propName] = (enterFromProps as any)[propName] || {};
                    }
                    extend(propName ? (enterFromProps as any)[propName] : enterFromProps, prop.enterFrom);
                }
            });
            const config = getElementAnimationConfig('enter', el, elOption, animatableModel, dataIndex);
            if (config.duration > 0) {
                el.animateFrom(enterFromProps, config);
            }
        }
        else {
            applyPropsTransition(el, elOption, dataIndex || 0, animatableModel, transFromProps);
        }
    }
    // Store leave to be used in leave transition.
    updateLeaveTo(el, elOption);

    styleOpt ? el.dirty() : el.markRedraw();
}

export function updateLeaveTo(el: Element, elOption: TransitionElementOption) {
    // Try merge to previous set leaveTo
    let leaveToProps: ElementProps = transitionInnerStore(el).leaveToProps;
    for (let i = 0; i < ELEMENT_ANIMATABLE_PROPS.length; i++) {
        const propName = ELEMENT_ANIMATABLE_PROPS[i];
        const prop: TransitionOptionMixin = propName ? elOption[propName] : elOption;
        if (prop && prop.leaveTo) {
            if (!leaveToProps) {
                leaveToProps = transitionInnerStore(el).leaveToProps = {};
            }
            if (propName) {
                (leaveToProps as any)[propName] = (leaveToProps as any)[propName] || {};
            }
            extend(propName ? (leaveToProps as any)[propName] : leaveToProps, prop.leaveTo);
        }
    }
}

export function applyLeaveTransition(
    el: Element,
    elOption: TransitionElementOption,
    animatableModel: Model<AnimationOptionMixin>,
    onRemove?: () => void
): void {
    if (el) {
        const parent = el.parent;
        const leaveToProps = transitionInnerStore(el).leaveToProps;
        if (leaveToProps) {
            // TODO TODO use leave after leaveAnimation in series is introduced
            // TODO Data index?
            const config = getElementAnimationConfig('update', el, elOption, animatableModel, 0);
            config.done = () => {
                parent && parent.remove(el);
                onRemove && onRemove();
            };
            el.animateTo(leaveToProps, config);
        }
        else {
            parent && parent.remove(el);
            onRemove && onRemove();
        }
    }
}

export function isTransitionAll(transition: TransitionProps): transition is 'all' {
    return transition === 'all';
}


function applyPropsDirectly(
    el: Element,
    // Can be null/undefined
    allPropsFinal: ElementProps,
    clearStyle: boolean
) {
    const styleOpt = (allPropsFinal as Displayable).style;
    if (!el.isGroup && styleOpt) {
        if (clearStyle) {
            (el as Displayable).useStyle({});

            // When style object changed, how to trade the existing animation?
            // It is probably complicated and not needed to cover all the cases.
            // But still need consider the case:
            // (1) When using init animation on `style.opacity`, and before the animation
            //     ended users triggers an update by mousewhel. At that time the init
            //     animation should better be continued rather than terminated.
            //     So after `useStyle` called, we should change the animation target manually
            //     to continue the effect of the init animation.
            // (2) PENDING: If the previous animation targeted at a `val1`, and currently we need
            //     to update the value to `val2` and no animation declared, should be terminate
            //     the previous animation or just modify the target of the animation?
            //     Therotically That will happen not only on `style` but also on `shape` and
            //     `transfrom` props. But we haven't handle this case at present yet.
            // (3) PENDING: Is it proper to visit `animators` and `targetName`?
            const animators = el.animators;
            for (let i = 0; i < animators.length; i++) {
                const animator = animators[i];
                // targetName is the "topKey".
                if (animator.targetName === 'style') {
                    animator.changeTarget((el as Displayable).style);
                }
            }
        }
        (el as Displayable).setStyle(styleOpt);
    }

    if (allPropsFinal) {
        // Not set style here.
        (allPropsFinal as DisplayableProps).style = null;
        // Set el to the final state firstly.
        allPropsFinal && el.attr(allPropsFinal);
        (allPropsFinal as DisplayableProps).style = styleOpt;
    }
}

function applyPropsTransition(
    el: Element,
    elOption: TransitionElementOption,
    dataIndex: number,
    model: Model<AnimationOptionMixin>,
    // Can be null/undefined
    transFromProps: ElementProps
): void {
    if (transFromProps) {
        const config = getElementAnimationConfig('update', el, elOption, model, dataIndex);
        if (config.duration > 0) {
            el.animateFrom(transFromProps, config);
        }
    }
}


function applyMiscProps(
    el: Element,
    elOption: TransitionElementOption
) {
    // Merge by default.
    hasOwn(elOption, 'silent') && (el.silent = elOption.silent);
    hasOwn(elOption, 'ignore') && (el.ignore = elOption.ignore);
    if (el instanceof Displayable) {
        hasOwn(elOption, 'invisible') && ((el as Path).invisible = elOption.invisible);
    }
    if (el instanceof Path) {
        hasOwn(elOption, 'autoBatch') && ((el as Path).autoBatch = elOption.autoBatch);
    }
}

// Use it to avoid it be exposed to user.
const tmpDuringScope = {} as {
    el: Element;
};
const transitionDuringAPI: TransitionDuringAPI = {
    // Usually other props do not need to be changed in animation during.
    setTransform(key: TransformProp, val: unknown) {
        if (__DEV__) {
            assert(hasOwn(TRANSFORM_PROPS_MAP, key), 'Only ' + transformPropNamesStr + ' available in `setTransform`.');
        }
        tmpDuringScope.el[key] = val as number;
        return this;
    },
    getTransform(key: TransformProp): number {
        if (__DEV__) {
            assert(hasOwn(TRANSFORM_PROPS_MAP, key), 'Only ' + transformPropNamesStr + ' available in `getTransform`.');
        }
        return tmpDuringScope.el[key];
    },
    setShape(key: any, val: unknown) {
        if (__DEV__) {
            assertNotReserved(key);
        }
        const el = tmpDuringScope.el as Path;
        const shape = el.shape || (el.shape = {});
        shape[key] = val;
        el.dirtyShape && el.dirtyShape();
        return this;
    },
    getShape(key: any): any {
        if (__DEV__) {
            assertNotReserved(key);
        }
        const shape = (tmpDuringScope.el as Path).shape;
        if (shape) {
            return shape[key];
        }
    },
    setStyle(key: any, val: unknown) {
        if (__DEV__) {
            assertNotReserved(key);
        }
        const el = tmpDuringScope.el as Displayable;
        const style = el.style;
        if (style) {
            if (__DEV__) {
                if (eqNaN(val)) {
                    warn('style.' + key + ' must not be assigned with NaN.');
                }
            }
            style[key] = val;
            el.dirtyStyle && el.dirtyStyle();
        }
        return this;
    },
    getStyle(key: any): any {
        if (__DEV__) {
            assertNotReserved(key);
        }
        const style = (tmpDuringScope.el as Displayable).style;
        if (style) {
            return style[key];
        }
    },
    setExtra(key: any, val: unknown) {
        if (__DEV__) {
            assertNotReserved(key);
        }
        const extra = (tmpDuringScope.el as LooseElementProps).extra
            || ((tmpDuringScope.el as LooseElementProps).extra = {});
        extra[key] = val;
        return this;
    },
    getExtra(key: string): unknown {
        if (__DEV__) {
            assertNotReserved(key);
        }
        const extra = (tmpDuringScope.el as LooseElementProps).extra;
        if (extra) {
            return extra[key];
        }
    }
};

function assertNotReserved(key: string) {
    if (__DEV__) {
        if (key === 'transition' || key === 'enterFrom' || key === 'leaveTo') {
            throw new Error('key must not be "' + key + '"');
        }
    }
}

function duringCall(
    this: {
        el: Element;
        userDuring: (params: TransitionDuringAPI) => void;
    }
): void {
    // Do not provide "percent" until some requirements come.
    // Because consider thies case:
    // enterFrom: {x: 100, y: 30}, transition: 'x'.
    // And enter duration is different from update duration.
    // Thus it might be confused about the meaning of "percent" in during callback.
    const scope = this;
    const el = scope.el;
    if (!el) {
        return;
    }
    // If el is remove from zr by reason like legend, during still need to called,
    // because el will be added back to zr and the prop value should not be incorrect.

    const latestUserDuring = transitionInnerStore(el).userDuring;
    const scopeUserDuring = scope.userDuring;
    // Ensured a during is only called once in each animation frame.
    // If a during is called multiple times in one frame, maybe some users' calculation logic
    // might be wrong (not sure whether this usage exists).
    // The case of a during might be called twice can be: by default there is a animator for
    // 'x', 'y' when init. Before the init animation finished, call `setOption` to start
    // another animators for 'style'/'shape'/'extra'.
    if (latestUserDuring !== scopeUserDuring) {
        // release
        scope.el = scope.userDuring = null;
        return;
    }

    tmpDuringScope.el = el;

    // Give no `this` to user in "during" calling.
    scopeUserDuring(transitionDuringAPI);

    // FIXME: if in future meet the case that some prop will be both modified in `during` and `state`,
    // consider the issue that the prop might be incorrect when return to "normal" state.
}

function prepareShapeOrExtraTransitionFrom(
    mainAttr: 'shape' | 'extra',
    fromEl: Element,
    elOption: TransitionOptionMixin,
    transFromProps: LooseElementProps
): void {

    const attrOpt: Dictionary<unknown> & TransitionOptionMixin = (elOption as any)[mainAttr];
    if (!attrOpt) {
        return;
    }

    const elPropsInAttr = (fromEl as LooseElementProps)[mainAttr];
    let transFromPropsInAttr: Dictionary<unknown>;

    if (elPropsInAttr) {
        const transition = elOption.transition;
        const attrTransition = attrOpt.transition;
        if (attrTransition) {
            !transFromPropsInAttr && (transFromPropsInAttr = transFromProps[mainAttr] = {});
            if (isTransitionAll(attrTransition)) {
                extend(transFromPropsInAttr, elPropsInAttr);
            }
            else {
                const transitionKeys = normalizeToArray(attrTransition);
                for (let i = 0; i < transitionKeys.length; i++) {
                    const key = transitionKeys[i];
                    const elVal = elPropsInAttr[key];
                    transFromPropsInAttr[key] = elVal;
                }
            }
        }
        else if (isTransitionAll(transition) || indexOf(transition, mainAttr) >= 0) {
            !transFromPropsInAttr && (transFromPropsInAttr = transFromProps[mainAttr] = {});
            const elPropsInAttrKeys = keys(elPropsInAttr);
            for (let i = 0; i < elPropsInAttrKeys.length; i++) {
                const key = elPropsInAttrKeys[i];
                const elVal = elPropsInAttr[key];
                if (isNonStyleTransitionEnabled((attrOpt as any)[key], elVal)) {
                    transFromPropsInAttr[key] = elVal;
                }
            }
        }
    }
}

function prepareShapeOrExtraAllPropsFinal(
    mainAttr: 'shape' | 'extra',
    elOption: TransitionElementOption,
    allProps: LooseElementProps
): void {
    const attrOpt: Dictionary<unknown> = (elOption as any)[mainAttr];
    if (!attrOpt) {
        return;
    }
    const allPropsInAttr = allProps[mainAttr] = {} as Dictionary<unknown>;
    const keysInAttr = keys(attrOpt);
    for (let i = 0; i < keysInAttr.length; i++) {
        const key = keysInAttr[i];
        // To avoid share one object with different element, and
        // to avoid user modify the object inexpectedly, have to clone.
        allPropsInAttr[key] = cloneValue((attrOpt as any)[key]);
    }
}

function prepareTransformTransitionFrom(
    el: Element,
    elOption: TransitionElementOption,
    transFromProps: ElementProps
): void {
    const transition = elOption.transition;
    const transitionKeys = isTransitionAll(transition)
        ? TRANSFORMABLE_PROPS
        : normalizeToArray(transition || []);
    for (let i = 0; i < transitionKeys.length; i++) {
        const key = transitionKeys[i];
        if (key === 'style' || key === 'shape' || key === 'extra') {
            continue;
        }
        const elVal = (el as any)[key];
        if (__DEV__) {
            checkTransformPropRefer(key, 'el.transition');
        }
        // Do not clone, animator will perform that clone.
        (transFromProps as any)[key] = elVal;
    }
}

function prepareTransformAllPropsFinal(
    el: Element,
    elOption: TransitionElementOption,
    allProps: ElementProps
): void {
    for (let i = 0; i < LEGACY_TRANSFORM_PROPS.length; i++) {
        const legacyName = LEGACY_TRANSFORM_PROPS[i];
        const xyName = LEGACY_TRANSFORM_PROPS_MAP[legacyName];
        const legacyArr = (elOption as any)[legacyName];
        if (legacyArr) {
            allProps[xyName[0]] = legacyArr[0];
            allProps[xyName[1]] = legacyArr[1];
        }
    }

    for (let i = 0; i < TRANSFORMABLE_PROPS.length; i++) {
        const key = TRANSFORMABLE_PROPS[i];
        if (elOption[key] != null) {
            allProps[key] = elOption[key];
        }
    }
}

function prepareStyleTransitionFrom(
    fromEl: Element,
    elOption: TransitionElementOption,
    styleOpt: TransitionElementOption['style'],
    transFromProps: LooseElementProps
): void {
    if (!styleOpt) {
        return;
    }

    const fromElStyle = (fromEl as LooseElementProps).style as LooseElementProps['style'];
    let transFromStyleProps: LooseElementProps['style'];

    if (fromElStyle) {
        const styleTransition = styleOpt.transition;
        const elTransition = elOption.transition;
        if (styleTransition && !isTransitionAll(styleTransition)) {
            const transitionKeys = normalizeToArray(styleTransition);
            !transFromStyleProps && (transFromStyleProps = transFromProps.style = {});
            for (let i = 0; i < transitionKeys.length; i++) {
                const key = transitionKeys[i];
                const elVal = (fromElStyle as any)[key];
                // Do not clone, see `checkNonStyleTansitionRefer`.
                (transFromStyleProps as any)[key] = elVal;
            }
        }
        else if (
            (fromEl as Displayable).getAnimationStyleProps
            && (
                isTransitionAll(elTransition)
                || isTransitionAll(styleTransition)
                || indexOf(elTransition, 'style') >= 0
            )
        ) {
            const animationProps = (fromEl as Displayable).getAnimationStyleProps();
            const animationStyleProps = animationProps ? animationProps.style : null;
            if (animationStyleProps) {
                !transFromStyleProps && (transFromStyleProps = transFromProps.style = {});
                const styleKeys = keys(styleOpt);
                for (let i = 0; i < styleKeys.length; i++) {
                    const key = styleKeys[i];
                    if ((animationStyleProps as Dictionary<unknown>)[key]) {
                        const elVal = (fromElStyle as any)[key];
                        (transFromStyleProps as any)[key] = elVal;
                    }
                }
            }
        }
    }
}

function isNonStyleTransitionEnabled(optVal: unknown, elVal: unknown): boolean {
    // The same as `checkNonStyleTansitionRefer`.
    return !isArrayLike(optVal)
        ? (optVal != null && isFinite(optVal as number))
        : optVal !== elVal;
}

let checkTransformPropRefer: (key: string, usedIn: string) => void;
if (__DEV__) {
    checkTransformPropRefer = function (key: string, usedIn: string): void {
        if (!hasOwn(TRANSFORM_PROPS_MAP, key)) {
            warn('Prop `' + key + '` is not a permitted in `' + usedIn + '`. '
               + 'Only `' + keys(TRANSFORM_PROPS_MAP).join('`, `') + '` are permitted.');
        }
    };
}
