#!/usr/bin/env python3
"""
container_upgrade.py (Pythonized from container_upgrade.sh)

Handles container upgrade logic: version checking, rollback, white-list,
upgrade flag management, cluster/node status tracking, and system table modification.
"""
import os
import re
import sys
import subprocess
import shutil
import time

CUR_PATH = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, CUR_PATH)

from config import get_config, get_value
from docker_common.docker_log import log_and_echo_info, log_and_echo_error
from dbstor_tool_opt_common import (
    update_local_status_file_path_by_dbstor,
    update_remote_status_file_path_by_dbstor,
    delete_fs_upgrade_file_or_path_by_dbstor,
    update_version_yml_by_dbstor,
)

_cfg = get_config()
_paths = _cfg.paths

PKG_PATH = os.path.abspath(os.path.join(CUR_PATH, "../.."))
SCRIPT_NAME = "container_upgrade.py"
VERSION_FILE = "versions.yml"
START_STATUS_NAME = "start_status.json"
CLUSTER_COMMIT_STATUS = ("prepared", "commit")
CLUSTER_PREPARED = 3

storage_metadata_fs = get_value("storage_metadata_fs")
node_id = get_value("node_id")
cms_ip = get_value("cms_ip")
ograc_user = get_value("deploy_user")
ograc_group = get_value("deploy_group")
deploy_mode = get_value("deploy_mode")
upgrade_mode = get_value("upgrade_mode")

METADATA_FS_PATH = _paths.metadata_fs_path(storage_metadata_fs) if storage_metadata_fs else ""
upgrade_path = os.path.join(METADATA_FS_PATH, "upgrade") if METADATA_FS_PATH else ""
upgrade_lock_path = os.path.join(METADATA_FS_PATH, "upgrade.lock") if METADATA_FS_PATH else ""
cluster_and_node_status_path = os.path.join(upgrade_path, "cluster_and_node_status") if upgrade_path else ""
DORADO_CONF_PATH = _paths.dorado_conf_path()
SYS_PASS = "sysPass"

local_version = ""
remote_version = ""
cluster_status_flag = ""
local_node_status_flag = ""
local_node_status = ""
modify_systable = ""
upgrade_flag = ""
updatesys_flag = ""


def exec_cmd(cmd, timeout=600):
    proc = subprocess.Popen(
        ["bash"], shell=False,
        stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE
    )
    proc.stdin.write(cmd.encode())
    proc.stdin.write(os.linesep.encode())
    try:
        stdout, stderr = proc.communicate(timeout=timeout)
    except subprocess.TimeoutExpired:
        proc.kill()
        return -1, "", "timeout"
    return proc.returncode, stdout.decode().strip(), stderr.decode().strip()


def read_file(filepath):
    if not os.path.isfile(filepath):
        return ""
    with open(filepath, "r") as f:
        return f.read().strip()


def write_file(filepath, content):
    with open(filepath, "w") as f:
        f.write(content)


def parse_version(version_file_path):
    """Extract normalized version from a versions.yml file."""
    if not os.path.isfile(version_file_path):
        return ""
    with open(version_file_path, "r") as f:
        for line in f:
            if "Version:" in line:
                raw = line.split(":")[-1].strip()
                return re.sub(r'^[a-zA-Z]*0*', '', raw).strip()
    return ""



def check_if_need_upgrade():
    """Return True if upgrade needed, False otherwise. Exit 1 if outdated."""
    global local_version, remote_version
    remote_versions_path = os.path.join(METADATA_FS_PATH, VERSION_FILE)
    if not os.path.isfile(remote_versions_path):
        log_and_echo_info(f"this is first to start node, no need to upgrade. [File:{SCRIPT_NAME}]")
        sys.exit(0)

    log_and_echo_info(f"check if the container needs to upgrade. [File:{SCRIPT_NAME}]")
    local_version = parse_version(os.path.join(PKG_PATH, VERSION_FILE))
    remote_version = parse_version(remote_versions_path)

    if local_version < remote_version:
        log_and_echo_error(
            f"The version is outdated. cluster version:{remote_version}, but this version:{local_version}. [File:{SCRIPT_NAME}]"
        )
        sys.exit(1)
    if local_version > remote_version:
        return True
    return False



