/**
 * @fileoverview Provides an ability to collect data about activities the user
 * has performed on the page.
 */

import {
    hasOwn
} from '#core/types/object';

import {
    Services
} from '#service';

import {
    listen
} from '#utils/event-helper';

import {
    registerServiceBuilderForDoc
} from '../../../src/service-helpers';

/**
 * The amount of time after an activity the user is considered engaged.
 * @private @const {number}
 */
const DEFAULT_ENGAGED_SECONDS = 5;

/**
 * @enum {string}
 */
const ActivityEventType = {
    ACTIVE: 'active',
    INACTIVE: 'inactive',
};

/**
 * @typedef {{
 *   type: string,
 *   time: number
 * }}
 */
let ActivityEventDef;

/**
 * Find the engaged time between the event and the time (exclusive of the time)
 * @param {ActivityEventDef} activityEvent
 * @param {number} time
 * @return {number}
 * @private
 */
function findEngagedTimeBetween(activityEvent, time) {
    let engagementBonus = 0;

    if (activityEvent.type === ActivityEventType.ACTIVE) {
        engagementBonus = DEFAULT_ENGAGED_SECONDS;
    }

    return Math.min(time - activityEvent.time, engagementBonus);
}

class ActivityHistory {
    /**
     * Creates an instance of ActivityHistory.
     */
    constructor() {
        /** @private {number} */
        this.totalEngagedTime_ = 0;

        /**
         * prevActivityEvent_ remains undefined until the first valid push call.
         * @private {ActivityEventDef|undefined}
         */
        this.prevActivityEvent_ = undefined;
    }

    /**
     * Indicate that an activity took place at the given time.
     * @param {ActivityEventDef} activityEvent
     */
    push(activityEvent) {
        if (
            this.prevActivityEvent_ &&
            this.prevActivityEvent_.time < activityEvent.time
        ) {
            this.totalEngagedTime_ += findEngagedTimeBetween(
                this.prevActivityEvent_,
                activityEvent.time
            );
        }
        this.prevActivityEvent_ = activityEvent;
    }

    /**
     * Get the total engaged time up to the given time recorded in
     * ActivityHistory.
     * @param {number} time
     * @return {number}
     */
    getTotalEngagedTime(time) {
        let totalEngagedTime = 0;
        if (this.prevActivityEvent_ !== undefined) {
            totalEngagedTime =
                this.totalEngagedTime_ +
                findEngagedTimeBetween(this.prevActivityEvent_, time);
        }
        return totalEngagedTime;
    }
}

/**
 * Array of event types which will be listened for on the document to indicate
 * activity. Other activities are also observed on the AmpDoc and Viewport
 * objects. See {@link setUpActivityListeners_} for listener implementation.
 * @private @const {Array<string>}
 */
const ACTIVE_EVENT_TYPES = [
    'mousedown',
    'mouseup',
    'mousemove',
    'keydown',
    'keyup',
];
/**
 * Array of event types which will be listened for on the document to indicate
 * leave from document. Other activities are also observed on the AmpDoc and Viewport
 * objects. See {@link setUpActivityListeners_} for listener implementation.
 * @private @const {Array<string>}
 */
const INACTIVE_EVENT_TYPES = ['mouseleave'];

/**
 * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampDoc
 */
export function installActivityServiceForTesting(ampDoc) {
    registerServiceBuilderForDoc(ampDoc, 'activity', Activity);
}

export class Activity {
    /**
     * Activity tracks basic user activity on the page.
     *  - Listeners are not registered on the activity event types until the
     *    AmpDoc's `whenFirstVisible` is resolved.
     *  - When the `whenFirstVisible` of AmpDoc is resolved, a first activity
     *    is recorded.
     *  - The first activity in any second causes all other activities to be
     *    ignored. This is similar to debounce functionality since some events
     *    (e.g. scroll) could occur in rapid succession.
     *  - In any one second period, active events or inactive events can override
     *    each other. Whichever type occured last has precedence.
     *  - Active events give a 5 second "bonus" to engaged time.
     *  - Inactive events cause an immediate stop to the engaged time bonus of
     *    any previous activity event.
     *  - At any point after instantiation, `getTotalEngagedTime` can be used
     *    to get the engage time up to the time the function is called. If
     *    `whenFirstVisible` has not yet resolved, engaged time is 0.
     * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc
     */
    constructor(ampdoc) {
        /** @const {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc */
        this.ampdoc = ampdoc;

        /** @private @const {function()} */
        this.boundStopIgnore_ = this.stopIgnore_.bind(this);

        /** @private @const {function()} */
        this.boundHandleActivity_ = this.handleActivity_.bind(this);

        /** @private @const {function()} */
        this.boundHandleInactive_ = this.handleInactive_.bind(this);

        /** @private @const {function()} */
        this.boundHandleVisibilityChange_ = this.handleVisibilityChange_.bind(this);

        /**
         * Contains the incrementalEngagedTime timestamps for named triggers.
         * @private {Object<string, number>}
         */
        this.totalEngagedTimeByTrigger_ = {
            /*
             * "$triggerName" : ${lastRequestTimestamp}
             */
        };

        /** @private {Array<!UnlistenDef>} */
        this.unlistenFuncs_ = [];

        /** @private {boolean} */
        this.ignoreActivity_ = false;

        /** @private {boolean} */
        this.ignoreInactive_ = false;

        /** @private @const {!ActivityHistory} */
        this.activityHistory_ = new ActivityHistory();

        /** @private @const {!../../../src/service/viewport/viewport-interface.ViewportInterface} */
        this.viewport_ = Services.viewportForDoc(this.ampdoc);

        this.ampdoc.whenFirstVisible().then(this.start_.bind(this));
    }

