/**
 * This file is a fork of https://github.com/reworkcss/css/blob/master/lib/parse/index.js
 * I fork it because:
 * 1. The css library was built for node.js which does not have tree-shaking supports.
 * 2. Rewrites into typescript give us a better type interface.
 */
/* eslint-disable tsdoc/syntax */

export interface ParserOptions {
  /** Silently fail on parse errors */
  silent?: boolean;
  /**
   * The path to the file containing css.
   * Makes errors and source maps more helpful, by letting them know where code comes from.
   */
  source?: string;
}

/**
 * Error thrown during parsing.
 */
export interface ParserError {
  /** The full error message with the source position. */
  message?: string;
  /** The error message without position. */
  reason?: string;
  /** The value of options.source if passed to css.parse. Otherwise undefined. */
  filename?: string;
  line?: number;
  column?: number;
  /** The portion of code that couldn't be parsed. */
  source?: string;
}

export interface Loc {
  line?: number;
  column?: number;
}

/**
 * Base AST Tree Node.
 */
export interface Node {
  /** The possible values are the ones listed in the Types section on https://github.com/reworkcss/css page. */
  type?: string;
  /** A reference to the parent node, or null if the node has no parent. */
  parent?: Node;
  /** Information about the position in the source string that corresponds to the node. */
  position?: {
    start?: Loc;
    end?: Loc;
    /** The value of options.source if passed to css.parse. Otherwise undefined. */
    source?: string;
    /** The full source string passed to css.parse. */
    content?: string;
  };
}

export interface Rule extends Node {
  /** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */
  selectors?: string[];
  /** Array of nodes with the types declaration and comment. */
  declarations?: Array<Declaration | Comment>;
}

export interface Declaration extends Node {
  /** The property name, trimmed from whitespace and comments. May not be empty. */
  property?: string;
  /** The value of the property, trimmed from whitespace and comments. Empty values are allowed. */
  value?: string;
}

/**
 * A rule-level or declaration-level comment. Comments inside selectors, properties and values etc. are lost.
 */
export interface Comment extends Node {
  comment?: string;
}

/**
 * The @charset at-rule.
 */
export interface Charset extends Node {
  /** The part following @charset. */
  charset?: string;
}

/**
 * The @custom-media at-rule
 */
export interface CustomMedia extends Node {
  /** The ---prefixed name. */
  name?: string;
  /** The part following the name. */
  media?: string;
}

/**
 * The @document at-rule.
 */
export interface Document extends Node {
  /** The part following @document. */
  document?: string;
  /** The vendor prefix in @document, or undefined if there is none. */
  vendor?: string;
  /** Array of nodes with the types rule, comment and any of the at-rule types. */
  rules?: Array<Rule | Comment | AtRule>;
}

/**
 * The @font-face at-rule.
 */
export interface FontFace extends Node {
  /** Array of nodes with the types declaration and comment. */
  declarations?: Array<Declaration | Comment>;
}

/**
 * The @host at-rule.
 */
export interface Host extends Node {
  /** Array of nodes with the types rule, comment and any of the at-rule types. */
  rules?: Array<Rule | Comment | AtRule>;
}

/**
 * The @import at-rule.
 */
export interface Import extends Node {
  /** The part following @import. */
  import?: string;
}

/**
 * The @keyframes at-rule.
 */
export interface KeyFrames extends Node {
  /** The name of the keyframes rule. */
  name?: string;
  /** The vendor prefix in @keyframes, or undefined if there is none. */
  vendor?: string;
  /** Array of nodes with the types keyframe and comment. */
  keyframes?: Array<KeyFrame | Comment>;
}

export interface KeyFrame extends Node {
  /** The list of "selectors" of the keyframe rule, split on commas. Each “selector” is trimmed from whitespace. */
  values?: string[];
  /** Array of nodes with the types declaration and comment. */
  declarations?: Array<Declaration | Comment>;
}

/**
 * The @media at-rule.
 */
export interface Media extends Node {
  /** The part following @media. */
  media?: string;
  /** Array of nodes with the types rule, comment and any of the at-rule types. */
  rules?: Array<Rule | Comment | AtRule>;
}

/**
 * The @namespace at-rule.
 */
export interface Namespace extends Node {
  /** The part following @namespace. */
  namespace?: string;
}

/**
 * The @page at-rule.
 */
export interface Page extends Node {
  /** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */
  selectors?: string[];
  /** Array of nodes with the types declaration and comment. */
  declarations?: Array<Declaration | Comment>;
}

