import { visitSkip } from '../lib/util/visit.js';
import { findReferences, hasScripts } from '../lib/svgo/tools.js';

/**
 * @typedef CleanupIdsParams
 * @property {boolean=} remove
 * @property {boolean=} minify
 * @property {string[]=} preserve
 * @property {string[]=} preservePrefixes
 * @property {boolean=} force
 */

export const name = 'cleanupIds';
export const description = 'removes unused IDs and minifies used';

const generateIdChars = [
  'a',
  'b',
  'c',
  'd',
  'e',
  'f',
  'g',
  'h',
  'i',
  'j',
  'k',
  'l',
  'm',
  'n',
  'o',
  'p',
  'q',
  'r',
  's',
  't',
  'u',
  'v',
  'w',
  'x',
  'y',
  'z',
  'A',
  'B',
  'C',
  'D',
  'E',
  'F',
  'G',
  'H',
  'I',
  'J',
  'K',
  'L',
  'M',
  'N',
  'O',
  'P',
  'Q',
  'R',
  'S',
  'T',
  'U',
  'V',
  'W',
  'X',
  'Y',
  'Z',
];
const maxIdIndex = generateIdChars.length - 1;

/**
 * Check if an ID starts with any one of a list of strings.
 *
 * @param {string} string
 * @param {ReadonlyArray<string>} prefixes
 * @returns {boolean}
 */
const hasStringPrefix = (string, prefixes) => {
  for (const prefix of prefixes) {
    if (string.startsWith(prefix)) {
      return true;
    }
  }
  return false;
};

/**
 * Generate unique minimal ID.
 *
 * @param {?number[]} currentId
 * @returns {number[]}
 */
const generateId = (currentId) => {
  if (currentId == null) {
    return [0];
  }
  currentId[currentId.length - 1] += 1;
  for (let i = currentId.length - 1; i > 0; i--) {
    if (currentId[i] > maxIdIndex) {
      currentId[i] = 0;
      if (currentId[i - 1] !== undefined) {
        currentId[i - 1]++;
      }
    }
  }
  if (currentId[0] > maxIdIndex) {
    currentId[0] = 0;
    currentId.unshift(0);
  }
  return currentId;
};

/**
 * Get string from generated ID array.
 *
 * @param {ReadonlyArray<number>} arr
 * @returns {string}
 */
const getIdString = (arr) => {
  return arr.map((i) => generateIdChars[i]).join('');
};

/**
 * Remove unused and minify used IDs (only if there are no `<style>` or
 * `<script>` nodes).
 *
 * @author Kir Belevich
 *
 * @type {import('../lib/types.js').Plugin<CleanupIdsParams>}
 */
export const fn = (_root, params) => {
  const {
    remove = true,
    minify = true,
    preserve = [],
    preservePrefixes = [],
    force = false,
  } = params;
  const preserveIds = new Set(
    Array.isArray(preserve) ? preserve : preserve ? [preserve] : [],
  );
  const preserveIdPrefixes = Array.isArray(preservePrefixes)
    ? preservePrefixes
    : preservePrefixes
      ? [preservePrefixes]
      : [];
  /** @type {Map<string, import('../lib/types.js').XastElement>} */
  const nodeById = new Map();
  /** @type {Map<string, {element: import('../lib/types.js').XastElement, name: string }[]>} */
  const referencesById = new Map();
  let deoptimized = false;

  return {
    element: {
      enter: (node) => {
        if (!force) {
          // deoptimize if style or scripts are present
          if (
            (node.name === 'style' && node.children.length !== 0) ||
            hasScripts(node)
          ) {
            deoptimized = true;
            return;
          }

          // avoid removing IDs if the whole SVG consists only of defs
          if (node.name === 'svg') {
            let hasDefsOnly = true;
            for (const child of node.children) {
              if (child.type !== 'element' || child.name !== 'defs') {
                hasDefsOnly = false;
                break;
              }
            }
            if (hasDefsOnly) {
              return visitSkip;
            }
          }
        }

        for (const [name, value] of Object.entries(node.attributes)) {
          if (name === 'id') {
            // collect all ids
            const id = value;
            if (nodeById.has(id)) {
              delete node.attributes.id; // remove repeated id
            } else {
              nodeById.set(id, node);
            }
          } else {
            const ids = findReferences(name, value);
            for (const id of ids) {
              let refs = referencesById.get(id);
              if (refs == null) {
                refs = [];
                referencesById.set(id, refs);
              }
              refs.push({ element: node, name });
            }
          }
        }
      },
    },

    root: {
      exit: () => {
        if (deoptimized) {
          return;
        }
        /**
         * @param {string} id
         * @returns {boolean}
         */
        const isIdPreserved = (id) =>
          preserveIds.has(id) || hasStringPrefix(id, preserveIdPrefixes);
        /** @type {?number[]} */
        let currentId = null;
        for (const [id, refs] of referencesById) {
          const node = nodeById.get(id);
          if (node != null) {
            // replace referenced IDs with the minified ones
            if (minify && isIdPreserved(id) === false) {
              /** @type {?string} */
              let currentIdString;
              do {
                currentId = generateId(currentId);
                currentIdString = getIdString(currentId);
              } while (
                isIdPreserved(currentIdString) ||
                (referencesById.has(currentIdString) &&
                  nodeById.get(currentIdString) == null)
              );
              node.attributes.id = currentIdString;
              for (const { element, name } of refs) {
                const value = element.attributes[name];
                if (value.includes('#')) {
                  // replace id in href and url()
                  element.attributes[name] = value
                    .replace(`#${encodeURI(id)}`, `#${currentIdString}`)
                    .replace(`#${id}`, `#${currentIdString}`);
                } else {
                  // replace id in begin attribute
                  element.attributes[name] = value.replace(
                    `${id}.`,
                    `${currentIdString}.`,
                  );
                }
              }
            }
            // keep referenced node
            nodeById.delete(id);
          }
        }
        // remove non-referenced IDs attributes from elements
        if (remove) {
          for (const [id, node] of nodeById) {
            if (isIdPreserved(id) === false) {
              delete node.attributes.id;
            }
          }
        }
      },
    },
  };
};
