"""
Define functions to collect log and info
Function starts with "collect_" will be called in parallel
"""
import sys
import os
import shutil
import re
import stat
import pwd
import datetime
from subprocess import TimeoutExpired
from typing import List

import crmsh.user_of_host
from crmsh import log, sh, corosync
from crmsh import utils as crmutils
from crmsh.report import constants, utils, core
from crmsh.sh import ShellUtils
from crmsh.service_manager import ServiceManager


logger = log.setup_report_logger(__name__)


def get_corosync_log() -> str:
    """
    Get the path of the corosync log file
    """
    corosync_log = ""
    corosync_conf_path = corosync.conf()
    if os.path.exists(corosync_conf_path):
        corosync_log = corosync.get_value("logging.logfile")
    else:
        logger.warning(f"File {corosync_conf_path} does not exist")
    return corosync_log


def get_pcmk_log() -> str:
    """
    Get the path of the pacemaker log file
    """
    pcmk_log_candidates = [
        "/var/log/pacemaker/pacemaker.log",
        "/var/log/pacemaker.log"
    ]

    if os.path.isfile(constants.PCMKCONF):
        data = crmutils.read_from_file(constants.PCMKCONF)
        if data:
            res = re.search(r'^ *PCMK_logfile *= *(.*)', data, re.M)
            if res:
                pcmk_log_candidates.insert(0, res.group(1))

    for log in pcmk_log_candidates:
        if os.path.isfile(log):
            return log

    logger.warning("No valid pacemaker log file found")
    return ""


def collect_ha_logs(context: core.Context) -> None:
    """
    Collect pacemaker, corosync and extra logs
    """
    log_list = [get_pcmk_log(), get_corosync_log()] + context.extra_log_list
    log_list = [os.path.expanduser(log) for log in log_list]
    log_list_marked_same_basename = utils.mark_duplicate_basenames(log_list)
    for log, same_basename in log_list_marked_same_basename:
        if log and os.path.isfile(log):
            utils.dump_logset(context, log, create_dir=same_basename)


def collect_journal_logs(context: core.Context) -> None:
    """
    Collect journal logs from a specific time range
    """
    from_time_str = utils.ts_to_str(context.from_time)
    to_time_str = utils.ts_to_str(context.to_time)
    logger.debug2(f"Collect journal logs since: {from_time_str} until: {to_time_str}")

    journal_target_dict = {
        "default": constants.JOURNAL_F,
        "pacemaker": constants.JOURNAL_PCMK_F,
        "corosync": constants.JOURNAL_COROSYNC_F,
        "sbd": constants.JOURNAL_SBD_F
    }
    for item, outf in journal_target_dict.items():
        journalctl_unit = "" if item == "default" else f" -u {item}"
        cmd = f'journalctl{journalctl_unit} -o short-iso-precise --since "{from_time_str}" --until "{to_time_str}" --no-pager | tail -n +2'
        output = utils.get_cmd_output(cmd)
        logger.debug2(f"Running command: {cmd}")
        _file = os.path.join(context.work_dir, outf)
        crmutils.str2file(output, _file)
        logger.debug(f"Dump jounal log for {item} into {utils.real_path(_file)}")


def dump_D_process() -> str:
    """
    Dump D-state process stack
    """
    out_string = ""

    sh_utils_inst = ShellUtils()
    _, out, _ = sh_utils_inst.get_stdout_stderr("ps aux|awk '$8 ~ /^D/{print $2}'")
    len_D_process = len(out.split('\n')) if out else 0
    out_string += f"Dump D-state process stack: {len_D_process}\n"
    if len_D_process == 0:
        return out_string

    for pid in out.split('\n'):
        _, cmd_out, _ = sh_utils_inst.get_stdout_stderr(f"cat /proc/{pid}/comm")
        out_string += f"pid: {pid}     comm: {cmd_out}\n"
        _, stack_out, _ = sh_utils_inst.get_stdout_stderr(f"cat /proc/{pid}/stack")
        out_string += stack_out + "\n\n"

    return out_string


