/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */

/* eslint no-shadow: ["error", { "allow": ["length"] }] */

import {
  a,
  span,
} from "resource://devtools/client/shared/vendor/react-dom-factories.mjs";
import PropTypes from "resource://devtools/client/shared/vendor/react-prop-types.mjs";

import {
  appendRTLClassNameIfNeeded,
  containsURL,
  escapeString,
  getGripType,
  rawCropString,
  sanitizeString,
  wrapRender,
  ELLIPSIS,
  uneatLastUrlCharsRegex,
  urlRegex,
} from "./rep-utils.mjs";

/**
 * Renders a string. String value is enclosed within quotes.
 */

StringRep.propTypes = {
  useQuotes: PropTypes.bool,
  escapeWhitespace: PropTypes.bool,
  style: PropTypes.object,
  cropLimit: PropTypes.number.isRequired,
  urlCropLimit: PropTypes.number,
  member: PropTypes.object,
  object: PropTypes.object.isRequired,
  openLink: PropTypes.func,
  className: PropTypes.string,
  title: PropTypes.string,
  isInContentPage: PropTypes.bool,
  shouldRenderTooltip: PropTypes.bool,
};

function StringRep(props) {
  const {
    className,
    style,
    cropLimit,
    urlCropLimit,
    object,
    useQuotes = true,
    escapeWhitespace = true,
    member,
    openLink,
    title,
    isInContentPage,
    transformEmptyString = false,
    shouldRenderTooltip,
  } = props;

  let text = object;
  const config = getElementConfig({
    className,
    style,
    actor: object.actor,
    title,
  });

  if (text == "" && transformEmptyString && !useQuotes) {
    return span(
      {
        ...config,
        title: "<empty string>",
        className: `${config.className} objectBox-empty-string`,
      },
      "<empty string>"
    );
  }

  const isLong = isLongString(object);
  const isOpen = member && member.open;
  const shouldCrop = !isOpen && cropLimit && text.length > cropLimit;

  if (isLong) {
    text = maybeCropLongString(
      {
        shouldCrop,
        cropLimit,
      },
      text
    );

    const { fullText } = object;
    if (isOpen && fullText) {
      text = fullText;
    }
  }

  text = formatText(
    {
      useQuotes,
      escapeWhitespace,
    },
    text
  );

  if (shouldRenderTooltip) {
    config.title = text;
  }

  if (!isLong) {
    if (containsURL(text)) {
      return span(
        config,
        getLinkifiedElements({
          text,
          cropLimit: shouldCrop ? cropLimit : null,
          urlCropLimit,
          openLink,
          isInContentPage,
        })
      );
    }

    // Cropping of longString has been handled before formatting.
    text = maybeCropString(
      {
        isLong,
        shouldCrop,
        cropLimit,
      },
      text
    );
  }

  config.className = appendRTLClassNameIfNeeded(config.className, text);

  return span(config, text);
}

function maybeCropLongString(opts, object) {
  const { shouldCrop, cropLimit } = opts;

  const grip = object && object.getGrip ? object.getGrip() : object;
  const { initial, length } = grip;

  let text = shouldCrop ? initial.substring(0, cropLimit) : initial;

  if (text.length < length) {
    text += ELLIPSIS;
  }

  return text;
}

function formatText(opts, text) {
  const { useQuotes, escapeWhitespace } = opts;

  return useQuotes
    ? escapeString(text, escapeWhitespace)
    : sanitizeString(text);
}

function getElementConfig(opts) {
  const { className, style, actor, title } = opts;

  const config = {};

  if (actor) {
    config["data-link-actor-id"] = actor;
  }

  if (title) {
    config.title = title;
  }

  const classNames = ["objectBox", "objectBox-string"];
  if (className) {
    classNames.push(className);
  }
  config.className = classNames.join(" ");

  if (style) {
    config.style = style;
  }

  return config;
}

function maybeCropString(opts, text) {
  const { shouldCrop, cropLimit } = opts;

  return shouldCrop ? rawCropString(text, cropLimit) : text;
}

/**
 * Get an array of the elements representing the string, cropped if needed,
 * with actual links.
 *
 * @param {object} An options object of the following shape:
 *                 - text {String}: The actual string to linkify.
 *                 - cropLimit {Integer}: The limit to apply on the whole text.
 *                 - urlCropLimit {Integer}: The limit to apply on each URL.
 *                 - openLink {Function} openLink: Function handling the link
 *                                                 opening.
 *                 - isInContentPage {Boolean}: pass true if the reps is
 *                                              rendered in the content page
 *                                              (e.g. in JSONViewer).
 * @returns {Array<string | ReactElement>}
 */
