const ARRAY = "array";
const BOOLEAN = "boolean";
const DATE = "date";
const NULL = "null";
const NUMBER = "number";
const OBJECT = "object";
const SPECIAL_OBJECT = "special-object";
const STRING = "string";

const PRIVATE_VARS = ["_selfCloseTag", "_attrs"];
const PRIVATE_VARS_REGEXP = new RegExp(PRIVATE_VARS.join("|"), "g");

/**
 * Determines the indent string based on current tree depth.
 */
const getIndentStr = (indent = "", depth = 0) => indent.repeat(depth);

/**
 * Sugar function supplementing JS's quirky typeof operator, plus some extra help to detect
 * "special objects" expected by jstoxml.
 * Example:
 * getType(new Date());
 * -> 'date'
 */
const getType = (val) =>
  (Array.isArray(val) && ARRAY) ||
  (typeof val === OBJECT && val !== null && val._name && SPECIAL_OBJECT) ||
  (val instanceof Date && DATE) ||
  (val === null && NULL) ||
  typeof val;

/**
 * Replaces matching values in a string with a new value.
 * Example:
 * filterStr('foo&bar', { '&': '&amp;' });
 * -> 'foo&amp;bar'
 */
const filterStr = (inputStr = "", filter = {}) => {
  // Passthrough/no-op for nonstrings (e.g. number, boolean).
  if (typeof inputStr !== "string") {
    return inputStr;
  }

  const regexp = new RegExp(
    `(${Object.keys(filter).join("|")})(?!(\\w|#)*;)`,
    "g"
  );

  return String(inputStr).replace(
    regexp,
    (str, entity) => filter[entity] || ""
  );
};

/**
 * Maps an object or array of arribute keyval pairs to a string.
 * Examples:
 * { foo: 'bar', baz: 'g' } -> 'foo="bar" baz="g"'
 * [ { ⚡: true }, { foo: 'bar' } ] -> '⚡ foo="bar"'
 */
const getAttributeKeyVals = (attributes = {}, filter) => {
  let keyVals = [];
  if (Array.isArray(attributes)) {
    // Array containing complex objects and potentially duplicate attributes.
    keyVals = attributes.map((attr) => {
      const key = Object.keys(attr)[0];
      const val = attr[key];

      const filteredVal = filter ? filterStr(val, filter) : val;
      const valStr = filteredVal === true ? "" : `="${filteredVal}"`;
      return `${key}${valStr}`;
    });
  } else {
    const keys = Object.keys(attributes);
    keyVals = keys.map((key) => {
      // Simple object - keyval pairs.

      // For boolean true, simply output the key.
      const filteredVal = filter
        ? filterStr(attributes[key], filter)
        : attributes[key];
      const valStr = attributes[key] === true ? "" : `="${filteredVal}"`;

      return `${key}${valStr}`;
    });
  }

  return keyVals;
};

/**
 * Converts an attributes object/array to a string of keyval pairs.
 * Example:
 * formatAttributes({ a: 1, b: 2 })
 * -> 'a="1" b="2"'
 */
const formatAttributes = (attributes = {}, filter) => {
  const keyVals = getAttributeKeyVals(attributes, filter);
  if (keyVals.length === 0) return "";

  const keysValsJoined = keyVals.join(" ");
  return ` ${keysValsJoined}`;
};

/**
 * Converts an object to a jstoxml array.
 * Example:
 * objToArray({ foo: 'bar', baz: 2 });
 * ->
 * [
 *   {
 *     _name: 'foo',
 *     _content: 'bar'
 *   },
 *   {
 *     _name: 'baz',
 *     _content: 2
 *   }
 * ]
 */
const objToArray = (obj = {}) =>
  Object.keys(obj).map((key) => {
    return {
      _name: key,
      _content: obj[key],
    };
  });

/**
 * Determines if a value is a primitive JavaScript value (not including Symbol).
 * Example:
 * isPrimitive(4);
 * -> true
 */
const PRIMITIVE_TYPES = [STRING, NUMBER, BOOLEAN];
const isPrimitive = (val) => PRIMITIVE_TYPES.includes(getType(val));

/**
 * Determines if a value is a simple primitive type that can fit onto one line.  Needed for
 * determining any needed indenting and line breaks.
 * Example:
 * isSimpleType(new Date());
 * -> true
 */
const SIMPLE_TYPES = [...PRIMITIVE_TYPES, DATE, SPECIAL_OBJECT];
const isSimpleType = (val) => SIMPLE_TYPES.includes(getType(val));
/**
 * Determines if an XML string is a simple primitive, or contains nested data.
 * Example:
 * isSimpleXML('<foo />');
 * -> false
 */
const isSimpleXML = (xmlStr) => !xmlStr.match("<");

/**
 * Assembles an XML header as defined by the config.
 */
const DEFAULT_XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>';
const getHeaderString = ({ header, indent, isOutputStart /*, depth */ }) => {
  const shouldOutputHeader = header && isOutputStart;
  if (!shouldOutputHeader) return "";

  const shouldUseDefaultHeader = typeof header === BOOLEAN;
  // return `${shouldUseDefaultHeader ? DEFAULT_XML_HEADER : header}${indent ? "\n" : ""
  //   }`;
  return shouldUseDefaultHeader ? DEFAULT_XML_HEADER : header;
};

/**
 * Recursively traverses an object tree and converts the output to an XML string.
 * Example:
 * toXML({ foo: 'bar' });
 * -> <foo>bar</foo>
 */
