/*
 * Copyright (c) 2019, 2024, Oracle and/or its affiliates.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License, version 2.0,
 * as published by the Free Software Foundation.
 *
 * This program is designed to work with certain software (including
 * but not limited to OpenSSL) that is licensed under separate terms,
 * as designated in a particular file or component or in included license
 * documentation.  The authors of MySQL hereby grant you an additional
 * permission to link the program and your derivative works with the
 * separately licensed software that they have either included with
 * the program or referenced in the documentation.
 *
 * This program is distributed in the hope that it will be useful,  but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See
 * the GNU General Public License, version 2.0, for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
 */

#include "modules/adminapi/dba/upgrade_metadata.h"

#include <algorithm>
#include <chrono>
#include <string>
#include <vector>

#include "modules/adminapi/common/accounts.h"
#include "modules/adminapi/common/metadata_management_mysql.h"
#include "modules/adminapi/common/router.h"
#include "mysqlshdk/include/shellcore/shell_options.h"
#include "mysqlshdk/include/shellcore/shell_resultset_dumper.h"
#include "mysqlshdk/libs/mysql/utils.h"
#include "mysqlshdk/libs/utils/array_result.h"
#include "mysqlshdk/libs/utils/compiler.h"
#include "mysqlshdk/libs/utils/debug.h"
#include "mysqlshdk/shellcore/shell_console.h"

