import {
    internalListenImplementation
} from '#core/dom/event-helper-listen';
import {
    lastChildElement
} from '#core/dom/query';
import * as mode from '#core/mode';

import {
    user
} from '#utils/log';

/** @const {string}  */
const LOAD_FAILURE_PREFIX = 'Failed to load:';

/** @const {string} */
export const MEDIA_LOAD_FAILURE_SRC_PROPERTY = '__AMP_MEDIA_LOAD_FAILURE_SRC';

/**
 * Returns a CustomEvent with a given type and detail; supports fallback for IE.
 * @param {!Window} win
 * @param {string} type
 * @param {!JsonObject|string|undefined|null} detail
 * @param {EventInit=} opt_eventInit
 * @return {!Event}
 */
export function createCustomEvent(win, type, detail, opt_eventInit) {
    const eventInit = /** @type {!CustomEventInit} */ ({
        detail
    });
    Object.assign(eventInit, opt_eventInit);
    // win.CustomEvent is a function on Edge, Chrome, FF, Safari but
    // is an object on IE 11.
    if (mode.isEsm() || typeof win.CustomEvent == 'function') {
        return new win.CustomEvent(type, eventInit);
    } else {
        // Deprecated fallback for IE.
        const e = win.document.createEvent('CustomEvent');
        e.initCustomEvent(
            type, !!eventInit.bubbles, !!eventInit.cancelable,
            detail
        );
        return e;
    }
}

/**
 * Listens for the specified event on the element.
 * @param {!EventTarget} element
 * @param {string} eventType
 * @param {function(!Event)} listener
 * @param {Object=} opt_evtListenerOpts
 * @return {!UnlistenDef}
 */
export function listen(element, eventType, listener, opt_evtListenerOpts) {
    return internalListenImplementation(
        element,
        eventType,
        listener,
        opt_evtListenerOpts
    );
}

/**
 * Returns the data property of an event with the correct type.
 * @param {!Event|{data: !JsonObject}} event
 * @return {?JsonObject|string|undefined}
 */
export function getData(event) {
    return /** @type {?JsonObject|string|undefined} */ (event.data);
}

/**
 * Returns the detail property of an event with the correct type.
 * @param {!Event|{detail: !JsonObject}} event
 * @return {?JsonObject|string|undefined}
 */
export function getDetail(event) {
    return /** @type {?JsonObject|string|undefined} */ (event.detail);
}

/**
 * Listens for the specified event on the element and removes the listener
 * as soon as event has been received.
 * @param {!EventTarget} element
 * @param {string} eventType
 * @param {function(!Event)} listener
 * @param {Object=} opt_evtListenerOpts
 * @return {!UnlistenDef}
 */
export function listenOnce(element, eventType, listener, opt_evtListenerOpts) {
    let localListener = listener;
    const unlisten = internalListenImplementation(
        element,
        eventType,
        (event) => {
            try {
                localListener(event);
            } finally {
                // Ensure listener is GC'd
                localListener = null;
                unlisten();
            }
        },
        opt_evtListenerOpts
    );
    return unlisten;
}

/**
 * Returns  a promise that will resolve as soon as the specified event has
 * fired on the element.
 * @param {!EventTarget} element
 * @param {string} eventType
 * @param {Object=} opt_evtListenerOpts
 * @param {function(!UnlistenDef)=} opt_cancel An optional function that, when
 *     provided, will be called with the unlistener. This gives the caller
 *     access to the unlistener, so it may be called manually when necessary.
 * @return {!Promise<!Event>}
 */
export function listenOncePromise(
    element,
    eventType,
    opt_evtListenerOpts,
    opt_cancel
) {
    let unlisten;
    const eventPromise = new Promise((resolve) => {
        unlisten = listenOnce(element, eventType, resolve, opt_evtListenerOpts);
    });
    eventPromise.then(unlisten, unlisten);
    if (opt_cancel) {
        opt_cancel(unlisten);
    }
    return eventPromise;
}

/**
 * Whether the specified element/window has been loaded already.
 * @param {!Element|!Window} eleOrWindow
 * @return {boolean}
 */
