#!/usr/bin/env python
################################################################################
# Copyright (c) 2015-17 by Cisco Systems, Inc.
# All rights reserved.
#
# Author: Shaurin Desai <shaurdes@cisco.com>
################################################################################
"""Parses CLI and Launches Agent process

Todo:
    * replace subprocess.call(nohup) call with subprocess.Popen without nohup
    * add API to retrieve <PNP Agent> process id
    * add start_pnp() API (called by "pnp start" implementation)
    * <POSIX PNP> First call to pnp CLI "start" -> DEBUG(PNP CLI issued) ->
        check if existing <PNP Agent> proc -> proc==NULL -> INFO("Starting PnP
        Agent") to screen and start_pnp() -> Popen <PNP Agent> Singleton proc
    * <POSIX PNP> launches <PNP Agent> Singleton Process (monitors pnp_config
        and handles CLI API calls -> logs to separate pnp_cli.log)
    * <PNP Agent> inits pnp logging and makes infra checks for PnP. Checks for
        crash dump and provides feedback -> INIT Profile Manager <PM> Singleton
    * <PM> gets pnp_config -> Check if [pnp_discovery] enabled=True (ensure CLI
        config for this will stop any default-running Discovery process)
    * <PM> Launches <Profile Discovery> process (special subclass of Profile) if
        no discovery is enabled and no profile is configured
    * <Profile Discovery> runs through PnP Discovery ->
        set_pnp_config[profile_zero_touch] -> <Profile Discovery> exits
    * <PNP AGENT> detects newly configured profile -> adds <profile_[...]> to
        <PM> -> <PNP AGENT> iterates through <profiles> in <PM> to run PNP Proto
    * <Profile> exits upon receiving PnPService.Backup(type=terminate)

"""
import argparse
import errno
import json
import os
import subprocess
import sys
from logging import getLogger
from string import Template
from time import gmtime, sleep, strftime, time

import pnp.infra.utils.pnp_constants as consts
import pnp.infra.utils.pnp_utils as pnp_utils
from pnp.infra.utils.pnp_file_paths import filepaths
from pnp.infra.utils.pnp_logging import initiate_logging

logger = getLogger(__name__)


def validate_ip(var):
    """validate if an input is a valid IPv4, IPv6 address
    Input: a string input
    Return: True if the input string contains a valid ip address
            False, otherwise
    """
    valid_ip = pnp_utils.is_valid_ip_address(var)

    return valid_ip


def validate_port(var):
    """validate if an input is a valid port #
    Input: a string input
    Return: True if the input string contains a valid port #
            False otherwise
    """
    try:
        int(var)
    except ValueError:
        print "Port # input not an integer"
        return False
    return True


def validate_transport(var):
    """validate if an input is a valid transport type input
    Input: a string input
    Return: True if the input string contains a support transport type
            False otherwise
    """
    # the following are the currently supported transport types
    if var == 'http':
        return True
    elif var == 'https':
        return True
    else:
        return False


def validate_cafile(var):
    """validate if an input is a valid cafile type input
    Input: a string input
    Return: True if the input string is the name of a file that exists
            False otherwise
    """
    # check if cafile exists
    return os.path.isfile(var)


def get_discovery_method(status):
    """convert the discovery method in the input string into
    Input: a string input carrying the discovery method type in text
    Return: the enum value of the discovery method corresponding to the
            given method type
    """
    if status == "dns_discovery":
        return consts.CBTYPE_DNS
    elif status == "dhcp_discovery":
        return consts.CBTYPE_DHCP
    elif status == "cco_discovery":
        return consts.CBTYPE_CCO
    else:
        return consts.CBTYPE_USER


def get_process_name_from_pid(pid):
    """return process name from process pid"""
    try:
        with open(os.path.join('/proc/', str(pid), 'cmdline'), 'r') as pid_file:
            return pid_file.readline()
    except IOError as err:
        logger.warning('Exception when opening /proc/<pid> - ' + repr(err))
        return ""


