#!/usr/bin/python3

"""
@file
@ingroup trs_common_ptpmond
@copyright ESO - European Southern Observatory
@author C. Soenke

@brief PTP monitoring daemon

To run as root for testing:
- export PYTHONPATH=<whatever_is_missing>:$PYTHONPATH
- /introot/eltdev/bin/ptpmond --loglevel INFO --conslog --period 10 --dsthost localhost

For monitoring ptp4l, do NOT use the read-only socket of ptp4l. Since we do not only poll
status, but also use the subscription mechanism, we must use the 'normal' socket which also
accepts commands.
"""


import argparse
#import json
import logging
import logging.handlers
import signal
import socket
import sys
import textwrap
import threading
from threading import Event
import time
import traceback
import yaml

from ptpmon import linuxptp
from ptpmon import local_interface as local_if
from ptpmon import payload as pmp
from trslib.protocols import mudpi
from trslib.ptp import mgmt
from trslib.ptp import ptp_stats
from trslib.tools import err_condition


class PtpmonMudpiSender(object):
    """Sends PTP monitoring data over MUDPI."""

    def __init__(self, dst_host, dst_port, sender_name, logger=None, compute_checksum=False):

        self.dst_host = dst_host
        self.dst_port = dst_port

        # We lookup the dstination IP address here to avoid DNS lookup delays when sending.
        # This is to reduce jitter/delay for the MUDPI timestamp difference measurement between
        # sender (this program) and receiver.
        addr_info = socket.getaddrinfo(self.dst_host, self.dst_port, type=socket.SOCK_DGRAM)
        self.dst_addr = addr_info[0][4]

        if sender_name is None:
            #self.sender_name = socket.gethostname()
            self.sender_name = socket.getaddrinfo(socket.gethostname(),
                                                  None,
                                                  flags=socket.AI_CANONNAME)[0][3]
        else:
            self.sender_name = sender_name

        # Logger
        if logger is not None:
            self.logger = logger
        else:
            self.logger = logging.getLogger('PtpmonMudpiSender')

        # Create a PTP statistics payload
        self.payload = pmp.PtpStatsPayload(version=2)
        self.payload.fields['deviceType'] = 1
        self.payload.fields['hostname'] = self.sender_name
        self.time_prop_flags = pmp.PtpTimePropFlags()

        # Create MUDPI packet
        self.compute_checksum = compute_checksum
        self.packet=mudpi.MudpiPacket()

        # Create socket
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)


    def send(self, data):
        """Send data."""

        self.payload.fields['errorStatus'] = 0

        if not data['daemon_error']:
            # Set the 'simple' fields
            self.payload.fields['stepsRemoved'] = \
                data['current_data_set'].steps_removed
            self.payload.fields['offsetFromMaster'] = \
                int(data['current_data_set'].offset_from_master)
            self.payload.fields['meanPathDelay'] = \
                int(data['current_data_set'].mean_path_delay)
            self.payload.fields['grandmasterIdentity'] = \
                data['parent_data_set'].grandmaster_identity
            self.payload.fields['parentPortIdentity'] = \
                data['parent_data_set'].parent_port_identity
            self.payload.fields['clockIdentity'] = \
                data['default_data_set'].clock_identity
            self.payload.fields['portState1'] = \
                data['port_data_set'].port_state
            self.payload.fields['currentUtcOffset'] = \
                data['time_properties_data_set'].current_utc_offset

            # Build and set the field containing the time properties flags
            self.time_prop_flags.currentUtcOffsetValid.set(
                data['time_properties_data_set'].current_utc_offset_valid)
            self.time_prop_flags.leap59.set(
                data['time_properties_data_set'].leap59)
            self.time_prop_flags.leap61.set(
                data['time_properties_data_set'].leap61)
            self.time_prop_flags.timeTraceable.set(
                data['time_properties_data_set'].time_traceable)
            self.time_prop_flags.frequencyTraceable.set(
                data['time_properties_data_set'].frequency_traceable)
            self.time_prop_flags.ptpTimescale.set(
                data['time_properties_data_set'].ptp_timescale)
            self.time_prop_flags.timeSource = \
                data['time_properties_data_set'].time_source
            #print(self.time_prop_flags)
            self.payload.fields['timePropFlags'] = self.time_prop_flags.get_int()

        else:
            self.logger.debug('ptp4l status record - dameon_error is set')
            self.payload.reset(preserve_static=True)
            self.time_prop_flags.reset()
            self.payload.fields['errorStatus'] = 1

        if data['csyncd_perf'] is not None:
            self.payload.fields['appSpecific1'] = int(data['csyncd_perf'].entry.offset)
            self.payload.fields['appSpecific2'] = int(data['csyncd_perf'].entry.delay)
            self.payload.fields['appSpecific3'] = int(data['csyncd_perf'].entry.clk_corr)
            self.payload.fields['appSpecific4'] = int(data['csyncd_perf'].entry.state)
            # print('csyncd: timestamp={}  ts_monotonic={}  ts_local={}'.format(
            #     data['csyncd_perf'].timestamp,
            #     data['csyncd_perf'].ts_monotonic,
            #     time.monotonic()
            # ))
        else:
            self.payload.fields['appSpecific1'] = 0
            self.payload.fields['appSpecific2'] = 0
            self.payload.fields['appSpecific3'] = 0
            self.payload.fields['appSpecific4'] = 0

        try:
            # Prepare the packet
            self.packet.set_payload(self.payload.pack())
            raw_packet = self.packet.pack(compute_checksum=self.compute_checksum)

            # Log data
            if self.packet.header['sampleId'] == 0:
                # pylint: disable-next=line-too-long
                self.logger.info('Sender         | sampleId   | MUDPI timestamp   | state        | offsetM  | pathDly  | clockIdentity    | gmIdentity       | parentPortIdentity   | lpS | sRm | er')
            # pylint: disable-next=line-too-long
            self.logger.info('{0:<14s} | {1:>10d} | {2:>15f} | {3:<12s} | {4:>8d} | {5:>8d} | {6:8s} | {7:8s} | {8:10s} | {9:>3d} | {10:>3d} | {11:>2d}'.format(self.sender_name,
                                          self.packet.header['sampleId'],
                                          self.packet.header['timestamp'],
                                          mgmt.PTP_STATES[int(self.payload.fields['portState1'])],
                                          self.payload.fields['offsetFromMaster'],
                                          self.payload.fields['meanPathDelay'],
                                          self.payload.fields['clockIdentity'].hex(),
                                          self.payload.fields['grandmasterIdentity'].hex(),
                                          self.payload.fields['parentPortIdentity'].hex(),
                                          self.payload.fields['currentUtcOffset'],
                                          self.payload.fields['stepsRemoved'],
                                          self.payload.fields['errorStatus']))

            # Send data
            if self.dst_host is not None:
                self.sock.sendto(raw_packet, self.dst_addr)

        except Exception as e:
            #print('MUDPI send error:', e)
            raise RuntimeError('MUDPI send error ({})'.format(e))

        self.packet.incr_sample_id()