export function isLoaded(eleOrWindow) {
    return !!(
        eleOrWindow.complete ||
        eleOrWindow.readyState == 'complete' ||
        (isHTMLMediaElement(eleOrWindow) && eleOrWindow.readyState > 0) ||
        // If the passed in thing is a Window, infer loaded state from
        //
        (eleOrWindow.document && eleOrWindow.document.readyState == 'complete')
    );
}

/**
 * Returns a promise that will resolve or fail based on the eleOrWindow's 'load'
 * and 'error' events. Optionally this method takes a timeout, which will reject
 * the promise if the resource has not loaded by then.
 * @param {T} eleOrWindow Supports both Elements and as a special case Windows.
 * @return {!Promise<T>}
 * @template T
 */
export function loadPromise(eleOrWindow) {
    let unlistenLoad;
    let unlistenError;
    if (isLoaded(eleOrWindow)) {
        return Promise.resolve(eleOrWindow);
    }
    const isMediaElement = isHTMLMediaElement(eleOrWindow);
    if (
        isMediaElement &&
        eleOrWindow[MEDIA_LOAD_FAILURE_SRC_PROPERTY] === eleOrWindow.currentSrc
    ) {
        return Promise.reject(eleOrWindow);
    }
    const loadingPromise = new Promise((resolve, reject) => {
        // Listen once since IE 5/6/7 fire the onload event continuously for
        // animated GIFs.
        if (isMediaElement) {
            // The following event can be triggered by the media or one of its
            // sources. Using capture is required as the media events do not bubble.
            unlistenLoad = listenOnce(eleOrWindow, 'loadedmetadata', resolve, {
                capture: true,
            });
        } else {
            unlistenLoad = listenOnce(eleOrWindow, 'load', resolve);
        }
        // Don't unlisten on error for Windows.
        if (!eleOrWindow.tagName) {
            return;
        }
        let errorTarget = eleOrWindow;
        // If the media element has no `src`, it will try to load the sources in
        // document order. If the last source errors, then the media element
        // loading errored.
        if (isMediaElement && !eleOrWindow.hasAttribute('src')) {
            errorTarget = lastChildElement(
                eleOrWindow,
                (child) => child.tagName === 'SOURCE'
            );
            if (!errorTarget) {
                return reject(new Error('Media has no source.'));
            }
        }
        unlistenError = listenOnce(errorTarget, 'error', reject);
    });

    return loadingPromise.then(
        () => {
            if (unlistenError) {
                unlistenError();
            }
            return eleOrWindow;
        },
        () => {
            if (unlistenLoad) {
                unlistenLoad();
            }
            failedToLoad(eleOrWindow);
        }
    );
}

/**
 * Emit error on load failure.
 * @param {!Element|!Window} eleOrWindow Supports both Elements and as a special
 *     case Windows.
 */
function failedToLoad(eleOrWindow) {
    // Mark the element as errored since some elements - like HTMLMediaElement
    // using HTMLSourceElement - do not provide any synchronous way to verify if
    // they already errored, even though the error event was already dispatched.
    if (isHTMLMediaElement(eleOrWindow)) {
        eleOrWindow[MEDIA_LOAD_FAILURE_SRC_PROPERTY] =
            eleOrWindow.currentSrc || true;
    }

    // Report failed loads as user errors so that they automatically go
    // into the "document error" bucket.
    let target = eleOrWindow;
    if (target && target.src) {
        target = target.src;
    }
    throw user().createError(LOAD_FAILURE_PREFIX, target);
}

/**
 * Returns true if the parameter is a HTMLMediaElement.
 * @param {!Element|!Window} eleOrWindow
 * @return {boolean}
 */
function isHTMLMediaElement(eleOrWindow) {
    return eleOrWindow.tagName === 'AUDIO' || eleOrWindow.tagName === 'VIDEO';
}

/**
 * Returns true if this error message is was created for a load error.
 * @param {string} message An error message
 * @return {boolean}
 */
export function isLoadErrorMessage(message) {
    return message.indexOf(LOAD_FAILURE_PREFIX) != -1;
}