def print_pnp_summary_general():
    """print out pnp general summary such as device udi"""
    platform_dict = pnp_utils.load_platform_info()
    udi = ''
    if platform_dict:
        if 'pid' not in platform_dict:
            logger.error("No pid entry found in platform info file. Set to "
                         "default value(0)")
        if 'sn' not in platform_dict:
            logger.error("No sn entry found in platform info file. Set to "
                         "default value(0)")
        udi = 'PID:%s,VID:V01,SN:%s' % (platform_dict.get('pid', '0'),
                                        platform_dict.get('sn', '0'))
    print 'PnP Schema Version: 1.0, Baseline Tracking: N/A'
    print 'Device UDI: ' + udi
    print 'UDI Checking: N/A'
    print "Security Enforced: N/A, PostReloadPriv'd Profile: N/A"
    print 'SUDI Certificate: N/A'
    print 'Device SUDI: N/A'


def print_pnp_summary_config():
    """print out pnp config summary"""
    print ''
    print 'Startup Config: N/A'
    print ('Running Config: N/A, Safe Now: N/A, CLI Changed: N/A, Bulk Count: '
           'N/A, Last Delta: N/A')
    print 'Config Ext Service: Data: N/A, File: N/A'
    print 'HA Present: N/A'


def print_pnp_summary_discovery():
    """print out pnp discovery summary"""
    print ''
    print 'PnP Discovery: N/A, Run Count: N/A, Status: N/A, Last Run: N/A'
    print 'PnP DHCP Discovery: Idn:N/A Op43-Text:N/A, Last-Op43-Text:N/A'
    print ''
    print ('PnP DHCP Snooping: Supported: N/A, Validation: N/A, Registry: '
           'DHCP: N/A, Vlan: N/A, Trust Interfaces: N/A')
    print 'PnP DHCP-Op43: N/A, Ready: N/A, DHCP-Op60: N/A, Ready: N/A'


def print_pnp_summary_proxy():
    """print out pnp proxy summary"""
    print ''
    print 'PnP Proxy Name: N/A, Associated Profile: N/A'
    print ('PnP Proxy Service Count: Change=0 Up=0 Down=0 NoOP=0 Fail=0 UDI=0 '
           'RSP=0')


def print_summary():
    """print out pnp summary mirroring information from IOS "show pnp summary"
    """
    print_pnp_summary_general()
    print_pnp_summary_config()
    print_pnp_summary_discovery()
    print_pnp_summary_proxy()


def print_version():
    """Prints PnP Agent version summary"""
    template_string = ('PnP Agent Version Summary\n\n'
                       'PnP Agent: %s\n'
                       'Platform Name: %s\n'
                       'PnP Platform: %s\n')
    output_string = template_string % (consts.PNP_VERSION,
                                       consts.PNP_PLATFORM,
                                       consts.PNP_PLATFORM_VERSION)
    print output_string


