/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 * 
 *   http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { detect } from 'detect-browser';
const browserInfo = detect();

export let logs;
let config;

// Interval Logging Globals
let intervalID;
let intervalType;
let intervalPath;
let intervalTimer;
let intervalCounter;
let intervalLog;

export let filterHandler = null;
export let mapHandler = null;
export let cbHandlers = {};

/**
 * Assigns a handler to filter logs out of the queue.
 * @deprecated Use addCallbacks and removeCallbacks instead
 * @param  {Function} callback The handler to invoke when logging.
 */
export function setLogFilter(callback) {
  console.warn("setLogFilter() is deprecated and will be removed in a futre release");
  filterHandler = callback;
}

/**
 * Assigns a handler to transform logs from their default structure.
 * @deprecated Use addCallbacks and removeCallbacks instead
 * @param  {Function} callback The handler to invoke when logging.
 */
export function setLogMapper(callback) {
  console.warn("setLogMapper() is deprecated and will be removed in a futre release");
  mapHandler = callback;
}

/**
 * Adds named callbacks to be executed when logging.
 * @param  {Object } newCallbacks An object containing named callback functions.
 */
export function addCallbacks(...newCallbacks) {
  newCallbacks.forEach((source) => {
    const descriptors = Object.keys(source).reduce((descriptors, key) => {
      descriptors[key] = Object.getOwnPropertyDescriptor(source, key);
      return descriptors;
    }, {});

    Object.getOwnPropertySymbols(source).forEach((sym) => {
      const descriptor = Object.getOwnPropertyDescriptor(source, sym);
      if (descriptor.enumerable) {
        descriptors[sym] = descriptor;
      }
    });
    Object.defineProperties(cbHandlers, descriptors);
  });
  return cbHandlers;
}

/**
 * Removes callbacks by name.
 * @param  {String[]} targetKeys A list of names of functions to remove.
 */
export function removeCallbacks(targetKeys) {
  targetKeys.forEach(key => {
    if(Object.hasOwn(cbHandlers, key)) {
      delete cbHandlers[key];
    }
  });
}

/**
 * Assigns the config and log container to be used by the logging functions.
 * @param  {Array} newLogs   Log container.
 * @param  {Object} newConfig Configuration to use while logging.
 */
export function initPackager(newLogs, newConfig) {
  logs = newLogs;
  config = newConfig;
  cbHandlers = [];
  intervalID = null;
  intervalType = null;
  intervalPath = null;
  intervalTimer = null;
  intervalCounter = 0;
  intervalLog = null;
}

/**
 * Transforms the provided HTML event into a log and appends it to the log queue.
 * @param  {Object} e         The event to be logged.
 * @param  {Function} detailFcn The function to extract additional log parameters from the event.
 * @return {boolean}           Whether the event was logged.
 */
export function packageLog(e, detailFcn) {
  if (!config.on) {
    return false;
  }

  let details = null;
  if (detailFcn) {
    details = detailFcn(e);
  }

  const timeFields = extractTimeFields(
    (e.timeStamp && e.timeStamp > 0) ? config.time(e.timeStamp) : Date.now()
  );

  let log = {
    'target' : getSelector(e.target),
    'path' : buildPath(e),
    'pageUrl': window.location.href,
    'pageTitle': document.title,
    'pageReferrer': document.referrer,
    'browser': detectBrowser(),
    'clientTime' : timeFields.milli,
    'microTime' : timeFields.micro,
    'location' : getLocation(e),
    'scrnRes' : getSreenRes(),
    'type' : e.type,
    'logType': 'raw',
    'userAction' : true,
    'details' : details,
    'userId' : config.userId,
    'toolVersion' : config.version,
    'toolName' : config.toolName,
    'useraleVersion': config.useraleVersion,
    'sessionID': config.sessionID,
    'httpSessionId': config.httpSessionId,
    'browserSessionId': config.browserSessionId,
  };

  if ((typeof filterHandler === 'function') && !filterHandler(log)) {
    return false;
  }

  if (typeof mapHandler === 'function') {
    log = mapHandler(log, e);
  }

  for (const func of Object.values(cbHandlers)) {
    if (typeof func === 'function') {
      log = func(log, e);
      if(!log) {
        return false;
      }
    }
  }

  logs.push(log);
  return true;
}

/**
 * Packages the provided customLog to include standard meta data and appends it to the log queue.
 * @param  {Object} customLog        The behavior to be logged.
 * @param  {Function} detailFcn     The function to extract additional log parameters from the event.
 * @param  {boolean} userAction     Indicates user behavior (true) or system behavior (false)
 * @return {boolean}           Whether the event was logged.
 */
export function packageCustomLog(customLog, detailFcn, userAction) {
    if (!config.on) {
        return false;
    }

    let details = null;
    if (detailFcn) {
        details = detailFcn();
    }

    const metaData = {
        'pageUrl': window.location.href,
        'pageTitle': document.title,
        'pageReferrer': document.referrer,
        'browser': detectBrowser(),
        'clientTime' : Date.now(),
        'scrnRes' : getSreenRes(),
        'logType': 'custom',
        'userAction' : userAction,
        'details' : details,
        'userId' : config.userId,
        'toolVersion' : config.version,
        'toolName' : config.toolName,
        'useraleVersion': config.useraleVersion,
        'sessionID': config.sessionID,
        'httpSessionId': config.httpSessionId,
        'browserSessionId': config.browserSessionId,
    };

    let log = Object.assign(metaData, customLog);

    if ((typeof filterHandler === 'function') && !filterHandler(log)) {
        return false;
    }

    if (typeof mapHandler === 'function') {
        log = mapHandler(log);
    }

    for (const func of Object.values(cbHandlers)) {
      if (typeof func === 'function') {
        log = func(log, null);
        if(!log) {
          return false;
        }
      }
    }

    logs.push(log);

    return true;
}

