"""
Copyright (c) 2023-2024 Wind River Systems, Inc.

SPDX-License-Identifier: Apache-2.0

"""
# PYTHON_ARGCOMPLETE_OK
import argcomplete
import argparse
import json
import os
import re
import requests
import signal
import software_client.constants as constants
import subprocess
import sys
import textwrap
import time

from requests_toolbelt import MultipartEncoder
from urllib.parse import urlparse

from tsconfig.tsconfig import SW_VERSION as RUNNING_SW_VERSION

api_addr = "127.0.0.1:5493"
auth_token = None

TERM_WIDTH = 72
VIRTUAL_REGION = 'SystemController'
IPV6_FAMILY = 6


def set_term_width():
    global TERM_WIDTH

    try:
        with open(os.devnull, 'w') as NULL:
            output = subprocess.check_output(["tput", "cols"], stderr=NULL)
            width = int(output)
            if width > 60:
                TERM_WIDTH = width - 4
    except Exception:
        pass


def check_rc(req):
    rc = 0
    if req.status_code == 200:
        data = json.loads(req.text)
        if 'error' in data and data["error"] != "":
            rc = 1
    else:
        rc = 1

    return rc


def print_result_debug(req):
    if req.status_code == 200:
        data = json.loads(req.text)
        if 'sd' in data:
            print(json.dumps(data['sd'],
                             sort_keys=True,
                             indent=4,
                             separators=(',', ': ')))
        elif 'data' in data:
            print(json.dumps(data['data'],
                             sort_keys=True,
                             indent=4,
                             separators=(',', ': ')))
        else:
            print(json.dumps(data,
                             sort_keys=True,
                             indent=4,
                             separators=(',', ': ')))
    elif req.status_code == 500:
        print("An internal error has occurred. Please check /var/log/software.log for details")
    else:
        m = re.search("(Error message:.*)", req.text, re.MULTILINE)
        if m:
            print(m.group(0))
        else:
            print("%s %s" % (req.status_code, req.reason))


def print_software_op_result(req):
    if req.status_code == 200:
        data = json.loads(req.text)

        if 'sd' in data:
            sd = data['sd']

            # Calculate column widths
            hdr_release = "Release"
            hdr_version = "Version"
            hdr_rr = "RR"
            hdr_state = "State"

            width_release = len(hdr_release)
            width_version = len(hdr_version)
            width_rr = len(hdr_rr)
            width_state = len(hdr_state)

            show_all = False

            for release_id in list(sd):
                width_release = max(len(release_id), width_release)
                width_state = max(len(sd[release_id]["state"]), width_state)
                if "sw_version" in sd[release_id]:
                    show_all = True
                    width_version = max(len(sd[release_id]["sw_version"]), width_version)

            if show_all:
                print("{0:^{width_release}}  {1:^{width_rr}}  {2:^{width_version}}  {3:^{width_state}}".format(
                    hdr_release, hdr_rr, hdr_version, hdr_state,
                    width_release=width_release, width_rr=width_rr,
                    width_version=width_version, width_state=width_state))

                print("{0}  {1}  {2}  {3}".format(
                    '=' * width_release, '=' * width_rr, '=' * width_version, '=' * width_state))

                for release_id in sorted(list(sd)):
                    if "reboot_required" in sd[release_id]:
                        rr = sd[release_id]["reboot_required"]
                    else:
                        rr = "Y"

                    print("{0:<{width_release}}  {1:^{width_rr}}  {2:^{width_version}}  {3:^{width_state}}".format(
                        release_id,
                        rr,
                        sd[release_id]["sw_version"],
                        sd[release_id]["state"],
                        width_release=width_release, width_rr=width_rr,
                        width_version=width_version, width_state=width_state))
            else:
                print("{0:^{width_release}}  {1:^{width_state}}".format(
                    hdr_release, hdr_state,
                    width_release=width_release, width_state=width_state))

                print("{0}  {1}".format(
                    '=' * width_release, '=' * width_state))

                for release_id in sorted(list(sd)):
                    if "reboot_required" in sd[release_id]:
                        rr = sd[release_id]["reboot_required"]
                    else:
                        rr = "Y"

                    print("{0:<{width_release}}  {1:^{width_rr}}  {2:^{width_state}}".format(
                        release_id,
                        rr,
                        sd[release_id]["state"],
                        width_release=width_release, width_rr=width_rr,
                        width_state=width_state))

            print("")

        if 'info' in data and data["info"] != "":
            print(data["info"])

        if 'warning' in data and data["warning"] != "":
            print("Warning:")
            print(data["warning"])

        if 'error' in data and data["error"] != "":
            print("Error:")
            print(data["error"])

    elif req.status_code == 500:
        print("An internal error has occurred. Please check /var/log/software.log for details")
    else:
        print("Error: %s has occurred. %s" % (req.status_code, req.reason))