const defaultEntityFilter = {
  "<": "&lt;",
  ">": "&gt;",
  "&": "&amp;",
};
export const toXML = (obj = {}, config = {}) => {
  const {
    // Tree depth
    depth = 0,
    indent,
    _isFirstItem,
    // _isLastItem,
    _isOutputStart = true,
    header,
    attributesFilter: rawAttributesFilter = {},
    filter: rawFilter = {},
  } = config;

  const shouldTurnOffAttributesFilter = typeof rawAttributesFilter === 'boolean' && !rawAttributesFilter;
  const attributesFilter = shouldTurnOffAttributesFilter ? {} : {
    ...defaultEntityFilter,
    ...{ '"': "&quot;" },
    ...rawAttributesFilter,
  };

  const shouldTurnOffFilter = typeof rawFilter === 'boolean' && !rawFilter;
  const filter = shouldTurnOffFilter ? {} : { ...defaultEntityFilter, ...rawFilter };

  // Determine indent string based on depth.
  const indentStr = getIndentStr(indent, depth);

  // For branching based on value type.
  const valType = getType(obj);

  const headerStr = getHeaderString({ header, indent, depth, isOutputStart: _isOutputStart });

  const isOutputStart = _isOutputStart && !headerStr && _isFirstItem && depth === 0;

  let outputStr = "";
  switch (valType) {
    case "special-object": {
      // Processes a specially-formatted object used by jstoxml.

      const { _name, _content } = obj;

      // Output text content without a tag wrapper.
      if (_content === null) {
        outputStr = _name;
        break;
      }

      // Handles arrays of primitive values. (#33)
      const isArrayOfPrimitives =
        Array.isArray(_content) && _content.every(isPrimitive);
      if (isArrayOfPrimitives) {
        const primitives = _content
          .map((a) => {
            return toXML(
              {
                _name,
                _content: a,
              },
              {
                ...config,
                depth,
                _isOutputStart: false
              }
            );
          });
        return primitives.join('');
      }

      // Don't output private vars (such as _attrs).
      if (_name.match(PRIVATE_VARS_REGEXP)) break;

      // Process the nested new value and create new config.
      const newVal = toXML(_content, { ...config, depth: depth + 1, _isOutputStart: isOutputStart });
      const newValType = getType(newVal);
      const isNewValSimple = isSimpleXML(newVal);

      // Pre-tag output (indent and line breaks).
      const preIndentStr = (indent && !isOutputStart) ? "\n" : "";
      const preTag = `${preIndentStr}${indentStr}`;

      // Special handling for comments, preserving preceding line breaks/indents.
      if (_name === '_comment') {
        outputStr += `${preTag}<!-- ${_content} -->`;
        break;
      }

      // Tag output.
      const valIsEmpty = newValType === "undefined" || newVal === "";
      const shouldSelfClose =
        typeof obj._selfCloseTag === BOOLEAN
          ? valIsEmpty && obj._selfCloseTag
          : valIsEmpty;
      const selfCloseStr = shouldSelfClose ? "/" : "";
      const attributesString = formatAttributes(obj._attrs, attributesFilter);
      const tag = `<${_name}${attributesString}${selfCloseStr}>`;

      // Post-tag output (closing tag, indent, line breaks).
      const preTagCloseStr = indent && !isNewValSimple ? `\n${indentStr}` : "";
      const postTag = !shouldSelfClose
        ? `${newVal}${preTagCloseStr}</${_name}>`
        : "";
      outputStr += `${preTag}${tag}${postTag}`;
      break;
    }

    case "object": {
      // Iterates over keyval pairs in an object, converting each item to a special-object.

      const keys = Object.keys(obj);
      const outputArr = keys.map((key, index) => {
        const newConfig = {
          ...config,
          _isFirstItem: index === 0,
          _isLastItem: index + 1 === keys.length,
          _isOutputStart: isOutputStart
        };

        const outputObj = { _name: key };

        if (getType(obj[key]) === "object") {
          // Sub-object contains an object.

          // Move private vars up as needed.  Needed to support certain types of objects
          // E.g. { foo: { _attrs: { a: 1 } } } -> <foo a="1"/>
          PRIVATE_VARS.forEach((privateVar) => {
            const val = obj[key][privateVar];
            if (typeof val !== "undefined") {
              outputObj[privateVar] = val;
              delete obj[key][privateVar];
            }
          });

          const hasContent = typeof obj[key]._content !== "undefined";
          if (hasContent) {
            // _content has sibling keys, so pass as an array (edge case).
            // E.g. { foo: 'bar', _content: { baz: 2 } } -> <foo>bar</foo><baz>2</baz>
            if (Object.keys(obj[key]).length > 1) {
              const newContentObj = Object.assign({}, obj[key]);
              delete newContentObj._content;

              outputObj._content = [
                ...objToArray(newContentObj),
                obj[key]._content,
              ];
            }
          }
        }

        // Fallthrough: just pass the key as the content for the new special-object.
        if (typeof outputObj._content === "undefined")
          outputObj._content = obj[key];

        const xml = toXML(outputObj, newConfig, key);

        return xml;
      }, config);

      outputStr = outputArr.join('');
      break;
    }

    case "function": {
      // Executes a user-defined function and returns output.

      const fnResult = obj(config);

      outputStr = toXML(fnResult, config);
      break;
    }

    case "array": {
      // Iterates and converts each value in an array.
      const outputArr = obj.map((singleVal, index) => {
        const newConfig = {
          ...config,
          _isFirstItem: index === 0,
          _isLastItem: index + 1 === obj.length,
          _isOutputStart: isOutputStart
        };
        return toXML(singleVal, newConfig);
      });

      outputStr = outputArr.join('');

      break;
    }

    // number, string, boolean, date, null, etc
    default: {
      outputStr = filterStr(obj, filter);
      break;
    }
  }

  return `${headerStr}${outputStr}`;
};

export default {
  toXML,
};
