/**
 * Base class of all displayable graphic objects
 */

import Element, {
    ElementProps, ElementStatePropNames, ElementAnimateConfig, ElementCommonState,
    IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE,
} from '../Element';
import BoundingRect from '../core/BoundingRect';
import { PropType, Dictionary, MapToType, IncrementalIdCompat } from '../core/types';
import Path from './Path';
import { keys, extend, createObject } from '../core/util';
import Animator from '../animation/Animator';
import { REDRAW_BIT, STYLE_CHANGED_BIT } from './constants';

// type CalculateTextPositionResult = ReturnType<typeof calculateTextPosition>

const STYLE_MAGIC_KEY = '__zr_style_' + Math.round((Math.random() * 10));

export interface CommonStyleProps {
    shadowBlur?: number
    shadowOffsetX?: number
    shadowOffsetY?: number
    shadowColor?: string

    opacity?: number
    /**
     * https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
     */
    blend?: string
}

export const DEFAULT_COMMON_STYLE: CommonStyleProps = {
    shadowBlur: 0,
    shadowOffsetX: 0,
    shadowOffsetY: 0,
    shadowColor: '#000',
    opacity: 1,
    blend: 'source-over'
};

export const DEFAULT_COMMON_ANIMATION_PROPS: MapToType<DisplayableProps, boolean> = {
    style: {
        shadowBlur: true,
        shadowOffsetX: true,
        shadowOffsetY: true,
        shadowColor: true,
        opacity: true
    }
 };

(DEFAULT_COMMON_STYLE as any)[STYLE_MAGIC_KEY] = true;

export interface DisplayableProps extends ElementProps {
    style?: Dictionary<any>

    zlevel?: number
    z?: number
    z2?: number

    culling?: boolean

    // TODO list all cursors
    cursor?: string

    rectHover?: boolean

    progressive?: boolean

    incremental?: Displayable['incremental']

    ignoreCoarsePointer?: boolean

    batch?: boolean
    invisible?: boolean
}

type DisplayableKey = keyof DisplayableProps
type DisplayablePropertyType = PropType<DisplayableProps, DisplayableKey>

export type DisplayableStatePropNames = ElementStatePropNames | 'style' | 'z' | 'z2' | 'invisible';
export type DisplayableState = Pick<DisplayableProps, DisplayableStatePropNames> & ElementCommonState;

const PRIMARY_STATES_KEYS = ['z', 'z2', 'invisible'] as const;
const PRIMARY_STATES_KEYS_IN_HOVER_LAYER = ['invisible'] as const;

export interface BeforeBrushParam {
    // [EXPERIMENTAL]
    // true means the layer is not cleared before this run of brush().
    contentRetained?: boolean
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Displayable<Props extends DisplayableProps = DisplayableProps> {
    animate(key?: '', loop?: boolean): Animator<this>
    animate(key: 'style', loop?: boolean): Animator<this['style']>

    getState(stateName: string): DisplayableState
    ensureState(stateName: string): DisplayableState

    states: Dictionary<DisplayableState>
    stateProxy: (stateName: string) => DisplayableState
}

class Displayable<Props extends DisplayableProps = DisplayableProps> extends Element<Props> {

    /**
     * Whether the displayable object is visible. when it is true, the displayable object
     * is not drawn, but the mouse event can still trigger the object.
     */
    invisible: boolean

    z: number

    z2: number

    /**
     * The z level determines the displayable object can be drawn in which layer canvas.
     */
    zlevel: number

    /**
     * If enable culling
     */
    culling: boolean

    /**
     * Mouse cursor when hovered
     */
    cursor: string

    /**
     * If hover area is bounding rect
     */
    rectHover: boolean

    incremental: IncrementalIdCompat

