/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/**
 * Contributors:
 *   Ben Bucksch <ben.bucksch beonex.com> <http://business.beonex.com> (RFC 5034 Authentication)
 */
/* This file implements test POP3 servers
 */

import {
  AuthPLAIN,
  AuthLOGIN,
  AuthCRAM,
} from "resource://testing-common/mailnews/Auth.sys.mjs";
import { mailTestUtils } from "resource://testing-common/mailnews/MailTestUtils.sys.mjs";

// Since we don't really need to worry about peristence, we can just
// use a UIDL counter.
var gUIDLCount = 1;

/**
 * Read the contents of a file to the string.
 *
 * @param fileName A path relative to the current working directory, or
 *                 a filename underneath the "data" directory relative to
 *                 the cwd.
 */
function readFile(fileName) {
  const cwd = Services.dirsvc.get("CurWorkD", Ci.nsIFile);

  // Try to find the file relative to either the data directory or to the
  // current working directory.
  let file = cwd.clone();
  if (fileName.includes("/")) {
    const parts = fileName.split("/");
    for (const part of parts) {
      if (part == "..") {
        file = file.parent;
      } else {
        file.append(part);
      }
    }
  } else {
    file.append("data");
    file.append(fileName);
  }

  if (!file.exists()) {
    throw new Error("Cannot find file named " + fileName);
  }

  return mailTestUtils.loadFileToString(file);
}

export class Pop3Daemon {
  _messages = [];
  _totalMessageSize = 0;

  /**
   * Set the messages that the POP3 daemon will provide to its clients.
   *
   * @param messages An array of either 1) strings that are filenames whose
   *     contents will be loaded from the files or 2) objects with a "fileData"
   *     attribute whose value is the content of the file.
   */
  setMessages(messages) {
    this._messages = [];
    this._totalMessageSize = 0;

    function addMessage(element) {
      // if it's a string, then it's a file-name.
      if (typeof element == "string") {
        this._messages.push({ fileData: readFile(element), size: -1 });
      } else {
        // Otherwise it's an object as dictionary already.
        this._messages.push(element);
      }
    }
    messages.forEach(addMessage, this);

    for (var i = 0; i < this._messages.length; ++i) {
      this._messages[i].size = this._messages[i].fileData.length;
      this._messages[i].uidl = "UIDL" + gUIDLCount++;
      this._totalMessageSize += this._messages[i].size;
    }
  }
  getTotalMessages() {
    return this._messages.length;
  }
  getTotalMessageSize() {
    return this._totalMessageSize;
  }
}

// POP3 TEST SERVERS
// -----------------

var kStateAuthNeeded = 1; // Not authenticated yet, need username and password
var kStateAuthPASS = 2; // got command USER, expecting command PASS
var kStateTransaction = 3; // Authenticated, can fetch and delete mail

/**
 * This handler implements the bare minimum required by RFC 1939.
 * If dropOnAuthFailure is set, the server will drop the connection
 * on authentication errors, to simulate servers that do the same.
 */
export class POP3_RFC1939_handler {
  kUsername = "fred";
  kPassword = "wilma";

  /**
   * A copy of `this._daemon._messages` as it was when `LIST` was last called.
   * Deleting a message mutates that list, so it can not be relied upon to
   * find a message at any given index. Use this list instead.
   */
  _mostRecentList;

  constructor(daemon, { username = "fred", password = "wilma" } = {}) {
    this._daemon = daemon;
    this.kUsername = username;
    this.kPassword = password;
    this.closing = false;
    this.dropOnAuthFailure = false;
    this._multiline = false;
    this.resetTest();
  }

  resetTest() {
    this._state = kStateAuthNeeded;
  }

