/*
Search for users.

- by exact account_id
- by exact email_address
- by partial match on first_name and last_name
- by @username
*/

import getPool from "@cocalc/database/pool";
import {
  cmp,
  isValidUUID,
  is_valid_email_address as isValidEmailAddress,
  parse_user_search as parseUserSearch,
} from "@cocalc/util/misc";
import { toEpoch } from "@cocalc/database/postgres/util";
import { getLogger } from "@cocalc/backend/logger";

const logger = getLogger("accounts/search");

export interface User {
  account_id: string;
  first_name?: string;
  last_name?: string;
  name?: string; // "vanity" username
  last_active?: number; // ms since epoch -- when account was last active
  created?: number; // ms since epoch -- when account created
  banned?: boolean; // true if this user has been banned (only set for admin searches, obviously)
  email_address_verified?: boolean; // true if their email has been verified (a sign they are more trustworthy).
  // For security reasons, the email_address *only* occurs in search queries that
  // are by email_address (or for admins); we must not reveal email addresses
  // of users queried by substring searches, obviously.
  email_address?: string;
}

interface DBUser {
  account_id: string;
  first_name?: string;
  last_name?: string;
  last_active?: Date;
  created?: number;
  banned?: boolean;
  email_address_verified?: object;
  email_address?: string;
}

interface Options {
  // query: comma separated list of email addresses or strings such as
  //        'foo bar' (find everything where foo and bar are in the name)
  query: string;
  // limit on string queries; email query always returns 0 or 1 result per email address
  // the default is 20.  Ordered by last_active, starting with most recently active first.
  limit?: number;
  // If account is given, we do a first phase of search on current collaborators of this user,
  // and only then do a general search (up to the limit).
  //account_id?: string;
  // admins get to do full substring query on *email addresses*, whereas normal
  // users can only find a user by exact email address match (or substring query on name).
  // Also, admins get unlisted users, whereas non-admins never find them except by
  // exact email address search.
  admin?: boolean;
}

export default async function search({
  /* account_id,*/
  query,
  limit,
  admin,
}: Options): Promise<User[]> {
  limit = limit ?? 20;
  admin = !!admin;
  logger.debug("search for ", query);

  // One special case: when the query is just an email address or uuid.
  // We just return that account or empty list if no match.
  if (isValidUUID(query)) {
    logger.debug("get user by account_id");
    const user = process(await getUserByAccountId(query), admin, false);
    return user ? [user] : [];
  }
  if (isValidEmailAddress(query)) {
    logger.debug("get user by email address");
    const user = process(await getUserByEmailAddress(query), admin, true);
    return user ? [user] : [];
  }

  const { string_queries, email_queries } = parseUserSearch(query);
  if (admin) {
    // For admin we just do substring queries anyways.
    for (const email_address of email_queries) {
      string_queries.push([email_address]);
    }
    email_queries.splice(0, email_queries.length); // empty array
  }

  const results: User[] = [];
  let matches: DBUser[] = await getUsersByEmailAddresses(email_queries, limit);

  for (const user of matches) {
    const x = process(user, admin, true);
    if (x) {
      results.push(x);
    }
  }

  matches = await getUsersByStringQueries(
    string_queries,
    admin,
    limit - matches.length
  );
  for (const user of matches) {
    const x = process(user, admin, false);
    if (x) {
      results.push(x);
    }
  }

  results.sort(
    (a, b) =>
      -cmp(
        Math.max(a.last_active ?? 0, a.created ?? 0),
        Math.max(b.last_active ?? 0, b.created ?? 0)
      )
  );
  return results;
}

function process(
  user: DBUser | undefined,
  admin: boolean = false,
  isEmailSearch: boolean
): User | undefined {
  if (user == null) return undefined;
  const x: any = { ...user };
  if (x.email_address && x.email_address_verified) {
    x.email_address_verified =
      x.email_address_verified[x.email_address] != null;
  }
  if (!admin) {
    if (!isEmailSearch) {
      delete x.email_address;
    }
    delete x.banned;
  }
  toEpoch(x, ["last_active", "created"]);
  return x;
}

const FIELDS =
  " account_id, first_name, last_name, name, email_address, last_active, created, banned, email_address_verified ";

async function getUserByEmailAddress(
  email_address: string
): Promise<DBUser | undefined> {
  const pool = getPool("medium");
  const { rows } = await pool.query(
    `SELECT ${FIELDS} FROM accounts WHERE email_address=$1`,
    [email_address.toLowerCase()]
  );
  return rows[0];
}

async function getUserByAccountId(
  account_id: string
): Promise<DBUser | undefined> {
  const pool = getPool("medium");
  const { rows } = await pool.query(
    `SELECT ${FIELDS} FROM accounts WHERE account_id=$1`,
    [account_id.toLowerCase()]
  );
  return rows[0];
}

async function getUsersByEmailAddresses(
  email_queries: string[],
  limit: number
): Promise<DBUser[]> {
  logger.debug("getUsersByEmailAddresses", email_queries);
  if (email_queries.length == 0 || limit <= 0) return [];

  const pool = getPool("medium");
  const { rows } = await pool.query(
    `SELECT ${FIELDS} FROM accounts WHERE email_address = ANY($1::TEXT[]) AND deleted IS NULL`,
    [email_queries]
  );
  return rows;
}

async function getUsersByStringQueries(
  string_queries: string[][],
  admin: boolean,
  limit: number
): Promise<DBUser[]> {
  logger.debug("getUsersByStringQueries", string_queries);
  if (limit <= 0 || string_queries.length <= 0) {
    return [];
  }

  /*  Substring search on first and last name, and for admin also email_address.
      With the two indexes, the query below is very fast, even on millions of accounts:
          CREATE INDEX accounts_first_name_idx ON accounts(first_name text_pattern_ops);
          CREATE INDEX accounts_last_name_idx  ON accounts(last_name text_pattern_ops);
  */
  const params: (string | number)[] = [];
  const where: string[] = [];
  let i = 1;
  for (const terms of string_queries) {
    const v: string[] = [];
    for (const s of terms) {
      v.push(
        `(lower(first_name) LIKE $${i}::TEXT OR lower(last_name) LIKE $${i}::TEXT OR '@' || lower(name) LIKE $${i}::TEXT ${
          admin ? `OR lower(email_address) LIKE $${i}::TEXT` : ""
        })`
      );
      params.push(`%${s}%`);
      i += 1;
    }
    where.push(`(${v.join(" AND ")})`);
  }

  let query = `SELECT ${FIELDS} FROM accounts WHERE deleted IS NOT TRUE AND (${where.join(
    " OR "
  )})`;

  if (!admin) {
    // Exclude unlisted users from search results
    query += " AND unlisted IS NOT true ";
  }
  // recently active users are much more relevant than old ones -- #2991
  query += " ORDER BY COALESCE(last_active, created) DESC NULLS LAST";
  query += ` LIMIT $${i}::INTEGER `;
  i += 1;
  params.push(limit);

  const pool = getPool("medium");
  const { rows } = await pool.query(query, params);
  return rows;
}