    /**
     * For an incremental element.
     * `true` can prevent its incremental layer from clearing even when `REDRAW_BIT` is set.
     * `false` is the normal behavior as other elements - can clear when `REDRAW_BIT` is set.
     *
     * NOTICE: The layer may be still cleared if marked as dirty by other incremental elements
     * sharing the same layer. Therefore, `contentRetained` is used in indicate whether the
     * content is retained, which enable the element to reset its internal draw index.
     *
     * Typical usage:
     *  ```
     *  class LargePath extends Path {
     *      reset() {
     *          this._idx = 0;
     *          this.notClear = false;
     *      }
     *      beforeBrush(param) {
     *          if (!param.contentRetained) { this.reset(); }
     *      }
     *      buildPath() {
     *          for (this._idx; this._idx < this.shape.points.length; this._idx++) {
     *              // draw
     *          }
     *          this.notClear = true;
     *      }
     *  }
     *  function incrementalUpdate(el, incrementalPoints) {
     *      const allPoints = mergePoints(el.shape.points, incrementalPoints);
     *      el.setShape({points: allPoints});
     *      // The REDRAW_BIT is set but need to retain the rendered content.
     *  }
     *  ```
     */
    notClear?: boolean
    /**
     * See `notClear`
     */
    __layerCleared?: boolean

    /**
     * Never increase to target size
     */
    ignoreCoarsePointer?: boolean

    // FIXME: do not use TS any.
    style: Dictionary<any>

    protected _normalState: DisplayableState

    protected _rect: BoundingRect
    protected _paintRect: BoundingRect
    protected _prevPaintRect: BoundingRect

    dirtyRectTolerance: number

    /************* Properties will be inejected in other modules. *******************/

    // @deprecated.
    useHoverLayer?: boolean

    __hoverStyle?: CommonStyleProps

    // TODO use WeakMap?

    // Shapes for cascade clipping.
    __clipPaths?: Path[]

    // FOR CANVAS PAINTER
    __canvasFillGradient: CanvasGradient
    __canvasStrokeGradient: CanvasGradient
    __canvasFillPattern: CanvasPattern
    __canvasStrokePattern: CanvasPattern

    // FOR SVG PAINTER
    __svgEl: SVGElement

    constructor(props?: Props) {
        super(props);
    }

    protected _init(props?: Props) {
        // Init default properties
        const keysArr = keys(props);
        for (let i = 0; i < keysArr.length; i++) {
            const key = keysArr[i];
            if (key === 'style') {
                this.useStyle(props[key] as Props['style']);
            }
            else {
                super.attrKV(key as any, props[key]);
            }
        }
        // Give a empty style
        if (!this.style) {
            this.useStyle({});
        }
    }

    // Hook provided to developers.
    beforeBrush(param: BeforeBrushParam) {}
    afterBrush() {}

    // Hook provided to inherited classes.
    // Executed between beforeBrush / afterBrush
    innerBeforeBrush() {}
    innerAfterBrush() {}

    shouldBePainted(
        viewWidth: number,
        viewHeight: number,
        considerClipPath: boolean,
        considerAncestors: boolean,
    ) {
        const m = this.transform;
        if (
            this.ignore
            // Ignore invisible element
            || this.invisible
            // Ignore transparent element
            || this.style.opacity === 0
            // Ignore culled element
            || (this.culling
                && isDisplayableCulled(this, viewWidth, viewHeight)
            )
            // Ignore scale 0 element, in some environment like node-canvas
            // Draw a scale 0 element can cause all following draw wrong
            // And setTransform with scale 0 will cause set back transform failed.
            || (m && !m[0] && !m[3])
        ) {
            return false;
        }

        if (considerClipPath && this.__clipPaths && this.__clipPaths.length) {
            for (let i = 0; i < this.__clipPaths.length; ++i) {
                if (this.__clipPaths[i].isZeroArea()) {
                    return false;
                }
            }
        }

        if (considerAncestors && this.parent) {
            let parent = this.parent;
            while (parent) {
                if (parent.ignore) {
                    return false;
                }
                parent = parent.parent;
            }
        }

        return true;
    }

    /**
     * If displayable element contain coord x, y
     */
    contain(x: number, y: number) {
        return this.rectContain(x, y);
    }

    traverse<Context>(
        cb: (this: Context, el: this) => void,
        context?: Context
    ) {
        cb.call(context, this);
    }

    /**
     * If bounding rect of element contain coord x, y
     */
    rectContain(x: number, y: number) {
        const coord = this.transformCoordToLocal(x, y);
        const rect = this.getBoundingRect();
        return rect.contain(coord[0], coord[1]);
    }

