/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  Downloads: "resource://gre/modules/Downloads.sys.mjs",
  FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});

const STRINGS_URI = "devtools/shared/locales/screenshot.properties";
const L10N = new LocalizationHelper(STRINGS_URI);

/**
 * Take a screenshot of a browser element matching the passed target and save it to a file
 * or the clipboard.
 *
 * @param {TargetFront} targetFront: The targetFront of the frame we want to take a screenshot of.
 * @param {Window} window: The DevTools Client window.
 * @param {Object} args
 * @param {Boolean} args.fullpage: Should the screenshot be the height of the whole page
 * @param {String} args.filename: Expected filename for the screenshot
 * @param {Boolean} args.clipboard: Whether or not the screenshot should be saved to the clipboard.
 * @param {Number} args.dpr: Scale of the screenshot. Defaults to the window `devicePixelRatio`.
 *                           ⚠️ Note that the scale might be decreased if the resulting
 *                           image would be too big to draw safely. Warning will be emitted
 *                           to the console if that's the case.
 * @param {Number} args.delay: Number of seconds to wait before taking the screenshot
 * @param {Boolean} args.help: Set to true to receive a message with the screenshot command
 *                             documentation.
 * @param {Boolean} args.disableFlash: Set to true to disable the flash animation when the
 *                  screenshot is taken.
 * @param {Boolean} args.ignoreDprForFileScale: Set to true to if the resulting screenshot
 *                  file size shouldn't be impacted by the dpr. Note that the dpr will still
 *                  be taken into account when taking the screenshot, only the size of the
 *                  file will be different.
 * @returns {Array<Object{text, level}>} An array of object representing the different
 *          messages emitted throught the process, that should be displayed to the user.
 */
async function captureAndSaveScreenshot(targetFront, window, args = {}) {
  if (args.help) {
    // Wrap message in an array so that the return value is consistant.
    return [{ text: getFormattedHelpData() }];
  }

  const captureResponse = await captureScreenshot(targetFront, args);

  if (captureResponse.error) {
    return captureResponse.messages || [];
  }

  const saveMessages = await saveScreenshot(window, args, captureResponse);
  return (captureResponse.messages || []).concat(saveMessages);
}

/**
 * Take a screenshot of a browser element matching the passed target
 * @param {TargetFront} targetFront: The targetFront of the frame we want to take a screenshot of.
 * @param {Object} args: See args param in captureAndSaveScreenshot
 */
async function captureScreenshot(targetFront, args) {
  // @backward-compat { version 87 } The screenshot-content actor was introduced in 87,
  // so we can always use it once 87 reaches release.
  const supportsContentScreenshot = targetFront.hasActor("screenshotContent");
  if (!supportsContentScreenshot) {
    const screenshotFront = await targetFront.getFront("screenshot");
    return screenshotFront.capture(args);
  }

  if (args.delay > 0) {
    await new Promise(res => setTimeout(res, args.delay * 1000));
  }

  const screenshotContentFront =
    await targetFront.getFront("screenshot-content");

  // Call the content-process on the server to retrieve informations that will be needed
  // by the parent process.
  const { rect, windowDpr, windowZoom, messages, error } =
    await screenshotContentFront.prepareCapture(args);

  if (error) {
    return { error, messages };
  }

  if (rect) {
    args.rect = rect;
  }

  args.dpr ||= windowDpr;

  args.snapshotScale = args.dpr * windowZoom;
  if (args.ignoreDprForFileScale) {
    args.fileScale = windowZoom;
  }

  args.browsingContextID = targetFront.browsingContextID;

  // We can now call the parent process which will take the screenshot via
  // the drawSnapshot API
  const rootFront = targetFront.client.mainRoot;
  const parentProcessScreenshotFront = await rootFront.getFront("screenshot");
  const captureResponse = await parentProcessScreenshotFront.capture(args);

  return {
    ...captureResponse,
    messages: (messages || []).concat(captureResponse.messages || []),
  };
}