def print_release_show_result(req, list_packages=False):
    if req.status_code == 200:
        data = json.loads(req.text)

        if 'metadata' in data:
            sd = data['metadata']
            contents = data['contents']
            for release_id in sorted(list(sd)):
                print("%s:" % release_id)

                if "sw_version" in sd[release_id] and sd[release_id]["sw_version"] != "":
                    print(textwrap.fill("    {0:<15} ".format("Version:") + sd[release_id]["sw_version"],
                                        width=TERM_WIDTH, subsequent_indent=' ' * 20))

                if "state" in sd[release_id] and sd[release_id]["state"] != "":
                    print(textwrap.fill("    {0:<15} ".format("State:") + sd[release_id]["state"],
                                        width=TERM_WIDTH, subsequent_indent=' ' * 20))

                if "status" in sd[release_id] and sd[release_id]["status"] != "":
                    print(textwrap.fill("    {0:<15} ".format("Status:") + sd[release_id]["status"],
                                        width=TERM_WIDTH, subsequent_indent=' ' * 20))

                if "unremovable" in sd[release_id] and sd[release_id]["unremovable"] != "":
                    print(textwrap.fill("    {0:<15} ".format("Unremovable:") + sd[release_id]["unremovable"],
                                        width=TERM_WIDTH, subsequent_indent=' ' * 20))

                if "reboot_required" in sd[release_id] and sd[release_id]["reboot_required"] != "":
                    print(textwrap.fill("    {0:<15} ".format("RR:") + sd[release_id]["reboot_required"],
                                        width=TERM_WIDTH, subsequent_indent=' ' * 20))

                if "apply_active_release_only" in sd[release_id] and sd[release_id]["apply_active_release_only"] != "":
                    print(textwrap.fill("    {0:<15} ".format("Apply Active Release Only:") + sd[release_id]["apply_active_release_only"],
                                        width=TERM_WIDTH, subsequent_indent=' ' * 20))

                if "summary" in sd[release_id] and sd[release_id]["summary"] != "":
                    print(textwrap.fill("    {0:<15} ".format("Summary:") + sd[release_id]["summary"],
                                        width=TERM_WIDTH, subsequent_indent=' ' * 20))

                if "description" in sd[release_id] and sd[release_id]["description"] != "":
                    first_line = True
                    for line in sd[release_id]["description"].split('\n'):
                        if first_line:
                            print(textwrap.fill("    {0:<15} ".format("Description:") + line,
                                                width=TERM_WIDTH, subsequent_indent=' ' * 20))
                            first_line = False
                        else:
                            print(textwrap.fill(line,
                                                width=TERM_WIDTH, subsequent_indent=' ' * 20,
                                                initial_indent=' ' * 20))

                if "install_instructions" in sd[release_id] and sd[release_id]["install_instructions"] != "":
                    print("    Install Instructions:")
                    for line in sd[release_id]["install_instructions"].split('\n'):
                        print(textwrap.fill(line,
                                            width=TERM_WIDTH, subsequent_indent=' ' * 20,
                                            initial_indent=' ' * 20))

                if "warnings" in sd[release_id] and sd[release_id]["warnings"] != "":
                    first_line = True
                    for line in sd[release_id]["warnings"].split('\n'):
                        if first_line:
                            print(textwrap.fill("    {0:<15} ".format("Warnings:") + line,
                                                width=TERM_WIDTH, subsequent_indent=' ' * 20))
                            first_line = False
                        else:
                            print(textwrap.fill(line,
                                                width=TERM_WIDTH, subsequent_indent=' ' * 20,
                                                initial_indent=' ' * 20))

                if "requires" in sd[release_id] and len(sd[release_id]["requires"]) > 0:
                    print("    Requires:")
                    for req_patch in sorted(sd[release_id]["requires"]):
                        print(' ' * 20 + req_patch)

                if "contents" in data and release_id in data["contents"]:
                    print("    Contents:\n")
                    if "number_of_commits" in contents[release_id] and \
                            contents[release_id]["number_of_commits"] != "":
                        print(textwrap.fill("    {0:<15} ".format("No. of commits:") +
                                            contents[release_id]["number_of_commits"],
                                            width=TERM_WIDTH, subsequent_indent=' ' * 20))
                    if "base" in contents[release_id] and \
                            contents[release_id]["base"]["commit"] != "":
                        print(textwrap.fill("    {0:<15} ".format("Base commit:") +
                                            contents[release_id]["base"]["commit"],
                                            width=TERM_WIDTH, subsequent_indent=' ' * 20))
                    if "number_of_commits" in contents[release_id] and \
                            contents[release_id]["number_of_commits"] != "":
                        for i in range(int(contents[release_id]["number_of_commits"])):
                            print(textwrap.fill("    {0:<15} ".format("Commit%s:" % (i + 1)) +
                                                contents[release_id]["commit%s" % (i + 1)]["commit"],
                                                width=TERM_WIDTH, subsequent_indent=' ' * 20))

                if list_packages:
                    if "packages" in sd[release_id] and len(sd[release_id]["packages"]):
                        print("    Packages:")
                        for package in sorted(sd[release_id]["packages"]):
                            print(" " * 20 + package)

                print("\n")

        if 'info' in data and data["info"] != "":
            print(data["info"])

        if 'warning' in data and data["warning"] != "":
            print("Warning:")
            print(data["warning"])

        if 'error' in data and data["error"] != "":
            print("Error:")
            print(data["error"])

    elif req.status_code == 500:
        print("An internal error has occurred. Please check /var/log/software.log for details")


def _print_result_list(header_data_list, data_list, has_error, sort_key=0):
    """
    Print a list of data in a simple table format
    :param header_data_list: Array of header data
    :param data_list: Array of data
    :param has_error: Boolean indicating if the request has error message
    :param sort_key: Sorting key for the list
    """

    if has_error:
        return

    if data_list is None or len(data_list) == 0:
        return

    # Find the longest header string in each column
    header_lengths = [len(str(x)) for x in header_data_list]
    # Find the longest content string in each column
    content_lengths = [max(len(str(x[i])) for x in data_list)
                       for i in range(len(header_data_list))]
    # Find the max of the two for each column
    col_lengths = [(x if x > y else y) for x, y in zip(header_lengths, content_lengths)]

    print('  '.join(f"{x.center(col_lengths[i])}" for i, x in enumerate(header_data_list)))
    print('  '.join('=' * length for length in col_lengths))
    for item in sorted(data_list, key=lambda d: d[sort_key]):
        print('  '.join(f"{str(x).center(col_lengths[i])}" for i, x in enumerate(item)))
    print("\n")


def software_command_not_implemented_yet(args):
    print("NOT IMPLEMENTED %s" % args)
    return 1