def rollback_check_cluster_status():
    if not os.path.isdir(cluster_and_node_status_path):
        log_and_echo_info("the cluster status dir is not exist, no need to rollback.")
        return False

    cs_flag = os.path.join(cluster_and_node_status_path, "cluster_status.txt")
    if not os.path.isfile(cs_flag):
        log_and_echo_info("the cluster status file is not exist, no need to rollback.")
        return False

    cs = read_file(cs_flag)
    if cs == "commit":
        log_and_echo_info("the cluster status is commit, failed to rollback.")
        sys.exit(1)
    if cs == "normal":
        log_and_echo_info("the cluster status is normal, no need to rollback.")
        return False

    modify_cluster_or_node_status(cs_flag, "rollback", "cluster")
    return True


def rollback_check_local_node_status():
    log_and_echo_info("begin to check if the current node needs to rollback.")
    lns_flag = os.path.join(cluster_and_node_status_path, f"node{node_id}_status.txt")
    if not os.path.isfile(lns_flag):
        log_and_echo_info("the current node has not been upgraded, no need to rollback.")
        return False
    modify_cluster_or_node_status(lns_flag, "rollback", f"node{node_id}")
    log_and_echo_info("pass check if the current node needs roll back.")
    return True


def rollback_check():
    log_and_echo_info("begin to check if the container needs to rollback.")
    if not rollback_check_cluster_status():
        log_and_echo_info("the cluster status is not prepared, no need to rollback.")
        return False
    if not rollback_check_local_node_status():
        log_and_echo_info("the current node status is not prepared, no need to rollback.")
        return False
    return True


def cluster_rollback_status_check():
    log_and_echo_info("begin to check cluster rollback status")
    node_count = cms_ip.count(";") + 1

    status_array = []
    if os.path.isdir(cluster_and_node_status_path):
        for fname in sorted(os.listdir(cluster_and_node_status_path)):
            if re.match(r'^node\d+_status\.txt$', fname):
                status_array.append(read_file(os.path.join(cluster_and_node_status_path, fname)))

    unique = list(set(status_array))
    if len(unique) != 1:
        log_and_echo_info(f"existing nodes have not been rollback successfully, details: {status_array}")
        return
    if unique[0] != "rollback_success":
        log_and_echo_error(f"none of the {node_count} nodes were rollback successfully.")
        sys.exit(1)

    cs_flag = os.path.join(cluster_and_node_status_path, "cluster_status.txt")
    modify_cluster_or_node_status(cs_flag, "normal", "cluster")
    log_and_echo_info("all nodes have been rollback successfully.")


def clear_flag_after_rollback():
    import glob as glob_mod
    for f in glob_mod.glob(os.path.join(upgrade_path, f"upgrade_node{node_id}.*")):
        os.remove(f)
    if os.path.isdir(cluster_and_node_status_path):
        shutil.rmtree(cluster_and_node_status_path, ignore_errors=True)
    delete_fs_upgrade_file_or_path_by_dbstor(f"upgrade_node{node_id}.*")
    delete_fs_upgrade_file_or_path_by_dbstor("cluster_and_node_status")


def do_rollback():
    log_and_echo_info("begin to rollback.")
    lns_flag = os.path.join(cluster_and_node_status_path, f"node{node_id}_status.txt")
    local_ns = read_file(lns_flag)
    if local_ns != "rollback_success":
        start_ogracd_by_cms()
        modify_cluster_or_node_status(lns_flag, "rollback_success", f"node{node_id}")
    cluster_rollback_status_check()
    clear_flag_after_rollback()