const screenshotDescription = L10N.getStr("screenshotDesc");
const screenshotGroupOptions = L10N.getStr("screenshotGroupOptions");
const screenshotCommandParams = [
  {
    name: "clipboard",
    type: "boolean",
    description: L10N.getStr("screenshotClipboardDesc"),
    manual: L10N.getStr("screenshotClipboardManual"),
  },
  {
    name: "delay",
    type: "number",
    description: L10N.getStr("screenshotDelayDesc"),
    manual: L10N.getStr("screenshotDelayManual"),
  },
  {
    name: "dpr",
    type: "number",
    description: L10N.getStr("screenshotDPRDesc"),
    manual: L10N.getStr("screenshotDPRManual"),
  },
  {
    name: "fullpage",
    type: "boolean",
    description: L10N.getStr("screenshotFullPageDesc"),
    manual: L10N.getStr("screenshotFullPageManual"),
  },
  {
    name: "selector",
    type: "string",
    description: L10N.getStr("inspectNodeDesc"),
    manual: L10N.getStr("inspectNodeManual"),
  },
  {
    name: "file",
    type: "boolean",
    description: L10N.getStr("screenshotFileDesc"),
    manual: L10N.getStr("screenshotFileManual"),
  },
  {
    name: "filename",
    type: "string",
    description: L10N.getStr("screenshotFilenameDesc"),
    manual: L10N.getStr("screenshotFilenameManual"),
  },
];

/**
 * Creates a string from an object for use when screenshot is passed the `--help` argument
 *
 * @param object param
 *        The param object to be formatted.
 * @return string
 *         The formatted information from the param object as a string
 */
function formatHelpField(param) {
  const padding = " ".repeat(5);
  return Object.entries(param)
    .map(([key, value]) => {
      if (key === "name") {
        const name = `${padding}--${value}`;
        return name;
      }
      return `${padding.repeat(2)}${key}: ${value}`;
    })
    .join("\n");
}

/**
 * Creates a string response from the screenshot options for use when
 * screenshot is passed the `--help` argument
 *
 * @return string
 *         The formatted information from the param object as a string
 */
function getFormattedHelpData() {
  const formattedParams = screenshotCommandParams
    .map(formatHelpField)
    .join("\n\n");

  return `${screenshotDescription}\n${screenshotGroupOptions}\n\n${formattedParams}`;
}

/**
 * Main entry point in this file; Takes the original arguments that `:screenshot` was
 * called with and the image value from the server, and uses the client window to add
 * and audio effect.
 *
 * @param object window
 *        The DevTools Client window.
 *
 * @param object args
 *        The original args with which the screenshot
 *        was called.
 * @param object value
 *        an object with a image value and file name
 *
 * @return string[]
 *         Response messages from processing the screenshot
 */
function saveScreenshot(window, args = {}, value) {
  // @backward-compat { version 87 } This is still needed by the console when connecting
  // to an older server. Once 87 is in release, we can remove this whole block since we
  // already handle args.help in captureScreenshotAndSave.
  if (args.help) {
    // Wrap message in an array so that the return value is consistant.
    return [{ text: getFormattedHelpData() }];
  }

  // Guard against missing image data.
  if (!value.data) {
    return [];
  }

  simulateCameraShutter(window);
  return save(window, args, value);
}

/**
 * This function is called to simulate camera effects
 *
 * @param object document
 *        The DevTools Client document.
 */
function simulateCameraShutter(window) {
  if (Services.prefs.getBoolPref("devtools.screenshot.audio.enabled")) {
    const audioCamera = new window.Audio(
      "resource://devtools/client/themes/audio/shutter.wav"
    );
    audioCamera.play();
  }
}

/**
 * Save the captured screenshot to one of several destinations.
 *
 * @param object window
 *        The DevTools Client window.
 *
 * @param object args
 *        The original args with which the screenshot was called.
 *
 * @param object image
 *        The image object that was sent from the server.
 *
 *
 * @return string[]
 *         Response messages from processing the screenshot.
 */