def lsof_cluster_fs_device(fs_type: str) -> str:
    """
    List open files for OCFS2/GFS2 device
    """
    out_string = ""

    sh_utils_inst = ShellUtils()
    _, out, _ = sh_utils_inst.get_stdout_stderr("mount")
    dev_list = re.findall(f"^(.*) on .* type {fs_type.lower()} ", out, re.M)
    for dev in dev_list:
        cmd = f"lsof {dev}"
        out_string += "\n\n#=====[ Command ] ==========================#\n"
        out_string += f"# {cmd}\n"
        _, cmd_out, _ = sh_utils_inst.get_stdout_stderr(cmd)
        if cmd_out:
            out_string += cmd_out

    return out_string


def cluster_fs_commands_output(fs_type: str) -> str:
    """
    Run OCFS2/GFS2 related commands, return outputs
    """
    out_string = ""

    cmds = [
        "dmesg",
        "ps -efL",
        "lsblk -o 'NAME,KNAME,MAJ:MIN,FSTYPE,LABEL,RO,RM,MODEL,SIZE,OWNER,GROUP,MODE,ALIGNMENT,MIN-IO,OPT-IO,PHY-SEC,LOG-SEC,ROTA,SCHED,MOUNTPOINT'",
        "findmnt",
        "mount"
    ]

    if fs_type.lower() == "ocfs2":
        cmds.extend([
            "mounted.ocfs2 -f",
            "cat /sys/fs/ocfs2/cluster_stack"
        ])

    for cmd in cmds:
        cmd_name = cmd.split()[0]
        if not shutil.which(cmd_name):
            continue
        if cmd_name == "cat" and not os.path.exists(cmd.split()[1]):
            continue
        out_string += "\n\n#===== [ Command ] ==========================#\n"
        out_string += f"# {cmd}\n"
        out_string += utils.get_cmd_output(cmd)

    return out_string


def collect_cluster_fs_info(context: core.Context) -> None:
    """
    Collects OCFS2 and GFS2 information
    """
    def collect_info(cmd: str, fs_type: str, output_file: str) -> None:
        out_string = ""
        no_partition_msg = f"No {fs_type} partitions found"

        rc, out, err = ShellUtils().get_stdout_stderr(cmd)
        if rc != 0:
            if fs_type == "OCFS2":
                error_msg = f"Failed to run \"{cmd}\": {err}"
                out_string += error_msg
                logger.error(error_msg)
            elif fs_type == "GFS2":
                out_string += no_partition_msg
        elif fs_type == "OCFS2" and len(out.split('\n')) == 1:
            out_string += no_partition_msg
        else:
            out_string += dump_D_process()
            out_string += lsof_cluster_fs_device(fs_type)
            out_string += cluster_fs_commands_output(fs_type)

        target_f = os.path.join(context.work_dir, output_file)
        logger.debug("Dump %s information into %s", fs_type, utils.real_path(target_f))
        crmutils.str2file(out_string, target_f)

    # Collect OCFS2 information
    if shutil.which("mounted.ocfs2"):
        collect_info("mounted.ocfs2 -d", "OCFS2", constants.OCFS2_F)

    # Collect GFS2 information
    collect_info('mount|grep "type gfs2"', "GFS2", constants.GFS2_F)


def collect_ratraces(context: core.Context) -> None:
    """
    Collect ra trace file from default /var/lib/heartbeat/trace_ra and custom one
    """
    trace_dir_str = ' '.join(context.trace_dir_list)
    if not trace_dir_str:
        return

    logger.debug2("Looking for RA trace files in \"%s\"", trace_dir_str)
    for f in utils.find_files_in_timespan(context, trace_dir_str.split()):
        dest_dir = os.path.join(context.work_dir, '/'.join(f.split('/')[-3:-1]))
        crmutils.mkdirp(dest_dir)
        shutil.copy2(f, dest_dir)
        logger.debug(f"Dump RA trace files into {utils.real_path(dest_dir)}")


def collect_corosync_blackbox(context: core.Context) -> None:
    fdata_list = []
    for f in utils.find_files_in_timespan(context, ["/var/lib/corosync"]):
        if re.search("fdata", f):
            fdata_list.append(f)
    if fdata_list:
        blackbox_f = os.path.join(context.work_dir, constants.COROSYNC_RECORDER_F)
        out_string = utils.get_cmd_output("corosync-blackbox")
        crmutils.str2file(out_string, blackbox_f)
        logger.debug(f"Dump corosync blackbox info into {utils.real_path(blackbox_f)}")


