import {
    isObject
} from '#core/types';
import {
    findIndex
} from '#core/types/array';

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

import {
    ExpansionOptions,
    variableServiceForDoc
} from './variables';

/**
 * A user-supplied JSON object that defines a resource to be reported. It is
 * expected to have some fields.
 * A resource timing enty will match against this resource if all of the
 * following properties match.
 * @property {string=} host A string whose value should be a RegExp. It defines
 *     a host or set of hosts to match against. By default, the RegExp will
 *     match all hosts if omitted.
 * @property {string=} path A string whose value should be a RegExp. It defines
 *     a path or set of paths to match against. By default, the RegExp will
 *     match all paths if omitted.
 * @property {string=} query A string whose value should be a RegExp. It defines
 *     a query string or set of query strings to match against. By default, the
 *     RegExp will match all query strings if omitted.
 * @typedef {!JsonObject}
 */
let IndividualResourceSpecDef;

/**
 * A parsed resource spec for a specific host or sets of hosts (as defined by
 * the hostPattern).
 * @typedef {{
 *   hostPattern: !RegExp,
 *   resources: !Array<{
 *     name: string,
 *     pathPattern: !RegExp,
 *     queryPattern: !RegExp,
 *   }>,
 * }}
 */
let ResourceSpecForHostDef;

/**
 * The default maximum buffer size for resource timing entries. After the limit
 * has been reached, the browser will stop recording resource timing entries.
 * This number is chosen by the spec: https://w3c.github.io/resource-timing.
 * @const {number}
 */
const RESOURCE_TIMING_BUFFER_SIZE = 150;

/**
 * Yields the thread before running the function to avoid causing jank. (i.e. a
 * task that takes over 16ms.)
 * @param {function(): OUT} fn
 * @return {!Promise<OUT>}
 * @template OUT
 */
function yieldThread(fn) {
    return new Promise((resolve) => {
        setTimeout(() => resolve(fn()));
    });
}

/**
 * Checks whether the given object is a valid resource timing spec.
 * @param {!JsonObject} spec
 * @return {boolean}
 */
function validateResourceTimingSpec(spec) {
    if (!isObject(spec['resources'])) {
        user().warn('ANALYTICS', 'resourceTimingSpec missing "resources" field');
        return false;
    }
    if (!spec['encoding'] ||
        !spec['encoding']['entry'] ||
        !spec['encoding']['delim']
    ) {
        user().warn(
            'ANALYTICS',
            'resourceTimingSpec is missing or has incomplete encoding options'
        );
        return false;
    }
    if (spec['encoding']['base'] < 2 || spec['encoding']['base'] > 36) {
        user().warn(
            'ANALYTICS',
            'resource timing variables only supports bases between 2 and 36'
        );
        return false;
    }
    if (
        spec['responseAfter'] != null &&
        typeof spec['responseAfter'] != 'number'
    ) {
        user().warn(
            'ANALYTICS',
            'resourceTimingSpec["responseAfter"] must be a number'
        );
        return false;
    }
    return true;
}

/**
 * Gets all resource timing entries from the given window.
 * @param {!Window} win
 * @return {!Array<!PerformanceResourceTiming>}
 */
function getResourceTimingEntries(win) {
    return /** @type {!Array<!PerformanceResourceTiming>} */ (
        win.performance.getEntriesByType('resource')
    );
}

/**
 * Converts a resource timing entry to the variables for this resource.
 * @param {!PerformanceResourceTiming} entry
 * @param {string} name Name of the resource set by the resourceTimingSpec.
 * @param {function(number, number=): string} format A function to format
 *    timestamps and intervals. (Two numbers will be passed in for intervals.)
 * @return {!ExpansionOptions}
 */
function entryToExpansionOptions(entry, name, format) {
    const vars = {
        // ${key} is the name of the resource from the resourceTimingSpec. i.e. it's
        // the key of the object that specifies the host and path patterns that this
        // resource matched against.
        'key': name,
        'startTime': format(entry.startTime),
        'redirectTime': format(entry.redirectEnd, entry.redirectStart),
        'domainLookupTime': format(entry.domainLookupEnd, entry.domainLookupStart),
        'tcpConnectTime': format(entry.connectEnd, entry.connectStart),
        'serverResponseTime': format(entry.responseStart, entry.requestStart),
        'networkTransferTime': format(entry.responseEnd, entry.responseStart),
        'transferSize': format(entry.transferSize || 0),
        'encodedBodySize': format(entry.encodedBodySize || 0),
        'decodedBodySize': format(entry.decodedBodySize || 0),
        'duration': format(entry.duration),
        'initiatorType': entry.initiatorType,
    };
    return new ExpansionOptions(vars, 1 /* opt_iterations */ );
}

/**
 * Returns the variables for the given resource timing entry if it matches one
 * of the defined resources, or null otherwise.
 * @param {!PerformanceResourceTiming} entry
 * @param {!Array<!ResourceSpecForHostDef>} resourcesByHost An array of resource
 *     specs to match against.
 * @return {?string} The name of the entry, or null if no matching name exists.
 */
