/* global IITC, L -- eslint */

/**
 * Namespace for IITC utils
 *
 * @memberof IITC
 * @namespace utils
 */

// The sv-SE locale is one of the closest to the ISO format among all locales
const timeWithSecondsFormatter = new Intl.DateTimeFormat('sv-SE', {
  hour: '2-digit',
  minute: '2-digit',
  second: '2-digit',
});

const timeFormatter = new Intl.DateTimeFormat('sv-SE', {
  hour: '2-digit',
  minute: '2-digit',
});

const dateFormatter = new Intl.DateTimeFormat('sv-SE', {
  year: 'numeric',
  month: '2-digit',
  day: '2-digit',
});

/**
 * Retrieves a parameter from the URL query string.
 *
 * @memberof IITC.utils
 * @function getURLParam
 * @param {string} param - The name of the parameter to retrieve.
 * @returns {string} The value of the parameter, or an empty string if not found.
 */
const getURLParam = (param) => {
  const urlParams = new URLSearchParams(window.location.search);
  return urlParams.get(param) || '';
};

/**
 * Retrieves the value of a cookie by name.
 *
 * @memberof IITC.utils
 * @function getCookie
 * @param {string} name - The name of the cookie to retrieve.
 * @returns {string|undefined} The value of the cookie, or undefined if not found.
 */
const getCookie = (name) => {
  const cookies = document.cookie.split('; ').reduce((acc, cookie) => {
    const [key, value] = cookie.split('=');
    acc[key] = decodeURIComponent(value);
    return acc;
  }, {});

  return cookies[name];
};

/**
 * Sets a cookie with a specified name and value, with a default expiration of 10 years.
 *
 * @memberof IITC.utils
 * @function setCookie
 * @param {string} name - The name of the cookie.
 * @param {string} value - The value of the cookie.
 * @param {number} [days=3650] - Optional: the number of days until the cookie expires (default is 10 years).
 */
const setCookie = (name, value, days = 3650) => {
  const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString();
  document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
};

/**
 * Deletes a cookie by name.
 *
 * @memberof IITC.utils
 * @function deleteCookie
 * @param {string} name - The name of the cookie to delete.
 */
const deleteCookie = (name) => {
  document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
};

/**
 * Formats a number with thousand separators (thin spaces).
 * see https://en.wikipedia.org/wiki/Space_(punctuation)#Table_of_spaces
 *
 * @memberof IITC.utils
 * @function formatNumber
 * @param {number} num - The number to format.
 * @returns {string} The formatted number with thousand separators.
 */
const formatNumber = (num) => {
  if (num === null || num === undefined) return '';
  // Convert number to string and use a thin space (U+2009) as thousand separator
  return String(num).replace(/\B(?=(\d{3})+(?!\d))/g, '\u2009');
};

/**
 * Pads a number with zeros up to a specified length.
 *
 * @memberof IITC.utils
 * @function zeroPad
 * @param {number} number - The number to pad.
 * @param {number} length - The desired length of the output string.
 * @returns {string} The padded number as a string.
 */
const zeroPad = (number, length) => number.toString().padStart(length, '0');

/**
 * Converts a UNIX timestamp to a human-readable string.
 * If the timestamp is from today, returns the time (HH:mm:ss format); otherwise, returns the date (YYYY-MM-DD).
 *
 * @memberof IITC.utils
 * @function unixTimeToString
 * @param {number|string} timestamp - The UNIX timestamp in milliseconds to convert.
 * @param {boolean} [full=false] - If true, returns both date and time in "YYYY-MM-DD <locale time>" format.
 * @returns {string|null} The formatted date and/or time string, or null if no timestamp provided.
 */
const unixTimeToString = (timestamp, full = false) => {
  if (!timestamp) return null;

  const dateObj = new Date(Number(timestamp));
  const today = new Date();

  // Check if the date is today
  const isToday = dateObj.getFullYear() === today.getFullYear() && dateObj.getMonth() === today.getMonth() && dateObj.getDate() === today.getDate();

  const time = timeWithSecondsFormatter.format(dateObj);
  const date = dateFormatter.format(dateObj);

  if (full) return `${date} ${time}`;
  return isToday ? time : date;
};

/**
 * Converts a UNIX timestamp to a precise date and time string in the local timezone.
 * Formatted in ISO-style YYYY-MM-DD hh:mm:ss.mmm - but using local timezone.
 *
 * @memberof IITC.utils
 * @function unixTimeToDateTimeString
 * @param {number} time - The UNIX timestamp to convert.
 * @param {boolean} [millisecond] - Whether to include millisecond precision.
 * @returns {string|null} The formatted date and time string.
 */
