/* global log, IITC, L */

/**
 * COMM data layer: stock all/faction/alerts channels and message-rendering primitives.
 * The chat-panel UI framework lives in chat.js
 *
 * @memberof IITC
 * @namespace comm
 */

/**
 * @type {chat.ChannelDescription[]}
 * @memberof IITC.comm
 */
const _channels = [
  {
    id: 'all',
    name: 'All',
    localBounds: true,
    inputPrompt: 'broadcast:',
    inputClass: 'public',
    request: requestChannel,
    render: renderChannel,
    sendMessage: sendChatMessage,
  },
  {
    id: 'faction',
    name: 'Faction',
    localBounds: true,
    inputPrompt: 'tell faction:',
    inputClass: 'faction',
    request: requestChannel,
    render: renderChannel,
    sendMessage: sendChatMessage,
  },
  {
    id: 'alerts',
    name: 'Alerts',
    inputPrompt: 'tell Jarvis:',
    inputClass: 'alerts',
    request: requestChannel,
    render: renderChannel,
    sendMessage: () => {
      alert("Jarvis: A strange game. The only winning move is not to play. How about a nice game of chess?\n(You can't comm to the 'alerts' channel!)");
    },
  },
];

/**
 * Holds data related to each intel channel.
 *
 * @type {Object}
 * @memberof IITC.comm
 */
const _channelsData = {};

/**
 * Initialize the channel data.
 *
 * @memberof IITC.comm
 * @private
 * @param {chat.ChannelDescription} id - The channel id.
 */
function _initChannelData(id) {
  // preserve channel object
  if (!_channelsData[id]) _channelsData[id] = {};
  _channelsData[id].data = {};
  _channelsData[id].guids = [];
  _channelsData[id].oldestTimestamp = -1;
  delete _channelsData[id].oldestGUID;
  _channelsData[id].newestTimestamp = -1;
  delete _channelsData[id].newestGUID;
}

/**
 * Template of portal link in comm.
 * @type {String}
 * @memberof IITC.comm
 */
let portalTemplate =
  '<a onclick="IITC.portal.selectByLatLng({lat}, {lng});return false" title="{title}" href="{url}" class="bidi-isolate help">{portal_name}</a>';
/**
 * Template for time cell.
 * @type {String}
 * @memberof IITC.comm
 */
let timeCellTemplate = '<td><time class="{class_names}" title="{time_title}" data-timestamp="{unixtime}">{time}</time></td>';
/**
 * Template for player's nickname cell.
 * @type {String}
 * @memberof IITC.comm
 */
let nickCellTemplate = '<td><span class="invisep">&lt;</span><mark class="{class_names}">{nick}</mark><span class="invisep">&gt;</span></td>';
/**
 * Template for chat message text cell.
 * @type {String}
 * @memberof IITC.comm
 */
let msgCellTemplate = '<td class="{class_names}">{msg}</td>';
/**
 * Template for message row, includes cells for time, player nickname and message text.
 * @type {String}
 * @memberof IITC.comm
 */
let msgRowTemplate = '<tr data-guid="{guid}" class="{class_names}">{time_cell}{nick_cell}{msg_cell}</tr>';
/**
 * Template for message divider.
 * @type {String}
 * @memberof IITC.comm
 */
let dividerTemplate = '<tr class="divider"><td><hr></td><td>{text}</td><td><hr></td></tr>';

/**
 * Fills a template with data, accepting both the modern `{key}` and the legacy `{{ key }}` placeholder
 *
 * @param {string} template - Template string with `{key}` (or legacy `{{ key }}`) placeholders
 * @param {Object} data - Values keyed by placeholder name
 * @returns {string} The filled template string
 */
function fillTemplate(template, data) {
  return L.Util.template(template.replace(/\{\{\s*([\w-]+)\s*\}\}/g, '{$1}'), data);
}

/**
 * Returns the coordinates for the message to be sent, default is the center of the map.
 *
 * @memberof IITC.comm
 * @returns {L.LatLng}
 */