    getPaintRect(): BoundingRect {
        let rect = this._paintRect;
        if (!this._paintRect || this.__dirty) {
            const transform = this.transform;
            const elRect = this.getBoundingRect();

            const style = this.style;
            const shadowSize = style.shadowBlur || 0;
            const shadowOffsetX = style.shadowOffsetX || 0;
            const shadowOffsetY = style.shadowOffsetY || 0;

            rect = this._paintRect || (this._paintRect = new BoundingRect(0, 0, 0, 0));
            if (transform) {
                BoundingRect.applyTransform(rect, elRect, transform);
            }
            else {
                rect.copy(elRect);
            }

            if (shadowSize || shadowOffsetX || shadowOffsetY) {
                rect.width += shadowSize * 2 + Math.abs(shadowOffsetX);
                rect.height += shadowSize * 2 + Math.abs(shadowOffsetY);
                rect.x = Math.min(rect.x, rect.x + shadowOffsetX - shadowSize);
                rect.y = Math.min(rect.y, rect.y + shadowOffsetY - shadowSize);

            }

            // For the accuracy tolerance of text height or line joint point
            const tolerance = this.dirtyRectTolerance;
            if (!rect.isZero()) {
                rect.x = Math.floor(rect.x - tolerance);
                rect.y = Math.floor(rect.y - tolerance);
                rect.width = Math.ceil(rect.width + 1 + tolerance * 2);
                rect.height = Math.ceil(rect.height + 1 + tolerance * 2);
            }
        }
        return rect;
    }

    setPrevPaintRect(paintRect: BoundingRect) {
        if (paintRect) {
            this._prevPaintRect = this._prevPaintRect || new BoundingRect(0, 0, 0, 0);
            this._prevPaintRect.copy(paintRect);
        }
        else {
            this._prevPaintRect = null;
        }
    }

    getPrevPaintRect(): BoundingRect {
        return this._prevPaintRect;
    }

    /**
     * Alias for animate('style')
     * @param loop
     */
    animateStyle(loop: boolean) {
        return this.animate('style', loop);
    }

    // Override updateDuringAnimation
    updateDuringAnimation(targetKey: string) {
        if (targetKey === 'style') {
            this.dirtyStyle();
        }
        else {
            this.markRedraw();
        }
    }

    attrKV(key: DisplayableKey, value: DisplayablePropertyType) {
        if (key !== 'style') {
            super.attrKV(key as keyof DisplayableProps, value);
        }
        else {
            if (!this.style) {
                this.useStyle(value as Dictionary<any>);
            }
            else {
                this.setStyle(value as Dictionary<any>);
            }
        }
    }

    setStyle(obj: Props['style']): this
    setStyle<T extends keyof Props['style']>(obj: T, value: Props['style'][T]): this
    setStyle(keyOrObj: keyof Props['style'] | Props['style'], value?: unknown): this {
        if (typeof keyOrObj === 'string') {
            this.style[keyOrObj] = value;
        }
        else {
            extend(this.style, keyOrObj as Props['style']);
        }
        this.dirtyStyle();
        return this;
    }

    // getDefaultStyleValue<T extends keyof Props['style']>(key: T): Props['style'][T] {
    //     // Default value is on the prototype.
    //     return this.style.prototype[key];
    // }

    dirtyStyle(notRedraw?: boolean) {
        if (!notRedraw) {
            this.markRedraw();
        }
        this.__dirty |= STYLE_CHANGED_BIT;
        // Clear bounding rect.
        if (this._rect) {
            this._rect = null;
        }
    }

    dirty() {
        this.dirtyStyle();
    }

    /**
     * Is style changed. Used with dirtyStyle.
     */
    styleChanged() {
        return !!(this.__dirty & STYLE_CHANGED_BIT);
    }

    /**
     * Mark style updated. Only useful when style is used for caching. Like in the text.
     */
    styleUpdated() {
        this.__dirty &= ~STYLE_CHANGED_BIT;
    }

    /**
     * Create a style object with default values in it's prototype.
     */
    createStyle(obj?: Props['style']) {
        return createObject(DEFAULT_COMMON_STYLE, obj);
    }