def collect_dlm_info(context: core.Context) -> None:
    """
    Get DLM information
    """
    if shutil.which("dlm_tool"):
        name_list = []
        out_string = "##### NOTICE - Lockspace overview:\n"
        out_string += utils.get_cmd_output("dlm_tool ls")
        name_list = re.findall(r"^name\s*(.*)$", out_string, re.MULTILINE)

        for name in name_list:
            out_string += f"\n\n## NOTICE - Lockspace {name}\n"
            lockdebug_cmd = f"dlm_tool lockdebug {name}"
            out_string += utils.get_cmd_output(lockdebug_cmd)

        out_string += "\n\n##### NOTICE - Lockspace history:\n"
        out_string += utils.get_cmd_output("dlm_tool dump")

        dlm_f = os.path.join(context.work_dir, constants.DLM_DUMP_F)
        crmutils.str2file(out_string, dlm_f)
        logger.debug(f"Dump DLM information into {utils.real_path(dlm_f)}")


def collect_perms_state(context: core.Context) -> None:
    """
    Check and collect permissions and ownership information for specific directories
    """
    results = []

    for check_dir in [context.pcmk_lib_dir, context.pe_dir, context.cib_dir]:
        if not os.path.isdir(check_dir):
            result = f"{check_dir} is not a directory or does not exist"
        else:
            stat_info = os.stat(check_dir)
            pwd_inst = pwd.getpwnam('hacluster')
            expected_uid = pwd_inst.pw_uid
            expected_gid = pwd_inst.pw_gid
            expected_mode = 0o750

            uid_match = stat_info.st_uid == expected_uid
            gid_match = stat_info.st_gid == expected_gid
            mode_match = stat_info.st_mode & 0o7777 == expected_mode

            if uid_match and gid_match and mode_match:
                result = "OK"
            else:
                result = f"Permissions or ownership for {check_dir} are incorrect"
        results.append(f"##### Check perms for {check_dir}: {result}\n")

    perms_f = os.path.join(context.work_dir, constants.PERMISSIONS_F)
    crmutils.str2file(''.join(results), perms_f)


def dump_configurations(workdir: str) -> None:
    config_list = constants.CONFIGURATIONS
    config_list.append(corosync.conf())

    for conf in config_list:
        if os.path.isfile(conf):
            shutil.copy2(conf, workdir)
        elif os.path.isdir(conf):
            shutil.copytree(conf, os.path.join(workdir, os.path.basename(conf)))


def find_binary_path_for_core(core_file: str) -> str:
    """
    Find the binary that generated the given core file
    """
    path_str = ""
    cmd = f"gdb --batch cat {core_file}"
    _, out, _ = ShellUtils().get_stdout_stderr(cmd)
    if out:
        res = re.search("Core was generated by `(.*)'", out, re.M)
        path_str = res.group(1) if res else ""

    if path_str:
        return f"Core {core_file} was generated by {path_str}"
    else:
        return f"Cannot find the program path for core {core_file}"


def dump_core_info(workdir: str, core_file_list: List[str]) -> None:
    """
    Dump coredump files information into file
    """
    out_string = ""
    if shutil.which("gdb"):
        for core_file in core_file_list:
            out_string += find_binary_path_for_core(core_file) + "\n"
        out_string += "\nPlease utilize the gdb and debuginfo packages to obtain more detailed information locally"
    else:
        msg = "Please install gdb to get more info for coredump files"
        out_string += msg
        logger.warning(msg)

    core_f = os.path.join(workdir, constants.COREDUMP_F)
    crmutils.str2file(out_string, core_f)
    logger.debug(f"Dump coredump info into {utils.real_path(core_f)}")


def collect_coredump_info(context: core.Context) -> None:
    """
    Collects coredump files information from the library path of Pacemaker and Corosync
    """
    cores = utils.find_files_in_timespan(context, context.cores_dir_list)
    flist = [f for f in cores if "core" in os.path.basename(f)]
    if flist:
        logger.warning(f"Found coredump file: {flist}")
        dump_core_info(context.work_dir, flist)