function getLatLngForSendingMessage() {
  return window.map.getCenter();
}

/**
 * Updates the oldest and newest message timestamps and GUIDs in the chat storage.
 *
 * @memberof IITC.comm
 * @private
 * @param {Object} newData - The new chat data received.
 * @param {Object} storageHash - The chat storage object.
 * @param {boolean} isOlderMsgs - Whether the new data contains older messages.
 * @param {boolean} isAscendingOrder - Whether the new data is in ascending order.
 */
function _updateOldNewHash(newData, storageHash, isOlderMsgs, isAscendingOrder) {
  // track oldest + newest timestamps/GUID
  if (newData.result.length > 0) {
    let first = {
      guid: newData.result[0][0],
      time: newData.result[0][1],
    };
    let last = {
      guid: newData.result[newData.result.length - 1][0],
      time: newData.result[newData.result.length - 1][1],
    };
    if (isAscendingOrder) {
      const temp = first;
      first = last;
      last = temp;
    }
    if (storageHash.oldestTimestamp === -1 || storageHash.oldestTimestamp >= last.time) {
      if (isOlderMsgs || storageHash.oldestTimestamp !== last.time) {
        storageHash.oldestTimestamp = last.time;
        storageHash.oldestGUID = last.guid;
      }
    }
    if (storageHash.newestTimestamp === -1 || storageHash.newestTimestamp <= first.time) {
      if (!isOlderMsgs || storageHash.newestTimestamp !== first.time) {
        storageHash.newestTimestamp = first.time;
        storageHash.newestGUID = first.guid;
      }
    }
  }
}

/**
 * Parses comm message data into a more convenient format.
 *
 * @memberof IITC.comm
 * @param {Object} data - The raw comm message data.
 * @returns {Object} The parsed comm message data.
 */
function parseMsgData(data) {
  const categories = data[2].plext.categories;
  const isPublic = (categories & 1) === 1;
  const isSecure = (categories & 2) === 2;
  const msgAlert = (categories & 4) === 4;

  const msgToPlayer = msgAlert && (isPublic || isSecure);

  const time = data[1];
  const team = window.teamStringToId(data[2].plext.team);
  const auto = data[2].plext.plextType !== 'PLAYER_GENERATED';
  const systemNarrowcast = data[2].plext.plextType === 'SYSTEM_NARROWCAST';

  const markup = data[2].plext.markup;

  const player = {
    name: '',
    team: team,
  };
  markup.forEach((ent) => {
    switch (ent[0]) {
      case 'SENDER': // user generated messages
        player.name = ent[1].plain.replace(/: $/, ''); // cut “: ” at end
        break;

      case 'PLAYER': // automatically generated messages
        player.name = ent[1].plain;
        player.team = window.teamStringToId(ent[1].team);
        break;

      default:
        break;
    }
  });

  return {
    guid: data[0],
    time: time,
    public: isPublic,
    secure: isSecure,
    alert: msgAlert,
    msgToPlayer: msgToPlayer,
    type: data[2].plext.plextType,
    narrowcast: systemNarrowcast,
    auto: auto,
    team: team,
    player: player,
    markup: markup,
  };
}

/**
 * Writes new chat data to the chat storage and manages the order of messages.
 *
 * @memberof IITC.comm
 * @private
 * @param {Object} newData - The new chat data received.
 * @param {Object} storageHash - The chat storage object.
 * @param {boolean} isOlderMsgs - Whether the new data contains older messages.
 * @param {boolean} isAscendingOrder - Whether the new data is in ascending order.
 */
function _writeDataToHash(newData, storageHash, isOlderMsgs, isAscendingOrder) {
  IITC.comm._updateOldNewHash(newData, storageHash, isOlderMsgs, isAscendingOrder);

  newData.result.forEach((json) => {
    // avoid duplicates
    if (json[0] in storageHash.data) {
      return true;
    }

    const parsedData = IITC.comm.parseMsgData(json);

    // format: timestamp, autogenerated, HTML message, nick, additional data (parsed, plugin specific data...)
    storageHash.data[parsedData.guid] = [parsedData.time, parsedData.auto, IITC.comm.renderMsgRow(parsedData), parsedData.player.name, parsedData];
    if (isAscendingOrder) {
      storageHash.guids.push(parsedData.guid);
    } else {
      storageHash.guids.unshift(parsedData.guid);
    }
  });
}