class ExecuteAgent(object): # pylint: disable=too-many-instance-attributes
                            # pylint: disable=too-many-return-statements
    """Execute CLI commands to perform Agent actions"""
    def __init__(self):
        self.pid_file_path = filepaths['log']['pid']
        self.job_status_path = filepaths['data']['job']
        self.profile_status_path = filepaths['data']['profile']
        self.pnp_config_path = filepaths['config']['pnp']
        self.pnp_log_path = filepaths['log']['pnp']
        self.pnp_status_path = filepaths['data']['status']
        self.profile_cfg_address = ''
        self.profile_cfg_port = ''
        self.profile_cfg_transport = ''
        self.profile_cfg_ca_file = ''
        self.profile_cfg_created_by = 'user'
        self.profile_cfg_pref_proto = 'ipv4'
        self.profile_cfg_modified = 'false'
        self.is_clear = False
        # check if files exist and create if it doesn't
        if not os.path.isfile(self.pnp_status_path):
            with open(self.pnp_status_path, 'w+') as pnp_status_file:
                pnp_status = {
                    'discovery': {
                        'last_start_time': None,
                        'last_stop_time': None,
                        'last_success_time': None,
                        'discovery_method': None,
                        'discovery_opt43': None
                    },
                    'agent': {
                        'last_start_time': None,
                        'last_stop_time': None
                    }
                }
                pnp_status_file.write(json.dumps(pnp_status))

    def start(self):
        """start pnp, if it is already running, don't start it"""
        if self.status():
            logger.info('PnP Agent is already running')
            return
        else:
            pnp_status = None
            with open(self.job_status_path, 'w') as job_status_file:
                all_jobs = {}
                job_status_file.write(json.dumps(all_jobs))
            with open(self.pnp_status_path, 'r') as pnp_status_file:
                pnp_status = json.loads(pnp_status_file.read())
            template = ('nohup %s %s/agent.py > '
                        '%s 2>&1 & echo $! > %s')
            args = template % (sys.executable, consts.PNP_BASE_DIR,
                               filepaths['log']['nohup'],
                               self.pid_file_path)
            logger.info('Starting PnP Agent')
            # write start time to pnp status file
            with open(self.pnp_status_path, 'w') as pnp_status_file:
                last_start_time = strftime("%H:%M:%S %b %d", gmtime(time()))
                pnp_status['agent']['last_start_time'] = last_start_time
                pnp_status_file.write(json.dumps(pnp_status))
            subprocess.call(args, shell=True)

    def stop(self):
        """stop pnp, if it is not running, don't do anything"""
        if self.status():
            pnp_utils.terminate_agent()
        else:
            logger.info('PnP Agent is not running')
            return

    def restart(self):
        """restart pnp, check if it is running, if so, stop then start pnp, else
         start pnp"""
        logger.info('Restarting PnP Agent')
        if self.status():
            pnp_utils.terminate_agent()
            sleep(2)
            self.start()
        else:
            self.start()

    def status(self):
        """status of pnp, will check if the pid file exists and read the pid to
        try to kill it to verify if its valid"""
        if os.path.isfile(self.pid_file_path):
            with open(self.pid_file_path) as f:
                pid = f.read().strip()
            process_name = get_process_name_from_pid(pid)
            if "/pnp/agent.py" not in str(process_name):
                # somehow pid file contains pid of other program. this can be a
                # result of a box reload without properly cleaning up pnp_agent.
                # just remove pid file and return
                logger.debug("%s (%s) does not match PnP Agent process ID.",
                             self.pid_file_path, pid)
                os.remove(self.pid_file_path)
                return False
            try:
                os.kill(int(pid), 0)
                return True
            except OSError as err:
                if err.errno == errno.EPERM:
                    proc_warning = "Process started by another user."
                    print proc_warning
                    logger.debug(proc_warning)
                    os._exit(0)  # pylint: disable=protected-access
                else:
                    logger.warning(repr(err))
                os.remove(self.pid_file_path)
                return False
        else:
            return False

    def profiles(self):
        """print out pnp profiles mirroring information from IOS "show pnp
         profiles"
        """
        return bool(self.print_pnp_profiles())

    def tasks(self):
        """print out pnp tasks mirroring information from IOS "show pnp
         profiles"
        """
        self.print_pnp_tasks()

    def services(self):
        """print out pnp service mirroring information from IOS "show pnp
         service"
        """
        self.print_pnp_services()

    def history(self):
        """print out pnp history mirroring information from IOS "show pnp
        history"
        """
        self.print_pnp_history()

    def read_profile_status(self):
        """retrieve the content of the PnP profile status file"""
        try:
            with open(self.profile_status_path, 'r') as f:
                try:
                    profile_status = json.load(f)
                    return profile_status
                except (ValueError, IOError):
                    print "Failed to load PnP profile config file"
                    logger.error("Failed to load PnP profile config file")
        except IOError:
            print "PnP profile status file unavailable"
            logger.error("PnP profile status file unavailable")
        return None

    def print_pnp_profiles(self):
        """print out pnp profiles info"""
        cfg_info = pnp_utils.read_pnp_config()
        if cfg_info is None:
            print "Failed to read PnP profile config file"
            logger.error("Failed to read PnP profile config file")
            return False

        profile_status = self.read_profile_status()
        if profile_status is None:
            print "Failed to read PnP profile status file"
            logger.error("Failed to read PnP profile status file")
            return False

        discovery_mthd = get_discovery_method(cfg_info['created_by'])
        cbtype_string = consts.cbtype_to_string(discovery_mthd)

        # compose a string template for printing out the show output
        tstring = ('Created by        \tUDI \n'
                   '$t_cbtype \t$t_status \n\n'
                   '     Primary transport: $t_transport \n'
                   '     Address: $t_ip \n'
                   '     Port: $t_port \n'
                   '     CA file: $t_cafile \n\n'
                   '     Work-Request Tracking: \n'
                   '         Pending-WR: Correlator= $t_curr_corr \n'
                   '         Last-WR:    Correlator= $t_last_corr \n'
                   '     PnP Response Tracking: \n'
                   '         Last-PR:    Correlator= $t_last_corr \n\n')
        otemplate = Template(tstring)
        try:
            # filling in the real show output contents into the template & print
            curr_corr = profile_status['WR-Tracking']['Current-Correlator']
            last_corr = profile_status['WR-Tracking']['Last-Correlator']
            print otemplate.substitute(t_cbtype=cbtype_string,
                                       t_status=profile_status['info']['udi'],
                                       t_transport=cfg_info['transport'],
                                       t_ip=cfg_info['address'],
                                       t_port=cfg_info['port'],
                                       t_cafile=cfg_info['cafile'],
                                       t_curr_corr=curr_corr,
                                       t_last_corr=last_corr)
        except (KeyError, IOError, ValueError):
            print "PnP agent not running/ready"
            logger.info("PnP agent not running/ready")
            return False
        return True

    def print_pnp_tasks(self):
        """print out tasks info"""
        line_list = self.read_last_run_from_log()
        task_list = {'Certificate-Install': 0, 'Device-Info': 0}
        for line in line_list:
            if "*** Receive Server Request ***: type certificate-install"\
                   in line:
                task_list["Certificate-Install"] += 1
            if "*** Receive Server Request ***: type device-info" in line:
                task_list["Device-Info"] += 1
        for task, count in task_list.iteritems():
            if count <= 0:
                print str(task) + " - Never Run"
            else:
                print str(task) + " - Run Count: " + str(count)

    def print_pnp_services(self):
        """print out pnp service info"""
        line_list = self.read_last_run_from_log()
        work_req_service_count = 0
        work_backoff_service_count = 0
        for line in line_list:
            if "XML Work-Req Service" in line:
                work_req_service_count += 1
            if "backoff Service" in line:
                work_backoff_service_count += 1
        print ("Service name: XML Work-Req Service, Run Count:",
               str(work_req_service_count))
        print ("Service name: XML Work-Backoff Service, Run Count:",
               str(work_backoff_service_count))

    def print_pnp_history(self):
        """print out pnp history info"""
        line_list = self.read_last_run_from_log()
        for line in line_list:
            if "Job" in line:
                print line.rstrip()

    def job_status(self):
        """reads the job status file and prints out the status of all jobs in
        the file"""
        if self.status():
            print 'PnP Agent is running'
        else:
            print 'PnP Agent is not running'
        with open(self.job_status_path) as f:
            all_jobs = json.load(f)
        for key, value in all_jobs.iteritems():
            print key
            for i, j in value.iteritems():
                if i == 'status':
                    print ("    " + i + ": " +
                           consts.service_status_to_string(j))
                else:
                    print "    " + i + ": " + str(j)

    def read_last_run_from_log(self):
        """read last pnp run from log and return list of lines of last run"""
        line_list = []
        try:
            for line in reversed(open(self.pnp_log_path).readlines()):
                if "Starting PnP Agent" in line:
                    break
                else:
                    line_list.insert(0, line)
        except IOError:
            print "Log files for last PnP run unavailable"
        return line_list

    def profile_validate(self, var):
        """validate profile config given via CLI
        Input: the argv input from the CLI input
        Return: True if the config validation is successful
                False otherwise
        """
        # validate each profile cfg input parameter before installing
        num_inputs = len(var) - 1
        if num_inputs == 0:
            print "Invalid profile command; need more input"
            logger.error("Invalid profile command; need more input")
            return False

        # look for missing input behind keywords
        if (num_inputs % 2) != 0:
            print "Missing input arguments behind keywords"
            logger.error("Missing input arguments behind keywords")
            return False

        for i in range(1, num_inputs, 2):
            if var[i] == 'ip':
                if not validate_ip(var[i+1]):
                    error_str = "Invalid IP addr input: " + var[i+1] + \
                                "; no cfg change"
                    print error_str
                    logger.error(error_str)
                    return False
                self.profile_cfg_address = var[i+1]

            elif var[i] == 'port':
                if not validate_port(var[i+1]):
                    error_str = "Invalid port input: " + var[i+1] + \
                                "; no cfg change"
                    print error_str
                    logger.error(error_str)
                    return False
                self.profile_cfg_port = var[i+1]

            elif var[i] == 'transport':
                if not validate_transport(var[i+1]):
                    error_str = "Invalid transport input: " + var[i+1] + \
                                "; no cfg change"
                    print error_str
                    logger.error(error_str)
                    return False
                self.profile_cfg_transport = var[i+1]

            elif var[i] == 'cafile':
                if not validate_cafile(var[i+1]):
                    error_str = "Invalid cafile input: " + var[i+1] + \
                                "; no cfg change"
                    print error_str
                    logger.error(error_str)
                    return False
                self.profile_cfg_ca_file = var[i+1]

            else:
                error_str = "Invalid input: \'" + var[i] + "\'; no cfg change"
                print error_str
                logger.error(error_str)
                return False
        return True

    def profile_set(self, var):
        """set profile config given via CLI into the pnp_config_path file
        Input: the argv input from the CLI input
        Return: True if the config installation is successful
                False otherwise
        """
        # if discovery process is running and pnp_config_path file is empty,
        # stop discovery
        if self.status() and not pnp_utils.profile_exists():
            pnp_utils.terminate_agent()
        # validate profile and then set it
        if not self.is_clear and not self.profile_validate(var):
            return False
        new_profile = {
            'transport': self.profile_cfg_transport,
            'address': self.profile_cfg_address,
            'port': self.profile_cfg_port,
            'cafile': self.profile_cfg_ca_file,
            'created_by': self.profile_cfg_created_by,
            'preferred_protocol': self.profile_cfg_pref_proto,
            'modified': self.profile_cfg_modified
        }
        if self.profile_cfg_ca_file:
            new_profile.update({'cafile': self.profile_cfg_ca_file})
        if not pnp_utils.update_pnp_config(new_profile):
            return False
        logger.info('pnp profile set completed successfully')
        self.print_profile_cfg()
        return True

    def profile_clear(self, var):
        """clear the profile config as requested via CLI
        Input: the argv input from the CLI input
        Return: True if the config clearing has succeeded
                False otherwise
        """
        # Clear cfg from the pnp_config_path file
        # when successful, print below
        self.is_clear = True
        try:
            self.profile_set(var)
        except (IOError, EOFError, ValueError):
            logger.info("pnp profile has not been cleared")
            return False
        print "pnp profile has been cleared successfully"
        logger.info('pnp profile cleared successfully')
        # stop agent
        self.stop()
        self.is_clear = False
        return True

    def print_profile_cfg(self):
        """Prints the contents of pnp_config"""
        template = ("PnP profile cfg set:\n  Address: %s\n  Port: %s\n  "
                    "Transport: %s\n  CA file: %s\n")
        print template % (self.profile_cfg_address, str(self.profile_cfg_port),
                          self.profile_cfg_transport, self.profile_cfg_ca_file)