function nameForEntry(entry, resourcesByHost) {
    const url = entry.name;
    for (let i = 0; i < resourcesByHost.length; ++i) {
        const {
            hostPattern,
            resources
        } = resourcesByHost[i];
        if (!hostPattern.test(url.host)) {
            continue;
        }
        const index = findIndex(
            resources,
            (res) =>
            res.pathPattern.test(url.pathname) && res.queryPattern.test(url.search)
        );
        if (index != -1) {
            return resources[index].name;
        }
    }
    return null; // No match.
}

/**
 * Groups all resource specs (which are defined in terms of {host, path, query}
 * patterns) by host pattern. This is used downstream to avoid running RegExps
 * for host patterns multiple times because we expect multiple resources to
 * use the same host pattern.
 * @param {!Object<string, !IndividualResourceSpecDef>} resourceDefs A map of
 *     names to the resource spec for that name.
 * @return {!Array<!ResourceSpecForHostDef>}
 */
function groupSpecsByHost(resourceDefs) {
    const byHost = {};
    for (const name in resourceDefs) {
        const host = resourceDefs[name]['host'] || '';
        const path = resourceDefs[name]['path'] || '';
        const query = resourceDefs[name]['query'] || '';
        const pattern = {
            name,
            pathPattern: new RegExp(path),
            queryPattern: new RegExp(query),
        };
        if (byHost[host]) {
            byHost[host].resources.push(pattern);
        } else {
            byHost[host] = {
                hostPattern: new RegExp(host),
                resources: [pattern],
            };
        }
    }
    const byHostArray = [];
    for (const host in byHost) {
        byHostArray.push(byHost[host]);
    }
    return byHostArray;
}

/**
 * Filters out resource timing entries that don't have a name defined in
 * resourceDefs. It returns a new array where each element contains a
 * resource timing entry and the corresponding name.
 * @param {!Array<!PerformanceResourceTiming>} entries
 * @param {!Object<string, !IndividualResourceSpecDef>} resourceDefs
 * @return {!Array<{entry: !PerformanceResourceTiming, name: string}>}
 */
function filterEntries(entries, resourceDefs) {
    // Group resource timing definitions by host since we expect multiple
    // definitions to have the same host.
    const byHost = groupSpecsByHost(resourceDefs);
    const results = [];
    entries.forEach((entry) => {
        const name = nameForEntry(entry, byHost);
        if (name) {
            results.push({
                entry,
                name
            });
        }
    });
    return results;
}

/**
 * Serializes resource timing entries that match the resourceTimingSpec into a
 * single string.
 * @param {!Array<!PerformanceResourceTiming>} entries
 * @param {!JsonObject} resourceTimingSpec
 * @param {!Element} element amp-analytics element.
 * @return {!Promise<string>}
 */
function serialize(entries, resourceTimingSpec, element) {
    const resources = resourceTimingSpec['resources'];
    const encoding = resourceTimingSpec['encoding'];

    const variableService = variableServiceForDoc(element);
    const format = (val, relativeTo = 0) =>
        Math.round(val - relativeTo).toString(encoding['base'] || 10);

    const promises = filterEntries(entries, resources)
        .map((resourceTimingEntry) => {
            const {
                entry,
                name
            } = resourceTimingEntry;
            return entryToExpansionOptions(entry, name, format);
        })
        .map((expansion) =>
            variableService.expandTemplate(encoding['entry'], expansion, element)
        );
    return Promise.all(promises).then((vars) => vars.join(encoding['delim']));
}

/**
 * Serializes resource timing entries according to the resource timing spec.
 * @param {!Element} element amp-analytics element.
 * @param {!JsonObject} resourceTimingSpec
 * @return {!Promise<string>}
 */
function serializeResourceTiming(element, resourceTimingSpec) {
    const {
        win
    } = element.getAmpDoc();
    // Check that the performance timing API exists before and that the spec is
    // valid before proceeding. If not, we simply return an empty string.
    if (
        resourceTimingSpec['done'] ||
        !win.performance ||
        !win.performance.now ||
        !win.performance.getEntriesByType ||
        !validateResourceTimingSpec(resourceTimingSpec)
    ) {
        resourceTimingSpec['done'] = true;
        return Promise.resolve('');
    }
    let entries = getResourceTimingEntries(win);
    if (entries.length >= RESOURCE_TIMING_BUFFER_SIZE) {
        // We've exceeded the maximum buffer size so no additional metrics will be
        // reported for this resourceTimingSpec.
        resourceTimingSpec['done'] = true;
    }

    const responseAfter = resourceTimingSpec['responseAfter'] || 0;
    // Update responseAfter for next time to avoid reporting the same resource
    // multiple times.
    resourceTimingSpec['responseAfter'] = Math.max(
        responseAfter,
        win.performance.now()
    );

    // Filter resources that are too early.
    entries = entries.filter((e) => e.startTime + e.duration >= responseAfter);
    if (!entries.length) {
        return Promise.resolve('');
    }
    // Yield the thread in case iterating over all resources takes a long time.
    return yieldThread(() => serialize(entries, resourceTimingSpec, element));
}

/**
 * @param {!Element} element amp-analytics element.
 * @param {!JsonObject|undefined} spec resource timing spec.
 * @param {number} startTime start timestamp.
 * @return {!Promise<string>}
 */
export function getResourceTiming(element, spec, startTime) {
    // Only allow collecting timing within 1s
    if (spec && Date.now() < startTime + 60 * 1000) {
        return serializeResourceTiming(element, spec);
    } else {
        return Promise.resolve('');
    }
}