/* 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";

// React deps
const {
  Component,
} = require("resource://devtools/client/shared/vendor/react.mjs");
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const { div, h1, h2, h3, p, a, button } = dom;

loader.lazyRequireGetter(
  this,
  "Telemetry",
  "resource://devtools/client/shared/telemetry.js"
);

// Localized strings for (devtools/client/locales/en-US/components.properties)
loader.lazyGetter(this, "L10N", function () {
  const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
  return new LocalizationHelper(
    "devtools/client/locales/components.properties"
  );
});

loader.lazyGetter(this, "FILE_BUG_BUTTON", function () {
  return L10N.getStr("appErrorBoundary.fileBugButton");
});

loader.lazyGetter(this, "RELOAD_PAGE_INFO", function () {
  return L10N.getStr("appErrorBoundary.reloadPanelInfo");
});

// File a bug for the selected component specifically
// Add format=__default__ to make sure users without EDITBUGS permission still
// use the regular UI to create bugs, including the prefilled description.
const bugLink =
  "https://bugzilla.mozilla.org/enter_bug.cgi?format=__default__&blocked=devtools-toolbox-crash&product=DevTools&component=";

/**
 * Error boundary that wraps around the a given component.
 */
class AppErrorBoundary extends Component {
  static get propTypes() {
    return {
      children: PropTypes.any.isRequired,
      panel: PropTypes.any.isRequired,
      componentName: PropTypes.string.isRequired,
      openLink: PropTypes.func,
    };
  }

  // Keep a flag to avoid submitting extra pings in case several components
  // fail in cascade.
  #pingSubmitted = false;

  constructor(props) {
    super(props);

    this.state = {
      errorMsg: "No error",
      errorStack: null,
      errorInfo: null,
      // Will be used to provide a button to close the toolbox.
      toolbox: null,
    };
  }

  /**
   *  Map the `info` object to a render.
   *  Currently, `info` usually just contains something similar to the
   *  following object (which is provided to componentDidCatch):
   *  componentStack: {"\n in (component) \n in (other component)..."}
   */
  renderErrorInfo(info = {}) {
    if (Object.keys(info).length) {
      return Object.keys(info)
        .filter(key => info[key])
        .map((obj, outerIdx) => {
          switch (obj) {
            case "componentStack": {
              const traceParts = info[obj]
                .split("\n")
                .map((part, idx) => p({ key: `strace${idx}` }, part));
              return div(
                { key: `st-div-${outerIdx}`, className: "stack-trace-section" },
                h3({}, "React Component Stack"),
                traceParts
              );
            }
            case "clientPacket":
            case "serverPacket": {
              // Only serverPacket has a stack.
              const stack = info[obj].stack;
              const traceParts = stack
                ? stack
                    .split("\n")
                    .map((part, idx) => p({ key: `strace${idx}` }, part))
                : null;

              // DevToolsProcessParent may receive stacktraces from the content process
              // and put them onto the exception's contentProcessStack attribute
              // which protocol.js Actor and Front layer will preserve so that we ultimately
              // can read it from here.
              // This is for server codebases using DevToolsProcess JS Actor to communicate
              // with the content processes from the parent process. Typically most methods
              // of the Watcher Actor.
              const contentProcessStack = info[obj].contentProcessStack;
              const contentProcessTraceParts = contentProcessStack
                ? contentProcessStack
                    .split("\n")
                    .map((part, idx) =>
                      p({ key: `contentProcessStrace${idx}` }, part)
                    )
                : null;

              return div(
                { className: "stack-trace-section" },
                h3(
                  {},
                  obj == "clientPacket" ? "Client packet" : "Server packet"
                ),
                // Display the packet as JSON, while removing the artificial `stack` attribute from it
                p(
                  {},
                  JSON.stringify({ ...info[obj], stack: undefined }, null, 2)
                ),
                stack ? h3({}, "Server stack") : null,
                traceParts,
                contentProcessStack
                  ? h3({}, "Server content process stack")
                  : null,
                contentProcessTraceParts
              );
            }
          }
          return null;
        });
    }

    return p({}, "undefined errorInfo");
  }

  renderStackTrace(stacktrace = "") {
    const re = /:\d+:\d+/g;
    const traces = stacktrace
      .replace(re, "$&,")
      .split(",")
      .map((trace, index) => {
        return p({ key: `rst-${index}` }, trace);
      });

    return div(
      { className: "stack-trace-section" },
      h3({}, "Stacktrace"),
      traces
    );
  }

  renderServerPacket(packet) {
    const traceParts = packet.stack
      .split("\n")
      .map((part, idx) => p({ key: `strace${idx}` }, part));
    return [
      div(
        { className: "stack-trace-section" },
        h3({}, "Server packet"),
        p({}, JSON.stringify(packet, null, 2))
      ),
      div(
        { className: "stack-trace-section" },
        h3({}, "Server Stack"),
        traceParts
      ),
    ];
  }

  // Return a valid object, even if we don't receive one
  getValidInfo(infoObj) {
    if (!infoObj.componentStack) {
      try {
        return { componentStack: JSON.stringify(infoObj) };
      } catch (err) {
        return { componentStack: `Unknown Error: ${err}` };
      }
    }
    return infoObj;
  }