/**
 * The @supports at-rule.
 */
export interface Supports extends Node {
  /** The part following @supports. */
  supports?: string;
  /** Array of nodes with the types rule, comment and any of the at-rule types. */
  rules?: Array<Rule | Comment | AtRule>;
}

/** All at-rules. */
export type AtRule =
  | Charset
  | CustomMedia
  | Document
  | FontFace
  | Host
  | Import
  | KeyFrames
  | Media
  | Namespace
  | Page
  | Supports;

/**
 * A collection of rules
 */
export interface StyleRules {
  source?: string;
  /** Array of nodes with the types rule, comment and any of the at-rule types. */
  rules: Array<Rule | Comment | AtRule>;
  /** Array of Errors. Errors collected during parsing when option silent is true. */
  parsingErrors?: ParserError[];
}

/**
 * The root node returned by css.parse.
 */
export interface Stylesheet extends Node {
  stylesheet?: StyleRules;
}

// http://www.w3.org/TR/CSS21/grammar.html
// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027
const commentre = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g;

export function parse(css: string, options: ParserOptions = {}) {
  /**
   * Positional.
   */

  let lineno = 1;
  let column = 1;

  /**
   * Update lineno and column based on `str`.
   */

  function updatePosition(str: string) {
    const lines = str.match(/\n/g);
    if (lines) {
      lineno += lines.length;
    }
    const i = str.lastIndexOf('\n');
    column = i === -1 ? column + str.length : str.length - i;
  }

  /**
   * Mark position and patch `node.position`.
   */

  function position() {
    const start = { line: lineno, column };
    return (
      node: Rule | Declaration | Comment | AtRule | Stylesheet | KeyFrame,
    ) => {
      node.position = new Position(start);
      whitespace();
      return node;
    };
  }

  /**
   * Store position information for a node
   */

  class Position {
    public content!: string;
    public start!: Loc;
    public end!: Loc;
    public source?: string;

    constructor(start: Loc) {
      this.start = start;
      this.end = { line: lineno, column };
      this.source = options.source;
    }
  }

  /**
   * Non-enumerable source string
   */

  Position.prototype.content = css;

  const errorsList: ParserError[] = [];

  function error(msg: string) {
    const err = new Error(
      `${options.source || ''}:${lineno}:${column}: ${msg}`,
    ) as ParserError;
    err.reason = msg;
    err.filename = options.source;
    err.line = lineno;
    err.column = column;
    err.source = css;

    if (options.silent) {
      errorsList.push(err);
    } else {
      throw err;
    }
  }

  /**
   * Parse stylesheet.
   */

  function stylesheet(): Stylesheet {
    const rulesList = rules();

    return {
      type: 'stylesheet',
      stylesheet: {
        source: options.source,
        rules: rulesList,
        parsingErrors: errorsList,
      },
    };
  }

  /**
   * Opening brace.
   */

  function open() {
    return match(/^{\s*/);
  }

  /**
   * Closing brace.
   */

  function close() {
    return match(/^}/);
  }

  /**
   * Parse ruleset.
   */

  function rules() {
    let node: Rule | void;
    const rules: Rule[] = [];
    whitespace();
    comments(rules);
    while (css.length && css.charAt(0) !== '}' && (node = atrule() || rule())) {
      if (node !== false) {
        rules.push(node);
        comments(rules);
      }
    }
    return rules;
  }

  /**
   * Match `re` and return captures.
   */

  function match(re: RegExp) {
    const m = re.exec(css);
    if (!m) {
      return;
    }
    const str = m[0];
    updatePosition(str);
    css = css.slice(str.length);
    return m;
  }

  /**
   * Parse whitespace.
   */

  function whitespace() {
    match(/^\s*/);
  }

  /**
   * Parse comments;
   */

  function comments(rules: Rule[] = []) {
    let c: Comment | void;
    while ((c = comment())) {
      if (c !== false) {
        rules.push(c);
      }
      c = comment();
    }
    return rules;
  }

  /**
   * Parse comment.
   */

  function comment() {
    const pos = position();
    if ('/' !== css.charAt(0) || '*' !== css.charAt(1)) {
      return;
    }

    let i = 2;
    while (
      '' !== css.charAt(i) &&
      ('*' !== css.charAt(i) || '/' !== css.charAt(i + 1))
    ) {
      ++i;
    }
    i += 2;

    if ('' === css.charAt(i - 1)) {
      return error('End of comment missing');
    }

    const str = css.slice(2, i - 2);
    column += 2;
    updatePosition(str);
    css = css.slice(i);
    column += 2;

    return pos({
      type: 'comment',
      comment: str,
    });
  }

  /**
   * Parse selector.
   */

  function selector() {
    const m = match(/^([^{]+)/);
    if (!m) {
      return;
    }
    /* @fix Remove all comments from selectors
     * http://ostermiller.org/findcomment.html */
    return trim(m[0])
      .replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '')
      .replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m) => {
        return m.replace(/,/g, '\u200C');
      })
      .split(/\s*(?![^(]*\)),\s*/)
      .map((s) => {
        return s.replace(/\u200C/g, ',');
      });
  }

  /**
   * Parse declaration.
   */

  function declaration(): Declaration | void | never {
    const pos = position();

    // prop
    // eslint-disable-next-line no-useless-escape
    const propMatch = match(/^(\*?[-#\/\*\\\w]+(\[[0-9a-z_-]+\])?)\s*/);
    if (!propMatch) {
      return;
    }
    const prop = trim(propMatch[0]);

    // :
    if (!match(/^:\s*/)) {
      return error(`property missing ':'`);
    }

    // val
    // eslint-disable-next-line no-useless-escape
    const val = match(/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^\)]*?\)|[^};])+)/);

    const ret = pos({
      type: 'declaration',
      property: prop.replace(commentre, ''),
      value: val ? trim(val[0]).replace(commentre, '') : '',
    });

    // ;
    match(/^[;\s]*/);

    return ret;
  }

  /**
   * Parse declarations.
   */

  function declarations() {
    const decls: Array<object> = [];

    if (!open()) {
      return error(`missing '{'`);
    }
    comments(decls);

    // declarations
    let decl;
    while ((decl = declaration())) {
      if ((decl as unknown) !== false) {
        decls.push(decl);
        comments(decls);
      }
      decl = declaration();
    }

    if (!close()) {
      return error(`missing '}'`);
    }
    return decls;
  }

  /**
   * Parse keyframe.
   */

  function keyframe() {
    let m;
    const vals = [];
    const pos = position();

    while ((m = match(/^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/))) {
      vals.push(m[1]);
      match(/^,\s*/);
    }

    if (!vals.length) {
      return;
    }

    return pos({
      type: 'keyframe',
      values: vals,
      declarations: declarations() as Declaration[],
    });
  }

  /**
   * Parse keyframes.
   */

  function atkeyframes() {
    const pos = position();
    let m = match(/^@([-\w]+)?keyframes\s*/);

    if (!m) {
      return;
    }
    const vendor = m[1];

    // identifier
    m = match(/^([-\w]+)\s*/);
    if (!m) {
      return error('@keyframes missing name');
    }
    const name = m[1];

    if (!open()) {
      return error(`@keyframes missing '{'`);
    }

    let frame;
    let frames = comments();
    while ((frame = keyframe())) {
      frames.push(frame);
      frames = frames.concat(comments());
    }

    if (!close()) {
      return error(`@keyframes missing '}'`);
    }

    return pos({
      type: 'keyframes',
      name,
      vendor,
      keyframes: frames,
    });
  }

  /**
   * Parse supports.
   */

  function atsupports() {
    const pos = position();
    const m = match(/^@supports *([^{]+)/);

    if (!m) {
      return;
    }
    const supports = trim(m[1]);

    if (!open()) {
      return error(`@supports missing '{'`);
    }

    const style = comments().concat(rules());

    if (!close()) {
      return error(`@supports missing '}'`);
    }

    return pos({
      type: 'supports',
      supports,
      rules: style,
    });
  }

  /**
   * Parse host.
   */

  function athost() {
    const pos = position();
    const m = match(/^@host\s*/);

    if (!m) {
      return;
    }

    if (!open()) {
      return error(`@host missing '{'`);
    }

    const style = comments().concat(rules());

    if (!close()) {
      return error(`@host missing '}'`);
    }

    return pos({
      type: 'host',
      rules: style,
    });
  }

  /**
   * Parse media.
   */

  function atmedia() {
    const pos = position();
    const m = match(/^@media *([^{]+)/);

    if (!m) {
      return;
    }
    const media = trim(m[1]);

    if (!open()) {
      return error(`@media missing '{'`);
    }

    const style = comments().concat(rules());

    if (!close()) {
      return error(`@media missing '}'`);
    }

    return pos({
      type: 'media',
      media,
      rules: style,
    });
  }

  /**
   * Parse custom-media.
   */

  function atcustommedia() {
    const pos = position();
    const m = match(/^@custom-media\s+(--[^\s]+)\s*([^{;]+);/);
    if (!m) {
      return;
    }

    return pos({
      type: 'custom-media',
      name: trim(m[1]),
      media: trim(m[2]),
    });
  }

  /**
   * Parse paged media.
   */

  function atpage() {
    const pos = position();
    const m = match(/^@page */);
    if (!m) {
      return;
    }

    const sel = selector() || [];

    if (!open()) {
      return error(`@page missing '{'`);
    }
    let decls = comments();

    // declarations
    let decl;
    while ((decl = declaration())) {
      decls.push(decl);
      decls = decls.concat(comments());
    }

    if (!close()) {
      return error(`@page missing '}'`);
    }

    return pos({
      type: 'page',
      selectors: sel,
      declarations: decls,
    });
  }

  /**
   * Parse document.
   */

  function atdocument() {
    const pos = position();
    const m = match(/^@([-\w]+)?document *([^{]+)/);
    if (!m) {
      return;
    }

    const vendor = trim(m[1]);
    const doc = trim(m[2]);

    if (!open()) {
      return error(`@document missing '{'`);
    }

    const style = comments().concat(rules());

    if (!close()) {
      return error(`@document missing '}'`);
    }

    return pos({
      type: 'document',
      document: doc,
      vendor,
      rules: style,
    });
  }

  /**
   * Parse font-face.
   */

  function atfontface() {
    const pos = position();
    const m = match(/^@font-face\s*/);
    if (!m) {
      return;
    }

    if (!open()) {
      return error(`@font-face missing '{'`);
    }
    let decls = comments();

    // declarations
    let decl;
    while ((decl = declaration())) {
      decls.push(decl);
      decls = decls.concat(comments());
    }

    if (!close()) {
      return error(`@font-face missing '}'`);
    }

    return pos({
      type: 'font-face',
      declarations: decls,
    });
  }

  /**
   * Parse import
   */

  const atimport = _compileAtrule('import');

  /**
   * Parse charset
   */

  const atcharset = _compileAtrule('charset');

  /**
   * Parse namespace
   */

  const atnamespace = _compileAtrule('namespace');

  /**
   * Parse non-block at-rules
   */

  function _compileAtrule(name: string) {
    const re = new RegExp('^@' + name + '\\s*([^;]+);');
    return () => {
      const pos = position();
      const m = match(re);
      if (!m) {
        return;
      }
      const ret: Record<string, string> = { type: name };
      ret[name] = m[1].trim();
      return pos(ret);
    };
  }

  /**
   * Parse at rule.
   */

  function atrule() {
    if (css[0] !== '@') {
      return;
    }

    return (
      atkeyframes() ||
      atmedia() ||
      atcustommedia() ||
      atsupports() ||
      atimport() ||
      atcharset() ||
      atnamespace() ||
      atdocument() ||
      atpage() ||
      athost() ||
      atfontface()
    );
  }

  /**
   * Parse rule.
   */

  function rule() {
    const pos = position();
    const sel = selector();

    if (!sel) {
      return error('selector missing');
    }
    comments();

    return pos({
      type: 'rule',
      selectors: sel,
      declarations: declarations() as Declaration[],
    });
  }

  return addParent(stylesheet());
}

/**
 * Trim `str`.
 */

function trim(str: string) {
  return str ? str.replace(/^\s+|\s+$/g, '') : '';
}

/**
 * Adds non-enumerable parent node reference to each node.
 */

function addParent(obj: Stylesheet, parent?: Stylesheet) {
  const isNode = obj && typeof obj.type === 'string';
  const childParent = isNode ? obj : parent;

  for (const k of Object.keys(obj)) {
    const value = obj[k as keyof Stylesheet];
    if (Array.isArray(value)) {
      value.forEach((v) => {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
        addParent(v, childParent);
      });
    } else if (value && typeof value === 'object') {
      addParent(value as Stylesheet, childParent);
    }
  }

  if (isNode) {
    Object.defineProperty(obj, 'parent', {
      configurable: true,
      writable: true,
      enumerable: false,
      value: parent || null,
    });
  }

  return obj;
}