const unixTimeToDateTimeString = (time, millisecond) => {
  if (!time) return null;
  const date = new Date(Number(time));
  const pad = (num) => IITC.utils.zeroPad(num, 2);

  const dateString = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
  const timeString = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
  const dateTimeString = `${dateString} ${timeString}`;
  return millisecond ? `${dateTimeString}.${IITC.utils.zeroPad(date.getMilliseconds(), 3)}` : dateTimeString;
};

/**
 * Converts a UNIX timestamp to a time string formatted as HH:mm.
 *
 * @memberof IITC.utils
 * @function unixTimeToHHmm
 * @param {number|string} time - The UNIX timestamp to convert.
 * @returns {string|null} Formatted time as HH:mm.
 */
const unixTimeToHHmm = (time) => {
  if (!time) return null;
  return timeFormatter.format(new Date(Number(time)));
};

/**
 * Formats an interval of time given in seconds into a human-readable string.
 *
 * @memberof IITC.utils
 * @function formatInterval
 * @param {number} seconds - The interval in seconds.
 * @param {number} [maxTerms] - The maximum number of time units to include.
 * @returns {string} The formatted time interval.
 */
const formatInterval = (seconds, maxTerms) => {
  const days = Math.floor(seconds / 86400);
  const hours = Math.floor((seconds % 86400) / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  const secs = seconds % 60;

  // Collect terms if they have a non-zero value
  const terms = [days ? `${days}d` : null, hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, secs ? `${secs}s` : null].filter(Boolean);

  // Limit terms to maxTerms if specified
  return (maxTerms ? terms.slice(0, maxTerms) : terms).join(' ') || '0s';
};

/**
 * Formats a distance in meters, converting to kilometers with appropriate precision
 * based on the distance range.
 *
 * For distances:
 * - Under 1000m: shows in meters, rounded to whole numbers
 * - 1000m to 9999m: shows in kilometers with 1 decimal place
 * - 10000m and above: shows in whole kilometers
 *
 * @memberof IITC.utils
 * @function formatDistance
 * @param {number} distance - The distance in meters.
 * @returns {string} The formatted distance.
 */
const formatDistance = (distance) => {
  if (distance === null || distance === undefined) return '';
  let value, unit;

  if (distance >= 10000) {
    // For 10km and above: show whole kilometers
    value = Math.round(distance / 1000);
    unit = 'km';
  } else if (distance >= 1000) {
    // For 1km to 9.9km: show kilometers with one decimal
    value = Math.round(distance / 100) / 10;
    unit = 'km';
  } else {
    // For under 1km: show in meters
    value = Math.round(distance);
    unit = 'm';
  }

  return `${IITC.utils.formatNumber(value)}${unit}`;
};

/**
 * Formats the time difference between two timestamps (in milliseconds) as a string.
 *
 * @memberof IITC.utils
 * @function formatAgo
 * @param {number} time - The past timestamp in milliseconds.
 * @param {number} now - The current timestamp in milliseconds.
 * @param {Object} [options] - Options for formatting.
 * @param {boolean} [options.showSeconds=false] - Whether to include seconds in the result.
 * @returns {string} The formatted time difference (e.g., "45s", "5m", "2h 45m", "1d 3h 45m")
 */
const formatAgo = (time, now, options = { showSeconds: false }) => {
  const secondsTotal = Math.floor(Math.max(0, (now - time) / 1000));

  // Calculate time units
  const days = Math.floor(secondsTotal / 86400);
  const hours = Math.floor((secondsTotal % 86400) / 3600);
  const minutes = Math.floor((secondsTotal % 3600) / 60);
  const seconds = secondsTotal % 60;

  const result = [];

  // Include units conditionally based on non-zero values
  if (days > 0) result.push(`${days}d`);
  if (hours > 0 || result.length !== 0) result.push(`${hours}h`);
  if (minutes > 0 || result.length !== 0) result.push(`${minutes}m`);
  if (options.showSeconds && (result.length === 0 || seconds > 0)) result.push(`${seconds}s`);

  // If no units were added, show "0" with the smallest available unit
  if (result.length === 0) {
    return options.showSeconds ? '0s' : '0m';
  }

  return result.join(' ');
};

/**
 * Checks if the device is a touch-enabled device.
 * Alias for `L.Browser.touch()`
 *
 * @memberof IITC.utils
 * @function isTouchDevice
 * @returns {boolean} True if the device is touch-enabled, otherwise false.
 */
const isTouchDevice = () => L.Browser.touch;

/**
 * Calculates the number of pixels left to scroll down before reaching the bottom of an element.
 *
 * @memberof IITC.utils
 * @function scrollBottom
 * @param {string|HTMLElement|jQuery} elm - The element or selector to calculate the scroll bottom for.
 * @returns {number} The number of pixels from the bottom.
 */
const scrollBottom = (elm) => {
  // Ensure elm is an HTMLElement: resolve selector strings or extract DOM element from jQuery object
  const element = typeof elm === 'string' ? document.querySelector(elm) : elm instanceof jQuery ? elm[0] : elm;
  return element.scrollHeight - element.clientHeight - element.scrollTop;
};

/**
 * Escapes special characters in a string for use in JavaScript.
 * (for strings passed as parameters to html onclick="..." for example)
 *
 * @memberof IITC.utils
 * @function escapeJS
 * @param {string} str - The string to escape.
 * @returns {string} The escaped string.
 */
const escapeJS = function (str) {
  return (str + '').replace(/[\\"']/g, '\\$&');
};

/**
 * Escapes HTML special characters in a string.
 *
 * @memberof IITC.utils
 * @function escapeHtml
 * @param {string} str - The string to escape.
 * @returns {string} The escaped string.
 */
const escapeHtml = function (str) {
  const escapeMap = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;',
  };
  return str.replace(/[&<>"']/g, (char) => escapeMap[char]);
};

/**
 * Formats the energy of a portal, converting to "k" units if over 1000.
 *
 * @memberof IITC.utils
 * @function prettyEnergy
 * @param {number} nrg - The energy value to format.
 * @returns {string|number} The formatted energy value.
 */
const prettyEnergy = (nrg) => (nrg > 1000 ? `${Math.round(nrg / 1000)}k` : nrg);

/**
 * Converts a list of items into a unique array, removing duplicates.
 *
 * @memberof IITC.utils
 * @function uniqueArray
 * @param {Array} arr - The array to process.
 * @returns {Array} A new array containing only unique elements.
 */
const uniqueArray = function (arr) {
  return [...new Set(arr)];
};

/**
 * Generates a four-column HTML table from an array of data blocks.
 *
 * @memberof IITC.utils
 * @param {Array} blocks - Array of data blocks, where each block is an array with details for one row.
 * @returns {string} HTML string representing the constructed table.
 */
const genFourColumnTable = function (blocks) {
  const rows = blocks
    .map((detail, index) => {
      if (!detail) return '';
      const title = detail[2] ? ` title="${IITC.utils.escapeHtml(detail[2])}"` : '';

      if (index % 2 === 0) {
        // If index is even, start a new row and add <td> for data and <th> for header
        return `<tr><td${title}>${detail[1]}</td><th${title}>${detail[0]}</th>`;
      } else {
        // If index is odd, complete the row with <th> for header and <td> for data, then close </tr>
        return `<th${title}>${detail[0]}</th><td${title}>${detail[1]}</td></tr>`;
      }
    })
    .join('');

  // If total number of blocks is odd, add empty cells to complete the last row
  const isOdd = blocks.length % 2 === 1;
  return isOdd ? rows + '<td></td><td></td></tr>' : rows;
};

/**
 * Converts text with newlines (`\n`) and tabs (`\t`) into an HTML table.
 *
 * @memberof IITC.utils
 * @function textToTable
 * @param {string} text - The text to convert.
 * @returns {string} The resulting HTML table.
 */
const textToTable = function (text) {
  // If no tabs are present, replace newlines with <br> and return
  if (!text.includes('\t')) return text.replace(/\n/g, '<br>');

  // Split text into rows and columns, tracking the max column count
  const rows = text.split('\n').map((row) => row.split('\t'));
  const columnCount = Math.max(...rows.map((row) => row.length));

  // Build the table rows
  const tableRows = [];
  for (const row of rows) {
    let rowHtml = '<tr>';
    for (let k = 0; k < row.length; k++) {
      const cell = IITC.utils.escapeHtml(row[k]);
      const colspan = k === 0 && row.length < columnCount ? ` colspan="${columnCount - row.length + 1}"` : '';
      rowHtml += `<td${colspan}>${cell}</td>`;
    }
    rowHtml += '</tr>';
    tableRows.push(rowHtml);
  }

  // Combine all rows into a single table HTML
  return `<table>${tableRows.join('')}</table>`;
};

/**
 * Clamps a given value between a minimum and maximum value.
 * Simple implementation for internal use.
 *
 * @memberof IITC.utils
 * @private
 * @function clamp
 * @param {number} n - The value to clamp.
 * @param {number} max - The maximum allowed value.
 * @param {number} min - The minimum allowed value.
 * @returns {number} The clamped value.
 */
const clamp = function (n, max, min) {
  if (n === 0) return 0;
  return n > 0 ? Math.min(n, max) : Math.max(n, min);
};

/**
 * The maximum absolute latitude that can be represented in Web Mercator projection (EPSG:3857).
 * This value is taken from L.Projection.SphericalMercator.MAX_LATITUDE
 *
 * @memberof IITC.utils
 * @constant {Number}
 */
const MAX_LATITUDE = 85.051128;

/**
 * Clamps a latitude and longitude to the maximum and minimum valid values.
 *
 * @memberof IITC.utils
 * @function clampLatLng
 * @param {L.LatLng} latlng - The latitude and longitude to clamp.
 * @returns {Array<number>} The clamped latitude and longitude.
 */
const clampLatLng = function (latlng) {
  // Ingress accepts requests only for this range
  return [clamp(latlng.lat, MAX_LATITUDE, -MAX_LATITUDE), clamp(latlng.lng, 179.999999, -180)];
};

/**
 * Clamps a latitude and longitude bounds to the maximum and minimum valid values.
 *
 * @memberof IITC.utils
 * @function clampLatLngBounds
 * @param {L.LatLngBounds} bounds - The bounds to clamp.
 * @returns {L.LatLngBounds} The clamped bounds.
 */
const clampLatLngBounds = function (bounds) {
  var SW = bounds.getSouthWest(),
    NE = bounds.getNorthEast();
  return L.latLngBounds(window.clampLatLng(SW), window.clampLatLng(NE));
};

/**
 * Determines if a point is inside a polygon.
 *
 * @memberof IITC.utils
 * @param {Array<L.LatLng>} polygon - The vertices of the polygon.
 * @param {L.LatLng} point - The point to test.
 * @returns {boolean} True if the point is inside the polygon, false otherwise.
 */
const isPointInPolygon = (polygon, point) => {
  let inside = 0;
  // j records previous value. Also handles wrapping around.
  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
    inside ^=
      polygon[i].y > point.y !== polygon[j].y > point.y &&
      point.x - polygon[i].x < ((polygon[j].x - polygon[i].x) * (point.y - polygon[i].y)) / (polygon[j].y - polygon[i].y);
  }
  // Let's make js as magical as C. Yay.
  return !!inside;
};

IITC.utils = {
  getURLParam,
  getCookie,
  setCookie,
  deleteCookie,
  formatNumber,
  zeroPad,
  unixTimeToString,
  unixTimeToDateTimeString,
  unixTimeToHHmm,
  formatInterval,
  formatDistance,
  formatAgo,
  isTouchDevice,
  scrollBottom,
  escapeJS,
  escapeHtml,
  prettyEnergy,
  uniqueArray,
  genFourColumnTable,
  textToTable,
  clamp,
  clampLatLng,
  clampLatLngBounds,
  isPointInPolygon,
};

// Map of legacy function names to their new names (or the same name if not renamed)
const legacyFunctionMappings = {
  getURLParam: 'getURLParam',
  readCookie: 'getCookie',
  writeCookie: 'setCookie',
  eraseCookie: 'deleteCookie',
  digits: 'formatNumber',
  zeroPad: 'zeroPad',
  unixTimeToString: 'unixTimeToString',
  unixTimeToDateTimeString: 'unixTimeToDateTimeString',
  unixTimeToHHmm: 'unixTimeToHHmm',
  formatInterval: 'formatInterval',
  formatDistance: 'formatDistance',
  isTouchDevice: 'isTouchDevice',
  scrollBottom: 'scrollBottom',
  escapeJavascriptString: 'escapeJS',
  escapeHtmlSpecialChars: 'escapeHtml',
  prettyEnergy: 'prettyEnergy',
  uniqueArray: 'uniqueArray',
  genFourColumnTable: 'genFourColumnTable',
  convertTextToTableMagic: 'textToTable',
  clamp: 'clamp',
  clampLatLng: 'clampLatLng',
  clampLatLngBounds: 'clampLatLngBounds',
  pnpoly: 'isPointInPolygon',
};

// Set up synchronization between `window` and `IITC.utils` with new names
Object.entries(legacyFunctionMappings).forEach(([oldName, newName]) => {
  // Initialize IITC.utils[newName] if not already defined
  window.IITC.utils[newName] = window.IITC.utils[newName] || function () {};

  // Define a getter/setter on `window` to synchronize with `IITC.utils`
  Object.defineProperty(window, oldName, {
    get() {
      return window.IITC.utils[newName];
    },
    set(newFunc) {
      window.IITC.utils[newName] = newFunc;
    },
    configurable: true,
  });
});