def release_is_available_req(args):

    releases = "/".join(args.release)
    url = "http://%s/v1/software/is_available/%s" % (api_addr, releases)

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.post(url, headers=headers)

    rc = 1

    if req.status_code == 200:
        result = json.loads(req.text)
        print(result)
        if result is True:
            rc = 0
    elif req.status_code == 500:
        print("An internal error has occurred. Please check /var/log/software.log for details")
    else:
        print("Error: %s has occurred. %s" % (req.status_code, req.reason))

    return rc


def release_is_deployed_req(args):

    releases = "/".join(args.release)
    url = "http://%s/v1/software/is_deployed/%s" % (api_addr, releases)

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.post(url, headers=headers)

    rc = 1

    if req.status_code == 200:
        result = json.loads(req.text)
        print(result)
        if result is True:
            rc = 0
    elif req.status_code == 500:
        print("An internal error has occurred. Please check /var/log/software.log for details")
    else:
        print("Error: %s has occurred. %s" % (req.status_code, req.reason))

    return rc


def release_is_committed_req(args):

    releases = "/".join(args.release)
    url = "http://%s/v1/software/is_committed/%s" % (api_addr, releases)

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.post(url, headers=headers)

    rc = 1

    if req.status_code == 200:
        result = json.loads(req.text)
        print(result)
        if result is True:
            rc = 0
    elif req.status_code == 500:
        print("An internal error has occurred. Please check /var/log/software.log for details")
    else:
        print("Error: %s has occurred. %s" % (req.status_code, req.reason))

    return rc


def release_upload_req(args):
    rc = 0

    # arg.release is a list
    releases = args.release
    is_local = args.local  # defaults to False

    # Ignore interrupts during this function
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    to_upload_files = {}
    valid_files = []
    invalid_files = []

    # Validate all the files
    valid_files = [os.path.abspath(software_file) for software_file in releases if os.path.isfile(
        software_file) and os.path.splitext(software_file)[1] in constants.SUPPORTED_UPLOAD_FILE_EXT]
    invalid_files = [os.path.abspath(software_file) for software_file in releases
                     if os.path.abspath(software_file) not in valid_files]

    for software_file in invalid_files:
        if os.path.isdir(software_file):
            print("Error: %s is a directory. Please use upload-dir" % software_file)
        elif os.path.isfile(software_file):
            print("Error: %s has the unsupported file extension." % software_file)
        else:
            print("Error: File does not exist: %s" % software_file)

    if len(valid_files) == 0:
        print("No file to be uploaded.")
        return rc

    if is_local:
        to_upload_filenames = json.dumps(valid_files)
        headers = {'Content-Type': 'text/plain'}
    else:
        for software_file in valid_files:
            with open(software_file, 'rb') as file:
                data_content = file.read()
            to_upload_files[software_file] = (software_file, data_content)

        encoder = MultipartEncoder(fields=to_upload_files)
        headers = {'Content-Type': encoder.content_type}

    url = "http://%s/v1/software/upload" % api_addr
    append_auth_token_if_required(headers)
    req = requests.post(url,
                        data=to_upload_filenames if is_local else encoder,
                        headers=headers)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)
        data = json.loads(req.text)
        data_list = [(k, v["id"])
                     for d in data["upload_info"] for k, v in d.items()
                     if not k.endswith(".sig")]

        header_data_list = ["Uploaded File", "Id"]
        has_error = 'error' in data and data["error"]
        _print_result_list(header_data_list, data_list, has_error)
    if check_rc(req) != 0:
        # We hit a failure.  Update rc but keep looping
        rc = 1

    return rc


def release_delete_req(args):
    # arg.release is a list
    releases = "/".join(args.release)

    # Ignore interrupts during this function
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    url = "http://%s/v1/software/delete/%s" % (api_addr, releases)

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.post(url, headers=headers)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)

    return check_rc(req)


def commit_patch_req(args):

    # Ignore interrupts during this function
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    # Default to running release
    # this all needs to be changed
    relopt = RUNNING_SW_VERSION

    headers = {}
    append_auth_token_if_required(headers)
    if args.sw_version and not args.all:
        # Disallow
        print("Use of --sw-version option requires --all")
        return 1
    elif args.all:
        # Get a list of all patches
        extra_opts = "&release=%s" % relopt
        url = "http://%s/v1/software/query?show=patch%s" % (api_addr, extra_opts)

        req = requests.get(url, headers=headers)

        patch_list = []
        if req.status_code == 200:
            data = json.loads(req.text)

            if 'sd' in data:
                patch_list = sorted(list(data['sd']))
        elif req.status_code == 500:
            print("Failed to get patch list. Aborting...")
            return 1

        if len(patch_list) == 0:
            print("There are no %s patches to commit." % relopt)
            return 0

        print("The following patches will be committed:")
        for patch_id in patch_list:
            print("    %s" % patch_id)
        print()

        patches = "/".join(patch_list)
    else:
        # args.patch is a list
        patches = "/".join(args.patch)

        # First, get a list of dependencies and ask for confirmation
        url = "http://%s/v1/software/query_dependencies/%s?recursive=yes" % (api_addr, patches)

        req = requests.get(url, headers=headers)

        if req.status_code == 200:
            data = json.loads(req.text)

            if 'patches' in data:
                print("The following patches will be committed:")
                for release_id in sorted(data['patches']):
                    print("    %s" % release_id)
                print()
            else:
                print("No patches found to commit")
                return 1

        elif req.status_code == 500:
            print("An internal error has occurred. Please check /var/log/software.log for details")
            return 1

    # Run dry-run
    url = "http://%s/v1/software/commit_dry_run/%s" % (api_addr, patches)

    req = requests.post(url, headers=headers)
    print_software_op_result(req)

    if check_rc(req) != 0:
        print("Aborting...")
        return 1

    if args.dry_run:
        return 0

    print()
    commit_warning = "WARNING: Committing a patch is an irreversible operation. " + \
                     "Committed patches cannot be removed."
    print(textwrap.fill(commit_warning, width=TERM_WIDTH, subsequent_indent=' ' * 9))
    print()

    user_input = input("Would you like to continue? [y/N]: ")
    if user_input.lower() != 'y':
        print("Aborting...")
        return 1

    url = "http://%s/v1/software/commit_patch/%s" % (api_addr, patches)
    req = requests.post(url, headers=headers)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)

    return check_rc(req)