def check_white_list():
    global modify_systable
    if upgrade_mode not in ("rollup", "offline"):
        return False, f"Invalid upgrade_mode '{upgrade_mode}'. It should be either 'rollup' or 'offline'."

    code, out, _ = exec_cmd(f"python3 {os.path.join(CUR_PATH, 'upgrade_version_check.py')}")
    parts = out.split()
    if not parts:
        return False, "upgrade_version_check returned empty"

    upgrade_stat = parts[0]
    if upgrade_stat == "True":
        upgrade_support_mode = parts[2] if len(parts) > 2 else ""
        if upgrade_support_mode == "offline" and upgrade_mode == "rollup":
            return False, "current version does not support 'rollup' mode"

        if upgrade_mode == "offline":
            if not os.path.isdir(cluster_and_node_status_path):
                code2, cms_ret_out, _ = exec_cmd(
                    f'su -s /bin/bash - {ograc_user} -c "cms stat" | awk \'{{print $3}}\' | grep "ONLINE" | wc -l'
                )
                cms_ret = int(cms_ret_out) if cms_ret_out.isdigit() else 0
                if cms_ret != 0:
                    update_local_status_file_path_by_dbstor()
                    if not os.path.isdir(cluster_and_node_status_path):
                        return False, f"Current upgrade mode is offline, but ONLINE count is {cms_ret}"

        modify_systable = parts[1] if len(parts) > 1 else ""
        return True, ""
    else:
        err_info = parts[1] if len(parts) > 1 else "unknown"
        return False, err_info



def check_upgrade_flag():
    if not os.path.isdir(upgrade_path):
        return
    for fname in os.listdir(upgrade_path):
        if fname.startswith("upgrade") and local_version not in fname and fname != "upgrade.lock":
            log_and_echo_error(f"The cluster is being upgraded to another version: {fname}, current target version: {local_version}")
            sys.exit(1)


def create_upgrade_flag():
    if not os.path.isdir(upgrade_path):
        os.makedirs(upgrade_path, mode=0o755, exist_ok=True)

    if not os.path.isfile(upgrade_flag):
        with open(upgrade_flag, "w"):
            pass
        os.chmod(upgrade_flag, 0o600)
        update_remote_status_file_path_by_dbstor(upgrade_flag)

    if not os.path.isfile(upgrade_lock_path):
        with open(upgrade_lock_path, "w"):
            pass
        os.chmod(upgrade_lock_path, 0o600)

    if modify_systable == "true":
        updatesys_success = os.path.join(upgrade_path, "updatesys.success")
        if os.path.isfile(updatesys_flag) or os.path.isfile(updatesys_success):
            log_and_echo_info("detected that the system tables file flag already exists.")
            return
        with open(updatesys_flag, "w"):
            pass
        os.chmod(updatesys_flag, 0o600)
        log_and_echo_info(f"detect need to update system tables, success to create updatesys_flag: '{updatesys_flag}'")


def upgrade_init_flag():
    global upgrade_flag, updatesys_flag
    upgrade_flag = os.path.join(upgrade_path, f"upgrade_node{node_id}.{local_version}")
    updatesys_flag = os.path.join(upgrade_path, "updatesys.true")

    check_upgrade_flag()
    create_upgrade_flag()


def upgrade_lock():
    """Acquire upgrade lock using flock."""
    exec_cmd(f"exec 506>\"{upgrade_lock_path}\" && flock -x --wait 600 506", timeout=660)



def init_cluster_or_node_status_flag():
    global cluster_status_flag, local_node_status_flag

    if not os.path.isdir(cluster_and_node_status_path):
        os.makedirs(cluster_and_node_status_path, mode=0o755, exist_ok=True)

    cluster_status_flag = os.path.join(cluster_and_node_status_path, "cluster_status.txt")
    local_node_status_flag = os.path.join(cluster_and_node_status_path, f"node{node_id}_status.txt")

    log_and_echo_info("init current cluster status and node status flag success.")

    if os.path.isfile(cluster_status_flag):
        cs = read_file(cluster_status_flag)
        if cs in CLUSTER_COMMIT_STATUS:
            log_and_echo_info(f"the current cluster status is already {cs}, no need to execute the upgrade.")
            sys.exit(0)

    commit_success_file = os.path.join(upgrade_path, f"ograc_upgrade_commit_{remote_version}")
    if os.path.isfile(commit_success_file):
        os.remove(commit_success_file)