namespace mysqlsh {
namespace dba {

namespace {
constexpr std::chrono::seconds k_router_version_update_timeout{4};

using MDState = mysqlsh::dba::metadata::State;
}  // namespace

Upgrade_metadata::Upgrade_metadata(
    const std::shared_ptr<MetadataStorage> &metadata, bool interactive,
    bool dry_run)
    : m_metadata(metadata),
      m_target_instance(m_metadata->get_md_server()),
      m_interactive(interactive),
      m_dry_run(dry_run),
      m_abort_rolling_upgrade(false) {}

/*
 * Validates the parameter and performs other validations regarding
 * the command execution
 */
void Upgrade_metadata::prepare() {
  std::string current_user, current_host;
  m_target_instance->get_current_user(&current_user, &current_host);

  std::string error_info;
  auto console = mysqlsh::current_console();
  if (!validate_cluster_admin_user_privileges(
          *m_target_instance, current_user, current_host,
          Cluster_type::GROUP_REPLICATION, &error_info)) {
    console->print_error(error_info);
    throw shcore::Exception::runtime_error(
        "The account " + shcore::make_account(current_user, current_host) +
        " is missing privileges required for this operation.");
  }

  mysqlshdk::utils::Version installed,
      current = mysqlsh::dba::metadata::current_version();
  m_metadata->check_version(&installed);

  auto instance_data = m_target_instance->descr();

  // If the installed version is lower than the current one, we first validate
  // if it is valid before continuing
  if (installed < current && !metadata::is_valid_version(installed)) {
    throw shcore::Exception::runtime_error(
        "Installed metadata at '" + instance_data +
        "' has an unknown version (" + installed.get_base() +
        "), upgrading this version of the metadata is not supported.");
  }

  switch (m_metadata->get_state()) {
    case MDState::EQUAL:
      console->print_note("Installed metadata at '" + instance_data +
                          "' is up to date (version " + installed.get_base() +
                          ").");
      break;
    case MDState::MAJOR_HIGHER:
    case MDState::MINOR_HIGHER:
    case MDState::PATCH_HIGHER:
      throw std::runtime_error(
          "Installed metadata at '" + instance_data +
          "' is newer than the version supported by this Shell (installed: " +
          installed.get_base() + ", shell: " + current.get_base() + ").");

      break;
    case MDState::PATCH_LOWER:
    case MDState::MINOR_LOWER:
    case MDState::MAJOR_LOWER:
      console->print_info(shcore::str_format(
          "Metadata Schema Upgrade\n\n"
          "The topology you are connected to is using an outdated metadata "
          "schema version %s and needs to be upgraded to %s.\n\n"
          "Without doing this upgrade, no AdminAPI calls except read only "
          "operations will be allowed.",
          installed.get_base().c_str(), current.get_base().c_str()));

      if (m_metadata->get_state() == MDState::MAJOR_LOWER) {
        console->print_info();
        console->print_note(
            "After the upgrade, this InnoDB Cluster/ReplicaSet can no longer "
            "be managed using older versions of MySQL Shell.");
      }

      if (m_metadata->get_state() != MDState::PATCH_LOWER)
        prepare_rolling_upgrade();
      break;
    case MDState::UPGRADING:
      throw std::runtime_error(shcore::str_subvars(
          mysqlsh::dba::metadata::kFailedUpgradeError,
          [](std::string_view var) {
            return shcore::get_member_name(var, shcore::current_naming_style());
          },
          "<<<", ">>>"));
      break;
    case MDState::FAILED_SETUP:
      throw std::runtime_error(
          "Metadata setup is not finished. Incomplete metadata schema must be"
          " dropped.");

    case MDState::FAILED_UPGRADE:
      // Nothing to do, prepare succeeds and lets the logic go right to
      // execute()
      break;
    case MDState::NONEXISTING:
      // NO OP: Preconditions won't allow this case to be called
      break;
  }
}

shcore::Array_t Upgrade_metadata::get_outdated_routers() {
  // We pool each 2 seconds
  auto timeout_sec = k_router_version_update_timeout / 2;
  shcore::Array_t routers;

  // When an outdated Router is upgraded and the Metadata schema upgrade process
  // starts, the first process done is to upgrade the router accounts and if
  // router is not restarted it won't update the version number immediately but
  // only after the first TTL is reached and the Metadata cache is reloaded. For
  // that reason, we wait k_router_version_update_timeout (4 seconds) before
  // returning to the user the results. Considering that Router's default TTL is
  // 0.5 seconds, this should overcome the race-condition in most cases
  while (timeout_sec-- >= std::chrono::seconds::zero()) {
    auto routers_dict =
        mysqlsh::dba::router_list(m_metadata.get(), "", true).as_map();

    if (routers_dict->empty()) return {};  // No outdated routers

    routers = shcore::make_array();

    auto columns = shcore::make_array();
    columns->emplace_back("Instance");
    columns->emplace_back("Version");
    columns->emplace_back("Last Check-in");
    columns->emplace_back("R/O Port");
    columns->emplace_back("R/W Port");

    routers->emplace_back(std::move(columns));

    for (const auto &router_md : *routers_dict) {
      auto router = shcore::make_array();
      router->emplace_back(router_md.first);
      auto router_data = router_md.second.as_map();
      router->push_back((*router_data)["version"]);
      router->push_back((*router_data)["lastCheckin"]);
      router->push_back((*router_data)["roPort"]);
      router->push_back((*router_data)["rwPort"]);

      routers->emplace_back(std::move(router));
    }

    // Wait 2 seconds before polling again
    shcore::sleep_ms(2000);
  }

  return routers;
}

void Upgrade_metadata::prepare_rolling_upgrade() {
  // The router accounts need to be upgraded first, no matter if there are
  // outdated routers or not, i.e. router 8.0.19 could have been used to
  // bootstrap under MD 1.0.1 in which case the account will be incomplete but
  // the router will be up to date
  upgrade_router_users();

  auto routers = get_outdated_routers();
  if (!routers) return;

  // For patch upgrades, rolling upgrade is not considered
  bool done_router_upgrade = m_metadata->get_state() == MDState::PATCH_LOWER;
  if (done_router_upgrade) return;

  auto console = current_console();

  // Doing the rolling upgrade is mandatory for a MAJOR upgrade, on a minor
  // upgrade the user can decide whether to do it or not
  bool do_rolling_upgrade = m_metadata->get_state() == MDState::MAJOR_LOWER;

  console->print_info(shcore::str_format(
      "An upgrade of all cluster router instances is %s. All router "
      "installations should be updated first before doing the actual "
      "metadata upgrade.\n",
      do_rolling_upgrade ? "required" : "recommended"));

  print_router_list(routers);

  if (m_interactive && !m_dry_run) {
    if (!do_rolling_upgrade) {
      do_rolling_upgrade =
          console->confirm("Do you want to proceed with the upgrade?") ==
          mysqlsh::Prompt_answer::YES;

      if (!do_rolling_upgrade) m_abort_rolling_upgrade = true;
    }

    while (do_rolling_upgrade && routers) {
      size_t count = routers->size() - 1;

      std::string answer;

      console->print_info(shcore::str_format(
          "There %s %zu Router%s to upgrade. Please "
          "upgrade %s and select Continue once %s restarted.\n",
          (count == 1) ? "is" : "are", count, (count == 1) ? "" : "s",
          (count == 1) ? "it" : "them", (count == 1) ? "it is" : "they are"));

      console->print_note(
          "If Router's Metadata Cache TTL is high Router's own update "
          "in the Metadata will take longer. Re-check for outdated "
          "Routers if that's the case or alternatively restart the "
          "Router.");
      console->print_info();

      std::vector<std::string> options = {
          "Re-check for outdated Routers and continue with the metadata "
          "upgrade.",
          "Unregister the remaining Routers.", "Abort the operation.", "Help"};

      if (console->select(
              "Please select an option", &answer, options, 0UL, true,
              [&options](const std::string &a) -> std::string {
                if (a.size() == 1) {
                  if (a.find_first_of("?aArCuUhH") == std::string::npos) {
                    return "Invalid option selected";
                  }
                } else {
                  if (std::find(options.begin(), options.end(), a) ==
                          std::end(options) &&
                      !shcore::str_caseeq(a, "abort") &&
                      !shcore::str_caseeq(a, "continue") &&
                      !shcore::str_caseeq(a, "unregister") &&
                      !shcore::str_caseeq(a, "help")) {
                    return "Invalid option selected";
                  }
                }
                return "";
              })) {
        switch (answer[0]) {
          case 'U':
          case 'u':
            if (console->confirm("Unregistering a Router implies it will not "
                                 "be used in the "
                                 "Cluster, do you want to continue?") ==
                mysqlsh::Prompt_answer::YES) {
              // First row is the table headers
              routers->erase(routers->begin());
              unregister_routers(routers);
            }
            // NOTE: This fallback is required, after unregistering the
            // listed routers it will refresh the list if needed (implicit
            // retry)
            FALLTHROUGH;
          case 'R':
          case 'r':
            DBUG_EXECUTE_IF("dba_EMULATE_ROUTER_UNREGISTER", {
              m_target_instance->execute(
                  "DELETE FROM mysql_innodb_cluster_metadata.routers WHERE "
                  "router_id = 2");
            });
            DBUG_EXECUTE_IF("dba_EMULATE_ROUTER_UPGRADE", {
              m_target_instance->execute(
                  "UPDATE mysql_innodb_cluster_metadata.routers SET "
                  "attributes=JSON_OBJECT('version','8.0.19') WHERE "
                  "router_id = 2");
            });

            routers = get_outdated_routers();
            if (routers) {
              print_router_list(routers);
            } else {
              done_router_upgrade = true;
            }
            break;
          case 'A':
          case 'a':
            do_rolling_upgrade = false;
            m_abort_rolling_upgrade = true;
            break;
          case 'H':
          case 'h':
          case '?':
            console->print_info(shcore::str_subvars(
                "To perform a rolling upgrade of the InnoDB "
                "Cluster/ReplicaSet metadata, execute the following "
                "steps:\n\n"
                "1 - Upgrade the Shell to the latest version\n"
                "2 - Execute dba.<<<upgradeMetadata>>>() (the current "
                "step)\n"
                "3 - Upgrade MySQL Router instances to the latest "
                "version\n"
                "4 - Continue with the metadata upgrade once all Router "
                "instances are upgraded or accounted for\n",
                [](std::string_view var) {
                  return shcore::get_member_name(
                      var, shcore::current_naming_style());
                },
                "<<<", ">>>"));

            routers = get_outdated_routers();
            if (routers) {
              console->print_info(
                  "If the following Router instances no longer exist, "
                  "select Unregister to delete their metadata.");
              print_router_list(routers);
            }
            break;
        }
      } else {
        // Ctrl+C was hit
        do_rolling_upgrade = false;
        m_abort_rolling_upgrade = true;
      }
    }
  }

  if (!done_router_upgrade) {
    if (m_dry_run) {
      size_t count = routers->size() - 1;
      console->print_info(shcore::str_format(
          "There %s %zu Router%s to be upgraded in order to perform the "
          "Metadata schema upgrade.\n",
          (count == 1) ? "is" : "are", count, (count == 1) ? "" : "s"));
    } else if (m_abort_rolling_upgrade) {
      console->print_info("The metadata upgrade has been aborted.");
    } else {
      throw shcore::Exception::runtime_error(
          "Outdated Routers found. Please upgrade the Routers before "
          "upgrading the Metadata schema");
    }
  }
}

void Upgrade_metadata::print_router_list(const shcore::Array_t &routers) {
  // Prints up to 10 rows, if more than 10 routers the last wor will have
  // ellipsis on each column
  const shcore::Array_t print_list = shcore::make_array();
  size_t index = 0;
  size_t count = routers->size();
  size_t max = count > 11 ? 10 : count;
  while (index < max) {
    print_list->push_back((*routers)[index]);
    index++;
  }

  if (count > 11) {
    auto etc = shcore::make_array();
    for (size_t idx = 0; idx < 5; idx++) {
      etc->push_back(shcore::Value("..."));
    }
    print_list->push_back(shcore::Value(etc));
  }

  shcore::Array_as_result result(print_list);

  mysqlsh::Resultset_dumper dumper(
      &result, mysqlsh::current_shell_options()->get().wrap_json, "table",
      false, false);
  dumper.dump("", true, false);
}

void Upgrade_metadata::unregister_routers(const shcore::Array_t &routers) {
  for (const auto &router : *routers) {
    auto router_def = router.as_array();
    m_metadata->remove_router((*router_def)[0].as_string());
  }
}

void Upgrade_metadata::upgrade_router_users() {
  std::vector<std::pair<std::string, std::string>> users;
  auto result = m_target_instance->query(
      "SELECT user, host FROM mysql.user WHERE user LIKE "
      "'mysql_router%'");

  auto row = result->fetch_one();
  while (row) {
    users.push_back({row->get_string(0), row->get_string(1)});
    row = result->fetch_one();
  }

  size_t size = users.size();

  auto console = current_console();

  console->print_info(
      "\nThe grants for the MySQL Router accounts that were created "
      "automatically when bootstrapping need to be updated to match the new "
      "metadata version's requirements.");

  if (!users.empty()) {
    console->print_info(
        m_dry_run
            ? shcore::str_format("%zi Router account%s need to be updated.",
                                 size, (size == 1) ? "" : "s")
            : "Updating Router accounts...");

    if (!m_dry_run) {
      std::vector<std::string> queries = {
          "GRANT SELECT, EXECUTE ON mysql_innodb_cluster_metadata.* TO ",
          "GRANT SELECT ON performance_schema.replication_group_members TO ",
          "GRANT SELECT ON performance_schema.replication_group_member_stats "
          "TO ",
          "GRANT SELECT ON performance_schema.global_variables TO ",
          "GRANT UPDATE, INSERT, DELETE ON "
          "mysql_innodb_cluster_metadata.v2_routers TO ",
          "GRANT UPDATE, INSERT, DELETE ON "
          "mysql_innodb_cluster_metadata.routers TO "};

      mysqlshdk::utils::Version installed;
      m_metadata->check_version(&installed);

      // When migrating to version 2.0.0 this dummy view is required to
      // enable giving the router users the  right permissions, it will be
      // updated to the correct view once the upgrade is done
      if (installed < mysqlshdk::utils::Version(2, 0, 0)) {
        m_target_instance->execute(
            "CREATE OR REPLACE VIEW mysql_innodb_cluster_metadata.v2_routers "
            "AS SELECT 1");
      }

      log_debug("Metadata upgrade for version %s, upgraded Router accounts:",
                mysqlsh::dba::metadata::current_version().get_base().c_str());
      for (const auto &user : users) {
        shcore::sqlstring user_host("!@!", 0);
        user_host << std::get<0>(user);
        user_host << std::get<1>(user);

        std::string str_user = user_host.str();

        log_debug("- %s", str_user.c_str());

        for (const auto &query : queries) {
          m_target_instance->execute(query + str_user);
        }
      }
      console->print_note(shcore::str_format(
          "%zi Router account%s %s been updated.", size, (size == 1) ? "" : "s",
          (size == 1 ? "has" : "have")));
    }
  } else {
    console->print_note("No automatically created Router accounts were found.");
  }

  console->print_warning(
      "If MySQL Routers have been bootstrapped using custom accounts, their "
      "grants can not be updated during the metadata upgrade, they have to "
      "be updated using the <<<setupRouterAccount>>> function. \nFor "
      "additional information use: \\? <<<setupRouterAccount>>>\n");
}

/*
 * Executes the API command.
 */
shcore::Value Upgrade_metadata::execute() {
  if (!m_abort_rolling_upgrade) {
    metadata::upgrade_or_restore_schema(m_target_instance, m_dry_run);
  }

  return shcore::Value();
}

}  // namespace dba
}  // namespace mysqlsh
