#!/usr/bin/env python3

# USM_OBSERVER
# ERP USERSIDE external module

import configparser
import json
import logging
import os
import ssl
from time import time
from urllib import request, error as urlliberror
from urllib.parse import urlencode

from pysnmp.hlapi.asyncore import *

version = '1.0.0-RC1'
base_path = os.path.abspath(os.path.dirname(__file__))


def read_settings():
    """
    Read usb_observer settings.
    :return: configparser.ConfigParser
    """
    cp = configparser.ConfigParser()
    cp.read(os.path.join(base_path, 'settings.ini'))
    return cp


class CreatingLogFolderError(PermissionError):
    def __init__(self, *args, **kwargs):
        super().__init__(*args)


def configure_logger(cfg):
    if cfg.has_option('log', 'path') is False \
            or not cfg.get('api', 'url'):
        cfg.set('log', 'path', os.getcwd())
    if cfg.has_option('log', 'level') is False:
        cfg.set('log', 'level', '2')
    try:
        if cfg.getint('log', 'level') < 1 \
                or cfg.getint('log', 'level') > 5:
            cfg.set('log', 'level', '2')
    except ValueError:
        cfg.set('log', 'level', '2')

    log_path = cfg.get('log', 'path')

    try:
        os.stat(log_path)
    except OSError:
        try:
            os.makedirs(log_path, exist_ok=True)
        except PermissionError as e:
            raise CreatingLogFolderError(e)

    logfile = '%s/usm_observer.log' % log_path
    log_level = cfg.getint('log', 'level') * 10
    if cfg.has_option('log', 'to_console') \
            and cfg.getboolean('log', 'to_console'):
        logging.basicConfig(level=log_level,
                            format=u'%(asctime)s | %(levelname)-8s | %(message)s')
    else:
        logging.basicConfig(filename=logfile, level=log_level,
                            format=u'%(asctime)s | %(levelname)-8s | %(message)s')


class USApi(object):
    def __init__(self, cfg, ssl_context):
        self.url = cfg.get('api', 'url')
        if not self.url.endswith('/api.php'):
            self.url = self.url + '/api.php'
        self.request_param = {
            'key': cfg.get('api', 'key'),
            'cat': 'usm_observer'
        }
        self.cfg = cfg
        self.ssl_context = ssl_context

    def get_data(self):
        logging.info('Getting initialization data from USERSIDE API')
        params = {"action": "get_data"}
        data = self.__make_get(params=params)
        logging.debug('Got %s host(s) for processing.' % len(data))
        return data

    def put_data(self, data):
        logging.info('Sending processed data to USERSIDE API')
        params = {"action": "put_data"}
        return self.__make_post(params=params, data=data)

    def module_start(self):
        logging.info('usm_observer started with PID=%s.' % os.getpid())
        params = {
            "action": "module_start",
            "version": version,
            "path": base_path,
            "logpath": self.cfg.get('log', 'path')
        }
        return self.__make_post(params=params)

    def module_finish(self):
        logging.info('Module usm_observer is finishing...')
        params = {"action": "module_finish"}
        return self.__make_post(params=params)

    def __make_get(self, params):
        logging.debug('Make GET request with the params: %s' % params)
        params.update(self.request_param)
        try:
            url = self.url + '?' + urlencode(params)
            response = request.urlopen(url, context=self.ssl_context).read().decode('utf8')
            try:
                result = json.loads(response)
                if result.get('result') != 'OK':
                    raise USApi.ApiInteractionError(
                        "The data was received, but 'result' is not 'OK'. 'error_text': %s" % result.get('error_text'))
                logging.debug("Received correct response with OK result status.")
                return result.get('data')
            except json.decoder.JSONDecodeError:
                logging.debug("Received invalid response: %s" % response)
                raise USApi.ApiInteractionError("JSON parse error. Finished abnormally.")
        except request.HTTPError as e:
            raise USApi.ApiInteractionError(
                "API (GET) Error #{0}: {1}".format(
                    e.code, e.msg))

    def __make_post(self, params, data=None):
        logging.debug('Make POST request with the params: %s' % params)
        params.update(self.request_param)
        try:
            url = self.url + '?' + urlencode(params)
            if data:
                data = json.dumps(data).encode('utf8')
            else:
                data = b''
            req = request.Request(url, data=data,
                                  headers={
                                      'content-type': 'application/json'
                                  })
            response = request.urlopen(req, context=self.ssl_context).read().decode('utf8')
            logging.debug("Received response: %s" % response)
            try:
                result = json.loads(response)
                if result.get('result') != 'OK':
                    raise USApi.ApiInteractionError(
                        "The data was received, but 'result' is not 'OK'. 'error_text': %s" % result.get('error_text'))
                return result.get('data')
            except json.decoder.JSONDecodeError:
                raise USApi.ApiInteractionError("JSON parse error. Finished abnormally.")
        except request.HTTPError as error:
            raise USApi.ApiInteractionError(
                "API (POST) Error #{0}: {1}".format(
                    error.code, error.msg))

    class ApiInteractionError(Exception):
        def __init__(self, *args, **kwargs):
            pass


