/*
 *  This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
 *  License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
 */

/*
Client account creation and deletion
*/

const MAX_ACCOUNTS_PER_30MIN = 150;
const MAX_ACCOUNTS_PER_30MIN_GOLD = 1500;

const auth = require("../auth");
import { parseDomain, ParseResultType } from "parse-domain";
import * as message from "smc-util/message";
import { CreateAccount } from "smc-util/message-types";
import {
  walltime,
  lower_email_address,
  required,
  defaults,
  is_valid_email_address,
  len,
} from "smc-util/misc";
import { delay } from "awaiting";
import { callback2 } from "smc-util/async-utils";
import { PostgreSQL } from "../postgres/types";
const { api_key_action } = require("../api/manage");
import {
  get_server_settings,
  have_active_registration_tokens,
  get_passports,
} from "../utils";
import { getLogger } from "smc-hub/logger";

const winston = getLogger("create-account");

export function is_valid_password(password: string) {
  if (typeof password !== "string") {
    return [false, "Password must be specified."];
  }
  if (password.length >= 6 && password.length <= 64) {
    return [true, ""];
  } else {
    return [false, "Password must be between 6 and 64 characters in length."];
  }
}

function issues_with_create_account(mesg) {
  const issues: any = {};
  if (mesg.email_address && !is_valid_email_address(mesg.email_address)) {
    issues.email_address = "Email address does not appear to be valid.";
  }
  if (mesg.password) {
    const [valid, reason] = is_valid_password(mesg.password);
    if (!valid) {
      issues.password = reason;
    }
  }
  return issues;
}

async function get_db_client(db: PostgreSQL) {
  const t0 = new Date().getTime();
  while (new Date().getTime() - t0 < 10 * 1000) {
    const client = db._client();
    if (client != null) {
      return client;
    } else {
      await delay(100);
    }
  }
  throw new Error("Unable to get a database client");
}

// if the email address's domain should go through SSO, return the domain name
async function is_domain_exclusive_sso(
  db: PostgreSQL,
  email?: string
): Promise<string | undefined> {
  if (email == null) return undefined;
  const raw_domain = email.split("@")[1]?.trim().toLowerCase();
  if (raw_domain == null) return;
  const parsed = parseDomain(raw_domain);
  const passports = await get_passports(db);

  const blocked = new Set<string>([]);
  for (const pp of passports) {
    for (const domain of pp.conf.exclusive_domains ?? []) {
      blocked.add(domain);
    }
  }
  if (parsed.type == ParseResultType.Listed) {
    const { domain, topLevelDomains } = parsed;
    const canonical = [domain ?? "", ...topLevelDomains].join(".");
    if (blocked.has(canonical)) {
      return canonical;
    }
  }
}

// return true if allowed to continue creating an account (either no token required or token matches)
async function check_registration_token(
  db: PostgreSQL,
  token: string | undefined
): Promise<string | undefined> {
  const have_tokens = await have_active_registration_tokens(db);

  // if there are no tokens set, it's ok
  if (!have_tokens) return;

  if (token == null || token == "") {
    return "No registration token provided";
  }

  // since we check the counter against the limit, we have to wrap this in a transaction
  // otherwise there is a chance to increase the counter above the limit
  const client = await get_db_client(db);

  try {
    await client.query("BEGIN");

    // overview: first, we check if the token matches.
    // → check if it is disabled?
    // → check expiration date → abort if expired
    // → if counter, check counter vs. limit
    //   → true: increase the counter → ok
    //   → false: ok
    const q_match = `SELECT "expires", "counter", "limit", "disabled"
                     FROM registration_tokens
                     WHERE token = $1::TEXT
                     FOR UPDATE`;
    const match = await client.query(q_match, [token]);

    if (match.rows.length != 1) {
      return "Registration token is wrong.";
    }
    // e.g. { expires: 2020-12-04T11:54:52.889Z, counter: null, limit: 10, disabled: ... }
    const {
      expires,
      counter: counter_raw,
      limit,
      disabled: disabled_raw,
    } = match.rows[0];
    const counter = counter_raw ?? 0;
    const disabled = disabled_raw ?? false;

    if (disabled) {
      return "Registration token disabled.";
    }

    if (expires != null && expires.getTime() < new Date().getTime()) {
      return "Registration token no longer valid.";
    }

    // we count in any case, but only enforce the limit if there is actually a limit set
    if (limit != null && limit <= counter) {
      return "Registration token used up.";
    } else {
      // increase counter
      const q_inc = `UPDATE registration_tokens SET "counter" = coalesce("counter", 0) + 1
                     WHERE token = $1::TEXT`;
      await client.query(q_inc, [token]);
    }

    // all good, let's commit
    await client.query("COMMIT");
  } catch (e) {
    await client.query("ROLLBACK");
    throw e;
  }
}

