/* 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";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";

// Hard limits on maximum message size that can be read/written
// These are defined in the native messaging documentation, note that
// the write limit is imposed by the "wire protocol" in which message
// boundaries are defined by preceding each message with its length as
// 4-byte unsigned integer so this is the largest value that can be
// represented.  Good luck generating a serialized message that large,
// the practical write limit is likely to be dictated by available memory.
const MAX_READ = 1024 * 1024;
const MAX_WRITE = 0xffffffff;

// Preferences that can lower the message size limits above,
// used for testing the limits.
const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes";
const PREF_MAX_WRITE =
  "webextensions.native-messaging.max-output-message-bytes";

const lazy = XPCOMUtils.declareLazy({
  AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
  NativeManifests: "resource://gre/modules/NativeManifests.sys.mjs",
  Subprocess: "resource://gre/modules/Subprocess.sys.mjs",
  maxRead: { pref: PREF_MAX_READ, default: MAX_READ },
  maxWrite: { pref: PREF_MAX_WRITE, default: MAX_WRITE },
  portal: {
    service: "@mozilla.org/extensions/native-messaging-portal;1",
    iid: Ci.nsINativeMessagingPortal,
  },
});

const { ExtensionError, promiseTimeout } = ExtensionUtils;

// For a graceful shutdown (i.e., when the extension is unloaded or when it
// explicitly calls disconnect() on a native port), how long we give the native
// application to exit before we start trying to kill it.  (in milliseconds)
const GRACEFUL_SHUTDOWN_TIME = 3000;

export class NativeApp extends EventEmitter {
  _throwGenericError(application) {
    // Report a generic error to not leak information about whether a native
    // application is installed to addons that do not have the right permission.
    throw new ExtensionError(`No such native application ${application}`);
  }

  /**
   * @param {BaseContext} context The context that initiated the native app.
   * @param {string} application The identifier of the native app.
   */
  constructor(context, application) {
    super();

    this.context = context;
    this.name = application;

    // We want a close() notification when the window is destroyed.
    this.context.callOnClose(this);

    this.proc = null;
    this.readPromise = null;
    this.sendQueue = [];
    this.writePromise = null;
    this.cleanupStarted = false;
    this.portalSessionHandle = null;

    if ("@mozilla.org/extensions/native-messaging-portal;1" in Cc) {
      if (lazy.portal.shouldUse()) {
        this.startupPromise = this._doInitPortal().catch(err => {
          this.startupPromise = null;
          Cu.reportError(err instanceof Error ? err : err.message);
          this._cleanup(err);
        });
        return;
      }
    }

    this.startupPromise = lazy.NativeManifests.lookupManifest(
      "stdio",
      application,
      context
    )
      .then(hostInfo => {
        if (!hostInfo) {
          this._throwGenericError(application);
        }

        let command = hostInfo.manifest.path;
        if (AppConstants.platform == "win") {
          // Normalize in case the extension used / instead of \.
          command = command.replaceAll("/", "\\");

          // Relative paths are only supported on Windows. On Linux and macOS,
          // _tryPath in NativeManifests.sys.mjs enforces that the command path
          // is absolute.
          if (!PathUtils.isAbsolute(command)) {
            // Note: hostInfo.path is an absolute path to the manifest.
            const parentPath = PathUtils.parent(
              hostInfo.path.replaceAll("/", "\\")
            );
            // PathUtils.joinRelative cannot be used because it throws for "..".
            // but command is allowed to contain ".." to traverse the directory.
            command = `${parentPath}\\${command}`;
          }
        }

        let subprocessOpts = {
          command: command,
          arguments: [hostInfo.path, context.extension.id],
          workdir: PathUtils.parent(command),
          stderr: "pipe",
          disclaim: true,
        };

        return lazy.Subprocess.call(subprocessOpts);
      })
      .then(proc => {
        this.startupPromise = null;
        this.proc = proc;
        this._startRead();
        this._startWrite();
        this._startStderrRead();
      })
      .catch(err => {
        this.startupPromise = null;
        Cu.reportError(err instanceof Error ? err : err.message);
        this._cleanup(err);
      });
  }

  async _doInitPortal() {
    let available = await lazy.portal.available;
    if (!available) {
      Cu.reportError("Native messaging portal is not available");
      this._throwGenericError(this.name);
    }

    let handle = await lazy.portal.createSession(this.name);
    this.portalSessionHandle = handle;

    let hostInfo = null;
    let path;
    try {
      let manifest = await lazy.portal.getManifest(
        handle,
        this.name,
        this.context.extension.id
      );
      path = manifest.substring(0, 30) + "...";
      hostInfo = await lazy.NativeManifests.parseManifest(
        "stdio",
        path,
        this.name,
        this.context,
        JSON.parse(manifest)
      );
    } catch (ex) {
      if (ex instanceof SyntaxError && ex.message.startsWith("JSON.parse:")) {
        Cu.reportError(`Error parsing native manifest ${path}: ${ex.message}`);
        this._throwGenericError(this.name);
      }
    }
    if (!hostInfo) {
      this._throwGenericError(this.name);
    }

    let pipes;
    try {
      pipes = await lazy.portal.start(
        handle,
        this.name,
        this.context.extension.id
      );
    } catch (err) {
      if (err.name == "NotFoundError") {
        this._throwGenericError(this.name);
      } else {
        throw err;
      }
    }
    this.proc = await lazy.Subprocess.connectRunning([
      pipes.stdin,
      pipes.stdout,
      pipes.stderr,
    ]);
    this.startupPromise = null;
    this._startRead();
    this._startWrite();
    this._startStderrRead();
  }

  /**
   * Open a connection to a native messaging host.
   *
   * @param {number} portId A unique internal ID that identifies the port.
   * @param {import("ExtensionParent.sys.mjs").NativeMessenger} port Parent NativeMessenger used to send messages.
   * @returns {import("ExtensionParent.sys.mjs").ParentPort}
   */
  onConnect(portId, port) {
    // eslint-disable-next-line
    this.on("message", (_, message) => {
      port.sendPortMessage(
        portId,
        new StructuredCloneHolder(
          `NativeMessaging/onConnect/${this.name}`,
          null,
          message
        )
      );
    });
    this.once("disconnect", (_, error) => {
      port.sendPortDisconnect(portId, error && new ClonedErrorHolder(error));
    });
    return {
      onPortMessage: holder => this.send(holder),
      onPortDisconnect: () => this.close(),
    };
  }

  /**
   * @param {BaseContext} context The scope from where `message` originates.
   * @param {*} message A message from the extension, meant for a native app.
   * @returns {ArrayBufferLike} An ArrayBuffer that can be sent to the native app.
   */
  static encodeMessage(context, message) {
    message = context.jsonStringify(message);
    let buffer = new TextEncoder().encode(message).buffer;
    if (buffer.byteLength > lazy.maxWrite) {
      throw new context.Error("Write too big");
    }
    return buffer;
  }

  // A port is definitely "alive" if this.proc is non-null.  But we have
  // to provide a live port object immediately when connecting so we also
  // need to consider a port alive if proc is null but the startupPromise
  // is still pending.
  get _isDisconnected() {
    return !this.proc && !this.startupPromise;
  }

  _startRead() {
    if (this.readPromise) {
      throw new Error("Entered _startRead() while readPromise is non-null");
    }
    this.readPromise = this.proc.stdout
      .readUint32()
      .then(len => {
        if (len > lazy.maxRead) {
          throw new ExtensionError(
            `Native application tried to send a message of ${len} bytes, which exceeds the limit of ${lazy.maxRead} bytes.`
          );
        }
        return this.proc.stdout.readJSON(len);
      })
      .then(msg => {
        this.emit("message", msg);
        this.readPromise = null;
        this._startRead();
      })
      .catch(err => {
        if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) {
          Cu.reportError(err instanceof Error ? err : err.message);
        }
        this._cleanup(err);
      });
  }

  _startWrite() {
    if (!this.sendQueue.length) {
      return;
    }

    if (this.writePromise) {
      throw new Error("Entered _startWrite() while writePromise is non-null");
    }

    let buffer = this.sendQueue.shift();
    let uintArray = Uint32Array.of(buffer.byteLength);

    this.writePromise = Promise.all([
      this.proc.stdin.write(uintArray.buffer),
      this.proc.stdin.write(buffer),
    ])
      .then(() => {
        this.writePromise = null;
        this._startWrite();
      })
      .catch(err => {
        Cu.reportError(err.message);
        this._cleanup(err);
      });
  }

  _startStderrRead() {
    let proc = this.proc;
    let app = this.name;
    (async function () {
      let partial = "";
      while (true) {
        let data = await proc.stderr.readString();
        if (!data.length) {
          // We have hit EOF, just stop reading
          if (partial) {
            Services.console.logStringMessage(
              `stderr output from native app ${app}: ${partial}`
            );
          }
          break;
        }

        let lines = data.split(/\r?\n/);
        lines[0] = partial + lines[0];
        partial = lines.pop();

        for (let line of lines) {
          Services.console.logStringMessage(
            `stderr output from native app ${app}: ${line}`
          );
        }
      }
    })();
  }

  send(holder) {
    if (this._isDisconnected) {
      throw new ExtensionError("Attempt to postMessage on disconnected port");
    }
    let msg = holder.deserialize(globalThis);
    if (Cu.getClassName(msg, true) != "ArrayBuffer") {
      // This error cannot be triggered by extensions; it indicates an error in
      // our implementation.
      throw new Error(
        "The message to the native messaging host is not an ArrayBuffer"
      );
    }

    let buffer = msg;

    if (buffer.byteLength > lazy.maxWrite) {
      throw new ExtensionError("Write too big");
    }

    this.sendQueue.push(buffer);
    if (!this.startupPromise && !this.writePromise) {
      this._startWrite();
    }
  }

  // Shut down the native application and (by default) signal to the extension
  // that the connect has been disconnected.
  async _cleanup(err, fromExtension = false) {
    if (this.cleanupStarted) {
      return;
    }
    this.cleanupStarted = true;
    this.context.forgetOnClose(this);

    if (!fromExtension) {
      if (err && err.errorCode == lazy.Subprocess.ERROR_END_OF_FILE) {
        err = null;
      }
      this.emit("disconnect", err);
    }

    await this.startupPromise;

    if (this.portalSessionHandle) {
      if (this.writePromise) {
        await this.writePromise.catch(Cu.reportError);
      }
      // When using the WebExtensions portal, we don't control the external
      // process, the portal does. So let the portal handle waiting/killing the
      // external process as it sees fit.
      await lazy.portal
        .closeSession(this.portalSessionHandle)
        .catch(Cu.reportError);
      this.portalSessionHandle = null;
      this.proc?.kill();
      this.proc = null;
      return;
    }

    if (!this.proc) {
      // Failed to initialize proc in the constructor.
      return;
    }

    // To prevent an uncooperative process from blocking shutdown, we take the
    // following actions, and wait for GRACEFUL_SHUTDOWN_TIME in between.
    //
    // 1. Allow exit by closing the stdin pipe.
    // 2. Allow exit by a kill signal.
    // 3. Allow exit by forced kill signal.
    // 4. Give up and unblock shutdown despite the process still being alive.

    // Close the stdin stream and allow the process to exit on its own.
    // proc.wait() below will resolve once the process has exited gracefully.
    this.proc.stdin.close().catch(err => {
      if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) {
        Cu.reportError(err);
      }
    });
    let exitPromise = Promise.race([
      // 1. Allow the process to exit on its own after closing stdin.
      this.proc.wait().then(() => {
        this.proc = null;
      }),
      promiseTimeout(GRACEFUL_SHUTDOWN_TIME).then(() => {
        if (this.proc) {
          // 2. Kill the process gracefully. 3. Force kill after a timeout.
          this.proc.kill(GRACEFUL_SHUTDOWN_TIME);

          // 4. If the process is still alive after a kill + timeout followed
          // by a forced kill + timeout, give up and just resolve exitPromise.
          //
          // Note that waiting for just one interval is not enough, because the
          // `proc.kill()` is asynchronous, so we need to wait a bit after the
          // kill signal has been sent.
          return promiseTimeout(2 * GRACEFUL_SHUTDOWN_TIME);
        }
      }),
    ]);

    lazy.AsyncShutdown.profileBeforeChange.addBlocker(
      `Native Messaging: Wait for application ${this.name} to exit`,
      exitPromise
    );
  }

  // Called when the Context or Port is closed.
  close() {
    this._cleanup(null, true);
  }

  sendMessage(holder) {
    let responsePromise = new Promise((resolve, reject) => {
      this.once("message", (what, msg) => {
        resolve(msg);
      });
      this.once("disconnect", (what, err) => {
        reject(err);
      });
    });

    let result = this.startupPromise.then(() => {
      // Skip .send() if _cleanup() has been called already;
      // otherwise the error passed to _cleanup/"disconnect" would be hidden by the
      // "Attempt to postMessage on disconnected port" error from this.send().
      if (!this.cleanupStarted) {
        this.send(holder);
      }
      return responsePromise;
    });

    result.then(
      () => {
        this._cleanup();
      },
      () => {
        // Prevent the response promise from being reported as an
        // unchecked rejection if the startup promise fails.
        responsePromise.catch(() => {});

        this._cleanup();
      }
    );

    return result;
  }
}