/**
 * Posts a chat message to intel comm context.
 *
 * @memberof IITC.comm
 * @param {string} tab intel tab name (either all or faction)
 * @param {string} msg message to be sent
 */
function sendChatMessage(tab, msg) {
  if (tab !== 'all' && tab !== 'faction') return;

  const latlng = IITC.comm.getLatLngForSendingMessage();

  const data = {
    message: msg,
    latE6: Math.round(latlng.lat * 1e6),
    lngE6: Math.round(latlng.lng * 1e6),
    tab: tab,
  };

  const errMsg = `Your message could not be delivered. You can copy&paste it here and try again if you want:\n\n${msg}`;

  window.postAjax(
    'sendPlext',
    data,
    (response) => {
      if (response.error) alert(errMsg);
      window.startRefreshTimeout(0.1 * 1000); // only comm uses the refresh timer stuff, so a perfect way of forcing an early refresh after a send message
    },
    () => {
      alert(errMsg);
    }
  );
}

let _oldBBox = null;
/**
 * Generates post data for chat requests.
 *
 * @memberof IITC.comm
 * @private
 * @param {string} channel - The chat channel.
 * @param {boolean} getOlderMsgs - Flag to determine if older messages are being requested.
 * @param args=undefined - Used for backward compatibility when calling a function with three arguments.
 * @returns {Object} The generated post data.
 */
function _genPostData(channel, getOlderMsgs, ...args) {
  if (typeof channel !== 'string') {
    throw new Error('API changed: isFaction flag now a channel string - all, faction, alerts');
  }
  if (args.length === 1) {
    getOlderMsgs = args[0];
  }

  const b = window.clampLatLngBounds(window.map.getBounds());

  // set a current bounding box if none set so far
  if (!_oldBBox) _oldBBox = b;

  // to avoid unnecessary comm refreshes, a small difference compared to the previous bounding box
  // is not considered different
  const CHAT_BOUNDINGBOX_SAME_FACTOR = 0.1;
  // if the old and new box contain each other, after expanding by the factor, don't reset comm
  if (!(b.pad(CHAT_BOUNDINGBOX_SAME_FACTOR).contains(_oldBBox) && _oldBBox.pad(CHAT_BOUNDINGBOX_SAME_FACTOR).contains(b))) {
    log.log(`Bounding Box changed, comm will be cleared (old: ${_oldBBox.toBBoxString()}; new: ${b.toBBoxString()})`);

    // need to reset these flags now because clearing will only occur
    // after the request is finished – i.e. there would be one almost
    // useless request.
    _channels.forEach((entry) => {
      if (entry.localBounds) {
        _initChannelData(entry.id);
        const el = document.getElementById(`chat${entry.id}`);
        if (el) IITC.chat.channelState(el).needsClearing = true;
      }
    });
    _oldBBox = b;
  }

  if (!_channelsData[channel]) _initChannelData(channel);
  const storageHash = _channelsData[channel];

  const ne = b.getNorthEast();
  const sw = b.getSouthWest();
  let data = {
    minLatE6: Math.round(sw.lat * 1e6),
    minLngE6: Math.round(sw.lng * 1e6),
    maxLatE6: Math.round(ne.lat * 1e6),
    maxLngE6: Math.round(ne.lng * 1e6),
    minTimestampMs: -1,
    maxTimestampMs: -1,
    tab: channel,
  };

  if (getOlderMsgs) {
    // ask for older comm when scrolling up
    data = Object.assign(data, {
      maxTimestampMs: storageHash.oldestTimestamp,
      plextContinuationGuid: storageHash.oldestGUID,
    });
  } else {
    // ask for newer comm
    const min = storageHash.newestTimestamp;
    // the initial request will have both timestamp values set to -1,
    // thus we receive the newest 50. After that, we will only receive
    // messages with a timestamp greater or equal to min above.
    // After resuming from idle, there might be more new messages than
    // desiredNumItems. So on the first request, we are not really up to
    // date. We will eventually catch up, as long as there are less new
    // messages than 50 per each refresh cycle.
    // A proper solution would be to query until no more new results are
    // returned.
    // Currently this edge case is not handled. Let’s see if this is a
    // problem in crowded areas.
    Object.assign(data, {
      minTimestampMs: min,
      plextContinuationGuid: storageHash.newestGUID,
    });
    // when requesting with an actual minimum timestamp, request oldest rather than newest first.
    // this matches the stock intel site, and ensures no gaps when continuing after an extended idle period
    if (min > -1) Object.assign(data, { ascendingTimestampOrder: true });
  }
  return data;
}

