/* 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/. */

"use strict";

const {
  Component,
} = require("resource://devtools/client/shared/vendor/react.mjs");
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
const {
  getUnicodeUrl,
  getUnicodeUrlPath,
  getUnicodeHostname,
} = require("resource://devtools/client/shared/unicode-url.js");
const {
  getSourceNames,
  parseURL,
  getSourceMappedFile,
} = require("resource://devtools/client/shared/source-utils.js");
const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
const {
  MESSAGE_SOURCE,
} = require("resource://devtools/client/webconsole/constants.js");

const l10n = new LocalizationHelper(
  "devtools/client/locales/components.properties"
);
const webl10n = new LocalizationHelper(
  "devtools/shared/locales/webconsole.properties"
);

function savedFrameToDebuggerLocation(frame) {
  const { source: url, line, column, sourceId } = frame;
  return {
    url,

    // Line is 1-based everywhere.
    line,

    // The column received from spidermonkey Frame objects are 1-based,
    // and RDP's console message as well as page errors are providing 1-based columns,
    // but most of DevTools frontend consider it to be 0-based, especially the debugger.
    //
    // Column set to 0 is unknown column location.
    column: column >= 1 ? column - 1 : null,

    // The sourceId will be a string if it's a source actor ID, otherwise
    // it is either a Spidermonkey-internal ID from a SavedFrame or missing,
    // and in either case we can't use the ID for anything useful.
    id: typeof sourceId === "string" ? sourceId : null,
  };
}

/**
 * Get the tooltip message.
 *
 * @param {string|undefined} messageSource
 * @param {string} url
 * @returns {string}
 */
function getTooltipMessage(messageSource, url) {
  if (messageSource && messageSource === MESSAGE_SOURCE.CSS) {
    return l10n.getFormatStr("frame.viewsourceinstyleeditor", url);
  }
  return l10n.getFormatStr("frame.viewsourceindebugger", url);
}

class Frame extends Component {
  static get propTypes() {
    return {
      // Optional className that will be put into the element.
      className: PropTypes.string,
      // SavedFrame, or an object containing all the required properties.
      frame: PropTypes.shape({
        functionDisplayName: PropTypes.string,
        // This could be a SavedFrame with a numeric sourceId, or it could
        // be a SavedFrame-like client-side object, in which case the
        // "sourceId" will be a source actor ID.
        sourceId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
        source: PropTypes.string.isRequired,
        line: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
        column: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
      }).isRequired,
      // Clicking on the frame link -- probably should link to the debugger.
      onClick: PropTypes.func,
      // Option to display a function name before the source link.
      showFunctionName: PropTypes.bool,
      // Option to display a function name even if it's anonymous.
      showAnonymousFunctionName: PropTypes.bool,
      // Option to display a host name after the source link.
      showHost: PropTypes.bool,
      // Option to display a host name if the filename is empty or just '/'
      showEmptyPathAsHost: PropTypes.bool,
      // Option to display a full source instead of just the filename.
      showFullSourceUrl: PropTypes.bool,
      // Service to enable the source map feature for console.
      sourceMapURLService: PropTypes.object,
      // The source of the message
      messageSource: PropTypes.string,
    };
  }

  static get defaultProps() {
    return {
      showFunctionName: false,
      showAnonymousFunctionName: false,
      showHost: false,
      showEmptyPathAsHost: false,
      showFullSourceUrl: false,
    };
  }

  constructor(props) {
    super(props);
    this.state = {
      originalLocation: null,
    };
    this._locationChanged = this._locationChanged.bind(this);
  }