  // Called automatically by React when a child component throws an error.
  componentDidCatch(error, info) {
    const validInfo = this.getValidInfo(info);
    const errorMessage = error.toString();

    this.setState({
      errorMsg: errorMessage,
      errorStack: error.stack,
      errorInfo: validInfo,
    });

    if (this.#pingSubmitted) {
      // If the component already sent the telemetry ping for this session, skip
      // recording a new event.
      return;
    }

    const extras = Telemetry.sanitizeEventExtras(
      {
        component_stack: validInfo.componentStack || "",
        error_name: error.name,
        stack: error.stack || "",
      },
      "devtoolsMain.toolboxComponentError",
      // Allow up to 500 characters for each extra value (maximum value
      // supported by Glean events).
      { limit: 500 }
    );
    Glean.devtoolsMain.toolboxComponentError.record(extras);
    this.#pingSubmitted = true;
  }

  // Manually called by devtools code when an exception is triggered before
  // or outside of React render codepath.
  handleException(exception, toolbox, showToolboxCloseButton = false) {
    const errorMessage = exception.toString();
    const clientPacket = exception.clientPacket || {};
    const serverPacket = exception.serverPacket || {};

    this.setState({
      errorMsg: errorMessage,
      errorStack: exception.stack,
      errorInfo: {
        clientPacket,
        serverPacket,
      },
      showToolboxCloseButton,
      toolbox,
    });

    if (this.#pingSubmitted) {
      // If the component already sent the telemetry ping for this session, skip
      // recording a new event.
      return;
    }

    const descriptorFront = toolbox.commands?.descriptorFront;
    const extras = Telemetry.sanitizeEventExtras(
      {
        descriptor_type: descriptorFront?.descriptorType,
        error_name: exception.name,
        host_type: toolbox.hostType,
        is_destroying: toolbox.isDestroying(),
        is_local_tab: descriptorFront?.isLocalTab,
        is_window_closed: !!toolbox.win?.closed,
        packet_error: serverPacket.error,
        packet_target: serverPacket.from,
        packet_type: clientPacket.type,
        server_stack: serverPacket.stack || "",
        server_content_process_stack: serverPacket.contentProcessStack || "",
        session_duration: Math.floor(toolbox.telemetry.msSinceSessionStart()),
        session_id: toolbox.sessionId,
        stack: exception.stack || "",
      },
      "devtoolsMain.toolboxServerError",
      // Allow up to 500 characters for each extra value (maximum value
      // supported by Glean events).
      { limit: 500 }
    );
    Glean.devtoolsMain.toolboxServerError.record(extras);
    this.#pingSubmitted = true;
  }

  getBugLink() {
    const { componentStack, clientPacket, serverPacket } = this.state.errorInfo;

    let msg =
      "## Steps to reproduce:\n\n" +
      "If possible, please share specific steps to reproduce the error.\n" +
      "Otherwise add any additional information useful to investigate the issue.\n\n";

    msg += `## Error in ${this.props.panel}: \n${this.state.errorMsg}\n\n`;

    if (componentStack) {
      msg += `## React Component Stack:${componentStack}\n\n`;
    }

    if (clientPacket) {
      msg += `## Client Packet:\n\`\`\`\n${JSON.stringify(clientPacket, null, 2)}\n\`\`\`\n\n`;
    }

    if (serverPacket) {
      // Display the packet as JSON, while removing the artificial `stack`/`contentProcessStack` attributes from it
      msg += `## Server Packet:\n\`\`\`\n${JSON.stringify({ ...serverPacket, stack: undefined, contentProcessStack: undefined }, null, 2)}\n\`\`\`\n\n`;
      msg += `## Server Stack:\n\`\`\`\n${serverPacket.stack}\n\`\`\`\n\n`;
      if (serverPacket.contentProcessStack) {
        msg += `## Server Content Process Stack:\n\`\`\`\n${serverPacket.contentProcessStack}\n\`\`\`\n\n`;
      }
    }

    msg += `## Stacktrace: \n\`\`\`\n${this.state.errorStack}\n\`\`\``;

    return `${bugLink}${this.props.componentName}&comment=${encodeURIComponent(
      msg
    )}`;
  }

  render() {
    if (this.state.errorInfo !== null) {
      // "The (componentDesc) has crashed"
      const errorDescription = L10N.getFormatStr(
        "appErrorBoundary.description",
        this.props.panel
      );

      const href = this.getBugLink();

      return div(
        {
          className: `app-error-panel`,
        },
        h1({ className: "error-panel-header" }, errorDescription),
        a(
          {
            className: "error-panel-file-button",
            href,
            target: "_blank",
            onClick: this.props.openLink
              ? e => this.props.openLink(href, e)
              : null,
          },
          FILE_BUG_BUTTON
        ),
        this.state.showToolboxCloseButton
          ? button({
              className: "devtools-tabbar-button error-panel-close",
              onClick: () => {
                this.state.toolbox.closeToolbox();
              },
            })
          : null,
        h2({ className: "error-panel-error" }, this.state.errorMsg),
        div({}, this.renderErrorInfo(this.state.errorInfo)),
        div({}, this.renderStackTrace(this.state.errorStack)),
        p({ className: "error-panel-reload-info" }, RELOAD_PAGE_INFO)
      );
    }
    return this.props.children;
  }
}

module.exports = AppErrorBoundary;