const _requestRunning = {};

/**
 * Requests chat messages.
 *
 * @memberof IITC.comm
 * @param {string} channel - Comm Intel channel (all/faction/alerts)
 * @param {boolean} getOlderMsgs - Flag to determine if older messages are being requested.
 * @param {boolean} [isRetry=false] - Flag to indicate if this is a retry attempt.
 */
function requestChannel(channel, getOlderMsgs, isRetry) {
  if (_requestRunning[channel] && !isRetry) return;
  if (window.isIdle()) return IITC.statusbar.map.update();
  _requestRunning[channel] = true;
  document.querySelector(`#chatcontrols a[data-channel='${channel}']`)?.classList.add('loading');

  const d = IITC.comm._genPostData(channel, getOlderMsgs);
  window.postAjax(
    'getPlexts',
    d,
    (data) => {
      _handleChannel(channel, data, getOlderMsgs, d.ascendingTimestampOrder);
    },
    isRetry
      ? () => {
          _requestRunning[channel] = false;
        }
      : (_, textStatus) => {
          if (textStatus === 'abort') _requestRunning[channel] = false;
          else IITC.comm.requestChannel(channel, getOlderMsgs, true);
        }
  );
}

/**
 * Handles faction chat response.
 *
 * @memberof IITC.comm
 * @private
 * @param {string} channel - Comm Intel channel (all/faction/alerts)
 * @param {Object} data - Response data from server.
 * @param {boolean} olderMsgs - Indicates if older messages were requested.
 * @param {boolean} ascendingTimestampOrder - Indicates if messages are in ascending timestamp order.
 */
function _handleChannel(channel, data, olderMsgs, ascendingTimestampOrder) {
  _requestRunning[channel] = false;
  document.querySelector(`#chatcontrols a[data-channel='${channel}']`)?.classList.remove('loading');

  if (!data || !data.result) {
    window.failedRequestCount++;
    return log.warn(`${channel} comm error. Waiting for next auto-refresh.`);
  }

  const channelEl = document.getElementById(`chat${channel}`);
  const channelState = channelEl ? IITC.chat.channelState(channelEl) : null;

  if (!data.result.length && !channelState?.needsClearing) {
    // no new data and current data in comm._faction.data is already rendered
    return;
  }

  if (channelState) channelState.needsClearing = null;

  if (!_channelsData[channel]) _initChannelData(channel);
  const old = _channelsData[channel].oldestGUID;
  IITC.comm._writeDataToHash(data, _channelsData[channel], olderMsgs, ascendingTimestampOrder);
  const oldMsgsWereAdded = old !== _channelsData[channel].oldestGUID;

  let hook = `${channel}ChatDataAvailable`;
  // backward compability
  if (channel === 'all') hook = 'publicChatDataAvailable';
  window.runHooks(hook, { raw: data, result: data.result, processed: _channelsData[channel].data });

  // generic hook
  window.runHooks('commDataAvailable', { channel: channel, raw: data, result: data.result, processed: _channelsData[channel].data });

  IITC.comm.renderChannel(channel, oldMsgsWereAdded);
}