def choices_descriptions():
    """Generates the description messages to populate the CLI help message.
    """
    commands = [('start', 'start PnP agent'),
                ('stop', 'stop PnP agent'),
                ('status', 'check PnP agent status'),
                ('restart', 'restart PnP agent')]
    output = "\nPnP supports the following commands:\n"
    for k, v in commands:
        output = output + '    ' + k + '\t\t' + v + '\n'
    # display in the help menu the sub command options
    output += "\nPnP supports the following show sub commands:\n"
    for k, v in show_commands:
        output = output + '    ' + k + '\t\t' + v + '\n'

    output += "\nPnP supports the following profile sub commands:\n"
    for k, v in profile_commands:
        output = output + '    ' + k + '\t\t' + v + '\n'

    output += ("\nPnP supports the following profile set options:\n    ip "
               "<ipaddr> port <port#> transport <http|https> cafile <file>\n\n")

    return output

show_commands = [
    ('show summary', 'PnP summary'),
    ('show profiles', 'PnP profiles summary'),
    ('show tasks', 'PnP tasks run status'),
    ('show service', 'List of PnP service'),
    ('show history', 'PnP run history'),
    ('show version', 'PnP Version Summary')]

profile_commands = [('profile set  ', 'set PnP profile'),
                    ('profile clear', 'clear PnP profile')]