def release_list_req(args):
    state = args.state  # defaults to "all"
    extra_opts = ""
    if args.release:
        extra_opts = "&release=%s" % args.release
    url = "http://%s/v1/software/query?show=%s%s" % (api_addr, state, extra_opts)
    headers = {}
    append_auth_token_if_required(headers)
    req = requests.get(url, headers=headers)

    if args.debug:
        print_result_debug(req)
    else:
        header_data_list = ["Release", "RR", "State"]
        data = json.loads(req.text)
        data_list = [(k, v["reboot_required"], v["state"]) for k, v in data["sd"].items()]
        has_error = 'error' in data and data["error"]
        _print_result_list(header_data_list, data_list, has_error)

    return check_rc(req)


def print_software_deploy_host_list_result(req):
    if req.status_code == 200:
        data = json.loads(req.text)
        if 'data' not in data:
            print("Invalid data returned:")
            print_result_debug(req)
            return

        agents = data['data']

        # Calculate column widths
        hdr_hn = "Hostname"
        hdr_rel = "Software Release"
        hdr_tg_rel = "Target Release"
        hdr_rr = "Reboot Required"
        hdr_state = "Host State"

        width_hn = len(hdr_hn)
        width_rel = len(hdr_rel)
        width_tg_rel = len(hdr_tg_rel)
        width_rr = len(hdr_rr)
        width_state = len(hdr_state)

        for agent in sorted(agents, key=lambda a: a["hostname"]):
            if agent.get("deploy_host_state") is None:
                agent["deploy_host_state"] = "No active deployment"
            if agent.get("to_release") is None:
                agent["to_release"] = "N/A"
            if len(agent["hostname"]) > width_hn:
                width_hn = len(agent["hostname"])
            if len(agent["sw_version"]) > width_rel:
                width_rel = len(agent["sw_version"])
            if len(agent["to_release"]) > width_tg_rel:
                width_tg_rel = len(agent["to_release"])
            if len(agent["deploy_host_state"]) > width_state:
                width_state = len(agent["deploy_host_state"])

        print("{0:^{width_hn}}  {1:^{width_rel}}  {2:^{width_tg_rel}}  {3:^{width_rr}}  {4:^{width_state}}".format(
            hdr_hn, hdr_rel, hdr_tg_rel, hdr_rr, hdr_state,
            width_hn=width_hn, width_rel=width_rel, width_tg_rel=width_tg_rel, width_rr=width_rr, width_state=width_state))

        print("{0}  {1}  {2}  {3}  {4}".format(
            '=' * width_hn, '=' * width_rel, '=' * width_tg_rel, '=' * width_rr, '=' * width_state))

        for agent in sorted(agents, key=lambda a: a["hostname"]):
            print("{0:<{width_hn}}  {1:^{width_rel}}  {2:^{width_tg_rel}}  {3:^{width_rr}}  {4:^{width_state}}".format(
                agent["hostname"],
                agent["sw_version"],
                agent["to_release"],
                "Yes" if agent.get("reboot_required", None) else "No",
                agent["deploy_host_state"],
                width_hn=width_hn, width_rel=width_rel, width_tg_rel=width_tg_rel, width_rr=width_rr, width_state=width_state))

    elif req.status_code == 500:
        print("An internal error has occurred. Please check /var/log/software.log for details")


def deploy_host_list_req(args):
    url = "http://%s/v1/software/host_list" % api_addr
    req = requests.get(url)
    if args.debug:
        print_result_debug(req)
    else:
        print_software_deploy_host_list_result(req)

    return check_rc(req)


def release_show_req(args):
    # arg.release is a list
    releases = "/".join(args.release)
    list_packages = args.packages

    url = "http://%s/v1/software/show/%s" % (api_addr, releases)

    headers = {}
    append_auth_token_if_required(headers)
    # todo(abailey): convert this to a GET
    req = requests.post(url, headers=headers)

    if args.debug:
        print_result_debug(req)
    else:
        print_release_show_result(req, list_packages=list_packages)

    return check_rc(req)


def wait_for_install_complete(agent_ip):
    url = "http://%s/v1/software/host_list" % api_addr
    rc = 0

    max_retries = 4
    retriable_count = 0

    while True:
        # Sleep on the first pass as well, to allow time for the
        # agent to respond
        time.sleep(5)

        try:
            req = requests.get(url)
        except requests.exceptions.ConnectionError:
            # The local software-controller may have restarted.
            retriable_count += 1
            if retriable_count <= max_retries:
                continue
            else:
                print("Lost communications with the software controller")
                rc = 1
                break

        if req.status_code == 200:
            data = json.loads(req.text)
            if 'data' not in data:
                print("Invalid host-list data returned:")
                print_result_debug(req)
                rc = 1
                break

            state = None
            agents = data['data']
            interim_state = None

            for agent in agents:
                if agent['hostname'] == agent_ip \
                   or agent['ip'] == agent_ip:
                    state = agent.get('state')
                    interim_state = agent.get('interim_state')

            if state is None:
                # If the software daemons have restarted, there's a
                # window after the software-controller restart that the
                # hosts table will be empty.
                retriable_count += 1
                if retriable_count <= max_retries:
                    continue
                else:
                    print("%s agent has timed out." % agent_ip)
                    rc = 1
                    break

            if state == constants.PATCH_AGENT_STATE_INSTALLING or \
                    interim_state is True:
                # Still installing
                sys.stdout.write(".")
                sys.stdout.flush()
            elif state == constants.PATCH_AGENT_STATE_INSTALL_REJECTED:
                print("\nInstallation rejected. Node must be locked")
                rc = 1
                break
            elif state == constants.PATCH_AGENT_STATE_INSTALL_FAILED:
                print("\nInstallation failed. Please check logs for details.")
                rc = 1
                break
            elif state == constants.PATCH_AGENT_STATE_IDLE:
                print("\nInstallation was successful.")
                rc = 0
                break
            else:
                print("\nPatch agent is reporting unknown state: %s" % state)
                rc = 1
                break

        elif req.status_code == 500:
            print("An internal error has occurred. Please check /var/log/software.log for details")
            rc = 1
            break
        else:
            m = re.search("(Error message:.*)", req.text, re.MULTILINE)
            if m:
                print(m.group(0))
            else:
                print(vars(req))
            rc = 1
            break

    return rc