def dump_runtime_state(workdir: str) -> None:
    """
    Dump runtime state files
    """
    cluster_shell_inst = sh.cluster_shell()

    # Dump the output of 'crm_mon' command with multiple options
    out = ""
    for option, desc in [
        ("-r1", "inactive resources"),
        ("-n1", "resources grouped by node"),
        ("-rf1", "resource fail counts"),
        ("-rnt1", "resource operation history with timing details"),
    ]:
        cmd = f"crm_mon {option}"
        out += f"\n#### Display cluster state and {desc}: {cmd} ####\n"
        out += cluster_shell_inst.get_stdout_or_raise_error(cmd)
        out += "\n\n"

    target_f = os.path.join(workdir, constants.CRM_MON_F)
    crmutils.str2file(out, target_f)
    logger.debug(f"Dump crm_mon state into {utils.real_path(target_f)}")

    # Dump other runtime state files
    for cmd, f, desc in [
        ("cibadmin -Q", constants.CIB_F, "CIB contents"),
        ("crm_node -p", constants.MEMBERSHIP_F, "members of this partition")
    ]:
        out = cluster_shell_inst.get_stdout_or_raise_error(cmd)
        target_f = os.path.join(workdir, f)
        crmutils.str2file(out, target_f)
        logger.debug(f"Dump {desc} into {utils.real_path(target_f)}")

    node = crmutils.get_dc()
    if node and node == crmutils.this_node():
        crmutils.str2file("", os.path.join(workdir, "DC"))
        logger.debug(f"Current DC is {node}; Touch file 'DC' in {utils.real_path(workdir)}")


def consume_cib_in_workdir(workdir: str) -> None:
    """
    Generate 'crm configure show' and 'crm_verify' outputs based on the cib.xml file in the work directory
    """
    cib_in_workdir = os.path.join(workdir, constants.CIB_F)
    if os.path.isfile(cib_in_workdir):
        cluster_shell_inst = sh.cluster_shell()
        cmd = f"CIB_file={cib_in_workdir} crm configure show"
        out = cluster_shell_inst.get_stdout_or_raise_error(cmd)
        crmutils.str2file(out, os.path.join(workdir, constants.CONFIGURE_SHOW_F))

        cmd = f"crm_verify -V -x {cib_in_workdir}"
        _, _, err = cluster_shell_inst.get_rc_stdout_stderr_without_input(None, cmd)
        if err:
            crmutils.str2file(err, os.path.join(workdir, constants.CRM_VERIFY_F))


def collect_config(context: core.Context) -> None:
    """
    """
    workdir = context.work_dir

    if ServiceManager().service_is_active("pacemaker.service"):
        dump_runtime_state(workdir)
        crmutils.str2file("", os.path.join(workdir, "RUNNING"))
        logger.debug(f"Touch file 'RUNNING' in {utils.real_path(workdir)}")
    else:
        # TODO should determine offline node was ha node
        if not os.path.isfile(os.path.join(context.cib_dir, constants.CIB_F)):
            logger.warning(f"Cannot find cib.xml in {context.cib_dir}")
            return
        shutil.copy2(os.path.join(context.cib_dir, constants.CIB_F), workdir)
        crmutils.str2file("", os.path.join(workdir, "STOPPED"))
        logger.debug(f"Touch file 'STOPPED' in {utils.real_path(workdir)}")

    consume_cib_in_workdir(workdir)
    dump_configurations(workdir)


def pe_to_dot(pe_file: str) -> None:
    dotf = os.path.splitext(pe_file)[0] + '.dot'
    cmd = f"{constants.PTEST} -D {dotf} -x {pe_file}"
    code, _, _ = ShellUtils().get_stdout_stderr(cmd)
    if code != 0:
        logger.warning("pe_to_dot: %s -> %s failed", pe_file, dotf)


def collect_pe_inputs(context: core.Context) -> None:
    """
    Collects PE files in the specified directory and generates DOT files if needed
    """
    logger.debug2(f"Looking for PE files in {context.pe_dir}")

    _list = utils.find_files_in_timespan(context, [context.pe_dir])
    pe_file_list = [f for f in _list if not f.endswith(".last")]
    if pe_file_list:
        pe_flist_dir = os.path.join(context.work_dir, os.path.basename(context.pe_dir))
        crmutils.mkdirp(pe_flist_dir)

        gen_dot = len(pe_file_list) <= 20 and not context.speed_up
        for f in pe_file_list:
            pe_file_path_in_report = os.path.join(pe_flist_dir, os.path.basename(f))
            os.symlink(f, pe_file_path_in_report)
            if gen_dot:
                pe_to_dot(pe_file_path_in_report)
        logger.debug2(f"Found {len(pe_file_list)} PE files in {context.pe_dir}")
        dump_path = f"{context.work_dir}/{os.path.basename(context.pe_dir)}"
        logger.debug(f"Dump PE files into {utils.real_path(dump_path)}")
    else:
        logger.debug2("No PE file found for the giving time")