    /**
     * Replace style property.
     * It will create a new style if given obj is not a valid style object.
     */
     // PENDING should not createStyle if it's an style object.
    useStyle(obj: Props['style']) {
        if (!obj[STYLE_MAGIC_KEY]) {
            obj = this.createStyle(obj);
        }
        // // See the comment `HOVER_LAYER_CONSTRAINTS` for `hoverStyle` case.
        this.style = obj;
        this.dirtyStyle();
    }

    protected _useHoverStyle(obj: Props['style']) {
        this.__hoverStyle = obj;
        // this.dirtyStyle();
        // PENDING:
        // Since HOVER_LAYER_CONSTRAINTS_TEXT is not supported, no need to call
        // `this.dirtyStyle()` here.
        // Sub texts updating requires `this.dirtyStyle()` to trigger them.
        // But a STYLE_CHANGED_BIT may cause repaint of the original layer if new TSpan is
        // created or updated, which is unexpected when hover layer is used.
    }

    /**
     * Determine if an object is a valid style object.
     * Which means it is created by `createStyle.`
     *
     * A valid style object will have all default values in it's prototype.
     * To avoid get null/undefined values.
     */
    isStyleObject(obj: Props['style']) {
        return obj[STYLE_MAGIC_KEY];
    }

    protected _innerSaveToNormal(toState: DisplayableState) {
        super._innerSaveToNormal(toState);

        const normalState = this._normalState;
        if (toState.style && !normalState.style) {
            // Clone style object.
            // TODO: Only save changed style.
            normalState.style = this._mergeStyle(this.createStyle(), this.style);
        }

        this._savePrimaryToNormal(toState, normalState, PRIMARY_STATES_KEYS);
    }

    protected _applyStateObj(
        stateName: string,
        state: DisplayableState,
        normalState: DisplayableState,
        keepCurrentStates: boolean,
        transition: boolean,
        animationCfg: ElementAnimateConfig
    ) {
        super._applyStateObj(stateName, state, normalState, keepCurrentStates, transition, animationCfg);

        const needsRestoreToNormal = !(state && keepCurrentStates);
        const inHoverOnlyStyleChange = this.__inHover === IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE;

        // NOTE: `transition` has been garanteed `false` when `this.__inHover` is a truthy value.

        let targetStyle: Props['style'];
        if (state && state.style) {
            // Only animate changed properties.
            if (transition) {
                if (keepCurrentStates) {
                    targetStyle = state.style;
                }
                else {
                    targetStyle = this._mergeStyle(this.createStyle(), normalState.style);
                    this._mergeStyle(targetStyle, state.style);
                }
            }
            else {
                targetStyle = this._mergeStyle(
                    this.createStyle(),
                    keepCurrentStates ? this.style : normalState.style
                );
                this._mergeStyle(targetStyle, state.style);
            }
        }
        else if (needsRestoreToNormal) {
            targetStyle = normalState.style;
        }

        if (targetStyle) {
            if (transition) { // transition must be false if hoverLayer is used.
                // Clone a new style. Not affect the original one.
                const sourceStyle = this.style;

                this.style = this.createStyle(needsRestoreToNormal ? {} : sourceStyle);
                // const sourceStyle = this.style = this.createStyle(this.style);

                if (needsRestoreToNormal) {
                    const changedKeys = keys(sourceStyle);
                    for (let i = 0; i < changedKeys.length; i++) {
                        const key = changedKeys[i];
                        if (key in targetStyle) {   // Not use `key == null` because == null may means no stroke/fill.
                            // Pick out from prototype. Or the property won't be animated.
                            (targetStyle as any)[key] = targetStyle[key];
                            // Omit the property has no default value.
                            (this.style as any)[key] = sourceStyle[key];
                        }
                    }
                }

                // If states is switched twice in ONE FRAME, for example:
                // one property(for example shadowBlur) changed from default value to a specifed value,
                // then switched back in immediately. this.style may don't set this property yet when switching back.
                // It won't treat it as an changed property when switching back. And it won't be animated.
                // So here we make sure the properties will be animated from default value to a specifed value are set.
                const targetKeys = keys(targetStyle);
                for (let i = 0; i < targetKeys.length; i++) {
                    const key = targetKeys[i];
                    this.style[key] = this.style[key];
                }

                this._transitionState(stateName, {
                    style: targetStyle
                } as Props, animationCfg, this.getAnimationStyleProps() as MapToType<Props, boolean>);
            }
            else {
                if (inHoverOnlyStyleChange) {
                    this._useHoverStyle(targetStyle);
                }
                else {
                    this.useStyle(targetStyle);
                }
            }
        }

        // Don't change z, z2 for element moved into hover layer.
        // It's not necessary and will cause paint list order changed.
        if (!inHoverOnlyStyleChange) {
            const statesKeys = this.__inHover ? PRIMARY_STATES_KEYS_IN_HOVER_LAYER : PRIMARY_STATES_KEYS;
            for (let i = 0; i < statesKeys.length; i++) {
                let key = statesKeys[i];
                if (state && state[key] != null) {
                    // Replace if it exist in target state
                    (this as any)[key] = state[key];
                }
                else if (needsRestoreToNormal) {
                    // Restore to normal state
                    if (normalState[key] != null) {
                        (this as any)[key] = normalState[key];
                    }
                }
            }
        }
    }