def host_install(args):
    rc = 0
    agent_ip = args.agent

    # Issue deploy_host request and poll for results
    url = "http://%s/v1/software/deploy_host/%s" % (api_addr, agent_ip)

    if args.force:
        url += "/force"

    req = requests.post(url)

    if req.status_code == 200:
        data = json.loads(req.text)
        if 'error' in data and data["error"] != "":
            print("Error:")
            print(data["error"])
            rc = 1
        else:
            rc = wait_for_install_complete(agent_ip)
    elif req.status_code == 500:
        print("An internal error has occurred. "
              "Please check /var/log/software.log for details")
        rc = 1
    else:
        m = re.search("(Error message:.*)", req.text, re.MULTILINE)
        if m:
            print(m.group(0))
        else:
            print("%s %s" % (req.status_code, req.reason))
        rc = 1

    return rc


def drop_host(args):
    host_ip = args.host

    url = "http://%s/v1/software/drop_host/%s" % (api_addr, host_ip)

    req = requests.post(url)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)

    return check_rc(req)


def install_local(args):  # pylint: disable=unused-argument
    # Ignore interrupts during this function
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    url = "http://%s/v1/software/install_local" % (api_addr)

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.get(url, headers=headers)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)

    return check_rc(req)


def release_upload_dir_req(args):
    # arg.release is a list
    release_dirs = args.release

    # Ignore interrupts during this function
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    to_upload_files = {}
    raw_files = []

    # Find all files that need to be uploaded in given directories
    for release_dir in release_dirs:
        if os.path.isdir(release_dir):
            raw_files = [f for f in os.listdir(release_dir)
                         if os.path.isfile(os.path.join(release_dir, f))]

            # Get absolute path of files
            raw_files = [os.path.abspath(os.path.join(release_dir, f)) for f in raw_files]
        else:
            print("Skipping invalid directory: %s" % release_dir, file=sys.stderr)

    if len(raw_files) == 0:
        print("No file to upload")
        return 0

    temp_iso_files = [f for f in raw_files if f.endswith(constants.ISO_EXTENSION)]
    if len(temp_iso_files) > 1:  # Verify that only one ISO file is being uploaded
        print("Only one ISO file can be uploaded at a time. Found: %s" %
              temp_iso_files, file=sys.stderr)
        return 1

    temp_sig_files = [f for f in raw_files if f.endswith(constants.SIG_EXTENSION)]
    if len(temp_sig_files) > 1:  # Verify that only one SIG file is being uploaded
        print("Only one SIG file can be uploaded at a time. Found: %s" %
              temp_sig_files, file=sys.stderr)
        return 1

    for software_file in sorted(set(raw_files)):
        _, ext = os.path.splitext(software_file)
        if ext in constants.SUPPORTED_UPLOAD_FILE_EXT:
            to_upload_files[software_file] = (software_file, open(software_file, 'rb'))
        else:
            print("Skipping unsupported file: %s" % software_file, file=sys.stderr)

    encoder = MultipartEncoder(fields=to_upload_files)
    url = "http://%s/v1/software/upload" % api_addr
    headers = {'Content-Type': encoder.content_type}
    append_auth_token_if_required(headers)
    req = requests.post(url,
                        data=encoder,
                        headers=headers)
    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)
    return check_rc(req)


def deploy_precheck_req(args):
    # args.deployment is a string
    deployment = args.deployment

    # args.region is a string
    region_name = args.region_name

    # Issue deploy_precheck request
    url = "http://%s/v1/software/deploy_precheck/%s" % (api_addr, deployment)
    if args.force:
        url += "/force"
    url += "?region_name=%s" % region_name

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.post(url, headers=headers)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)

    return check_rc(req)


def deploy_start_req(args):
    # args.deployment is a string
    deployment = args.deployment

    # Ignore interrupts during this function
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    # Issue deploy_start request
    if args.force:
        url = "http://%s/v1/software/deploy_start/%s/force" % (api_addr, deployment)
    else:
        url = "http://%s/v1/software/deploy_start/%s" % (api_addr, deployment)

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.post(url, headers=headers)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)

    return check_rc(req)


def deploy_activate_req(args):
    # args.deployment is a string
    deployment = args.deployment

    # Ignore interrupts during this function
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    # Issue deploy_start request
    url = "http://%s/v1/software/deploy_activate/%s" % (api_addr, deployment)

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.post(url, headers=headers)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)

    return check_rc(req)


def deploy_complete_req(args):
    # args.deployment is a string
    deployment = args.deployment

    # Ignore interrupts during this function
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    # Issue deploy_complete request
    url = "http://%s/v1/software/deploy_complete/%s" % (api_addr, deployment)

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.post(url, headers=headers)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)

    return check_rc(req)


