    /*

    Siesta 5.3.0
    Copyright(c) 2009-2019 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,

            /**
             * @cfg {Array} ignoreCssClasses An array of CSS class strings, which should be ignored by the recorder as it builds CSS selector locators. Use
             * this to avoid certain CSS selectors which don't provide any useful distinction about a DOM node
             */
            ignoreCssClasses            : Joose.I.Array,

            // 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;
                extractorConfig.ignoreCssClasses = this.ignoreCssClasses;

                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
    });