class Ptp4lMon(object):
    """
    Ptp4l monitoring
    """

    def __init__(self, args):
        # Store command line arguments
        self.cla_config = args
        #print(self.cla_config)

        # Read the yaml config file
        with open(self.cla_config.cfgfile, 'r') as yamlcfg:
            try:
                print('Loading config file', self.cla_config.cfgfile)
                self.config = yaml.safe_load(yamlcfg)
                #print(json.dumps(self.config, indent=4))
            except yaml.YAMLError as e:
                print(e)
                raise RuntimeError('Cannot load YAML file {}: {}'.format(
                    e, self.cla_config.cfgfile))

        # Overwrite file-config from CLA-config
        #print('Overwriting config from CLAs')
        for arg, value in self.cla_config.__dict__.items():
            #print('Arg:', arg, value)
            if (arg in self.config) and value is not None:
                # print('Overwriting arg {}: {} --> {}'.format(
                #     arg, self.config[arg], value
                # ))
                self.config[arg] = value

        # Configure logging
        self.logger = logging.getLogger('Ptp4lMon')
        self.configure_logging()

        # Global synchronization status
        self.global_status = local_if.GlobalStatus.PTP_BAD
        self.last_global_status = local_if.GlobalStatus.PTP_BAD

        # Add error checks
        self.err_checks = []
        self.init_error_checks()

        # Clock sync. daemon status
        self.csyncd_err = err_condition.TimedErrorCondition(
                'clock synchronization daemon no data',
                check_type=err_condition.CheckType.OK_VALUE,
                check_cond='ok',
                grace_period=self.config['csyncd_grace_period']
            )
        self.csyncd_clock_jump = err_condition.TimedErrorCondition(
                'clock synchronization daemon clock jump',
                check_type=err_condition.CheckType.OK_VALUE,
                check_cond=False,
                grace_period=0
            )

        # Create a ptp4l status monitor
        self.ptp4l_mon = linuxptp.Ptp4lStatusMon(
            uds_adrs=self.config['udsadrs'],
            event_callback=self.event_callback,
            mon_period=self.config['period'],
            monitor_phc2sys=self.config['monitor_csyncd'],
            phc2sys_logfile=self.config['csyncd_logfile'],
            logger=self.logger)

        # Create a MUDPI sender
        comp_checksum = False
        try:
            comp_checksum = self.config['compute_checksum']
        except KeyError:
            pass
        self.mudpi_sender = PtpmonMudpiSender(
            self.config['dsthost'],
            self.config['dstport'],
            self.config['sendername'],
            logger=self.logger,
            compute_checksum=comp_checksum)

        # Create local notification object
        kw = dict(logger=self.logger)
        if 'udsadrs_ptpmond' in self.config:
            kw["uds_adrs"] = '\0' + self.config['udsadrs_ptpmond']
        self.local_if = local_if.UdsServer(self, **kw)

        # Create statistics class
        self.stats = ptp_stats.PtpStats()


    # Event callback for ptp4l state changes
    def event_callback(self, status):
        """Callback for status updates from the ptp4l status monitor."""

        # Handle every update through a separate thread
        t = threading.Thread(target=self.handle_event, args=(status,))
        t.name = 'Ptp4lMon_evt_callback'
        t.start()


    def configure_logging(self):
        """Configure logging."""
        # Set global loglevel
        self.logger.setLevel(self.config['loglevel'])

        # create syslog channel
        if self.config['syslog']:
            ch = logging.handlers.SysLogHandler(address='/dev/log')
            #ch.ident = 'trala'
            ch.setLevel(self.config['loglevel'])
            formatter = logging.Formatter('ptpmon - %(message)s')
            ch.setFormatter(formatter)
            self.logger.addHandler(ch)

        # create stream channel
        if self.config['conslog']:
            ch = logging.StreamHandler(sys.stdout)
            ch.setLevel(self.config['loglevel'])
            #formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
            #ch.setFormatter(formatter)
            self.logger.addHandler(ch)

        # Log to file
        if self.config['logfile'] is not None:
            ch = logging.FileHandler(self.config['logfile'])
            ch.setLevel(self.config['loglevel'])
            self.logger.addHandler(ch)


    def init_error_checks(self):
        """
        Initialize list of error checks from config
        """
        for err_cond in self.config['error_conditions']:
            self.logger.debug('Adding error check: {}'.format(err_cond['name']))
            ec = err_condition.TimedErrorCondition(
                err_cond['name'],
                check_type=err_condition.CheckType.OK_VALUE,
                check_cond = err_cond['ok_value'],
                grace_period=err_cond['grace_period']
            )
            self.err_checks.append(
                {
                    'err_cond': ec,
                    'dataset': err_cond['dataset'],
                    'dataset_item': err_cond['dataset_item']
                }
            )


    def check_csyncd_errors(self, status):
        """Check errors on the clock synchronization daemon"""

        notify_reason = ''

        # Check if we received data from the daemon
        err = 'ok'
        if status['csyncd_perf'] is None:
            self.logger.debug('csyncd: no perf data')
            err = 'nok'
        else:
            delta_t = time.monotonic() - status['csyncd_perf'].ts_monotonic
            if  delta_t > (self.config['period'] + 1):
                self.logger.debug(f'csyncd - no perf data since {delta_t:.1f} seconds')
                err = 'nok'

        perf_active, perf_recovered, perf_new_error, perf_reason = \
            self.csyncd_err.check(err)
        if perf_new_error:
            self.logger.warning('WARNING: {}'.format(perf_reason))
        if perf_recovered:
            self.logger.info(perf_reason)
        if perf_new_error or perf_recovered:
            notify_reason = perf_reason

        # Check if a clock jump was detected
        # if status['csyncd_clock_jump']:
        #     self.logger.warning('Clock jump detected in phc2sys')
        cj_active, cj_recovered, cj_new_error, cj_reason = \
            self.csyncd_clock_jump.check(status['csyncd_clock_jump'])
        if cj_new_error:
            self.logger.warning('WARNING: {}'.format(cj_reason))
        if cj_recovered:
            self.logger.info(cj_reason)
        if cj_new_error or cj_recovered:
            notify_reason = cj_reason

        # Calculate overall status
        active = perf_active or cj_active
        recovered = perf_recovered or cj_recovered
        new_error = perf_new_error or cj_new_error

        return (active, recovered, new_error, notify_reason)


    def handle_event(self, status):
        """Event handler"""

        self.logger.debug('Ptp4lMon::handle_event()')

        # Send MUDPI packet
        try:
            self.mudpi_sender.send(status)
        except Exception as e:
            self.logger.error('Error while sending MUDPI data to {}:{}'.format(
                self.config['dsthost'], self.config['dstport']))
            self.logger.error(e)
            self.logger.debug(traceback.format_exc())

        # Check for errors and notify local client
        try:
            #daemon_state_change = False
            is_recovered = False
            is_new_error = False
            notify_reason = ''
            is_error = False

            # Check if daemon status changed
            if status['daemon_error'] != status['last_daemon_error']:
                #daemon_state_change = True
                if status['daemon_error']:
                    notify_reason = 'PTP daemon error'
                else:
                    notify_reason = 'PTP daemon OK'

            # Check error conditions on PTP daemon
            if not status['daemon_error']:
                for err_cond in self.err_checks:
                    self.logger.debug('Checking error condition "{}"'.format(
                        err_cond['err_cond'].name))
                    value = getattr(status[err_cond['dataset']], err_cond['dataset_item'])
                    active, recovered, new_error, reason = err_cond['err_cond'].check(
                        value)
                    if new_error:
                        self.logger.warning('WARNING: {}'.format(reason))
                    if recovered:
                        self.logger.info(reason)
                    if new_error or recovered:
                        notify_reason = reason
                    is_new_error = is_new_error or new_error
                    is_recovered = is_recovered or recovered
                    is_error = is_error or active

            # Check error conditions on clock sync. daemon
            if self.config['monitor_csyncd']:
                csyncd_active, csyncd_recovered, csyncd_new_error, csyncd_notify_reason = \
                    self.check_csyncd_errors(status)
                if csyncd_new_error or csyncd_recovered:
                    notify_reason = csyncd_notify_reason

                # Update global error flags
                is_new_error = is_new_error or csyncd_new_error
                is_recovered = is_recovered or csyncd_recovered
                is_error = is_error or csyncd_active

            # Set global state flag
            if status['daemon_error'] or is_error:
                self.global_status = local_if.GlobalStatus.PTP_BAD
            else:
                self.global_status = local_if.GlobalStatus.PTP_GOOD

            # Check if global status changed and store state
            global_status_changed = self.global_status != self.last_global_status
            self.last_global_status = self.global_status

            self.logger.debug('Global status: {} (d_err={} recov={} new_e={} e={})'.format(
                self.global_status,
                status['daemon_error'],
                is_recovered,
                is_new_error,
                is_error))

            # Notify clients if required
            # Note: we only notify when the global state changes
            if global_status_changed:
                self.logger.info('Global status changed {} - {}'.format(
                    self.global_status, notify_reason))
                self.local_if.notify(self.global_status, notify_reason)

        except Exception as e:
            self.logger.error('error during error check or local notification')
            self.logger.error(e)
            self.logger.debug(traceback.format_exc())

        # Write statistics if enabled
        if self.config['record_stats']:
            if (status['update_reason'] == 'event') or \
                    (status['daemon_error'] != status['last_daemon_error']):
                #print('========== Adding stats for event')
                if status['daemon_error']:
                    self.stats.add_event(True, None, None, None, None)
                else:
                    self.stats.add_event(False,
                                         status['port_data_set'].port_state,
                                         status['parent_data_set'].parent_port_identity,
                                         status['parent_data_set'].grandmaster_identity,
                                         status['current_data_set'].steps_removed)

            elif status['update_reason'] == 'scheduled':
                if not status['daemon_error']:
                    #print('========== Adding stats for scheduled update')
                    self.stats.add_data(status['current_data_set'].offset_from_master,
                                         status['current_data_set'].mean_path_delay)


    def start(self):
        """Start monitor."""
        self.logger.debug('Ptp4lMon::start()')
        self.ptp4l_mon.start()
        self.local_if.start()


    def stop(self):
        """Stop monitor."""
        self.logger.debug('Ptp4lMon::stop()')
        self.ptp4l_mon.stop()
        self.local_if.stop()
        #self.stats.print_events()