def deploy_show_req(args):
    url = "http://%s/v1/software/deploy_show" % api_addr
    headers = {}
    append_auth_token_if_required(headers)
    req = requests.get(url, headers=headers)

    if req.status_code >= 500:
        print("An internal error has occurred. Please check /var/log/software.log for details")
        return 1
    elif req.status_code >= 400:
        print("Respond code %d. Error: %s" % (req.status_code, req.reason))
        return 1

    data = json.loads(req.text)
    if not data:
        print("No deploy in progress.\n")
    else:
        data["reboot_required"] = "Yes" if data.get("reboot_required") else "No"
        data_list = [[k, v] for k, v in data.items()]
        transposed_data_list = list(zip(*data_list))

        transposed_data_list[0] = [s.title().replace('_', ' ') for s in transposed_data_list[0]]
        # Find the longest header string in each column
        header_lengths = [len(str(x)) for x in transposed_data_list[0]]
        # Find the longest content string in each column
        content_lengths = [len(str(x)) for x in transposed_data_list[1]]
        # Find the max of the two for each column
        col_lengths = [(x if x > y else y) for x, y in zip(header_lengths, content_lengths)]

        print('  '.join(f"{x.center(col_lengths[i])}" for i,
              x in enumerate(transposed_data_list[0])))
        print('  '.join('=' * length for length in col_lengths))
        print('  '.join(f"{x.center(col_lengths[i])}" for i,
              x in enumerate(transposed_data_list[1])))

    return 0


def deploy_host_req(args):
    rc = 0
    agent_ip = args.agent

    # Issue deploy_host request and poll for results
    url = "http://%s/v1/software/deploy_host/%s" % (api_addr, agent_ip)

    if args.force:
        url += "/force"

    req = requests.post(url)

    if req.status_code == 200:
        data = json.loads(req.text)
        if 'error' in data and data["error"] != "":
            print("Error:")
            print(data["error"])
            rc = 1
        else:
            rc = wait_for_install_complete(agent_ip)
    elif req.status_code == 500:
        print("An internal error has occurred. "
              "Please check /var/log/software.log for details")
        rc = 1
    else:
        m = re.search("(Error message:.*)", req.text, re.MULTILINE)
        if m:
            print(m.group(0))
        else:
            print("%s %s" % (req.status_code, req.reason))
        rc = 1
    return rc


def patch_init_release(args):
    # Ignore interrupts during this function
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    release = args.release

    url = "http://%s/v1/software/init_release/%s" % (api_addr, release)

    req = requests.post(url)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)

    return check_rc(req)


def patch_del_release(args):
    # Ignore interrupts during this function
    signal.signal(signal.SIGINT, signal.SIG_IGN)

    release = args.release

    url = "http://%s/v1/software/del_release/%s" % (api_addr, release)

    req = requests.post(url)

    if args.debug:
        print_result_debug(req)
    else:
        print_software_op_result(req)

    return check_rc(req)


def patch_report_app_dependencies_req(args):  # pylint: disable=unused-argument
    extra_opts = [args.app]
    extra_opts_str = '?%s' % '&'.join(extra_opts)

    patches = "/".join(args)
    url = "http://%s/v1/software/report_app_dependencies/%s%s" \
          % (api_addr, patches, extra_opts_str)

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.post(url, headers=headers)

    if req.status_code == 200:
        return 0
    else:
        print("An internal error has occurred. "
              "Please check /var/log/software.log for details.")
        return 1


def patch_query_app_dependencies_req():
    url = "http://%s/v1/software/query_app_dependencies" % api_addr

    headers = {}
    append_auth_token_if_required(headers)
    req = requests.post(url, headers=headers)

    if req.status_code == 200:
        data = json.loads(req.text)
        if len(data) == 0:
            print("There are no application dependencies.")
        else:
            hdr_app = "Application"
            hdr_list = "Required Patches"
            width_app = len(hdr_app)
            width_list = len(hdr_list)

            for app, patch_list in data.items():
                width_app = max(width_app, len(app))
                width_list = max(width_list, len(', '.join(patch_list)))

            print("{0:<{width_app}}  {1:<{width_list}}".format(
                hdr_app, hdr_list,
                width_app=width_app, width_list=width_list))

            print("{0}  {1}".format(
                '=' * width_app, '=' * width_list))

            for app, patch_list in sorted(data.items()):
                print("{0:<{width_app}}  {1:<{width_list}}".format(
                    app, ', '.join(patch_list),
                    width_app=width_app, width_list=width_list))

        return 0
    else:
        print("An internal error has occurred. "
              "Please check /var/log/software.log for details.")
        return 1


def get_auth_token_and_endpoint(region_name, interface):
    from keystoneauth1 import exceptions
    from keystoneauth1 import identity
    from keystoneauth1 import session

    if not region_name:
        return None, None

    user_env_map = {'OS_USERNAME': 'username',
                    'OS_PASSWORD': 'password',
                    'OS_PROJECT_NAME': 'project_name',
                    'OS_AUTH_URL': 'auth_url',
                    'OS_USER_DOMAIN_NAME': 'user_domain_name',
                    'OS_PROJECT_DOMAIN_NAME': 'project_domain_name'}

    for k, v in user_env_map.items():
        if k not in os.environ:
            return None, None

    user = dict()
    for k, v in user_env_map.items():
        user[v] = os.environ.get(k)

    auth = identity.V3Password(**user)
    sess = session.Session(auth=auth)
    try:
        token = auth.get_token(sess)
        endpoint = auth.get_endpoint(sess, service_type='usm',
                                     interface=interface,
                                     region_name=region_name)
    except (exceptions.http.Unauthorized, exceptions.EndpointNotFound) as e:
        print(str(e))
        return None, None

    return token, endpoint


def append_auth_token_if_required(headers):
    global auth_token
    if auth_token is not None:
        headers['X-Auth-Token'] = auth_token


def format_url_address(address):
    import netaddr
    try:
        ip_addr = netaddr.IPAddress(address)
        if ip_addr.version == IPV6_FAMILY:
            return "[%s]" % address
        else:
            return address
    except netaddr.AddrFormatError:
        return address