/**
 * Renders intel chat.
 *
 * @memberof IITC.comm
 * @param {string} channel - Comm Intel channel (all/faction/alerts)
 * @param {boolean} oldMsgsWereAdded - Indicates if old messages were added in the current rendering.
 */
function renderChannel(channel, oldMsgsWereAdded) {
  if (!_channelsData[channel]) _initChannelData(channel);
  IITC.comm.renderData(_channelsData[channel].data, `chat${channel}`, oldMsgsWereAdded, _channelsData[channel].guids);
}

//
// Rendering primitive for markup, chat cells (td) and chat row (tr)
//

/**
 * Renders text for the chat, converting plain text to HTML and adding links.
 *
 * @memberof IITC.comm
 * @param {Object} text - An object containing the plain text to render.
 * @returns {string} The rendered HTML string.
 */
function renderText(text) {
  const content = document.createElement('div');

  if (text.team) {
    let teamId = window.teamStringToId(text.team);
    if (teamId === window.TEAM_NONE) teamId = window.TEAM_MAC;
    const span = document.createElement('span');
    span.className = window.TEAM_TO_CSS[teamId];
    span.textContent = text.plain;
    content.appendChild(span);
  } else {
    content.textContent = text.plain;
  }

  return content.innerHTML.autoLink();
}

/**
 * List of transformations for portal names used in chat.
 * Each transformation function takes the portal markup object and returns a transformed name.
 * If a transformation does not apply, the original name is returned.
 *
 * @const IITC.comm.portalNameTransformations
 * @example
 * // Adding a transformation that appends the portal location to its name
 * portalNameTransformations.push((markup) => {
 *   const latlng = `${markup.latE6 / 1E6},${markup.lngE6 / 1E6}`; // Convert E6 format to decimal
 *   return `[${latlng}] ${markup.name}`;
 * });
 */
const portalNameTransformations = [
  // Transformation for 'US Post Office'
  (markup) => {
    if (markup.name === 'US Post Office') {
      const address = markup.address.split(',');
      return `USPS: ${address[0]}`;
    }
    return markup.name;
  },
];

/**
 * Overrides portal names used repeatedly in chat, such as 'US Post Office', with more specific names.
 * Applies a series of transformations to the portal name based on the portal markup.
 *
 * @memberof IITC.comm
 * @param {Object} markup - An object containing portal markup, including the name and address.
 * @returns {string} The processed portal name.
 */
function getChatPortalName(markup) {
  // Use reduce to apply each transformation to the data
  const transformedData = portalNameTransformations.reduce((initialMarkup, transform) => {
    const updatedName = transform(initialMarkup);
    return { ...initialMarkup, name: updatedName };
  }, markup);

  return transformedData.name;
}

/**
 * Renders a portal link for use in the chat.
 *
 * @memberof IITC.comm
 * @param {Object} portal - The portal data.
 * @returns {string} HTML string of the portal link.
 */
function renderPortal(portal) {
  const lat = portal.latE6 / 1e6;
  const lng = portal.lngE6 / 1e6;
  const permalink = IITC.portal.display.makePermalink([lat, lng]);
  const portalName = IITC.comm.getChatPortalName(portal);

  return fillTemplate(IITC.comm.portalTemplate, {
    lat: lat.toString(),
    lng: lng.toString(),
    title: portal.address,
    url: permalink,
    portal_name: portalName,
  });
}

/**
 * Renders a faction entity for use in the chat.
 *
 * @memberof IITC.comm
 * @param {Object} faction - The faction data.
 * @returns {string} HTML string representing the faction.
 */
function renderFactionEnt(faction) {
  const teamId = window.teamStringToId(faction.team);
  const name = window.TEAM_NAMES[teamId];
  const spanClass = window.TEAM_TO_CSS[teamId];
  const span = document.createElement('span');
  span.className = spanClass;
  span.textContent = name;
  return span.outerHTML;
}