function getLinkifiedElements({
  text,
  cropLimit,
  urlCropLimit,
  openLink,
  isInContentPage,
}) {
  const halfLimit = Math.ceil((cropLimit - ELLIPSIS.length) / 2);
  const startCropIndex = cropLimit ? halfLimit : null;
  const endCropIndex = cropLimit ? text.length - halfLimit : null;

  const items = [];
  let currentIndex = 0;
  let contentStart;
  while (true) {
    const url = urlRegex.exec(text);
    // Pick the regexp with the earlier content; index will always be zero.
    if (!url) {
      break;
    }
    contentStart = url.index + url[1].length;
    if (contentStart > 0) {
      const nonUrlText = text.substring(0, contentStart);
      items.push(
        getCroppedString(nonUrlText, currentIndex, startCropIndex, endCropIndex)
      );
    }

    // There are some final characters for a URL that are much more likely
    // to have been part of the enclosing text rather than the end of the
    // URL.
    let useUrl = url[2];
    const uneat = uneatLastUrlCharsRegex.exec(useUrl);
    if (uneat) {
      useUrl = useUrl.substring(0, uneat.index);
    }

    currentIndex = currentIndex + contentStart;
    const linkText = getCroppedString(
      useUrl,
      currentIndex,
      startCropIndex,
      endCropIndex
    );

    if (linkText) {
      const linkItems = [];
      const shouldCrop = urlCropLimit && useUrl.length > urlCropLimit;
      if (shouldCrop) {
        const urlCropHalf = Math.ceil((urlCropLimit - ELLIPSIS.length) / 2);
        // We cut the string into 3 elements and we'll visually hide the second one
        // in CSS. This way people can still copy the full link.
        linkItems.push(
          span(
            { className: "cropped-url-start" },
            useUrl.substring(0, urlCropHalf)
          ),
          span(
            { className: "cropped-url-middle" },
            useUrl.substring(urlCropHalf, useUrl.length - urlCropHalf)
          ),
          span(
            { className: "cropped-url-end" },
            useUrl.substring(useUrl.length - urlCropHalf)
          )
        );
      } else {
        linkItems.push(linkText);
      }

      items.push(
        a(
          {
            key: `${useUrl}-${currentIndex}`,
            className: "url" + (shouldCrop ? " cropped-url" : ""),
            title: useUrl,
            draggable: false,
            // Because we don't want the link to be open in the current
            // panel's frame, we only render the href attribute if `openLink`
            // exists (so we can preventDefault) or if the reps will be
            // displayed in content page (e.g. in the JSONViewer).
            href: openLink || isInContentPage ? useUrl : null,
            target: "_blank",
            rel: "noopener noreferrer",
            onClick: openLink
              ? e => {
                  e.preventDefault();
                  openLink(useUrl, e);
                }
              : null,
          },
          linkItems
        )
      );
    }

    currentIndex = currentIndex + useUrl.length;
    text = text.substring(url.index + url[1].length + useUrl.length);
  }

  // Clean up any non-URL text at the end of the source string,
  // i.e. not handled in the loop.
  if (text.length) {
    if (currentIndex < endCropIndex) {
      text = getCroppedString(text, currentIndex, startCropIndex, endCropIndex);
    }
    items.push(text);
  }

  return items;
}

/**
 * Returns a cropped substring given an offset, start and end crop indices in a
 * parent string.
 *
 * @param {string} text: The substring to crop.
 * @param {Integer} offset: The offset corresponding to the index at which
 *                          the substring is in the parent string.
 * @param {Integer|null} startCropIndex: the index where the start of the crop
 *                                       should happen in the parent string.
 * @param {Integer|null} endCropIndex: the index where the end of the crop
 *                                     should happen in the parent string
 * @returns {string | null} The cropped substring, or null if the text is
 *                        completly cropped.
 */
function getCroppedString(text, offset = 0, startCropIndex, endCropIndex) {
  if (!startCropIndex) {
    return text;
  }

  const start = offset;
  const end = offset + text.length;

  const shouldBeVisible = !(start >= startCropIndex && end <= endCropIndex);
  if (!shouldBeVisible) {
    return null;
  }

  const shouldCropEnd = start < startCropIndex && end > startCropIndex;
  const shouldCropStart = start < endCropIndex && end > endCropIndex;
  if (shouldCropEnd) {
    const cutIndex = startCropIndex - start;
    return (
      text.substring(0, cutIndex) +
      ELLIPSIS +
      (shouldCropStart ? text.substring(endCropIndex - start) : "")
    );
  }

  if (shouldCropStart) {
    // The string should be cropped at the beginning.
    const cutIndex = endCropIndex - start;
    return text.substring(cutIndex);
  }

  return text;
}

function isLongString(object) {
  const grip = object && object.getGrip ? object.getGrip() : object;
  return grip && grip.type === "longString";
}

function supportsObject(object, noGrip = false) {
  // Accept the object if the grip-type (or type for noGrip objects) is "string"
  if (getGripType(object, noGrip) == "string") {
    return true;
  }

  // Also accept longString objects if we're expecting grip
  if (!noGrip) {
    return isLongString(object);
  }

  return false;
}

const rep = wrapRender(StringRep);

// Exports from this module

export { rep, supportsObject, isLongString };