def check_for_os_region_name(args):
    # argparse converts os-region-name to os_region_name
    region = args.os_region_name
    if region is None:
        return False

    global VIRTUAL_REGION
    if region != VIRTUAL_REGION:
        return False

    # check it is running on the active controller
    # not able to use sm-query due to it requires sudo
    try:
        subprocess.check_output("pgrep -f dcorch-api-proxy", shell=True)
    except subprocess.CalledProcessError:
        return False

    # get a token and fetch the internal endpoint in SystemController
    global auth_token
    auth_token, endpoint = get_auth_token_and_endpoint(region, 'internal')
    if endpoint is not None:
        global api_addr
        url = urlparse(endpoint)
        address = format_url_address(url.hostname)
        api_addr = '{}:{}'.format(address, url.port)

    return True


def register_deploy_commands(commands):
    """deploy commands
      - precheck
      - start
      - host
      - activate
      - complete
    non root/sudo users can run:
       - host-list
       - show
    Deploy commands are region_restricted, which means
    that they are not permitted to be run in DC
    """

    cmd_area = 'deploy'
    cmd_parser = commands.add_parser(
        cmd_area,
        help='Software Deploy',
        epilog="StarlingX Unified Software Deployment"
    )
    cmd_parser.set_defaults(cmd_area=cmd_area)

    # Deploy commands are region_restricted, which means
    # that they are not permitted to be run in DC
    cmd_parser.set_defaults(region_restricted=True)

    sub_cmds = cmd_parser.add_subparsers(
        title='Software Deploy Commands',
        metavar=''
    )
    sub_cmds.required = True

    # --- software deploy precheck -----------------------
    cmd = sub_cmds.add_parser(
        'precheck',
        help='Verify whether prerequisites for installing the software deployment are satisfied'
    )
    cmd.set_defaults(cmd='precheck')
    cmd.set_defaults(func=deploy_precheck_req)
    cmd.add_argument('deployment',
                     help='Verify if prerequisites are met for this Deployment ID')
    cmd.add_argument('-f',
                     '--force',
                     action='store_true',
                     required=False,
                     help='Allow bypassing non-critical checks')
    cmd.add_argument('--region_name',
                     default='RegionOne',
                     required=False,
                     help='Run precheck against a subcloud')

    # --- software deploy start --------------------------
    cmd = sub_cmds.add_parser(
        'start',
        help='Start the software deployment'
    )
    cmd.set_defaults(cmd='start')
    cmd.set_defaults(func=deploy_start_req)
    cmd.add_argument('deployment',
                     help='Deployment ID to start')
    cmd.add_argument('-f',
                     '--force',
                     action='store_true',
                     required=False,
                     help='Allow bypassing non-critical checks')

    # --- software deploy host ---------------------------
    cmd = sub_cmds.add_parser(
        'host',
        help='Deploy prestaged software deployment to the host'
    )
    cmd.set_defaults(cmd='host')
    cmd.set_defaults(func=deploy_host_req)
    cmd.add_argument('agent',
                     help="Agent on which host deploy is triggered")
    cmd.add_argument('-f',
                     '--force',
                     action='store_true',
                     required=False,
                     help="Force deploy host")

    # --- software deploy activate -----------------------
    cmd = sub_cmds.add_parser(
        'activate',
        help='Activate the software deployment'
    )
    cmd.set_defaults(cmd='activate')
    cmd.set_defaults(func=deploy_activate_req)
    cmd.add_argument('deployment',
                     help='Deployment ID to activate')

    # --- software deploy complete -----------------------
    cmd = sub_cmds.add_parser(
        'complete',
        help='Complete the software deployment'
    )
    cmd.set_defaults(cmd='complete')
    cmd.set_defaults(func=deploy_complete_req)
    cmd.add_argument('deployment',
                     help='Deployment ID to complete')

    # --- software deploy show ---------------------------
    cmd = sub_cmds.add_parser(
        'show',
        help='Show the software deployments states'
    )
    cmd.set_defaults(cmd='show')
    cmd.set_defaults(func=deploy_show_req)
    cmd.set_defaults(restricted=False)  # can run non root
    # --deployment is an optional argument
    cmd.add_argument('--deployment',
                     required=False,
                     help='List the deployment specified')
    # --state is an optional argument.
    # default: "all"
    # acceptable values: inactive, active, prestaging, prestaged, all
    cmd.add_argument('--state',
                     default="all",
                     required=False,
                     help='List all deployments that have this state')

    # --- software deploy host-list -------------
    cmd = sub_cmds.add_parser(
        'host-list',
        help='List of hosts for software deployment'
    )
    cmd.set_defaults(cmd='host-list')
    cmd.set_defaults(func=deploy_host_list_req)
    cmd.set_defaults(restricted=False)  # can run non root