    protected _mergeStates(states: DisplayableState[]) {
        const mergedState = super._mergeStates(states) as DisplayableState;
        let mergedStyle: Props['style'];
        for (let i = 0; i < states.length; i++) {
            const state = states[i];
            if (state.style) {
                mergedStyle = mergedStyle || {};
                this._mergeStyle(mergedStyle, state.style);
            }
        }
        if (mergedStyle) {
            mergedState.style = mergedStyle;
        }
        return mergedState;
    }

    protected _mergeStyle(
        targetStyle: CommonStyleProps,
        sourceStyle: CommonStyleProps
    ) {
        extend(targetStyle, sourceStyle);
        return targetStyle;
    }

    getAnimationStyleProps() {
        return DEFAULT_COMMON_ANIMATION_PROPS;
    }

    /**
     * The string value of `textPosition` needs to be calculated to a real postion.
     * For example, `'inside'` is calculated to `[rect.width/2, rect.height/2]`
     * by default. See `contain/text.js#calculateTextPosition` for more details.
     * But some coutom shapes like "pin", "flag" have center that is not exactly
     * `[width/2, height/2]`. So we provide this hook to customize the calculation
     * for those shapes. It will be called if the `style.textPosition` is a string.
     * @param out Prepared out object. If not provided, this method should
     *        be responsible for creating one.
     * @param style
     * @param rect {x, y, width, height}
     * @return out The same as the input out.
     *         {
     *             x: number. mandatory.
     *             y: number. mandatory.
     *             textAlign: string. optional. use style.textAlign by default.
     *             textVerticalAlign: string. optional. use style.textVerticalAlign by default.
     *         }
     */
    // calculateTextPosition: (out: CalculateTextPositionResult, style: Dictionary<any>, rect: RectLike) => CalculateTextPositionResult

    protected static initDefaultProps = (function () {
        const dispProto = Displayable.prototype;
        dispProto.type = 'displayable';
        dispProto.invisible = false;
        dispProto.z = 0;
        dispProto.z2 = 0;
        dispProto.zlevel = 0;
        dispProto.culling = false;
        dispProto.cursor = 'pointer';
        dispProto.rectHover = false;
        dispProto.incremental = 0;
        dispProto._rect = null;
        dispProto.dirtyRectTolerance = 0;

        dispProto.__dirty = REDRAW_BIT | STYLE_CHANGED_BIT;
    })()
}

const tmpRect = new BoundingRect(0, 0, 0, 0);
const viewRect = new BoundingRect(0, 0, 0, 0);
function isDisplayableCulled(el: Displayable, width: number, height: number) {
    tmpRect.copy(el.getBoundingRect());
    if (el.transform) {
        tmpRect.applyTransform(el.transform);
    }
    viewRect.width = width;
    viewRect.height = height;
    return !tmpRect.intersect(viewRect);
}

export default Displayable;