/*************************************************************
 *
 *  Copyright (c) 2018-2026 The MathJax Consortium
 *
 *  Licensed 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.
 */

/**
 * @file  Implements a lite CssStyleDeclaration replacement
 *                (very limited in scope)
 *
 * @author dpvc@mathjax.org (Davide Cervone)
 */

/**
 * An object contining name: value pairs
 */
export type StyleList = { [name: string]: string };

/**
 * Data for how to map a combined style (like border) to its children
 */
/* prettier-ignore */
export type connection = {
  children: string[],               // suffix names to add to the base name
  parts?: string[],                 // suffix names for sub-parts
  split: (name: string) => void,    // function to split the value for the children
  combine: (name: string) => void   // function to combine the child values when one changes
  subPart?: boolean                 // true means children combine to a different parent
};

/**
 * A collection of connections
 */
export type connections = { [name: string]: connection };

/*********************************************************/
/**
 * Some common children arrays
 */
export const TRBL = ['top', 'right', 'bottom', 'left'];
export const WSC = ['width', 'style', 'color'];

/**
 * Split a style at spaces (taking quotation marks and commas into account)
 *
 * @param {string} text  The combined styles to be split at spaces
 * @returns {string[]}   Array of parts of the style (separated by spaces)
 */
function splitSpaces(text: string): string[] {
  const parts = text.split(/((?:'[^'\n]*'|"[^"\n]*"|,[\s\n]|[^\s\n])*)/g);
  const split = [] as string[];
  while (parts.length > 1) {
    parts.shift();
    split.push(parts.shift());
  }
  return split;
}

/*********************************************************/
/**
 * Split a top-right-bottom-left group into its parts
 * Format:
 *    x           all are the same value
 *    x y         same as x y x y
 *    x y z       same as x y z y
 *    x y z w     each specified
 *
 * @param {string} name   The style to be processed
 */
function splitTRBL(name: string) {
  const parts = splitSpaces(this.styles[name]);
  if (parts.length === 0) {
    parts.push('');
  }
  if (parts.length === 1) {
    parts.push(parts[0]);
  }
  if (parts.length === 2) {
    parts.push(parts[0]);
  }
  if (parts.length === 3) {
    parts.push(parts[1]);
  }
  for (const child of Styles.connect[name].children) {
    this.setStyle(this.childName(name, child), parts.shift());
  }
}

/**
 * Combine top-right-bottom-left into one entry
 * (removing unneeded values)
 *
 * @param {string} name   The style to be processed
 */
function combineTRBL(name: string) {
  const children = Styles.connect[name].children;
  const parts = [] as string[];
  for (const child of children) {
    const part = this.styles[this.childName(name, child)];
    if (!part) {
      delete this.styles[name];
      return;
    }
    parts.push(part);
  }
  if (parts[3] === parts[1]) {
    parts.pop();
    if (parts[2] === parts[0]) {
      parts.pop();
      if (parts[1] === parts[0]) {
        parts.pop();
      }
    }
  }
  this.styles[name] = parts.join(' ');
}

/**
 * Combine styles for a given border part (e.g., border-color)
 *
 * @param {string} name  The name of the part to process
 */
function combinePart(name: string) {
  combineTRBL.call(this, name);
  this.combineChildren(name);
  combineSame.call(this, name);
  this.combineParent(name);
}

/*********************************************************/
/**
 * Use the same value for all children
 *
 * @param {string} name   The style to be processed
 */
function splitSame(name: string) {
  for (const child of Styles.connect[name].children) {
    this.setStyle(this.childName(name, child), this.styles[name]);
  }
}

/**
 * Check that all children have the same values and
 * if so, set the parent to that value
 *
 * @param {string} name   The style to be processed
 */
function combineSame(name: string) {
  if (!Styles.connect[name]) return;
  const children = [...Styles.connect[name].children];
  const value = this.styles[this.childName(name, children.shift())];
  for (const child of children) {
    if (this.styles[this.childName(name, child)] !== value) {
      delete this.styles[name];
      return;
    }
  }
  if (value) {
    this.styles[name] = value;
  }
}

/*********************************************************/
/**
 * Patterns for the parts of a boarder
 */
const BORDER: { [name: string]: RegExp } = {
  width: /^(?:[\d.]+(?:[a-z]+)|thin|medium|thick|inherit|initial|unset)$/,
  style:
    /^(?:none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset|inherit|initial|unset)$/,
};

/**
 * Split a width-style-color border definition
 *
 * @param {string} name   The style to be processed
 */
function splitWSC(name: string) {
  const parts = { width: '', style: '', color: '' } as StyleList;
  for (const part of splitSpaces(this.styles[name])) {
    if (part.match(BORDER.width) && parts.width === '') {
      parts.width = part;
    } else if (part.match(BORDER.style) && parts.style === '') {
      parts.style = part;
    } else {
      parts.color = part;
    }
  }
  for (const child of Styles.connect[name].children) {
    this.setStyle(this.childName(name, child), parts[child]);
  }
}

/**
 * Combine width-style-color border definition from children
 *
 * @param {string} name   The style to be processed
 */
function combineWSC(name: string) {
  const parts = [] as string[];
  for (const child of Styles.connect[name].children) {
    const value = this.styles[this.childName(name, child)];
    if (value) {
      parts.push(value);
    }
  }
  if (parts.length > 1) {
    this.styles[name] = parts.join(' ');
  } else {
    delete this.styles[name];
  }
}

/*********************************************************/
/**
 * Patterns for the parts of a font declaration
 */
const FONT: { [name: string]: RegExp } = {
  style: /^(?:normal|italic|oblique|inherit|initial|unset)$/,
  variant: new RegExp(
    '^(?:' +
      [
        'normal|none',
        'inherit|initial|unset',
        'common-ligatures|no-common-ligatures',
        'discretionary-ligatures|no-discretionary-ligatures',
        'historical-ligatures|no-historical-ligatures',
        'contextual|no-contextual',
        '(?:stylistic|character-variant|swash|ornaments|annotation)\\([^)]*\\)',
        'small-caps|all-small-caps|petite-caps|all-petite-caps|unicase|titling-caps',
        'lining-nums|oldstyle-nums|proportional-nums|tabular-nums',
        'diagonal-fractions|stacked-fractions',
        'ordinal|slashed-zero',
        'jis78|jis83|jis90|jis04|simplified|traditional',
        'full-width|proportional-width',
        'ruby',
      ].join('|') +
      ')$'
  ),
  weight: /^(?:normal|bold|bolder|lighter|[1-9]00|inherit|initial|unset)$/,
  stretch: new RegExp(
    '^(?:' +
      [
        'normal',
        '(?:(?:ultra|extra|semi)-)?(?:condensed|expanded)',
        'inherit|initial|unset',
      ].join('|') +
      ')$'
  ),
  size: new RegExp(
    '^(?:' +
      [
        'xx-small|x-small|small|medium|large|x-large|xx-large|larger|smaller',
        '[\\d.]+%|[\\d.]+[a-z]+',
        'inherit|initial|unset',
      ].join('|') +
      ')' +
      '(?:/(?:normal|[\\d.]+(?:%|[a-z]+)?))?$'
  ),
};

/**
 * Split a font declaration into is parts (not perfect but good enough for now)
 *
 * @param {string} name   The style to be processed
 */
function splitFont(name: string) {
  const parts = splitSpaces(this.styles[name]);
  //
  //  The parts found (array means can be more than one word)
  //
  const value = {
    style: '',
    variant: [],
    weight: '',
    stretch: '',
    size: '',
    family: '',
    'line-height': '',
  } as { [name: string]: string | string[] };
  for (const part of parts) {
    if (!value.family) {
      value.family = part; // assume it is family unless otherwise (family must be present)
    }
    for (const name of Object.keys(FONT)) {
      if (
        (Array.isArray(value[name]) || value[name] === '') &&
        part.match(FONT[name])
      ) {
        if (value.family === part) {
          value.family = '';
        }
        if (name === 'size') {
          //
          // Handle size/line-height
          //
          const [size, height] = part.split(/\//);
          value[name] = size;
          if (height) {
            value['line-height'] = height;
          }
        } else if (value.size === '') {
          //
          // style, weight, variant, stretch must appear before size
          //
          if (Array.isArray(value[name])) {
            (value[name] as string[]).push(part);
          } else if (value[name] === '') {
            value[name] = part;
          }
        }
      }
    }
  }
  saveFontParts.call(this, name, value);
  delete this.styles[name]; // only use the parts, not the font declaration itself
}

/**
 * @param {string} name   The style to be processed
 * @param {{[name: string]: string | string[]}} value  The list of parts detected above
 */
function saveFontParts(
  name: string,
  value: { [name: string]: string | string[] }
) {
  for (const child of Styles.connect[name].children) {
    const cname = this.childName(name, child);
    if (Array.isArray(value[child])) {
      const values = value[child] as string[];
      if (values.length) {
        this.styles[cname] = values.join(' ');
      }
    } else if (value[child] !== '') {
      this.styles[cname] = value[child];
    }
  }
}

/**
 * Combine font parts into one (we don't actually do that)
 *
 * @param {string} _name The font name
 */
function combineFont(_name: string) {}

/*********************************************************/
/**
 * Implements the Styles object (lite version of CssStyleDeclaration)
 */
export class Styles {
  /**
   * Patterns for style values and comments
   */
  public static pattern: { [name: string]: RegExp } = {
    sanitize: /['";]/,
    value:
      /^((:?'(?:\\.|[^'])*(?:'|$)|"(?:\\.|[^"])*(?:"|$)|\n|\\.|[^'";])*?)[\s\n]*(?:;|$).*/,
    style:
      /([-a-z]+)[\s\n]*:[\s\n]*((?:'(?:\\.|[^'])*(?:'|$)|"(?:\\.|[^"])*(?:"|$)|\n|\\.|[^'";])*?)[\s\n]*(?:;|$)/g,
    comment: /\/\*[^]*?\*\//g,
  };

  /**
   * The mapping of parents to children, and how to split and combine them
   */
  public static connect: connections = {
    padding: {
      children: TRBL,
      split: splitTRBL,
      combine: combineTRBL,
    },

    margin: {
      children: TRBL,
      split: splitTRBL,
      combine: combineTRBL,
    },

    border: {
      children: TRBL,
      parts: WSC,
      split: splitSame,
      combine: combineSame,
    },
    'border-top': {
      children: WSC,
      split: splitWSC,
      combine: combineWSC,
    },
    'border-right': {
      children: WSC,
      split: splitWSC,
      combine: combineWSC,
    },
    'border-bottom': {
      children: WSC,
      split: splitWSC,
      combine: combineWSC,
    },
    'border-left': {
      children: WSC,
      split: splitWSC,
      combine: combineWSC,
    },
    'border-width': {
      children: TRBL,
      split: splitTRBL,
      combine: combinePart,
      subPart: true,
    },
    'border-style': {
      children: TRBL,
      split: splitTRBL,
      combine: combinePart,
      subPart: true,
    },
    'border-color': {
      children: TRBL,
      split: splitTRBL,
      combine: combinePart,
      subPart: true,
    },

    font: {
      children: [
        'style',
        'variant',
        'weight',
        'stretch',
        'line-height',
        'size',
        'family',
      ],
      split: splitFont,
      combine: combineFont,
    },
  };

  /**
   * The list of styles defined for this declaration
   */
  protected styles: StyleList;

  /**
   * @param {string} cssText  The initial definition for the style
   * @class
   */
  constructor(cssText: string = '') {
    this.parse(cssText);
  }

  /**
   * @param {string} text  The value to be sanitized
   * @returns {string}     The sanitized value
   *                         (removes ; and anything past that, and balances quotation marks)
   */
  protected sanitizeValue(text: string): string {
    const PATTERN = (this.constructor as typeof Styles).pattern;
    if (!text.match(PATTERN.sanitize)) {
      return text;
    }
    text = text.replace(PATTERN.value, '$1');
    const test = text
      .replace(/\\./g, '')
      .replace(/(['"]).*?\1/g, '')
      .replace(/[^'"]/g, '');
    if (test.length) {
      text += test.charAt(0);
    }
    return text;
  }

  /**
   * @returns {string}  The CSS string for the styles currently defined
   */
  public get cssText(): string {
    const styles = [] as string[];
    for (const name of Object.keys(this.styles)) {
      const parent = this.parentName(name);
      const cname = name.replace(/.*-/, '');
      const pname = this.childName(this.parentName(parent), cname);
      if (
        this.styles[name] &&
        !this.styles[pname] &&
        (!this.styles[parent] ||
          !Styles.connect[parent]?.children?.includes(cname))
      ) {
        styles.push(`${name}: ${this.styles[name]};`);
      }
    }
    return styles.join(' ');
  }

  /**
   * @returns {StyleList} The object to map style names to the values
   */
  public get styleList(): StyleList {
    return { ...this.styles };
  }

  /**
   * @param {string} name   The name of the style to set
   * @param {string|number|boolean} value The value to set it to
   */
  public set(name: string, value: string | number | boolean) {
    name = this.normalizeName(name);
    this.setStyle(name, String(value));
    const connect = Styles.connect[name];
    if (connect?.subPart) {
      connect.combine.call(this, name);
      return;
    }
    this.combineParent(name);
    if (name.match(/-.*-/)) {
      const pname = name.replace(/-.*-/, '-');
      combineSame.call(this, pname);
    }
  }

  /**
   * When we change a child, we need to try to combine
   * it with its parent's other children.
   *
   * @param {string} name   The child that was changed
   */
  public combineParent(name: string) {
    while (name.match(/-/)) {
      const cname = name;
      name = this.parentName(name);
      const connect = Styles.connect[name];
      if (
        !Styles.connect[cname] &&
        !connect?.children?.includes(cname.substring(name.length + 1))
      ) {
        break;
      }
      connect.combine.call(this, name);
    }
    if (!this.styles[name]) {
      return;
    }
    const connect = Styles.connect[name];
    for (const cname of connect?.parts || []) {
      delete this.styles[this.childName(name, cname)];
    }
  }

  /**
   * @param {string} name  The name of the style to get
   * @returns {string}     The value of the style (or empty string if not defined)
   */
  public get(name: string): string {
    name = this.normalizeName(name);
    return Object.hasOwn(this.styles, name) ? this.styles[name] : '';
  }

  /**
   * @param {string} name   The name of the style to set (without causing parent updates)
   * @param {string} value  The value to set it to
   */
  protected setStyle(name: string, value: string) {
    this.styles[name] = this.sanitizeValue(value);
    if (Styles.connect[name]?.children) {
      Styles.connect[name].split.call(this, name);
    }
    if (value === '') {
      delete this.styles[name];
    }
  }

  /**
   * @param {string} name   The name of the style whose parent is to be combined
   */
  protected combineChildren(name: string) {
    const parent = this.parentName(name);
    for (const child of Styles.connect[name].children) {
      const cname = this.childName(parent, child);
      Styles.connect[cname].combine.call(this, cname);
    }
  }

  /**
   * @param {string} name   The name of the style whose parent style is to be found
   * @returns {string}      The name of the parent, or '' if none
   */
  protected parentName(name: string): string {
    const parent = name.replace(/-[^-]*$/, '');
    return name === parent ? '' : parent;
  }

  /**
   * @param {string} name   The name of the parent style
   * @param {string} child  The suffix to be added to the parent
   * @returns {string}      The combined name
   */
  protected childName(name: string, child: string): string {
    //
    // If the child contains a dash, it is already the full name
    //
    if (child.match(/-/)) {
      return child;
    }
    //
    // For sub-part styles, like border-width, insert
    //   the child name before the final word, e.g., border-top-width
    //
    if (Styles.connect[name]?.subPart) {
      child += name.replace(/.*-/, '-');
      name = this.parentName(name);
    }
    return name + '-' + child;
  }

  /**
   * @param {string} name  The name of a style to normalize
   * @returns {string}     The name converted from CamelCase to lowercase with dashes
   */
  protected normalizeName(name: string): string {
    return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
  }

  /**
   * @param {string} cssText  A style text string to be parsed into separate styles
   *                          (by using this.set(), we get all the sub-styles created
   *                           as well as the merged style shorthands)
   */
  protected parse(cssText: string = '') {
    const PATTERN = (this.constructor as typeof Styles).pattern;
    this.styles = {};
    const parts = cssText
      .replace(/\n/g, ' ')
      .replace(PATTERN.comment, '')
      .split(PATTERN.style);
    while (parts.length > 1) {
      const [space, name, value] = parts.splice(0, 3);
      if (space.match(/[^\s\n;]/)) return;
      this.set(name, value);
    }
  }
}