def generate_snmp_targets(hosts_list):
    """
    Generate targets for PySNMP command
    :param hosts_list:
    :return: list of snmp targets
    """
    t = list()
    for host, data in hosts_list.items():
        community = 'public'
        mp_model = 1
        port = 161
        timeout = 8

        snmp_settings = data.get('snmp')
        if snmp_settings:
            c = snmp_settings.get('c')
            if c and c != '':
                community = c
            v = snmp_settings.get('v')
            if v == '1' or v == 1:
                mp_model = 0
            p = snmp_settings.get('p')
            if p:
                port = int(p)
            to = snmp_settings.get('t')
            if to:
                timeout = int(to)

        oid_list = []
        for o in data.get('oid'):
            if o is None:
                logging.warning("OID is Null. Host: %s" % host)
                continue
            oid_list.append(ObjectType(ObjectIdentity(o)))

        t.append(
            (
                CommunityData(community, mpModel=mp_model),
                UdpTransportTarget((host, port), timeout=timeout, retries=2),
                oid_list
            )
        )
    return t


def lockfile_is_locked():
    logging.debug('Checking the lock file.')
    try:
        os.stat(os.path.join(base_path, '.lock'))
        logging.debug('Lock file found.')
        return True
    except OSError:
        logging.debug('Lock file not found.')
        return False


def lockfile_lock():
    logging.debug('Creating a lock file.')
    open(os.path.join(base_path, '.lock'), 'w').close()


def lockfile_unlock():
    try:
        os.remove(os.path.join(base_path, '.lock'))
        logging.debug('Lock file removed.')
    except OSError:
        pass


def snmp_response_handler(snmpEngine, sendRequestHandle, errorIndication,
                          errorStatus, errorIndex, varBindTable, cbCtx):
    global answer
    (transportTarget) = cbCtx
    host_ip = transportTarget.transportAddr[0]

    if errorIndication:
        logging.warning('Host: %s. Timeout.' % host_ip)
        answer[host_ip] = 'timeout'
        return
    elif errorStatus:
        status_text = errorStatus.prettyPrint()
        logging.warning(
            'Host: %s. Error: %s.' % (host_ip, status_text))
        answer[host_ip] = "error:" + status_text
        return
    else:
        logging.info('Received SNMP response from host: %s' % host_ip)
        response_data = dict()
        for varBindRow in varBindTable:
            dotted_oid = varBindRow[0].getOid().prettyPrint()
            string_value = str(varBindRow[1])
            logging.debug('=> {0}:{1}={2}'.format(
                host_ip, dotted_oid, string_value))
            response_data.update({dotted_oid: string_value})
        answer[host_ip] = response_data
        return True


if __name__ == '__main__':
    try:
        answer = dict()
        config = read_settings()
        configure_logger(config)

        logging.info('*** Module usm_observer version %s is starting...' % version)

        # Check config
        if config.has_option('api', 'key') is False \
                or not config.get('api', 'key'):
            raise configparser.Error(
                'Config error: Option api/key is empty or not defined')
        if config.has_option('api', 'url') is False \
                or not config.get('api', 'url'):
            raise configparser.Error(
                'Config error: Option api/url is empty or not defined')

        # Check another running copy of usm_observer
        if lockfile_is_locked():
            raise PermissionError(
                'Another copy of the usm_observer found in the memory. Exiting.')

        # Starting...
        startTime = time()
        lockfile_lock()

        ctx = None
        if config.has_option('api', 'ssl_verify') \
                and config.getboolean('api', 'ssl_verify') is False:
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE

        api = USApi(config, ctx)
        api.module_start()

        # Get hosts from USERSIDE API
        api_data = api.get_data()

        if api_data:
            snmp_targets = generate_snmp_targets(api_data)

            # Creating SNMP Engine and making GET Requests
            snmp_engine = SnmpEngine()
            for auth_data, transport_target, var_binds in snmp_targets:
                logging.info('Creating SNMP request to host: %s' % transport_target.transportAddr[0])
                getCmd(snmp_engine, auth_data, transport_target, ContextData(), *var_binds,
                       **dict(cbFun=snmp_response_handler, cbCtx=(transport_target)))

            # Run SNMP Engine Dispatcher
            snmp_engine.transportDispatcher.runDispatcher()

            # Send answers to USERSIDE API
            api.put_data(answer)
        else:
            logging.info('There are no items for observe.')

        # Finishing
        api.module_finish()
        finishTime = time()
        lockfile_unlock()
        logging.info(
            '*** Finished normally. Complete in {:.3f} sec.'.format(finishTime - startTime))
    except USApi.ApiInteractionError as error:
        logging.error(error)
    except configparser.Error as error:
        logging.error('Config error: %s. Finished abnormally.' % error.message)
    except urlliberror.URLError as error:
        logging.error(error)
    except CreatingLogFolderError as error:
        import sys

        print("Log folder creating error. %s" % error, file=sys.stderr)
    except PermissionError as error:
        logging.warning(error)
    except BaseException:
        logging.exception('Got unknown error! See details below:')
    finally:
        lockfile_unlock()