async function save(window, args, image) {
  const fileNeeded = args.filename || !args.clipboard || args.file;
  const results = [];

  if (args.clipboard) {
    const result = saveToClipboard(image.data);
    results.push(result);
  }

  if (fileNeeded) {
    const result = await saveToFile(window, image);
    results.push(result);
  }
  return results;
}

/**
 * Save the image data to the clipboard. This returns a promise, so it can
 * be treated exactly like file processing.
 *
 * @param string base64URI
 *        The image data encoded in a base64 URI that was sent from the server.
 *
 * @return string
 *         Response message from processing the screenshot.
 */
function saveToClipboard(base64URI) {
  try {
    const imageTools = Cc["@mozilla.org/image/tools;1"].getService(
      Ci.imgITools
    );

    const base64Data = base64URI.replace("data:image/png;base64,", "");

    const image = atob(base64Data);
    const img = imageTools.decodeImageFromBuffer(
      image,
      image.length,
      "image/png"
    );

    const transferable = Cc[
      "@mozilla.org/widget/transferable;1"
    ].createInstance(Ci.nsITransferable);
    transferable.init(null);
    transferable.addDataFlavor("image/png");
    transferable.setTransferData("image/png", img);

    Services.clipboard.setData(
      transferable,
      null,
      Services.clipboard.kGlobalClipboard
    );
    return { text: L10N.getStr("screenshotCopied") };
  } catch (ex) {
    console.error(ex);
    return { level: "error", text: L10N.getStr("screenshotErrorCopying") };
  }
}

let _outputDirectory = null;

/**
 * Returns the default directory for DevTools screenshots.
 * For consistency with the Firefox Screenshots feature, this will default to
 * the preferred downloads directory.
 *
 * @return {Promise<String>} Resolves the path as a string
 */
async function getOutputDirectory() {
  if (_outputDirectory) {
    return _outputDirectory;
  }

  _outputDirectory = await lazy.Downloads.getPreferredDownloadsDirectory();
  return _outputDirectory;
}

/**
 * Save the screenshot data to disk, returning a promise which is resolved on
 * completion.
 *
 * @param object window
 *        The DevTools Client window.
 *
 * @param object image
 *        The image object that was sent from the server.
 *
 * @return string
 *         Response message from processing the screenshot.
 */
async function saveToFile(window, image) {
  let filename = image.filename;

  // Guard against missing image data.
  if (!image.data) {
    return "";
  }

  // Check there is a .png extension to filename
  if (!filename.match(/.png$/i)) {
    filename += ".png";
  }

  const dir = await getOutputDirectory();
  const dirExists = await IOUtils.exists(dir);
  if (dirExists) {
    // If filename is absolute, it will override the downloads directory and
    // still be applied as expected.
    filename = PathUtils.isAbsolute(filename)
      ? filename
      : PathUtils.joinRelative(dir, filename);
  }

  const targetFile = new lazy.FileUtils.File(filename);

  // Create download and track its progress.
  try {
    const download = await lazy.Downloads.createDownload({
      source: {
        url: image.data,
        // Here we want to know if the window in which the screenshot is taken is private.
        // We have a ChromeWindow when this is called from Browser Console (:screenshot) and
        // RDM (screenshot button).
        isPrivate: window.isChromeWindow
          ? lazy.PrivateBrowsingUtils.isWindowPrivate(window)
          : lazy.PrivateBrowsingUtils.isBrowserPrivate(
              window.browsingContext.embedderElement
            ),
      },
      target: targetFile,
    });
    const list = await lazy.Downloads.getList(lazy.Downloads.ALL);
    // add the download to the download list in the Downloads list in the Browser UI
    list.add(download);
    // Await successful completion of the save via the download manager
    await download.start();
    return { text: L10N.getFormatStr("screenshotSavedToFile", filename) };
  } catch (ex) {
    console.error(ex);
    return {
      level: "error",
      text: L10N.getFormatStr("screenshotErrorSavingToFile", filename),
    };
  }
}

module.exports = {
  captureAndSaveScreenshot,
  captureScreenshot,
  saveScreenshot,
};