/**
 * Renders a player's nickname in chat.
 *
 * @memberof IITC.comm
 * @param {Object} player - The player object containing nickname and team.
 * @param {boolean} at - Whether to prepend '@' to the nickname.
 * @param {boolean} sender - Whether the player is the sender of a message.
 * @returns {string} The HTML string representing the player's nickname in chat.
 */
function renderPlayer(player, at, sender) {
  let name = player.plain;
  if (sender) {
    name = player.plain.replace(/: $/, '');
  } else if (at) {
    name = player.plain.replace(/^@/, '');
  }
  const thisToPlayer = name === window.PLAYER.nickname;
  const spanClass = `nickname ${thisToPlayer ? 'pl_nudge_me' : `${player.team} pl_nudge_player`}`;
  const span = document.createElement('span');
  span.className = spanClass;
  span.textContent = `${at ? '@' : ''}${name}`;
  return span.outerHTML;
}

/**
 * Renders a chat message entity based on its type.
 *
 * @memberof IITC.comm
 * @param {Array} ent - The entity array, where the first element is the type and the second element is the data.
 * @returns {string} The HTML string representing the chat message entity.
 */
function renderMarkupEntity(ent) {
  switch (ent[0]) {
    case 'TEXT':
      return IITC.comm.renderText(ent[1]);
    case 'PORTAL':
      return IITC.comm.renderPortal(ent[1]);
    case 'FACTION':
      return IITC.comm.renderFactionEnt(ent[1]);
    case 'SENDER':
      return IITC.comm.renderPlayer(ent[1], false, true);
    case 'PLAYER':
      return IITC.comm.renderPlayer(ent[1]);
    case 'AT_PLAYER':
      return IITC.comm.renderPlayer(ent[1], true);
    default:
  }
  const div = document.createElement('div');
  div.textContent = `${ent[0]}:<${ent[1].plain}>`;
  return div.innerHTML;
}

/**
 * Renders the markup of a chat message, converting special entities like player names, portals, etc., into HTML.
 *
 * @memberof IITC.comm
 * @param {Array} markup - The markup array of a chat message.
 * @returns {string} The HTML string representing the complete rendered chat message.
 */
function renderMarkup(markup) {
  let msg = '';

  markup.forEach((ent, ind) => {
    switch (ent[0]) {
      case 'SENDER':
      case 'SECURE':
        // skip as already handled
        break;

      case 'PLAYER': // automatically generated messages
        if (ind > 0) msg += IITC.comm.renderMarkupEntity(ent); // don’t repeat nick directly
        break;

      default:
        // add other enitities whatever the type
        msg += IITC.comm.renderMarkupEntity(ent);
        break;
    }
  });
  return msg;
}

/**
 * List of transformations to be applied to the message data.
 * Each transformation function takes the full message data object and returns the transformed markup.
 * The default transformations aim to convert the message markup into an older, more straightforward format,
 * facilitating easier understanding and backward compatibility with plugins expecting the older message format.
 *
 * @const IITC.comm.messageTransformFunctions
 * @example
 * // Adding a new transformation function to the array
 * // This new function adds a "new" prefix to the player's plain text if the player is from the RESISTANCE team
 * messageTransformFunctions.push((data) => {
 *   const markup = data.markup;
 *   if (markup.length > 2 && markup[0][0] === 'PLAYER' && markup[0][1].team === 'RESISTANCE') {
 *     markup[1][1].plain = 'new ' + markup[1][1].plain;
 *   }
 *   return markup;
 * });
 */
const messageTransformFunctions = [
  // Collapse <faction> + "Link"/"Field".
  (data) => {
    const markup = data.markup;
    if (
      markup.length > 4 &&
      markup[3][0] === 'FACTION' &&
      markup[4][0] === 'TEXT' &&
      (markup[4][1].plain === ' Link ' || markup[4][1].plain === ' Control Field @')
    ) {
      markup[4][1].team = markup[3][1].team;
      markup.splice(3, 1);
    }
    return markup;
  },
  // Skip "Agent <player>" at the beginning
  (data) => {
    const markup = data.markup;
    if (markup.length > 1 && markup[0][0] === 'TEXT' && markup[0][1].plain === 'Agent ' && markup[1][0] === 'PLAYER') {
      markup.splice(0, 2);
    }
    return markup;
  },
  // Skip "<faction> agent <player>" at the beginning
  (data) => {
    const markup = data.markup;
    if (markup.length > 2 && markup[0][0] === 'FACTION' && markup[1][0] === 'TEXT' && markup[1][1].plain === ' agent ' && markup[2][0] === 'PLAYER') {
      markup.splice(0, 3);
    }
    return markup;
  },
];