  componentDidMount() {
    if (this.props.sourceMapURLService) {
      const location = savedFrameToDebuggerLocation(this.props.frame);

      // If the location has a line=0 and column=0 or 1, we assume this is a
      // default location which means there is no real line and column
      // information related to this location. Since the sourcemap service is
      // unable to resolve locations with line=0 anyway, bail out here.
      if (
        location.line === 0 &&
        (location.column === 0 || location.column === 1)
      ) {
        return;
      }

      // Many things that make use of this component either:
      // a) Pass in no sourceId because they have no way to know.
      // b) Pass in no sourceId because the actor wasn't created when the
      //    server sent its response.
      //
      // and due to that, we need to use subscribeByLocation in order to
      // handle both cases with an without an ID.
      this.unsubscribeSourceMapURLService =
        this.props.sourceMapURLService.subscribeByLocation(
          location,
          this._locationChanged
        );
    }
  }

  componentWillUnmount() {
    if (this.unsubscribeSourceMapURLService) {
      this.unsubscribeSourceMapURLService();
    }
  }

  _locationChanged(originalLocation) {
    this.setState({ originalLocation });
  }

  /**
   * Get current location's source, line, and column.
   *
   * @returns {{sourceURL: string, line: number|null, column: number|null}}
   */
  #getCurrentLocationInfo = () => {
    const { frame } = this.props;
    const { originalLocation } = this.state;

    const generatedLocation = savedFrameToDebuggerLocation(frame);
    const currentLocation = originalLocation || generatedLocation;

    const column = Number.parseInt(currentLocation.column, 10);