  USER(args) {
    if (this._state != kStateAuthNeeded) {
      return "-ERR invalid state";
    }

    if (args == this.kUsername) {
      this._state = kStateAuthPASS;
      return "+OK user recognized";
    }

    return "-ERR sorry, no such mailbox";
  }
  PASS(args) {
    if (this._state != kStateAuthPASS) {
      return "-ERR invalid state";
    }

    if (args == this.kPassword) {
      this._state = kStateTransaction;
      return "+OK maildrop locked and ready";
    }

    this._state = kStateAuthNeeded;
    if (this.dropOnAuthFailure) {
      this.closing = true;
    }
    return "-ERR invalid password";
  }
  STAT() {
    if (this._state != kStateTransaction) {
      return "-ERR invalid state";
    }

    return (
      "+OK " +
      this._daemon.getTotalMessages() +
      " " +
      this._daemon.getTotalMessageSize()
    );
  }
  LIST() {
    if (this._state != kStateTransaction) {
      return "-ERR invalid state";
    }

    this._mostRecentList = this._daemon._messages.slice();
    let result = "+OK " + this._daemon._messages.length + " messages\r\n";
    for (let i = 0; i < this._daemon._messages.length; ++i) {
      result += i + 1 + " " + this._daemon._messages[i].size + "\r\n";
    }

    result += ".";
    return result;
  }
  UIDL() {
    if (this._state != kStateTransaction) {
      return "-ERR invalid state";
    }
    let result = "+OK\r\n";
    for (let i = 0; i < this._daemon._messages.length; ++i) {
      result += i + 1 + " " + this._daemon._messages[i].uidl + "\r\n";
    }

    result += ".";
    return result;
  }
  TOP(args) {
    const [messageNumber, numberOfBodyLines] = args.split(" ");
    if (this._state != kStateTransaction) {
      return "-ERR invalid state";
    }
    let result = "+OK\r\n";
    const msg = this._daemon._messages[messageNumber - 1].fileData;
    const index = msg.indexOf("\r\n\r\n");
    result += msg.slice(0, index);
    if (numberOfBodyLines) {
      result += "\r\n\r\n";
      const bodyLines = msg.slice(index + 4).split("\r\n");
      result += bodyLines.slice(0, numberOfBodyLines).join("\r\n");
    }
    result += "\r\n.";
    return result;
  }
  RETR(args) {
    if (this._state != kStateTransaction) {
      return "-ERR invalid state";
    }

    const message = this._mostRecentList[args - 1];
    let result = "+OK " + message.size + "\r\n";
    result += message.fileData;
    result += ".";
    return result;
  }
  DELE(args) {
    if (this._state != kStateTransaction) {
      return "-ERR invalid state";
    }
    const message = this._mostRecentList[args - 1];
    const index = this._daemon._messages.findIndex(m => m == message);
    if (index >= 0) {
      this._daemon._messages.splice(index, 1);
    }
    return "+OK";
  }
  NOOP() {
    if (this._state != kStateTransaction) {
      return "-ERR invalid state";
    }
    return "+OK";
  }
  RSET() {
    if (this._state != kStateTransaction) {
      return "-ERR invalid state";
    }
    this._state = kStateAuthNeeded;
    return "+OK";
  }
  QUIT() {
    // Let the client close the socket
    // this.closing = true;
    return "+OK fakeserver signing off";
  }
  onStartup() {
    this.closing = false;
    this._state = kStateAuthNeeded;
    return "+OK Fake POP3 server ready";
  }
  onError(command) {
    return "-ERR command " + command + " not implemented";
  }
  onServerFault(e) {
    return "-ERR internal server error: " + e;
  }
  postCommand(reader) {
    reader.setMultiline(this._multiline);
    if (this.closing) {
      reader.closeSocket();
    }
  }
}

/**
 * This implements CAPA
 *
 * @see RFC 2449
 */
export class POP3_RFC2449_handler extends POP3_RFC1939_handler {
  kCapabilities = ["UIDL", "TOP"]; // the test may adapt this as necessary

  CAPA() {
    var capa = "+OK List of our wanna-be capabilities follows:\r\n";
    for (var i = 0; i < this.kCapabilities.length; i++) {
      capa += this.kCapabilities[i] + "\r\n";
    }
    if (this.capaAdditions) {
      capa += this.capaAdditions();
    }
    capa += "IMPLEMENTATION fakeserver\r\n.";
    return capa;
  }
}

/**
 * This implements the AUTH command, i.e. authentication using CRAM-MD5 etc.
 *
 * @see RFC 5034
 * @author Ben Bucksch <ben.bucksch beonex.com> <http://business.beonex.com>
 */
export class POP3_RFC5034_handler extends POP3_RFC2449_handler {
  kAuthSchemes = ["CRAM-MD5", "PLAIN", "LOGIN"]; // the test may adapt this as necessary
  _usedCRAMMD5Challenge = null; // not base64-encoded

  constructor(daemon, options) {
    super(daemon, options);

    this._kAuthSchemeStartFunction = {
      "CRAM-MD5": this.authCRAMStart,
      PLAIN: this.authPLAINStart,
      LOGIN: this.authLOGINStart,
    };
  }