def check_if_any_node_in_upgrade_status():
    if deploy_mode == "dbstor":
        return
    log_and_echo_info("begin to check if any nodes in upgrading state")

    if not os.path.isdir(cluster_and_node_status_path):
        return

    status_array = []
    for fname in sorted(os.listdir(cluster_and_node_status_path)):
        if re.match(r'^node\d+_status\.txt$', fname) and f"node{node_id}" not in fname:
            status_array.append(read_file(os.path.join(cluster_and_node_status_path, fname)))

    if not status_array:
        return

    unique = list(set(status_array))
    if len(unique) != 1:
        log_and_echo_error(f"currently existing nodes are in upgrading state, details: {status_array}")
        sys.exit(1)
    if unique[0] != f"{upgrade_mode}_success":
        log_and_echo_error(f"there are currently other nodes in upgrading or other upgrade mode. current mode: {upgrade_mode}, details: {unique[0]}")
        sys.exit(1)
    log_and_echo_info("check pass, currently no nodes are in upgrading state.")


def modify_cluster_or_node_status(status_file, new_status, label):
    old_status = ""

    if status_file and not os.path.isfile(status_file):
        log_and_echo_info(f"{upgrade_mode} upgrade status of '{label}' does not exist.")

    if os.path.isfile(status_file):
        old_status = read_file(status_file)
        if old_status == new_status:
            log_and_echo_info(f"the old status of {label} is consistent with the new status, both are {new_status}")
            return

    write_file(status_file, new_status)
    log_and_echo_info(f"change upgrade status of {label} from '{old_status}' to '{new_status}' success.")

    if deploy_mode == "dbstor":
        update_remote_status_file_path_by_dbstor(status_file)


def local_node_upgrade_status_check():
    global local_node_status
    log_and_echo_info("begin to check local node upgrade status")
    if os.path.isfile(local_node_status_flag):
        cur = read_file(local_node_status_flag)
        if cur == f"{upgrade_mode}_success":
            local_node_status = f"{upgrade_mode}_success"
            log_and_echo_info(f"node{node_id} has been upgraded successfully")
            return
        elif cur == upgrade_mode:
            log_and_echo_info(f"node_{node_id} is in {upgrade_mode} state")
            return

    modify_cluster_or_node_status(local_node_status_flag, upgrade_mode, f"node{node_id}")
    log_and_echo_info("pass check local node upgrade status")


def cluster_upgrade_status_check():
    log_and_echo_info("begin to check cluster upgrade status")
    node_count = cms_ip.count(";") + 1

    status_array = []
    if os.path.isdir(cluster_and_node_status_path):
        for fname in sorted(os.listdir(cluster_and_node_status_path)):
            if re.match(r'^node\d+_status\.txt$', fname):
                status_array.append(read_file(os.path.join(cluster_and_node_status_path, fname)))

    if len(status_array) != node_count:
        log_and_echo_info(f"currently only {len(status_array)} nodes have performed the {upgrade_mode} upgrade, totals:{node_count}.")
        return 0

    unique = list(set(status_array))
    if len(unique) != 1:
        log_and_echo_info(f"existing nodes have not been upgraded successfully, details: {status_array}")
        return 0
    if unique[0] != f"{upgrade_mode}_success":
        log_and_echo_error(f"none of the {node_count} nodes were upgraded successfully")
        sys.exit(1)

    log_and_echo_info(f"all {node_count} nodes were upgraded successfully, pass check cluster upgrade status")
    return 3



def start_ogracd_by_cms():
    log_and_echo_info("begin to start cms")
    cms_appctl = os.path.join(CUR_PATH, "../cms/appctl.sh")
    code, _, _ = exec_cmd(f"sh {cms_appctl} start")
    if code != 0:
        log_and_echo_error("start cms when upgrade failed")
        sys.exit(1)

    log_and_echo_info("begin to start ogracd")
    for i in range(1, 11):
        code, _, _ = exec_cmd(f'su -s /bin/bash - "{ograc_user}" -c "cms res -start db -node {node_id}"')
        if code != 0:
            log_and_echo_error(f"start ogracd by cms failed, remaining Attempts: {i}/10")
            time.sleep(20)
        else:
            start_status = _paths.start_status_json
            exec_cmd(
                f'su -s /bin/bash - "{ograc_user}" -c '
                f'"sed -i \'s/\\"start_status\\": \\"default\\"/\\"start_status\\": \\"started\\"/\' {start_status}"'
            )
            log_and_echo_info("start ogracd by cms success")
            return

    log_and_echo_error("start ogracd by cms failed")
    sys.exit(1)