def setup_argparse():
    parser = argparse.ArgumentParser(prog="software",
                                     description="Unified Software Management",
                                     epilog="Used for patching and upgrading")
    parser.add_argument('--debug', action='store_true', help="Enable debug output")
    # parser.add_argument('--os-auth-url', default=None)
    # parser.add_argument('--os-project-name', default=None)
    # parser.add_argument('--os-project-domain-name', default=None)
    # parser.add_argument('--os-username', default=None)
    # parser.add_argument('--os-password', default=None)
    # parser.add_argument('--os-user-domain-name', default=None)
    parser.add_argument('--os-region-name', default=None)
    # parser.add_argument('--os-interface', default=None)

    # All commands are considered restricted, unless explicitly set to False
    parser.set_defaults(restricted=True)
    # All functions are initially defined as 'not implemented yet'
    # The func will be overridden by the command definition as they are completed
    parser.set_defaults(func=software_command_not_implemented_yet)

    # No commands are region restricted, unless explicitly set to True
    parser.set_defaults(region_restricted=False)

    commands = parser.add_subparsers(title='Commands', metavar='')
    commands.required = True

    # -- software commit-patch <release> ---------------
    cmd = commands.add_parser(
        'commit-patch',
        help='Commit patches to free disk space. WARNING: This action is irreversible!'
    )
    cmd.set_defaults(cmd='commit-patch')
    cmd.set_defaults(func=commit_patch_req)
    cmd.add_argument('patch',
                     nargs="+",  # accepts a list
                     help='Patch ID/s to commit')
    # --dry-run is an optional argument
    cmd.add_argument('--dry-run',
                     action='store_true',
                     required=False,
                     help='Check the space savings without committing the patch')
    # --all is an optional argument
    cmd.add_argument('--all',
                     action='store_true',
                     required=False,
                     help='Commit all the applied patches')
    # --sw-version is an optional argument
    cmd.add_argument('--sw-version',
                     required=False,
                     help='Software release version')

    # -- software delete <release> ---------------
    cmd = commands.add_parser(
        'delete',
        help='Delete the software release'
    )
    cmd.set_defaults(cmd='delete')
    cmd.set_defaults(func=release_delete_req)
    cmd.add_argument('release',
                     nargs="+",  # accepts a list
                     help='Release ID to delete')

    # -- software install-local ---------------
    cmd = commands.add_parser(
        'install-local',
        help='Trigger patch install/remove on the local host. ' +
             'This command can only be used for patch installation ' +
             'prior to initial configuration.'
    )
    cmd.set_defaults(cmd='install-local')
    cmd.set_defaults(func=install_local)

    # --- software is-available <release> ------
    cmd = commands.add_parser(
        'is-available',
        help='Query Available state for list of releases. Returns True if all are Available, False otherwise.'
    )
    cmd.set_defaults(cmd='is-available')
    cmd.set_defaults(func=release_is_available_req)
    cmd.add_argument('release',
                     nargs="+",  # accepts a list
                     help='List of releases')

    # --- software is-committed <release> ------
    cmd = commands.add_parser(
        'is-committed',
        help='Query Committed state for list of releases. Returns True if all are Committed, False otherwise.'
    )
    cmd.set_defaults(cmd='is-committed')
    cmd.set_defaults(func=release_is_committed_req)
    cmd.add_argument('release',
                     nargs="+",  # accepts a list
                     help='List of releases')

    # --- software is-deployed  <release> ------
    cmd = commands.add_parser(
        'is-deployed',
        help='Query Deployed state for list of releases. Returns True if all are Deployed, False otherwise.'
    )
    cmd.set_defaults(cmd='is-deployed')
    cmd.set_defaults(func=release_is_deployed_req)
    cmd.add_argument('release',
                     nargs="+",  # accepts a list
                     help='List of releases')

    # --- software list ---------------------------
    cmd = commands.add_parser(
        'list',
        help='List the software releases'
    )
    cmd.set_defaults(cmd='list')
    cmd.set_defaults(func=release_list_req)
    cmd.set_defaults(restricted=False)  # can run non root
    # --release is an optional argument
    cmd.add_argument('--release',
                     required=False,
                     help='filter against a release ID')
    # --state is an optional argument. default: "all"
    cmd.add_argument('--state',
                     default="all",
                     required=False,
                     help='filter against a release state')

    # --- software show <release> -----------------
    cmd = commands.add_parser(
        'show',
        help='Show the software release'
    )
    cmd.set_defaults(cmd='show')
    cmd.set_defaults(func=release_show_req)
    cmd.set_defaults(restricted=False)  # can run non root
    cmd.add_argument('release',
                     nargs="+",  # accepts a list
                     help='release ID to print detailed information')
    cmd.add_argument('--packages',
                     required=False,
                     default=False,
                     action='store_true',
                     help='list packages contained in the release')

    # --- software upload <release> ---------------
    cmd = commands.add_parser(
        'upload',
        help='Upload software major or patch releases'
    )
    cmd.set_defaults(cmd='upload')
    cmd.set_defaults(func=release_upload_req)
    cmd.add_argument('release',
                     metavar='(iso + sig) | patch',
                     nargs="+",  # accepts a list
                     help=('pair of install iso and sig files for major release '
                           '(GA or patched) and/or one or more files containing a '
                           'patch release. NOTE: specify at most ONE pair of (iso + sig)'))
    cmd.add_argument('--local',
                     required=False,
                     default=False,
                     action='store_true',
                     help='Upload files from active controller')

    # --- software upload-dir <release dir> ------
    cmd = commands.add_parser(
        'upload-dir',
        help='Upload a software release dir'
    )
    cmd.set_defaults(cmd='upload-dir')
    cmd.set_defaults(func=release_upload_dir_req)
    cmd.add_argument('release',
                     nargs="+",  # accepts a list
                     help='directory containing software releases to upload')

    register_deploy_commands(commands)
    return parser


def main():
    set_term_width()

    rc = 0
    parser = setup_argparse()
    argcomplete.autocomplete(parser)
    args = parser.parse_args()
    dc_request = check_for_os_region_name(args)

    # Reject the commands that are not supported in the virtual region
    if dc_request and args.region_restricted:
        global VIRTUAL_REGION
        print("\n%s %s command is not allowed in %s region" % (args.cmd_area,
                                                               args.cmd,
                                                               VIRTUAL_REGION))
        rc = 1
        exit(rc)

    global auth_token
    if not auth_token:
        region = os.environ.get("OS_REGION_NAME", None)
        auth_token, endpoint = get_auth_token_and_endpoint(region, 'public')
        if endpoint is not None:
            global api_addr
            url = urlparse(endpoint)
            address = format_url_address(url.hostname)
            api_addr = '{}:{}'.format(address, url.port)

    if auth_token is None and os.geteuid() != 0:
        if args.restricted:
            print("Error: Command must be run as sudo or root", file=sys.stderr)
            rc = 1
            exit(rc)

    # Call the function registered with argparse, and pass the 'args' to it
    rc = args.func(args)
    exit(rc)