interface AccountCreationOptions {
  client: any;
  mesg: CreateAccount;
  database: PostgreSQL;
  host?: string;
  port?: number;
  sign_in?: boolean; // if true, the newly created user will also be signed in; only makes sense for browser clients!
}

interface CreateAccountData {
  account_id: string;
  first_name: string;
  last_name: string;
  email_address?: string;
  created_by: string;
  analytics_token?: string;
}

// This should not actually throw in case of trouble, but instead send
// error directly to the client.
export async function create_account(
  opts: AccountCreationOptions
): Promise<void> {
  // we still use defaults/required due to coffeescript client.
  opts = defaults(opts, {
    client: required,
    mesg: required,
    database: required,
    host: undefined,
    port: undefined,
    sign_in: false, // if true, the newly created user will also be signed in; only makes sense for browser clients!
  });
  const id: string = opts.mesg.id;
  let mesg1: { [key: string]: any };
  winston.info(
    `create_account ${opts.mesg.first_name} ${opts.mesg.last_name} ${opts.mesg.email_address}`
  );
  function dbg(m): void {
    winston.debug(`create_account (${opts.mesg.email_address}): ${m}`);
  }

  const tm = walltime();
  if (opts.mesg.email_address != null) {
    opts.mesg.email_address = lower_email_address(opts.mesg.email_address);
  }

  let account_id: string = "";

  async function createAccount() {
    dbg("run tests on generic validity of input");

    // check if we even allow account creating via email/password
    const settings = await get_server_settings(opts.database);
    if (!settings.email_signup) {
      return { other: "Signing up via email/password is disabled." };
    }

    // issues_with_create_account also does check is_valid_password!
    const issues = issues_with_create_account(opts.mesg);

    // TODO -- only uncomment this for easy testing to allow any password choice.
    // the client test suite will then fail, which is good, so we are reminded
    // to comment this out before release!
    // delete issues['password']

    if (len(issues) > 0) {
      return issues;
    }

    // Make sure this ip address hasn't requested too many accounts recently,
    // just to avoid really nasty abuse, but still allow for demo registration
    // behind a single router.
    dbg("make sure not too many accounts were created from the given ip");
    const n = await callback2(opts.database.count_accounts_created_by, {
      ip_address: opts.client.ip_address,
      age_s: 60 * 30,
    });
    if (n >= MAX_ACCOUNTS_PER_30MIN) {
      let m = MAX_ACCOUNTS_PER_30MIN;

      // Check if account is being created via API by a "gold" user.
      if (
        opts.client.account_id != null &&
        (await callback2(opts.database.user_is_in_group, {
          account_id: opts.client.account_id,
          group: "gold",
        }))
      ) {
        m = MAX_ACCOUNTS_PER_30MIN_GOLD;
      }

      if (n >= m) {
        return {
          other: `Too many accounts are being created from the ip address ${opts.client.ip_address}; try again later.  By default at most ${m} accounts can be created every 30 minutes from one IP; if you're using the API and need a higher limit, contact us.`,
        };
      }
    }

    if (opts.mesg.email_address) {
      dbg("query database to determine whether the email address is available");

      const not_available = await callback2(opts.database.account_exists, {
        email_address: opts.mesg.email_address,
      });
      if (not_available) {
        return { email_address: "This e-mail address is already taken." };
      }

      dbg("check that account is not banned");
      const is_banned = await callback2(opts.database.is_banned_user, {
        email_address: opts.mesg.email_address,
      });
      if (is_banned) {
        return { email_address: "This e-mail address is banned." };
      }
    }

    dbg("check if a registration token is required");
    const check_token = await check_registration_token(
      opts.database,
      opts.mesg.token
    );
    if (check_token) {
      return { token: check_token };
    }

    dbg("check if email domain has to go through an SSO mechanism");
    const check_domain = await is_domain_exclusive_sso(
      opts.database,
      opts.mesg.email_address
    );
    if (check_domain != null) {
      return {
        email_address: `To sign up with "@${check_domain}", you have to use the corresponding SSO connect mechanism listed above!`,
      };
    }

    dbg("create new account");
    account_id = await callback2(opts.database.create_account, {
      first_name: opts.mesg.first_name,
      last_name: opts.mesg.last_name,
      email_address: opts.mesg.email_address,
      password_hash: opts.mesg.password
        ? auth.password_hash(opts.mesg.password)
        : undefined,
      created_by: opts.client.ip_address,
      usage_intent: opts.mesg.usage_intent,
    });

    if (opts.mesg.token != null) {
      // we also record that we used a registration token ...
      await callback2(opts.database.log, {
        event: "create_account_registration_token",
        value: { token: opts.mesg.token, account_id },
      });
    }

    // log to database
    const data: CreateAccountData = {
      account_id,
      first_name: opts.mesg.first_name,
      last_name: opts.mesg.last_name,
      email_address: opts.mesg.email_address,
      created_by: opts.client.ip_address,
    };

    await callback2(opts.database.log, {
      event: "create_account",
      value: data,
    });

    if (opts.mesg.email_address) {
      dbg("check for any account creation actions");
      // do not block
      await callback2(opts.database.do_account_creation_actions, {
        email_address: opts.mesg.email_address,
        account_id,
      });
    }

    if (opts.sign_in) {
      dbg("set remember_me cookie...");
      // so that proxy server will allow user to connect and
      // download images, etc., the very first time right after they make a new account.
      await callback2(opts.client.remember_me, {
        account_id,
      });
      dbg(
        `send message back to user that they are logged in as the new user (in ${walltime(
          tm
        )}seconds)`
      );
      // no analytics token is logged, because it is already done in the create_account entry above.
      mesg1 = message.signed_in({
        id,
        account_id,
        email_address: opts.mesg.email_address,
        first_name: opts.mesg.first_name,
        last_name: opts.mesg.last_name,
        remember_me: false,
        hub: opts.host + ":" + opts.port,
      });
      opts.client.signed_in(mesg1); // records this creation in database...
    } else {
      mesg1 = message.account_created({ id, account_id });
    }

    if (opts.mesg.email_address != null) {
      try {
        dbg("send email verification request");
        await callback2(auth.verify_email_send_token, {
          account_id,
          database: opts.database,
        });
      } catch (err) {
        // We make this nonfatal since email might just be misconfigured,
        // and we don't want that to completely break account creation.
        dbg(`WARNING -- error sending email verification (non-fatal): ${err}`);
      }
    }

    if (opts.mesg.get_api_key) {
      dbg("get_api_key -- generate key and include in response message");
      mesg1.api_key = await callback2(api_key_action, {
        database: opts.database,
        account_id,
        password: opts.mesg.password,
        action: "regenerate",
      });
    }

    opts.client.push_to_client(mesg1);
  }

  let reason: any = undefined;
  try {
    reason = await createAccount();
  } catch (err) {
    // This can happen, e.g., the call to opts.database.create_account above
    // is not wrapped in try/catch and if the first name is wstein.org,
    // then there is an exception saying the first name is a URL. (ASIDE: This is
    // really minimal security, since as of writing this you can just change your
    // name to anything after you make an account.)
    reason = { other: `${err}` };
  }
  if (reason) {
    // IMPORTANT: There are various settings where the user simply never sees
    // this, since they aren't setup to listen for it...
    dbg(
      `send message to user that there was an error (in ${walltime(
        tm
      )}seconds) -- ${JSON.stringify(reason)}`
    );
    opts.client.push_to_client(message.account_creation_failed({ id, reason }));
  }
}

interface DeleteAccountOptions {
  client?: any;
  mesg?: any;
  database: PostgreSQL;
}

// This should not actually throw in case of trouble, but instead send
// error directly to the client.
export async function delete_account(
  opts: DeleteAccountOptions
): Promise<void> {
  opts = defaults(opts, {
    client: undefined,
    mesg: required,
    database: required,
  });
  winston.info(`delete_account("${opts.mesg.account_id}")`);

  let error: any = undefined;
  try {
    await callback2(opts.database.mark_account_deleted, {
      account_id: opts.mesg.account_id,
    });
  } catch (err) {
    error = err;
  }
  if (opts.client != null) {
    opts.client.push_to_client(
      message.account_deleted({ id: opts.mesg.id, error })
    );
  }
}