    return {
      sourceURL: currentLocation.url || "",
      // line is 1-based
      line: Number(currentLocation.line) || null,
      // column is 0-based while we display 1-based numbers
      column: typeof column == "number" ? column + 1 : null,
    };
  };

  /**
   * Get unicode hostname of the source link.
   *
   * @returns {string}
   */
  #getCurrentLocationUnicodeHostName = () => {
    const { sourceURL } = this.#getCurrentLocationInfo();

    const { host } = getSourceNames(sourceURL);
    return host ? getUnicodeHostname(host) : "";
  };

  /**
   * Check if the current location is linkable.
   *
   * @returns {boolean}
   */
  #isCurrentLocationLinkable = () => {
    const { frame } = this.props;
    const { originalLocation } = this.state;

    const generatedLocation = savedFrameToDebuggerLocation(frame);

    // Reparse the URL to determine if we should link this; `getSourceNames`
    // has already cached this indirectly. We don't want to attempt to
    // link to "self-hosted" and "(unknown)".
    // Source mapped sources might not necessary linkable, but they
    // are still valid in the debugger.
    // If we have a source ID then we can show the source in the debugger.
    return !!(
      originalLocation ||
      generatedLocation.id ||
      !!parseURL(generatedLocation.url)
    );
  };

  /**
   * Get the props of the top element.
   */
  #getTopElementProps = () => {
    const { className } = this.props;

    const { sourceURL, line, column } = this.#getCurrentLocationInfo();
    const { long } = getSourceNames(sourceURL);
    const props = {
      "data-url": long,
      className: "frame-link" + (className ? ` ${className}` : ""),
    };

    // If we have a line number > 0.
    if (line) {
      // Add `data-line` attribute for testing
      props["data-line"] = line;

      // Intentionally exclude 0
      if (column) {
        // Add `data-column` attribute for testing
        props["data-column"] = column;
      }
    }
    return props;
  };

  /**
   * Get the props of the source element.
   */
  #getSourceElementsProps = () => {
    const { frame, onClick, messageSource } = this.props;

    const generatedLocation = savedFrameToDebuggerLocation(frame);
    const { sourceURL, line, column } = this.#getCurrentLocationInfo();
    const { long } = getSourceNames(sourceURL);
    let url = getUnicodeUrl(long);

    // Exclude all falsy values, including `0`, as line numbers start with 1.
    if (line) {
      url += `:${line}`;
      // Intentionally exclude 0
      if (column) {
        url += `:${column}`;
      }
    }

    const isLinkable = this.#isCurrentLocationLinkable();

    // Inner el is useful for achieving ellipsis on the left and correct LTR/RTL
    // ordering. See CSS styles for frame-link-source-[inner] and bug 1290056.
    const tooltipMessage = getTooltipMessage(messageSource, url);

    const sourceElConfig = {
      key: "source",
      className: "frame-link-source",
      title: isLinkable ? tooltipMessage : url,
    };

    if (isLinkable) {
      return {
        ...sourceElConfig,
        onClick: e => {
          // We always need to prevent the default behavior of <a> link
          e.preventDefault();
          if (onClick) {
            e.stopPropagation();

            onClick(generatedLocation);
          }
        },
        href: sourceURL,
        draggable: false,
      };
    }

    return sourceElConfig;
  };

  /**
   * Render the source elements.
   *
   * @returns {React.ReactNode}
   */
  #renderSourceElements = () => {
    const { line, column } = this.#getCurrentLocationInfo();

    const sourceElements = [this.#renderDisplaySource()];

    if (line) {
      let lineInfo = `:${line}`;

      // Intentionally exclude 0
      if (column) {
        lineInfo += `:${column}`;
      }

      sourceElements.push(
        dom.span(
          {
            key: "line",
            className: "frame-link-line",
          },
          lineInfo
        )
      );
    }

    if (this.#isCurrentLocationLinkable()) {
      return dom.a(this.#getSourceElementsProps(), sourceElements);
    }
    // If source is not a URL (self-hosted, eval, etc.), don't make
    // it an anchor link, as we can't link to it.
    return dom.span(this.#getSourceElementsProps(), sourceElements);
  };

  /**
   * Render the display source.
   *
   * @returns {React.ReactNode}
   */
  #renderDisplaySource = () => {
    const { showEmptyPathAsHost, showFullSourceUrl } = this.props;
    const { originalLocation } = this.state;

    const { sourceURL } = this.#getCurrentLocationInfo();
    const { short, long, host } = getSourceNames(sourceURL);
    const unicodeShort = getUnicodeUrlPath(short);
    const unicodeLong = getUnicodeUrl(long);
    let displaySource = showFullSourceUrl ? unicodeLong : unicodeShort;
    if (originalLocation) {
      displaySource = getSourceMappedFile(displaySource);

      // In case of pretty-printed HTML file, we would only get the formatted suffix; replace
      // it with the full URL instead
      if (showEmptyPathAsHost && displaySource == ":formatted") {
        displaySource = host + displaySource;
      }
    } else if (
      showEmptyPathAsHost &&
      (displaySource === "" || displaySource === "/")
    ) {
      displaySource = host;
    }

    return dom.span(
      {
        key: "filename",
        className: "frame-link-filename",
      },
      displaySource
    );
  };

  /**
   * Render the function display name.
   *
   * @returns {React.ReactNode}
   */
  #renderFunctionDisplayName = () => {
    const { frame, showFunctionName, showAnonymousFunctionName } = this.props;
    if (!showFunctionName) {
      return null;
    }
    const functionDisplayName = frame.functionDisplayName;
    if (functionDisplayName || showAnonymousFunctionName) {
      return [
        dom.span(
          {
            key: "function-display-name",
            className: "frame-link-function-display-name",
          },
          functionDisplayName || webl10n.getStr("stacktrace.anonymousFunction")
        ),
        " ",
      ];
    }
    return null;
  };

  render() {
    const { showHost } = this.props;

    const elements = [
      this.#renderFunctionDisplayName(),
      this.#renderSourceElements(),
    ];

    const unicodeHost = showHost
      ? this.#getCurrentLocationUnicodeHostName()
      : null;
    if (unicodeHost) {
      elements.push(" ");
      elements.push(
        dom.span(
          {
            key: "host",
            className: "frame-link-host",
          },
          unicodeHost
        )
      );
    }

    return dom.span(this.#getTopElementProps(), ...elements);
  }
}

module.exports = Frame;