/**
 * Applies transformations to the markup array based on the transformations defined in
 * the {@link IITC.comm.messageTransformFunctions} array.
 * Assumes all transformations return a new markup array.
 * May be used to build an entirely new markup to be rendered without altering the original one.
 *
 * @memberof IITC.comm
 * @param {Object} data - The data for the message, including time, player, and message content.
 * @returns {Object} The transformed markup array.
 */
const transformMessage = (data) => {
  const initialData = JSON.parse(JSON.stringify(data));

  // Use reduce to apply each transformation to the data
  const transformedData = messageTransformFunctions.reduce((data, transform) => {
    const updatedMarkup = transform(data);
    return { ...data, markup: updatedMarkup };
  }, initialData);

  return transformedData.markup;
};

/**
 * Renders a cell in the chat table to display the time a message was sent.
 * Formats the time and adds it to a <time> HTML element with a tooltip showing the full date and time.
 *
 * @memberof IITC.comm
 * @param {number} unixtime - The timestamp of the message.
 * @param {string} classNames - Additional class names to be added to the time cell.
 * @returns {string} The HTML string representing a table cell with the formatted time.
 */
function renderTimeCell(unixtime, classNames) {
  const time = window.unixTimeToHHmm(unixtime);
  const datetime = window.unixTimeToDateTimeString(unixtime, true);
  // add <small> tags around the milliseconds
  const datetime_title = `${datetime.slice(0, 19)}<small class="milliseconds">${datetime.slice(19)}</small>`
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');

  return fillTemplate(IITC.comm.timeCellTemplate, {
    class_names: classNames,
    datetime: datetime,
    time_title: datetime_title,
    unixtime: unixtime.toString(),
    time: time,
  });
}

/**
 * Renders a cell in the chat table for a player's nickname.
 * Wraps the nickname in <mark> HTML element for highlighting.
 *
 * @memberof IITC.comm
 * @param {string} nick - The nickname of the player.
 * @param {string} classNames - Additional class names to be added to the nickname cell.
 * @returns {string} The HTML string representing a table cell with the player's nickname.
 */
function renderNickCell(nick, classNames) {
  return fillTemplate(IITC.comm.nickCellTemplate, { class_names: classNames, nick: nick });
}

/**
 * Renders a cell in the chat table for a chat message.
 * The message is inserted as inner HTML of the table cell.
 *
 * @memberof IITC.comm
 * @param {string} msg - The chat message to be displayed.
 * @param {string} classNames - Additional class names to be added to the message cell.
 * @returns {string} The HTML string representing a table cell with the chat message.
 */
function renderMsgCell(msg, classNames) {
  return fillTemplate(IITC.comm.msgCellTemplate, { class_names: classNames, msg: msg });
}

/**
 * Renders a row for a chat message including time, nickname, and message cells.
 *
 * @memberof IITC.comm
 * @param {Object} data - The data for the message, including time, player, and message content.
 * @returns {string} The HTML string representing a row in the chat table.
 */