def modify_sys_tables():
    ms_flag = os.path.join(upgrade_path, "updatesys.true")
    ms_success = os.path.join(upgrade_path, "updatesys.success")
    ms_failed = os.path.join(upgrade_path, "updatesys.failed")
    systable_home = _paths.systable_home
    old_initdb_sql = os.path.join(METADATA_FS_PATH, "initdb.sql")
    new_initdb_sql = os.path.join(systable_home, "..", "initdb.sql")

    if not os.path.isfile(ms_flag) or os.path.isfile(ms_success):
        log_and_echo_info("detected that the system tables have been modified or does not need to be modified")
        return

    code, _, _ = exec_cmd(f'diff "{old_initdb_sql}" "{new_initdb_sql}" > /dev/null')
    if code == 0:
        log_and_echo_info("two init.sql files are the same, no need to modify sys tables.")
        return

    log_and_echo_info("modify sys tables start")
    sys_password_file = os.path.join(DORADO_CONF_PATH, SYS_PASS)
    sys_password = ""
    if os.path.isfile(sys_password_file):
        with open(sys_password_file, "r") as f:
            sys_password = f.read().strip()

    exec_cmd(f'chown "{ograc_user}":"{ograc_group}" "{old_initdb_sql}"')

    bin_path = os.path.join(systable_home, "../../../bin")
    upgrade_systable_py = os.path.join(CUR_PATH, "upgrade_systable.py")
    cmd = (
        f'echo -e "{sys_password}" | su -s /bin/bash - "{ograc_user}" -c '
        f'"sh {CUR_PATH}/../docker/upgrade_systable.sh 127.0.0.1 {bin_path} {old_initdb_sql} {new_initdb_sql} {systable_home}"'
    )
    code, _, _ = exec_cmd(cmd, timeout=1800)
    if code != 0:
        log_and_echo_error("modify sys tables failed")
        with open(ms_failed, "w"):
            pass
        os.chmod(ms_failed, 0o600)
        sys.exit(1)

    if os.path.isfile(ms_flag):
        os.remove(ms_flag)
    with open(ms_success, "w"):
        pass
    os.chmod(ms_success, 0o600)
    log_and_echo_info("modify sys tables success")



def container_upgrade_check():
    need_upgrade = check_if_need_upgrade()
    if not need_upgrade:
        if rollback_check():
            upgrade_lock()
            do_rollback()
            sys.exit(0)
        log_and_echo_info(f"now is the latest version, no need to upgrade. [File:{SCRIPT_NAME}]")
        sys.exit(0)

    ok, err_info = check_white_list()
    if not ok:
        log_and_echo_error(f"failed to white list check. err_info:{err_info} [File:{SCRIPT_NAME}]")
        sys.exit(1)

    log_and_echo_info(f"upgrade check succeeded. [File:{SCRIPT_NAME}]")


def container_upgrade():
    log_and_echo_info(f"Begin to upgrade. [File:{SCRIPT_NAME}]")
    upgrade_init_flag()
    upgrade_lock()
    init_cluster_or_node_status_flag()
    check_if_any_node_in_upgrade_status()
    modify_cluster_or_node_status(cluster_status_flag, upgrade_mode, "cluster")
    local_node_upgrade_status_check()

    if local_node_status != f"{upgrade_mode}_success":
        start_ogracd_by_cms()
        modify_sys_tables()
        modify_cluster_or_node_status(local_node_status_flag, f"{upgrade_mode}_success", f"node{node_id}")

    ret = cluster_upgrade_status_check()
    if ret == CLUSTER_PREPARED:
        modify_cluster_or_node_status(cluster_status_flag, "prepared", "cluster")

    log_and_echo_info(f"container upgrade success. [File:{SCRIPT_NAME}]")


def main():
    update_local_status_file_path_by_dbstor()
    container_upgrade_check()
    container_upgrade()


if __name__ == "__main__":
    main()