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

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

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

XPCOMUtils.defineLazyModuleGetters(lazy, {
  GlodaUtils: "resource:///modules/gloda/GlodaUtils.jsm",
  MailUtils: "resource:///modules/MailUtils.jsm",
  NetUtil: "resource://gre/modules/NetUtil.jsm",
});

XPCOMUtils.defineLazyServiceGetters(lazy, {
  gHandlerService: [
    "@mozilla.org/uriloader/handler-service;1",
    "nsIHandlerService",
  ],
  gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"],
});

XPCOMUtils.defineLazyGetter(lazy, "messengerBundle", () => {
  return Services.strings.createBundle(
    "chrome://messenger/locale/messenger.properties"
  );
});

/**
 * A class to handle attachment information and actions.
 */
export class AttachmentInfo {
  /**
   * A cache of message/rfc822 attachments saved to temporary files for display.
   * Saving the same attachment again is avoided.
   *
   * @type {Map<string, nsIFile>}
   */
  #temporaryFiles = new Map();

  /**
   * A function to call when checking to see if an attachment exists or not, so
   * that the display can be updated.
   *
   * @type {Function}
   */
  #updateAttachmentsDisplayFn = null;

  /**
   * Create a new attachment object which goes into the data attachment array.
   * This method checks whether the passed attachment is empty or not.
   *
   * @param {object} options
   * @param {string} options.contentType - The attachment's mimetype.
   * @param {string} options.url - The URL for the attachment.
   * @param {string} options.name - The name to be displayed for this attachment
   *   (usually the filename).
   * @param {string} options.uri - The URI for the message containing the
   *   attachment.
   * @param {boolean} options.isExternalAttachment - True if the attachment has
   *   been detached to file or is a link attachment.
   * @param {object} options.message - The message object associated to this
   *   attachment.
   * @param {function} [updateAttachmentsDisplayFn] - An optional callback
   *   function that is called to update the attachment display at appropriate
   *   times.
   */
  constructor({
    contentType,
    url,
    name,
    uri,
    isExternalAttachment,
    message,
    updateAttachmentsDisplayFn,
  }) {
    this.message = message;
    this.contentType = contentType;
    this.name = name;
    this.url = url;
    this.uri = uri;
    this.isExternalAttachment = isExternalAttachment;
    this.#updateAttachmentsDisplayFn = updateAttachmentsDisplayFn;
    // A |size| value of -1 means we don't have a valid size. Check again if
    // |sizeResolved| is false. For internal attachments and link attachments
    // with a reported size, libmime streams values to addAttachmentField()
    // which updates this object. For external file attachments, |size| is updated
    // in the #() function when the list is built. Deleted attachments
    // are resolved to -1.
    this.size = -1;
    this.sizeResolved = this.isDeleted;

    // Remove [?&]part= from remote urls, after getting the partID.
    // Remote urls, unlike non external mail part urls, may also contain query
    // strings starting with ?; PART_RE does not handle this.
    if (this.isLinkAttachment || this.isFileAttachment) {
      let match = url.match(/[?&]part=[^&]+$/);
      match = match && match[0];
      this.partID = match && match.split("part=")[1];
      this.url = url.replace(match, "");
    } else {
      let match = lazy.GlodaUtils.PART_RE.exec(url);
      this.partID = match && match[1];
    }
  }

  /**
   * Save this attachment to a file.
   *
   * @param {nsIMessenger} messenger
   *   The messenger object associated with the window.
   */
  async save(messenger) {
    if (!this.hasFile) {
      return;
    }

    let empty = await this.isEmpty();
    if (empty) {
      return;
    }

    messenger.saveAttachment(
      this.contentType,
      this.url,
      encodeURIComponent(this.name),
      this.uri,
      this.isExternalAttachment
    );
  }