  // called by this.CAPA()
  capaAdditions() {
    var capa = "";
    if (this.kAuthSchemes.length > 0) {
      capa += "SASL";
      for (var i = 0; i < this.kAuthSchemes.length; i++) {
        capa += " " + this.kAuthSchemes[i];
      }
      capa += "\r\n";
    }
    return capa;
  }
  AUTH(lineRest) {
    // |lineRest| is a string containing the rest of line after "AUTH "
    if (this._state != kStateAuthNeeded) {
      return "-ERR invalid state";
    }

    // AUTH without arguments returns a list of supported schemes
    if (!lineRest) {
      var capa = "+OK I like:\r\n";
      for (var i = 0; i < this.kAuthSchemes.length; i++) {
        capa += this.kAuthSchemes[i] + "\r\n";
      }
      capa += ".\r\n";
      return capa;
    }

    var args = lineRest.split(" ");
    var scheme = args[0].toUpperCase();
    // |scheme| contained in |kAuthSchemes|?
    if (
      !this.kAuthSchemes.some(function (s) {
        return s == scheme;
      })
    ) {
      return "-ERR AUTH " + scheme + " not supported";
    }

    var func = this._kAuthSchemeStartFunction[scheme];
    if (!func || typeof func != "function") {
      return (
        "-ERR I just pretended to implement AUTH " + scheme + ", but I don't"
      );
    }
    return func.call(this, "1" in args ? args[1] : undefined);
  }

  onMultiline(line) {
    if (this._nextAuthFunction) {
      var func = this._nextAuthFunction;
      this._multiline = false;
      this._nextAuthFunction = undefined;
      if (line == "*") {
        return "-ERR Okay, as you wish. Chicken";
      }
      if (!func || typeof func != "function") {
        return "-ERR I'm lost. Internal server error during auth";
      }
      try {
        return func.call(this, line);
      } catch (e) {
        return "-ERR " + e;
      }
    }

    if (super.onMultiline) {
      // Call parent.
      return super.onMultiline.call(this, line);
    }
    return undefined;
  }

  authPLAINStart() {
    this._nextAuthFunction = this.authPLAINCred;
    this._multiline = true;

    return "+";
  }
  authPLAINCred(line) {
    var req = AuthPLAIN.decodeLine(line);
    if (req.username == this.kUsername && req.password == this.kPassword) {
      this._state = kStateTransaction;
      return "+OK Hello friend! Friends give friends good advice: Next time, use CRAM-MD5";
    }
    if (this.dropOnAuthFailure) {
      this.closing = true;
    }
    return "-ERR Wrong username or password, crook!";
  }

  authCRAMStart() {
    this._nextAuthFunction = this.authCRAMDigest;
    this._multiline = true;

    this._usedCRAMMD5Challenge = AuthCRAM.createChallenge("localhost");
    return "+ " + this._usedCRAMMD5Challenge;
  }
  authCRAMDigest(line) {
    var req = AuthCRAM.decodeLine(line);
    var expectedDigest = AuthCRAM.encodeCRAMMD5(
      this._usedCRAMMD5Challenge,
      this.kPassword
    );
    if (req.username == this.kUsername && req.digest == expectedDigest) {
      this._state = kStateTransaction;
      return "+OK Hello friend!";
    }
    if (this.dropOnAuthFailure) {
      this.closing = true;
    }
    return "-ERR Wrong username or password, crook!";
  }

  authLOGINStart() {
    this._nextAuthFunction = this.authLOGINUsername;
    this._multiline = true;

    return "+ " + btoa("Username:");
  }
  authLOGINUsername(line) {
    var req = AuthLOGIN.decodeLine(line);
    if (req == this.kUsername) {
      this._nextAuthFunction = this.authLOGINPassword;
    } else {
      // Don't return error yet, to not reveal valid usernames.
      this._nextAuthFunction = this.authLOGINBadUsername;
    }
    this._multiline = true;
    return "+ " + btoa("Password:");
  }
  authLOGINBadUsername() {
    if (this.dropOnAuthFailure) {
      this.closing = true;
    }
    return "-ERR Wrong username or password, crook!";
  }
  authLOGINPassword(line) {
    var req = AuthLOGIN.decodeLine(line);
    if (req == this.kPassword) {
      this._state = kStateTransaction;
      return "+OK Hello friend! Where did you pull out this old auth scheme?";
    }
    if (this.dropOnAuthFailure) {
      this.closing = true;
    }
    return "-ERR Wrong username or password, crook!";
  }
}

/**
 * Implements XOAUTH2 authentication.
 */
export class POP3_OAUTH2_handler extends POP3_RFC5034_handler {
  kAuthSchemes = ["XOAUTH2"];

  constructor(daemon, options) {
    super(daemon, options);
    this._kAuthSchemeStartFunction.XOAUTH2 = this.authXOAUTH2Start;
  }

  authXOAUTH2Start() {
    this._nextAuthFunction = this.authXOAUTH2Cred;
    this._multiline = true;

    return "+";
  }
  authXOAUTH2Cred(lineRest) {
    const [user, auth] = atob(lineRest).split("\u0001");
    if (
      user == `user=${this.kUsername}` &&
      auth == `auth=Bearer ${this.kPassword}`
    ) {
      this._state = kStateTransaction;
      return "+OK Yeah, that's the right access token.";
    }
    return "-ERR Yeah, nah, that's the wrong access token.";
  }
}
