/*

Siesta 5.1.0
Copyright(c) 2009-2018 Bryntum AB
https://bryntum.com/contact
https://bryntum.com/products/siesta/license

*/
/**
@class Siesta.Recorder.Recorder
@mixin Siesta.Recorder.Role.CanRecordScroll
@mixin Siesta.Recorder.Role.CanRecordWindowResize
@mixin Siesta.Recorder.Role.CanRecordPointOfInterest
@mixin Siesta.Recorder.Role.CanRecordMouseMovePath
@mixin Siesta.Recorder.Role.CanRecordMouseMoveOnIdle

This class implements a recorder for user actions. It records the events of the window it's attached to.
It has a number of options, defining what should be recorder. Since it's JS based, we cannot record
native dialog interactions, such as alert, print or confirm etc.

*/
Class('Siesta.Recorder.Recorder', {

    does : [
        // "utility" roles
        JooseX.Observable,
        Siesta.Util.Role.Dom,
        Siesta.Util.Role.CanParseOs,
        Siesta.Util.Role.CanGetType,
        Siesta.Recorder.Role.CanSwallowException,

        // "feature" roles
        Siesta.Recorder.Role.CanRecordMouseDownUp,
        Siesta.Recorder.Role.CanRecordMouseMove,

        Siesta.Recorder.Role.CanRecordMouseMoveOnIdle,
        Siesta.Recorder.Role.CanRecordPointOfInterest,
        Siesta.Recorder.Role.CanRecordWindowResize,
        Siesta.Recorder.Role.CanRecordScroll,

        // this has to go last (override will be executed first),
        // to be able to disable the previous roles
        Siesta.Recorder.Role.CanRecordMouseMovePath
    ],

    has : {
        active              : null,

        extractor           : null,

        extractorClass      : Siesta.Recorder.TargetExtractor,

        extractorConfig     : null,

        /**
         * @cfg {Array[String]/String} uniqueComponentProperty A string or an array of strings, containing attribute names
         * that the Recorder will use to identify Ext JS components.
         */
        uniqueComponentProperty : null,

        /**
         * @cfg {String} uniqueDomNodeProperty A property that will be used to uniquely identify DOM nodes. By default the `id`
         * property is used.
         */
        uniqueDomNodeProperty : 'id',

        /**
         * @cfg {Function} shouldIgnoreDomElementId If provided, this function will be called to determine if DOM element's
         * id can be used as part of the queries, created by recorder. Its quite common for various frameworks, to assign
         * auto-generated ids to DOM elements. Such ids usually changes very often and should not be used in the queries.
         *
         * The function should return `true` if element's id should not be used and will be called with the following arguments:
         *
         * @cfg {String} shouldIgnoreDomElementId.id The id of the DOM element to check
         * @cfg {HTMLElement} shouldIgnoreDomElementId.el The DOM element itself
         */
        shouldIgnoreDomElementId    : null,

        // logic for offset:
        // we always record it, except for the "click" where element has not changed during the click,
        // and it was available at center point during "mousedown" - such clicks  are considered "simple" and don't require offset
        // in the opposite, individually recorded "mousedown" or "mouseup" events are usually part of the drag operation
        // and for "drag" we want to be precise

        /**
         * @cfg {Boolean} recordOffsets Set to `true` to record the offset to each targeted DOM element for recorded
         * actions, to make sure the recorded action can be played back with exact precision.
         * Normally Siesta removes the offset of some actions, if target element is reachable at the center point.
         */
        recordOffsets       : false,

        // ignore events generated by Siesta (bypass in normal use, but for testing recorder we need it)
        ignoreSynthetic     : true,

        // The window this recorder is observing for events
        window              : null,

        // merge `mousedown+mouseup+click` to just `click` only if timespan between `mousedown` and `mouseup`
        // is less than this value (ms)
        clickMergeThreshold : 200,

        // console.logs all DOM events detected
        debugMode           : false,

        eventsToRecord      : { lazy : 'this.buildEventsToRecord' },

        // "raw" log of all dom events
        events              : Joose.I.Array,

        // Strictly for debugging purposes
        processingEvent       : null,

        actions             : Joose.I.Array,
        actionsByEventId    : Joose.I.Object,

        dragPixelThreshold  : 3, // If mousedown/mouseup position differs by less, we consider it a click

        actionClass         : Siesta.Recorder.Action,

        warnAboutIframeMissingId : true
    },


    methods : {

        buildEventsToRecord : function () {
            var events  = [
                "keydown",
                "keypress",
                "keyup",

                "click",
                "dblclick",
                "contextmenu"
            ]

            if (window.PointerEvent)
                events.push(
                    'pointerdown',
                    'pointerup'
                )
            else if (window.MSPointerEvent)
                events.push(
                    'MSPointerDown',
                    'MSPointerUp'
                )
            else
                events.push(
                    'mousedown',
                    'mouseup'
                )

            return events
        },


        initialize : function () {
            this.onUnload                           = this.onUnload.bind(this);
            this.onFrameLoad                        = this.onFrameLoad.bind(this);

            this.onDomEvent                         = this.safeBind(this.onDomEvent);

            var extractorConfig                     = this.extractorConfig || {}

            // used as bubble target for `exception` event
            extractorConfig.recorder                = this

            extractorConfig.uniqueComponentProperty = extractorConfig.uniqueComponentProperty || this.uniqueComponentProperty;
            extractorConfig.uniqueDomNodeProperty   = extractorConfig.uniqueDomNodeProperty || this.uniqueDomNodeProperty;
            extractorConfig.swallowExceptions       = this.swallowExceptions;

            extractorConfig.shouldIgnoreDomElementId = this.shouldIgnoreDomElementId

            this.extractor                          = new this.extractorClass(extractorConfig);
        },


        isSamePoint : function (event1, event2) {
            return Math.abs(Math.round(event1.x) - Math.round(event2.x)) <= this.dragPixelThreshold &&
                Math.abs(Math.round(event1.y) - Math.round(event2.y)) <= this.dragPixelThreshold;
        },


        isSameTarget : function (event1, event2) {
            return event1.target == event2.target || this.contains(event1.target, event2.target) || this.contains(event2.target, event1.target);
        },


        clear          : function () {
            var me              = this

            me.events           = []
            me.actions          = []
            me.actionsByEventId = {}

            me.fireEvent('clear', me)
        },


        // We monitor page loads so the recorder can add a waitForPageLoad action
        onUnload : function () {
            var actions     = this.actions,
                last        = actions.length && actions[ actions.length - 1 ];

            if (last && last.target) {
                last.waitForPageLoad = true;
            }

            this.stop(true);
        },

        // After frame has loaded, stop listening to old window and restart on new frame window
        onFrameLoad    : function (event) {
            // Frame could be violating same-origin policy at this point
            try {
                var win     = event.target.contentWindow;

                // will throw if different origin
                var a       = win.location.href;
            } catch (e) {
                win         = null;
            }

            if (win) {
                this.attach(win);

                this.start();
            }
        },

        /*
         * Attaches the recorder to a Window object
         * @param {Window} window The window to attach to.
         **/
        attach         : function (window) {
            if (this.window !== window) {
                this.stop()
            }

            // clear only events, keep the actions
            this.events = []

            this.window = window;
        },

        /*
         * Starts recording events of the current Window object
         **/
        start          : function () {
            if (this.active) return

            if (!this.ignoreSynthetic && !this.hasOwnProperty(('clickMergeThreshold'))) this.clickMergeThreshold = Infinity

            this.stop();

            this.active         = Date.now();

            this.onStart();
            this.fireEvent('start', this);
        },

        /*
         * Stops the recording of events
         **/
        stop           : function (keepOnLoadListenerOnIframe) {
            if (this.active) {
                this.active     = null;

                this.onStop(keepOnLoadListenerOnIframe);
                this.fireEvent('stop', this);
            }
        },


        getRecordedEvents : function () {
            return this.events;
        },


        getRecordedActions : function () {
            return this.actions
        },


        getRecordedActionsAsSteps : function () {
            return Joose.A.map(this.actions, function (action) {
                return action.asStep()
            })
        },

        // main listener
        onDomEvent : function (e) {
            var target          = e.target

            this.processingEvent = e;

            if (this.debugMode && this.window.console && typeof this.window.console.log === 'function') {
                console.log('[EVENT] : ', e.type, target, e.keyIdentifier || e.key);
            }

            // Never trust IE - target may be absent
            // Ignore events from played back test (if user plays test and records before it's stopped)
            if (!target || (this.ignoreSynthetic && (e.synthetic || !e.isTrusted))) return;

            var event           = Siesta.Recorder.Event.fromDomEvent(e)

            this.convertToAction(event)

            this.events.push(event)

            // do not store more than 10 events
            if (this.events.length > 10) this.events.shift()

            this.fireEvent('domevent', event)
        },


        eventToAction : function (event, onlyXY) {
            var type        = event.type

            var actionName

            if (type.match(/^key/))
                // convert all key events to type for now
                actionName  = 'type'
            else
                if (this.isPointerDownEvent(event))
                    actionName  = 'mousedown'
                else if (this.isPointerUpEvent(event))
                    actionName  = 'mouseup'
                else
                    actionName  = type

            var config      = {
                action          : actionName,

                target          : this.getPossibleTargets(event, true, null, onlyXY),

                options         : event.options,

                sourceEvent                         : event,
                sourceEventTargetReachableAtCenter  : this.isElementReachableAtCenter(event.target, false)
            }

            // `window` object to which the event target belongs
            var win             = event.target.ownerDocument.defaultView;

            // Case of nested iframe
            if (win !== this.window && !onlyXY) {

                if (!win.frameElement.id && this.warnAboutIframeMissingId) {
                    throw new Error('To record events in a nested iframe, please set an "id" property on your frames');
                }

                // Prepend the frame id to each suggested target
                config.target = config.target.filter(function (actionTarget) {
                    if (typeof actionTarget.target === 'string') {
                        actionTarget.target = '#' + win.frameElement.id + ' -> ' + actionTarget.target;

                        return true;
                    }
                    // Skip array coordinates for nested iframes, make little sense
                    return false;
                });
            }

            return new this.actionClass(config)
        },


        recordAsAction : function (event, omitTarget) {
            var action      = this.eventToAction(event, omitTarget)

            if (action) {
                this.addAction(action)

                return action;
            }
        },


        addAction : function (action) {
            if (!(action instanceof this.actionClass)) {
                action = new this.actionClass(action);
            }

            this.beforeAddAction(action);

            this.actions.push(action)

            if (action.sourceEvent) this.actionsByEventId[ action.sourceEvent.id ] = action

            if (this.debugMode && this.window.console && typeof this.window.console.log === 'function') {
                console.log('[ACTION] : ' + action.action, action.getTarget() && action.asStep().target || '', action);
            }

            this.fireEvent('actionadd', action)
        },


        removeAction : function (actionToRemove) {
            var actions     = this.actions;

            for (var i = 0; i < actions.length; i++) {
                var action  = actions[ i ]

                if (action == actionToRemove) {
                    actions.splice(i, 1)

                    if (action.sourceEvent) delete this.actionsByEventId[ action.sourceEvent.id ]

                    this.fireEvent('actionremove', actionToRemove)

                    break;
                }
            }
        },


        removeActionByEventId : function (eventId) {
            this.removeAction(this.getActionByEventId(eventId))
        },


        removeActionByEvent : function (event) {
            this.removeAction(this.getActionByEventId(event.id))
        },


        getActionByEvent : function (event) {
            return this.actionsByEventId[ event.id ]
        },


        getActionByEventId : function (eventId) {
            return this.actionsByEventId[ eventId ]
        },


        getLastAction : function () {
            return this.actions[ this.actions.length - 1 ]
        },


        getLastEvent : function () {
            return this.events[ this.events.length - 1 ]
        },


        canCombineTypeActions : function (prevOptions, curOptions) {
            return prevOptions.ctrlKey  == curOptions.ctrlKey &&
                prevOptions.metaKey     == curOptions.metaKey &&
                prevOptions.shiftKey    == curOptions.shiftKey &&
                prevOptions.altKey      == curOptions.altKey;
        },


        finalizeDragAction : function (mouseUpEvent, mouseDownEvent) {
            // omit the target queries finding for `mouseup` event, since we'll add coordinate target
            // manually anyway
            var action      = this.eventToAction(mouseUpEvent, true);

            // For drag drop operations, we use a simple coordinate for mouseup always
            action.target   = new Siesta.Recorder.Target({
                targets : [{
                    target : [ mouseUpEvent.x, mouseUpEvent.y ],
                    type   : 'xy'
                }]
            })

            this.addAction(action)
        },


        // Method which tries to identify "composite" DOM interactions such as 'click/contextmenu' (3 events), double click
        // but also complex scenarios such as 'drag'
        convertToAction : function (event) {
            var type        = event.type

            var events      = this.getRecordedEvents(),
                length      = events.length,
                tail        = this.getLastEvent();

            var tailPrev    = length >= 2 ? events[ length - 2 ] : null;

            if (this.shouldIgnoreEvent(event, tail, tailPrev)) {
                return;
            }

            if (type == 'keypress' || type == 'keyup' || type == 'keydown') {
                this.convertKeyEventToAction(event);
                return
            }

            // if there's no already recorded events - there's nothing to coalesce
            if (!length) {
                this.recordAsAction(event)

                return
            }

            if (type == 'dblclick') {
                // On Android Mobile Safari it seems 'dblclick' can be fired without 2 preceding clicks somehow
                if (this.getRecordedActions().length > 1) {
                    // removing the last `click` action - one click event will still remain
                    this.removeAction(this.getLastAction())
                }

                this.getLastAction().action = 'dblclick'

                this.fireEvent('actionupdate', this.getLastAction())

                return
            }

            // // if mousedown/up happened in a row in different points - this is considered to be a drag operation
            if (
                this.isPointerDownEvent(tail) && this.isPointerUpEvent(event)
                && event.button == tail.button && !this.isSamePoint(event, tail)
            ) {
                this.finalizeDragAction(event, tail)

                return
            }

            if (
                tail && this.isPointerUpEvent(event) && this.isPointerDownEvent(tail)
                && event.button == tail.button
                && this.isSamePoint(event, tail)
                // FF57 does not fire `click` when target changes in mousedown, so we need the `mouseup` target and can not
                // optimize
                // possibly will be fixed in later FF releases since its contradicts with Chrome
                && !bowser.gecko
            ) {
                // record `mouseup` which happened in the same point as `mousedown`
                // w/o target - since it will be removed by the "click" processing anyway
                // this is to save some CPU cycles (which can be up to ~200-300ms in heavy DOM)
                this.recordAsAction(event, true)
                return
            }

            var isFF57BrokenOnWindows     = bowser.gecko && bowser.windows

            // Merge events to click action
            if (tailPrev && type === 'click') {
                if (
                    // Verify tail
                    this.isPointerUpEvent(tail) &&
                    event.button == tail.button &&
                    event.button == tailPrev.button &&
                    this.isSamePoint(event, tail) &&

                    // Verify previous tail
                    this.isPointerDownEvent(tailPrev) &&
                    this.isSameTarget(event, tail) &&
                    this.isSameTarget(event, tailPrev) &&
                    this.isSamePoint(event, tailPrev)
                ) {
                    // Convert mousedown action to a click action, and use all context recorded at mousedown since DOM may have changed
                    // at the time 'click' was observed
                    var mouseDownAction = this.getActionByEvent(tailPrev)

                    // Don't merge to 'click' if there is a noticable delay between mousedown/mouseup timestamp
                    // Application could have logic implemented for longpress etc
                    if (Date.now() - mouseDownAction.getTimestamp() > this.clickMergeThreshold) {

                    } else {
                        this.removeActionByEvent(tail)

                        if (
                            event.target == tail.target && tail.target == tailPrev.target &&
                            !this.recordOffsets && mouseDownAction.sourceEventTargetReachableAtCenter
                        ) {
                            // do not completely remove the offset for FF57 on Windows but save it,
                            // since it may be needed for "contextmenu" action, see below
                            mouseDownAction.clearTargetOffset(isFF57BrokenOnWindows)
                        }

                        mouseDownAction.action = 'click';

                        this.fireEvent('actionupdate', mouseDownAction);
                    }

                    return;
                } else {
                    // click event is fired after a drag, which should not be recorded as a separate click
                    return;
                }
            } else if (type === 'contextmenu') {
                var lastAction      = this.getLastAction()

                // FF 57 on Windows fires mousedown/up/click/contextmenu for right click (crazy)
                if (isFF57BrokenOnWindows && tail.type == 'click' && lastAction.action == 'click') {
                    lastAction.action   = 'contextmenu'

                    lastAction.restoreTargetOffset()

                    this.fireEvent('actionupdate', lastAction)

                    return
                }

                // Verify tail (Mac OSX doesn't fire mouse up)
                if (
                    (this.isPointerUpEvent(tail) || this.isPointerDownEvent(tail))
                    && event.button == tail.button && this.isSamePoint(event, tail)
                ) {
                    this.removeActionByEvent(tail)
                }

                // Verify previous tail
                if (this.isPointerUpEvent(tail) && this.isPointerDownEvent(tailPrev) &&
                    this.isSameTarget(event, tail) &&
                    this.isSameTarget(event, tailPrev) &&
                    this.isSamePoint(event, tailPrev)
                ) {
                    this.removeActionByEvent(tailPrev)
                }
            }

            this.recordAsAction(event)
        },


        shouldIgnoreEvent : function (event, tail, tailPrev) {
            // In some situations the mouseup event may remove/overwrite the current element and no click will be triggered
            // so we need to catch drag operation on mouseup (see above) and ignore following "click" event

            // TODO NEEDED?
            // if (event.type === 'click' && this.getLastAction() && this.getLastAction().action === 'drag') {
            //     return true
            // }

            var eventType       = event.type
            var isKeyEvent      = eventType.match(/^key/)

            var keyCode         = event.keyCode
            var keys            = Siesta.Test.UserAgent.KeyCodes().keys

            // Ignore modifier keys which are used only in combination with other keys
            if (isKeyEvent && (keyCode === keys.SHIFT || keyCode === keys.CTRL || keyCode === keys.ALT || keyCode === keys.CMD)) return true;

            // On Mac ignore mouseup happening after contextmenu
            if (this.isPointerUpEvent(event) && tail && tail.type === 'contextmenu') {
                return true
            }

            // ignore keypress for CTRL+KEY
            if (
                event.type == 'keypress' && tail && tail.type == 'keydown' && tailPrev && tailPrev.type == 'keydown'
                && event.rawEvent.key == tail.rawEvent.key && tailPrev.rawEvent.key == 'Control'
                && (event.rawEvent.timeStamp - tail.rawEvent.timeStamp) < 3
            ) {
                return true
            }


            // Clicks on a <label> with produces 2 "click" events, just ignore the 2nd event and do not record it as an action
            // in FF, the 2nd "click" will have 0, 0 coordinates, so we have to disable `isSamePoint` extra sanity check
            if (event.type == 'click' && tail && tail.type == 'click' && tail.target.nodeName.toLowerCase() === 'label') {
                return true
            }
        },


        convertKeyEventToAction : function (event) {
            var type            = event.type
            var tail            = this.getLastEvent();

            var KC              = Siesta.Test.UserAgent.KeyCodes();

            var isSpecial       = type == 'keydown' && (KC.isSpecial(event.keyCode) || KC.isNav(event.keyCode));
            var isModifier      = KC.isModifier(event.keyCode);

            var options         = event.options;

            var prevType        = tail && tail.type;
            var prevSpecial     = type == 'keypress' && prevType == 'keydown' && (KC.isSpecial(tail.keyCode) || KC.isNav(tail.keyCode));

            var isWindows       = this.parseOS(navigator.platform) === 'Windows';
            var isMac           = this.parseOS(navigator.platform) === 'MacOS';

            // On Windows and Linux, no keypress is triggered if CTRL key is pressed along with a regular char (e.g Ctrl-C).
            // On Mac, no keypress is triggered if CMD key is pressed along with a regular char (e.g Cmd-C).
            if (type == 'keypress' && !isSpecial && !prevSpecial || (type == 'keydown' && (isSpecial || isModifier || (!isMac && options.ctrlKey) || (isMac && options.metaKey)))) {
                var lastAction      = this.getLastAction()
                var text            = event.rawEvent.key ? event.rawEvent.key : (isSpecial ? KC.fromCharCode(event.charCode, true) : String.fromCharCode(event.charCode));

                text                = isSpecial ? '[' + text.toUpperCase() + ']' : text;

                // Crude check to make sure we don't merge a CTRL-C with the next "normal" keystroke
                if (lastAction && lastAction.action === 'type' && this.canCombineTypeActions(lastAction.options, event.options)) {
                    if (!KC.isModifier(event.keyCode)) {
                        lastAction.value += text

                        this.fireEvent('actionupdate', lastAction)
                    }
                } else {
                    this.addAction({
                        action          : 'type',

                        target          : this.getPossibleTargets(event),

                        value           : text,

                        sourceEvent     : event,
                        options         : event.options
                    })
                }

                return
            }

            // ignore 'keydown' events
        },


        onStart : function () {
            var me              = this,
                window          = me.window,
                doc             = window.document,
                body            = doc.body,
                resizeTimeout   = null,
                frameWindows    = this.getNestedFrames().map(function(frame) { return frame.contentWindow; });

            // Listen to test window and any frames nested in it
            [ window ].concat(frameWindows).forEach(function (win) {
                me.registerWindowListeners(win);
            })

            window.frameElement && window.frameElement.addEventListener('load', this.onFrameLoad);
            window.addEventListener('unload', this.onUnload);

            // To make sure we can record key events immediately
            window.focus();
        },


        onStop : function (keepOnLoadListenerOnIframe) {
            var me              = this,
                window          = me.window,
                doc             = window.document,
                body            = doc.body,
                frameWindows    = this.getNestedFrames().map(function (frame) { return frame.contentWindow; });

            // Unlisten to test window and any frames nested in it
            [ window ].concat(frameWindows).forEach(function (win) {
                me.deregisterWindowListeners(win);
            })

            // if recorder is attached to a iframed window, in the "unload" event, we want to cleanup the listeners
            // but keep the 'load' listener for the <iframe> element itself, to re-attach recorder to newly loaded page
            if (!keepOnLoadListenerOnIframe) {
                window.frameElement && window.frameElement.removeEventListener('load', me.onFrameLoad);
            }
            window.removeEventListener('unload', this.onUnload);
        },


        registerWindowListeners : function (win) {
            if (this.isCrossOriginWindow(win)) return

            var me      = this;

            me.getEventsToRecord().forEach(function (name) {
                win.document.addEventListener(name, me.onDomEvent, true);
            });
        },


        deregisterWindowListeners : function (win) {
            if (this.isCrossOriginWindow(win)) return

            var me      = this;

            me.getEventsToRecord().forEach(function (name) {
                win.document.removeEventListener(name, me.onDomEvent, true);
            });
        },

        // Returns only frames on the same domain
        getNestedFrames : function() {
            var me              = this

            return Array.prototype.slice.apply(this.window.document.getElementsByTagName('iframe')).filter(function(frame) {
                return !me.isCrossOriginWindow(frame.contentWindow)
            });
        },

        // Hook called before adding actions to inject 'helping' actions
        beforeAddAction : function (action) {
        },


        getPossibleTargets : function (event, recordOffsets, targetOverride, onlyXY) {
            if (this.typeOf(event.target) == 'HTMLDocument') event.target = event.target.body

            return this.extractor.getTargets(event, recordOffsets, targetOverride, onlyXY);
        }
    }
    // eof methods
});