  /**
   * Open this attachment.
   *
   * @param {integer} [browsingContextId]
   *   The browsingContext of the browser that this attachment is being opened
   *   from.
   */
  async open(browsingContext) {
    if (!this.hasFile) {
      return;
    }

    let win = browsingContext.topChromeWindow;
    let empty = await this.isEmpty();
    if (empty) {
      let prompt = lazy.messengerBundle.GetStringFromName(
        this.isExternalAttachment
          ? "externalAttachmentNotFound"
          : "emptyAttachment"
      );
      Services.prompt.alert(win, null, prompt);
    } else {
      // @see MsgComposeCommands.js which has simililar opening functionality
      let dotPos = this.name.lastIndexOf(".");
      let extension =
        dotPos >= 0 ? this.name.substring(dotPos + 1).toLowerCase() : "";
      if (this.contentType == "application/pdf" || extension == "pdf") {
        let handlerInfo = lazy.gMIMEService.getFromTypeAndExtension(
          this.contentType,
          extension
        );
        // Only open a new tab for pdfs if we are handling them internally.
        if (
          !handlerInfo.alwaysAskBeforeHandling &&
          handlerInfo.preferredAction == Ci.nsIHandlerInfo.handleInternally
        ) {
          // Add the content type to avoid a "how do you want to open this?"
          // dialog. The type may already be there, but that doesn't matter.
          let url = this.url;
          if (!url.includes("type=")) {
            url += url.includes("?") ? "&" : "?";
            url += "type=application/pdf";
          }
          let tabmail = win.document.getElementById("tabmail");
          if (!tabmail) {
            // If no tabmail available in this window, try and find it in
            // another.
            let win = Services.wm.getMostRecentWindow("mail:3pane");
            tabmail = win?.document.getElementById("tabmail");
          }
          if (tabmail) {
            tabmail.openTab("contentTab", {
              url,
              background: false,
              linkHandler: "single-page",
            });
            tabmail.ownerGlobal.focus();
            return;
          }
          // If no tabmail, open PDF same as other attachments.
        }
      }

      // Just use the old method for handling messages, it works.

      let { name, url } = this;

      let sourceURI = Services.io.newURI(url);
      async function saveToFile(path, isTmp = false) {
        let buffer = await new Promise((resolve, reject) => {
          lazy.NetUtil.asyncFetch(
            {
              uri: sourceURI,
              loadUsingSystemPrincipal: true,
            },
            (inputStream, status) => {
              if (Components.isSuccessCode(status)) {
                resolve(lazy.NetUtil.readInputStream(inputStream));
              } else {
                reject(
                  new Components.Exception(`Failed to fetch ${path}`, status)
                );
              }
            }
          );
        });
        await IOUtils.write(path, new Uint8Array(buffer));

        if (!isTmp) {
          // Create a download so that saved files show up under... Saved Files.
          let file = await IOUtils.getFile(path);
          lazy.Downloads.createDownload({
            source: {
              url: sourceURI.spec,
            },
            target: file,
            startTime: new Date(),
          })
            .then(async download => {
              await download.start();
              let list = await lazy.Downloads.getList(lazy.Downloads.ALL);
              await list.add(download);
            })
            .catch(console.error);
        }
      }

      if (this.contentType == "message/rfc822") {
        let tempFile = this.#temporaryFiles.get(url);
        if (!tempFile?.exists()) {
          tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
          tempFile.append("subPart.eml");
          tempFile.createUnique(0, 0o600);
          await saveToFile(tempFile.path, true);

          this.#temporaryFiles.set(url, tempFile);
        }

        lazy.MailUtils.openEMLFile(
          win,
          tempFile,
          Services.io.newFileURI(tempFile)
        );
        return;
      }

      // Get the MIME info from the service.

      let mimeInfo;
      try {
        mimeInfo = lazy.gMIMEService.getFromTypeAndExtension(
          this.contentType,
          extension
        );
      } catch (ex) {
        // If the call above fails, which can happen on Windows where there's
        // nothing registered for the file type, assume this generic type.
        mimeInfo = lazy.gMIMEService.getFromTypeAndExtension(
          "application/octet-stream",
          ""
        );
      }
      // The default action is saveToDisk, which is not what we want.
      // If we don't have a stored handler, ask before handling.
      if (!lazy.gHandlerService.exists(mimeInfo)) {
        mimeInfo.alwaysAskBeforeHandling = true;
        mimeInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
      }

      // If we know what to do, do it.

      name = lazy.DownloadPaths.sanitize(name);

      let createTemporaryFileAndOpen = async mimeInfo => {
        let tmpPath = PathUtils.join(
          Services.dirsvc.get("TmpD", Ci.nsIFile).path,
          "pid-" + Services.appinfo.processID
        );
        await IOUtils.makeDirectory(tmpPath, { permissions: 0o700 });
        let tempFile = Cc["@mozilla.org/file/local;1"].createInstance(
          Ci.nsIFile
        );
        tempFile.initWithPath(tmpPath);

        tempFile.append(name);
        tempFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
        tempFile.remove(false);

        Cc["@mozilla.org/uriloader/external-helper-app-service;1"]
          .getService(Ci.nsPIExternalAppLauncher)
          .deleteTemporaryFileOnExit(tempFile);

        await saveToFile(tempFile.path, true);
        // Before opening from the temp dir, make the file read-only so that
        // users don't edit and lose their edits...
        await IOUtils.setPermissions(tempFile.path, 0o400); // Set read-only
        this._openFile(mimeInfo, tempFile);
      };

      let openLocalFile = mimeInfo => {
        let fileHandler = Services.io
          .getProtocolHandler("file")
          .QueryInterface(Ci.nsIFileProtocolHandler);

        try {
          let externalFile = fileHandler.getFileFromURLSpec(this.displayUrl);
          this._openFile(mimeInfo, externalFile);
        } catch (ex) {
          console.error(
            "AttachmentInfo.open: file - " + this.displayUrl + ", " + ex
          );
        }
      };

      if (!mimeInfo.alwaysAskBeforeHandling) {
        switch (mimeInfo.preferredAction) {
          case Ci.nsIHandlerInfo.saveToDisk:
            if (Services.prefs.getBoolPref("browser.download.useDownloadDir")) {
              let destFile = new lazy.FileUtils.File(
                await lazy.Downloads.getPreferredDownloadsDirectory()
              );
              destFile.append(name);
              destFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755);
              destFile.remove(false);
              await saveToFile(destFile.path);
            } else {
              let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance(
                Ci.nsIFilePicker
              );
              filePicker.defaultString = this.name;
              filePicker.defaultExtension = extension;
              filePicker.init(
                win,
                lazy.messengerBundle.GetStringFromName("SaveAttachment"),
                Ci.nsIFilePicker.modeSave
              );
              let rv = await new Promise(resolve => filePicker.open(resolve));
              if (rv != Ci.nsIFilePicker.returnCancel) {
                await saveToFile(filePicker.file.path);
              }
            }
            return;
          case Ci.nsIHandlerInfo.useHelperApp:
          case Ci.nsIHandlerInfo.useSystemDefault:
            // Attachments can be detached and, if this is the case, opened from
            // their location on disk instead of copied to a temporary file.
            if (this.isExternalAttachment) {
              openLocalFile(mimeInfo);
              return;
            }

            await createTemporaryFileAndOpen(mimeInfo);
            return;
        }
      }

      // Ask what to do, then do it.
      let appLauncherDialog = Cc[
        "@mozilla.org/helperapplauncherdialog;1"
      ].createInstance(Ci.nsIHelperAppLauncherDialog);
      appLauncherDialog.show(
        {
          QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncher"]),
          MIMEInfo: mimeInfo,
          source: Services.io.newURI(this.url),
          suggestedFileName: this.name,
          cancel(reason) {},
          promptForSaveDestination() {
            appLauncherDialog.promptForSaveToFileAsync(
              this,
              win,
              this.suggestedFileName,
              "." + extension, // Dot stripped by promptForSaveToFileAsync.
              false
            );
          },
          launchLocalFile() {
            openLocalFile(mimeInfo);
          },
          async setDownloadToLaunch(handleInternally, file) {
            await createTemporaryFileAndOpen(mimeInfo);
          },
          async saveDestinationAvailable(file) {
            if (file) {
              await saveToFile(file.path);
            }
          },
          setWebProgressListener(webProgressListener) {},
          targetFile: null,
          targetFileIsExecutable: null,
          timeDownloadStarted: null,
          contentLength: this.size,
          browsingContextId: browsingContext.id,
        },
        win,
        null
      );
    }
  }

  /**
   * Unless overridden by a test, opens a saved attachment when called by `open`.
   *
   * @param {nsIMIMEInfo} mimeInfo
   * @param {nsIFile} file
   */
  _openFile(mimeInfo, file) {
    mimeInfo.launchWithFile(file);
  }

  /**
   * Detach this attachment from the message.
   *
   * @param {nsIMessenger} messenger
   *   The messenger object associated with the window.
   * @param {boolean} aSaveFirst - true if the attachment should be saved
   *                               before detaching, false otherwise.
   */
  detach(messenger, aSaveFirst) {
    messenger.detachAttachment(
      this.contentType,
      this.url,
      encodeURIComponent(this.name),
      this.uri,
      aSaveFirst
    );
  }

  /**
   * This method checks whether the attachment has been deleted or not.
   *
   * @returns true if the attachment has been deleted, false otherwise.
   */
  get isDeleted() {
    return this.contentType == "text/x-moz-deleted";
  }

  /**
   * This method checks whether the attachment is a detached file.
   *
   * @returns true if the attachment is a detached file, false otherwise.
   */
  get isFileAttachment() {
    return this.isExternalAttachment && this.url.startsWith("file:");
  }

  /**
   * This method checks whether the attachment is an http link.
   *
   * @returns true if the attachment is an http link, false otherwise.
   */
  get isLinkAttachment() {
    return this.isExternalAttachment && /^https?:/.test(this.url);
  }

  /**
   * This method checks whether the attachment has an associated file or not.
   * Deleted attachments or detached attachments with missing external files
   * do *not* have a file.
   *
   * @returns true if the attachment has an associated file, false otherwise.
   */
  get hasFile() {
    if (this.sizeResolved && this.size == -1) {
      return false;
    }

    return true;
  }

  /**
   * Return display url, decoded and converted to utf8 from IDN punycode ascii,
   * if the attachment is external (http or file schemes).
   *
   * @returns {string} url.
   */
  get displayUrl() {
    if (this.isExternalAttachment) {
      // For status bar url display purposes, we want the displaySpec.
      // The ?part= has already been removed.
      return decodeURI(Services.io.newURI(this.url).displaySpec);
    }

    return this.url;
  }

  /**
   * This method checks whether the attachment url location exists and
   * is accessible. For http and file urls, fetch() will have the size
   * in the content-length header.
   *
   * @returns {Boolean}
   *   true if the attachment is empty or error, false otherwise.
   */
  async isEmpty() {
    if (this.isDeleted) {
      return true;
    }

    const isFetchable = url => {
      let uri = Services.io.newURI(url);
      return !(uri.username || uri.userPass);
    };

    // We have a resolved size.
    if (this.sizeResolved) {
      return this.size < 1;
    }

    if (!isFetchable(this.url)) {
      return false;
    }

    let empty = true;
    let size = -1;
    let options = { method: "GET" };

    let request = new Request(this.url, options);

    if (this.isExternalAttachment && this.#updateAttachmentsDisplayFn) {
      this.#updateAttachmentsDisplayFn(this, true);
    }

    await fetch(request)
      .then(response => {
        if (!response.ok) {
          console.warn(
            "AttachmentInfo.#: fetch response error - " +
              response.statusText +
              ", response.url - " +
              response.url
          );
          return null;
        }

        if (this.isLinkAttachment) {
          if (response.status < 200 || response.status > 304) {
            console.warn(
              "AttachmentInfo.#: link fetch response status - " +
                response.status +
                ", response.url - " +
                response.url
            );
            return null;
          }
        }

        return response;
      })
      .then(async response => {
        if (this.isExternalAttachment) {
          size = response ? response.headers.get("content-length") : -1;
        } else {
          // Check the attachment again if addAttachmentField() sets a
          // libmime -1 return value for size in this object.
          // Note: just test for a non zero size, don't need to drain the
          // stream. We only get here if the url is fetchable.
          // The size for internal attachments is not calculated here but
          // will come from libmime.
          let reader = response.body.getReader();
          let result = await reader.read();
          reader.cancel();
          size = result && result.value ? result.value.length : -1;
        }

        if (size > 0) {
          empty = false;
        }
      })
      .catch(error => {
        console.warn(`AttachmentInfo.#: ${error.message} url - ${this.url}`);
      });

    this.sizeResolved = true;

    if (this.isExternalAttachment) {
      // For link attachments, we may have had a published value or -1
      // indicating unknown value. We now know the real size, so set it and
      // update the ui. For detached file attachments, get the size here
      // instead of the old xpcom way.
      this.size = size;
      this.#updateAttachmentsDisplayFn?.(this, false);
    }

    return empty;
  }

  /**
   * Open a file attachment's containing folder.
   */
  openFolder() {
    if (!this.isFileAttachment || !this.hasFile) {
      return;
    }

    // The file url is stored in the attachment info part with unix path and
    // needs to be converted to os path for nsIFile.
    let fileHandler = Services.io
      .getProtocolHandler("file")
      .QueryInterface(Ci.nsIFileProtocolHandler);
    try {
      fileHandler.getFileFromURLSpec(this.displayUrl).reveal();
    } catch (ex) {
      console.error(
        "AttachmentInfo.openFolder: file - " + this.displayUrl + ", " + ex
      );
    }
  }
}