# Stop event
stop = Event()


# Signal handler
def sig_handler(signum, frame):
    """Signal handler."""
    print('ptpmond - received signal', signum)
    stop.set()


def parse_args():
    """
    @brief Parse command line arguments
    """

    description = textwrap.dedent('''\
            Monitoring daemon for ptp4l

            Note: requires root privileges to access ptp4l via UDS'

            Command line arguments overwrite config from file.
            ''')

    # Note: args shall NOT have a default - the defaults come from the config file.
    #       If an argument is not specified, the parser will set it to None - this
    #       will be used to determine if it is present and if so, will overwrite the
    #       config from file
    parser = argparse.ArgumentParser(description=description,
                                     formatter_class=argparse.RawDescriptionHelpFormatter)

    parser.add_argument('cfgfile', help='Configuration file in yaml format',
                        action='store', default='/etc/ptpmon.yaml')

    parser.add_argument('--dstport', help='Destination port number', action='store',
                        dest='dstport', type=int)
    parser.add_argument('--dsthost', help='Destination hostname', action='store',
                        dest='dsthost')
    parser.add_argument('--period', help='Monitor period', action='store',
                        dest='period', type=float)
    parser.add_argument('--loglevel', help='Log level [ERROR|WARNING|INFO|DEBUG]', action='store',
                        dest='loglevel')
    parser.add_argument('--conslog', help='Log to stdout', action='store_const',
                        const=True, default=None)
    parser.add_argument('--syslog', help='Log to syslog', action='store_const',
                        const=True, default=None)
    parser.add_argument('--logfile', help='Log to the specified file name', action='store',
                        dest='logfile')
    parser.add_argument('--udsadrs', help='UNIX domain socket address of ptp4l instance',
                        action='store', dest='udsadrs')
    parser.add_argument('--udsadrs_ptpmond',
                        help='UNIX domain socket of the monitoring daemon without leading \\0',
                        action='store', dest='udsadrs_ptpmond')
    parser.add_argument('--sendername', help='Name of sender', action='store',
                        dest='sendername')
    parser.add_argument('--record-stats', help='Enable recording of stats', action='store_const',
                        dest='record_stats', const=True, default=None)
    parser.add_argument('--monitor-csyncd',
                        help='Enable monitoring of clock synchronization daemon',
                        action='store_const', dest='monitor_csyncd', const=True, default=None)
    parser.add_argument('--csyncd-logfile', help='Log file of the clock synchronization daemon',
                        action='store', dest='csyncd_logfile')

    return parser.parse_args()


def main():
    """Main."""

    # Parse command line arguments
    args = parse_args()

    # Register the signal handler
    signal.signal(signal.SIGINT, sig_handler)
    signal.signal(signal.SIGTERM, sig_handler)

    # Create monitor and start
    try:
        monitor = Ptp4lMon(args)
        monitor.start()
    except Exception as e:
        print('ERROR creating/starting class Ptp4lMon')
        print(e)
        print(traceback.format_exc())

    # Wait for signals and stop the monitor
    stop.wait()
    monitor.stop()


if __name__ == "__main__":
    main()