function renderMsgRow(data) {
  const timeClass = data.msgToPlayer ? 'pl_nudge_date' : '';
  const timeCell = IITC.comm.renderTimeCell(data.time, timeClass);

  const nickClasses = ['nickname'];
  if (window.TEAM_TO_CSS[data.player.team]) {
    nickClasses.push(window.TEAM_TO_CSS[data.player.team]);
  }

  // highlight things said/done by the player in a unique colour
  // (similar to @player mentions from others in the chat text itself)
  if (data.player.name === window.PLAYER.nickname) {
    nickClasses.push('pl_nudge_me');
  }
  const nickCell = IITC.comm.renderNickCell(data.player.name, nickClasses.join(' '));

  const markup = IITC.comm.transformMessage(data);
  const msg = IITC.comm.renderMarkup(markup);
  const msgClass = data.narrowcast ? 'system_narrowcast' : '';
  const msgCell = IITC.comm.renderMsgCell(msg, msgClass);

  let className = '';
  if (!data.auto && data.public) {
    className = 'public';
  } else if (!data.auto && data.secure) {
    className = 'faction';
  }

  return fillTemplate(IITC.comm.msgRowTemplate, {
    class_names: className,
    guid: data.guid,
    time_cell: timeCell,
    nick_cell: nickCell,
    msg_cell: msgCell,
  });
}

/**
 * Renders a divider row in the chat table.
 *
 * @memberof IITC.comm
 * @param {string} text - Text to display within the divider row.
 * @returns {string} The HTML string representing a divider row in the chat table.
 */
function renderDivider(text) {
  return fillTemplate(IITC.comm.dividerTemplate, { text: text });
}

/**
 * Renders data from the data-hash to the element defined by the given ID.
 *
 * @memberof IITC.comm
 * @param {Object} data - Chat data to be rendered.
 * @param {string} element - ID of the DOM element to render the chat into.
 * @param {boolean} likelyWereOldMsgs - Flag indicating if older messages are likely to have been added.
 * @param {Array} sortedGuids - Sorted array of GUIDs representing the order of messages.
 */
function renderData(data, element, likelyWereOldMsgs, sortedGuids) {
  const elm = document.getElementById(element);
  if (!IITC.utils._isVisible(elm)) {
    return;
  }

  // if sortedGuids is not specified (legacy), sort old to new
  // (disregarding server order)
  let vals = sortedGuids;
  if (vals === undefined) {
    vals = Object.keys(data).sort((a, b) => data[a][0] - data[b][0]);
  }

  // render to string with date separators inserted
  let msgs = '';
  let prevTime = null;
  vals.forEach((guid) => {
    const msg = data[guid];
    if (IITC.comm.declarativeMessageFilter.filterMessage(msg[4])) return;
    const nextTime = new Date(msg[0]).toLocaleDateString();
    if (prevTime && prevTime !== nextTime) {
      msgs += IITC.comm.renderDivider(nextTime);
    }
    msgs += msg[2];
    prevTime = nextTime;
  });

  const firstRender = !elm.hasChildNodes();
  const scrollBefore = window.scrollBottom(elm);
  elm.innerHTML = `<table>${msgs}</table>`;

  const state = IITC.chat.channelState(elm);
  if (firstRender) {
    state.needsScrollTop = 99999999;
  } else {
    IITC.chat.keepScrollPosition(elm, scrollBefore, likelyWereOldMsgs);
  }

  if (state.needsScrollTop) {
    state.ignoreNextScroll = true;
    elm.scrollTop = state.needsScrollTop;
    state.needsScrollTop = null;
  }
}

for (const channel of _channels) {
  _initChannelData(channel.id);
}

IITC.comm = {
  channels: _channels,
  sendChatMessage,
  parseMsgData,
  getLatLngForSendingMessage,
  // List of transformations
  portalNameTransformations,
  messageTransformFunctions,
  // Render primitive, may be override
  renderMsgRow,
  renderDivider,
  renderTimeCell,
  renderNickCell,
  renderMsgCell,
  renderMarkup,
  transformMessage,
  renderMarkupEntity,
  renderPlayer,
  renderFactionEnt,
  renderPortal,
  renderText,
  getChatPortalName,
  // templates
  portalTemplate,
  timeCellTemplate,
  nickCellTemplate,
  msgCellTemplate,
  msgRowTemplate,
  dividerTemplate,
  // exposed API for legacy
  requestChannel,
  renderChannel,
  renderData,
  _channelsData,
  _genPostData,
  _updateOldNewHash,
  _writeDataToHash,
};