    /** @private */
    start_() {
        /** @private @const {number} */
        this.startTime_ = Date.now();
        // record an activity since this is when the page became visible
        this.handleActivity_();
        this.setUpActivityListeners_();
    }

    /**
     * @private
     * @return {number}
     */
    getTimeSinceStart_() {
        const timeSinceStart = Date.now() - this.startTime_;
        // Ensure that a negative time is never returned. This may cause loss of
        // data if there is a time change during the session but it will decrease
        // the likelyhood of errors in that situation.
        return timeSinceStart > 0 ? timeSinceStart : 0;
    }

    /**
     * Return to a state where neither activities or inactivity events are
     * ignored when that event type is fired.
     * @private
     */
    stopIgnore_() {
        this.ignoreActivity_ = false;
        this.ignoreInactive_ = false;
    }

    /** @private */
    setUpActivityListeners_() {
        this.setUpListenersFromArray_(
            this.ampdoc.getRootNode(),
            ACTIVE_EVENT_TYPES,
            this.boundHandleActivity_
        );

        this.setUpListenersFromArray_(
            this.ampdoc.getRootNode(),
            INACTIVE_EVENT_TYPES,
            this.boundHandleInactive_
        );

        this.unlistenFuncs_.push(
            this.ampdoc.onVisibilityChanged(this.boundHandleVisibilityChange_)
        );

        // Viewport.onScroll does not return an unlisten function.
        // TODO(britice): If Viewport is updated to return an unlisten function,
        // update this to capture the unlisten function.
        this.viewport_.onScroll(this.boundHandleActivity_);
    }

    /**
     *  @private
     *  @param {!EventTarget} target
     *  @param {Array<string>} events
     *  @param {function()} listener
     */
    setUpListenersFromArray_(target, events, listener) {
        for (let i = 0; i < events.length; i++) {
            this.unlistenFuncs_.push(listen(target, events[i], listener));
        }
    }

    /** @private */
    handleActivity_() {
        if (this.ignoreActivity_) {
            return;
        }
        this.ignoreActivity_ = true;
        this.ignoreInactive_ = false;

        this.handleActivityEvent_(ActivityEventType.ACTIVE);
    }

    /** @private */
    handleInactive_() {
        if (this.ignoreInactive_) {
            return;
        }
        this.ignoreInactive_ = true;
        this.ignoreActivity_ = false;

        this.handleActivityEvent_(ActivityEventType.INACTIVE);
    }

    /**
     * @param {ActivityEventType} type
     * @private
     */
    handleActivityEvent_(type) {
        const timeSinceStart = this.getTimeSinceStart_();
        const secondKey = Math.floor(timeSinceStart / 1000);
        const timeToWait = 1000 - (timeSinceStart % 1000);

        // stop ignoring activity at the start of the next activity bucket
        setTimeout(this.boundStopIgnore_, timeToWait);

        this.activityHistory_.push({
            type,
            time: secondKey,
        });
    }

    /** @private */
    handleVisibilityChange_() {
        if (this.ampdoc.isVisible()) {
            this.handleActivity_();
        } else {
            this.handleInactive_();
        }
    }

    /**
     * Remove all listeners associated with this Activity instance.
     * @private
     */
    unlisten_() {
        for (let i = 0; i < this.unlistenFuncs_.length; i++) {
            const unlistenFunc = this.unlistenFuncs_[i];
            // TODO(britice): Due to eslint typechecking, this check may not be
            // necessary.
            if (typeof unlistenFunc === 'function') {
                unlistenFunc();
            }
        }
        this.unlistenFuncs_ = [];
    }

    /**
     * @private
     * @visibleForTesting
     */
    cleanup_() {
        this.unlisten_();
    }

    /**
     * Get total engaged time since the page became visible.
     * @return {number}
     */
    getTotalEngagedTime() {
        const secondsSinceStart = Math.floor(this.getTimeSinceStart_() / 1000);
        return this.activityHistory_.getTotalEngagedTime(secondsSinceStart);
    }

    /**
     * Get the incremental engaged time since the last push and reset it if asked.
     * @param {string} name
     * @param {boolean=} reset
     * @return {number}
     */
    getIncrementalEngagedTime(name, reset = true) {
        if (!hasOwn(this.totalEngagedTimeByTrigger_, name)) {
            if (reset) {
                this.totalEngagedTimeByTrigger_[name] = this.getTotalEngagedTime();
            }
            return this.getTotalEngagedTime();
        }
        const currentIncrementalEngagedTime = this.totalEngagedTimeByTrigger_[name];
        if (reset === false) {
            return this.getTotalEngagedTime() - currentIncrementalEngagedTime;
        }
        this.totalEngagedTimeByTrigger_[name] = this.getTotalEngagedTime();
        return (
            this.totalEngagedTimeByTrigger_[name] - currentIncrementalEngagedTime
        );
    }
}