def collect_sbd_info(context: core.Context) -> None:
    """
    Collect SBD config file and information
    """
    if not os.path.exists(constants.SBDCONF):
        logger.debug(f"SBD config file {constants.SBDCONF} does not exist")
        return
    shutil.copy2(constants.SBDCONF, context.work_dir)
    if not shutil.which("sbd"):
        return

    sbd_f = os.path.join(context.work_dir, constants.SBD_F)
    cmd_list = [
        f". {constants.SBDCONF};export SBD_DEVICE;sbd dump;sbd list",
        "crm sbd configure show",
        "crm sbd status"
    ]
    with open(sbd_f, "w") as f:
        for cmd in cmd_list:
            f.write("\n\n#=====[ Command ] ==========================#\n")
            f.write(f"# {cmd}\n")
            f.write(utils.get_cmd_output(cmd))

    logger.debug(f"Dump SBD config file into {utils.real_path(sbd_f)}")


def collect_sys_stats(context: core.Context) -> None:
    """
    Collect system statistics
    """
    cmd_list = [
        "hostname", "uptime", "ps axf", "ps auxw", "top -b -n 1",
        "ip addr", "ip -s link", "ip n show", "lsscsi", "lspci",
        "mount", "cat /proc/cpuinfo", "df"
    ]

    out_string = ""
    for cmd in cmd_list:
        out_string += f"##### Run \"{cmd}\" #####\n"
        try:
            out_string += utils.get_cmd_output(cmd, timeout=5) + "\n"
        except TimeoutExpired:
            logger.warning(f"Timeout while running command: {cmd}")

    _file = os.path.join(context.work_dir, constants.SYSSTATS_F)
    crmutils.str2file(out_string, _file)
    logger.debug(f"Dump system statistics into {utils.real_path(_file)}")


def collect_sys_info(context: core.Context) -> None:
    """
    Collect the versions of cluster-related packages and platform information
    """
    pkg_inst = utils.Package(constants.PACKAGES)
    version_info = pkg_inst.version()
    packages_info = "##### Installed cluster related packages #####\n"
    packages_info += version_info + '\n\n'
    if not context.speed_up:
        packages_info += "##### Verification output of packages #####\n"
        packages_info += pkg_inst.verify()

    platform, _, release, _, arch = os.uname()
    sys_info = (
            f"##### System info #####\n"
            f"Platform: {platform}\n"
            f"Kernel release: {release}\n"
            f"Architecture: {arch}\n"
            )
    if platform == "Linux":
        sys_info += f"Distribution: {utils.get_distro_info()}\n"
    out_string = f"{sys_info}\n{packages_info}"

    _file = os.path.join(context.work_dir, constants.SYSINFO_F)
    crmutils.str2file(out_string, _file)
    logger.debug(f"Dump packages and platform info into {utils.real_path(_file)}")


def collect_qdevice_info(context: core.Context) -> None:
    """
    Collect quorum/qdevice/qnetd information
    """
    service_manager = ServiceManager()
    if not service_manager.service_is_active("corosync.service"):
        return
    out_string = "##### Quorum status #####\n"
    out_string += corosync.query_quorum_status() + "\n"

    if service_manager.service_is_active("corosync-qdevice.service"):
        out_string += "\n##### Qdevice status #####\n"
        out_string += corosync.query_qdevice_status() + "\n"
        out_string += "\n##### Qnetd status #####\n"
        out_string += corosync.query_qnetd_status() + "\n"

    _file = os.path.join(context.work_dir, constants.QDEVICE_F)
    crmutils.str2file(out_string, _file)
    logger.debug(f"Dump quorum/qdevice/qnetd information into {utils.real_path(_file)}")