def cli_parser():
    """cli parser takes in arguments with argparse"""
    cli_parser.exe = ExecuteAgent()
    commands = ['start', 'stop', 'status', 'restart', 'show', 'profile']
    show_cmds = [
        ('summary', print_summary),
        ('profiles', cli_parser.exe.profiles),
        ('tasks', cli_parser.exe.tasks),
        ('service', cli_parser.exe.services),
        ('history', cli_parser.exe.history),
        ('version', print_version)]

    profile_cmds = [
        ('set', cli_parser.exe.profile_set),
        ('clear', cli_parser.exe.profile_clear)
    ]
    argparser_kwargs = {
        'formatter_class': argparse.RawTextHelpFormatter,
        'epilog': choices_descriptions(),
        'prog': 'pnp',
        'usage': '%(prog)s [command]',
        'description': 'PnP Agent'
    }
    parser = argparse.ArgumentParser(**argparser_kwargs)
    parser.add_argument('execute', choices=commands, action='store',
                        help='commands for pnp to execute')

    if len(sys.argv) < 3:
        args = parser.parse_args()
        if args.execute == 'start':
            cli_parser.exe.start()
        elif args.execute == 'stop':
            cli_parser.exe.stop()
        elif args.execute == 'status':
            cli_parser.exe.job_status()
        elif args.execute == 'restart':
            cli_parser.exe.restart()
    else:
        if sys.argv[1] == 'show':
            # sub-level commands for 'show'
            help_msg = 'additional commands under show'
            subparsers = parser.add_subparsers(help=help_msg, dest='show')
            for command_name, command_func in show_cmds:
                subparsers.add_parser(command_name)

            args = parser.parse_args(sys.argv[1:])
            for command_name, command_func in show_cmds:
                if args.show == command_name:
                    command_func()

        elif sys.argv[1] == 'profile':
            # sub-level commands for 'profile'
            help_msg = 'additional commands under profile'
            subparsers = parser.add_subparsers(help=help_msg, dest='profile')
            for command_name, command_func in profile_cmds:
                subparsers.add_parser(command_name)

            if sys.argv[-1] == '--h' or sys.argv[-1] == '--help':
                # if --h or --help is given, include it in the parse_args() call
                # to facilitate help msg printing; otherwise, don't
                args = parser.parse_args(sys.argv[1:])
                argsv_func = []
            else:
                # pass only the CLI keywords "profile <option>" to parse_args
                # but not the rest
                args = parser.parse_args(sys.argv[1:3])
                argsv_func = sys.argv[2:]

            for command_name, command_func in profile_cmds:
                if args.profile == command_name:
                    # pass the CLI cfg parameters into command_func() but not
                    # the "profile <option>" keywords
                    if not command_func(argsv_func):
                        print "Config failed"
        else:
            print "Invalid command"


if __name__ == '__main__':
    initiate_logging()
    logger = getLogger('pnp.posix_pnp_nohup')
    cli_parser()