/**
 * Extract the millisecond and microsecond portions of a timestamp.
 * @param  {Number} timeStamp The timestamp to split into millisecond and microsecond fields.
 * @return {Object}           An object containing the millisecond
 *                            and microsecond portions of the timestamp.
 */
export function extractTimeFields(timeStamp) {
  return {
    milli: Math.floor(timeStamp),
    micro: Number((timeStamp % 1).toFixed(3)),
  };
}

/**
 * Track intervals and gather details about it.
 * @param {Object} e
 * @return boolean
 */
export function packageIntervalLog(e) {
    const target = getSelector(e.target);
    const path = buildPath(e);
    const type = e.type;
    const timestamp = Math.floor((e.timeStamp && e.timeStamp > 0) ? config.time(e.timeStamp) : Date.now());

    // Init - this should only happen once on initialization
    if (intervalID == null) {
        intervalID = target;
        intervalType = type;
        intervalPath = path;
        intervalTimer = timestamp;
        intervalCounter = 0;
    }

    if (intervalID !== target || intervalType !== type) {
        // When to create log? On transition end
        // @todo Possible for intervalLog to not be pushed in the event the interval never ends...

        intervalLog = {
            'target': intervalID,
            'path': intervalPath,
            'pageUrl': window.location.href,
            'pageTitle': document.title,
            'pageReferrer': document.referrer,
            'browser': detectBrowser(),
            'count': intervalCounter,
            'duration': timestamp - intervalTimer,  // microseconds
            'startTime': intervalTimer,
            'endTime': timestamp,
            'type': intervalType,
            'logType': 'interval',    
            'targetChange': intervalID !== target,
            'typeChange': intervalType !== type,
            'userAction': false,
            'userId': config.userId,
            'toolVersion': config.version,
            'toolName': config.toolName,
            'useraleVersion': config.useraleVersion,
            'sessionID': config.sessionID,
            'httpSessionId': config.httpSessionId,
            'browserSessionId': config.browserSessionId,
        };

        if (typeof filterHandler === 'function' && !filterHandler(intervalLog)) {
          return false;
        }

        if (typeof mapHandler === 'function') {
          intervalLog = mapHandler(intervalLog, e);
        }

        for (const func of Object.values(cbHandlers)) {
          if (typeof func === 'function') {
            intervalLog = func(intervalLog, null);
            if(!intervalLog) {
              return false;
            }
          }
        }

        logs.push(intervalLog);

        // Reset
        intervalID = target;
        intervalType = type;
        intervalPath = path;
        intervalTimer = timestamp;
        intervalCounter = 0;
    }

    // Interval is still occuring, just update counter
    if (intervalID == target && intervalType == type) {
        intervalCounter = intervalCounter + 1;
    }

    return true;
}

/**
 * Extracts coordinate information from the event
 * depending on a few browser quirks.
 * @param  {Object} e The event to extract coordinate information from.
 * @return {Object}   An object containing nullable x and y coordinates for the event.
 */
export function getLocation(e) {
  if (e.pageX != null) {
    return { 'x' : e.pageX, 'y' : e.pageY };
  } else if (e.clientX != null) {
    return { 'x' : document.documentElement.scrollLeft + e.clientX, 'y' : document.documentElement.scrollTop + e.clientY };
  } else {
    return { 'x' : null, 'y' : null };
  }
}

/**
 * Extracts innerWidth and innerHeight to provide estimates of screen resolution
 * @return {Object} An object containing the innerWidth and InnerHeight
 */
export function getSreenRes() {
    return { 'width': window.innerWidth, 'height': window.innerHeight};
}

/**
 * Builds a string CSS selector from the provided element
 * @param  {HTMLElement} ele The element from which the selector is built.
 * @return {string}     The CSS selector for the element, or Unknown if it can't be determined.
 */
export function getSelector(ele) {
  if (ele.localName) {
    return ele.localName + (ele.id ? ('#' + ele.id) : '') + (ele.className ? ('.' + ele.className) : '');
  } else if (ele.nodeName) {
    return ele.nodeName + (ele.id ? ('#' + ele.id) : '') + (ele.className ? ('.' + ele.className) : '');
  } else if (ele && ele.document && ele.location && ele.alert && ele.setInterval) {
    return "Window";
  } else {
    return "Unknown";
  }
}

/**
 * Builds an array of elements from the provided event target, to the root element.
 * @param  {Object} e Event from which the path should be built.
 * @return {HTMLElement[]}   Array of elements, starting at the event target, ending at the root element.
 */
export function buildPath(e) {
    if (e instanceof window.Event) {
        const path = e.composedPath();
        return selectorizePath(path);
    }
}

/**
 * Builds a CSS selector path from the provided list of elements.
 * @param  {HTMLElement[]} path Array of HTMLElements from which the path should be built.
 * @return {string[]}      Array of string CSS selectors.
 */
export function selectorizePath(path) {
  let i = 0;
  let pathEle;
  const pathSelectors = [];
  while (pathEle = path[i]) {
    pathSelectors.push(getSelector(pathEle));
    ++i;
  }
  return pathSelectors;
}

export function detectBrowser() {
    return {
        'browser': browserInfo ? browserInfo.name : '',
        'version': browserInfo ? browserInfo.version : ''
    };
}