#!/usr/bin/env python3

# Copyright 2020 Akamai Technologies, Inc. All Rights Reserved
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
cli-eaa
:author: Antoine Drochon <androcho@akamai.com>
"""

from __future__ import print_function
import os
import sys
import json
import requests
import time
import base64
import hmac
import hashlib
import platform
from datetime import datetime
import logging
import signal
from threading import Event
import util
import fnmatch
from urllib.parse import urljoin
from enum import Enum
import re

# 3rd party
import six
from akamai.edgegrid import EdgeGridAuth, EdgeRc
from config import EdgeGridConfig
from jinja2 import Template

__version__ = '0.2.6'

# If all parameters are set already, use them.  Otherwise
# use the config
config = EdgeGridConfig({'verbose': False}, 'default')
verbose = getattr(config, 'verbose', False)

SOURCE = 'akamai-cli/eaa'
LOG_FMT = '%(asctime)s [%(levelname)s] %(threadName)s %(message)s'

log_file = None
stop_event = Event()


class cli:
    """
    Utility methods for the CLI.
    """

    @staticmethod
    def print(s):
        sys.stdout.write("%s\n" % s)

    @staticmethod
    def header(s):
        sys.stderr.write("%s\n" % s)

    @staticmethod
    def footer(s):
        sys.stderr.write("%s\n" % s)

    @staticmethod
    def log_level():
        if config.debug:
            return logging.DEBUG
        elif config.verbose:
            return logging.INFO
        else:
            return logging.ERROR

    @staticmethod
    def exit(code):
        logging.info("Exit cli-eaa with code %s" % code)
        exit(code)

    @staticmethod
    def exit_gracefully(signum, frame):
        logging.info("Stop due to SIGTERM or SIGINT signal received")
        stop_event.set()


class EAALegacyAuth(requests.auth.AuthBase):
    """
    EAA legacy API authentication for Requests
    """
    def __init__(self, key, secret):
        self._key = key
        self._secret = secret
        self._signature = self.get_signature()

    def get_signature(self):
        encoding = 'ascii'
        msg = "%s:%s" % (self._key, self._secret)
        signature = hmac.new(
            key=self._secret.encode(encoding),
            msg=msg.encode(encoding),
            digestmod=hashlib.sha256).digest()
        signature = base64.b64encode(signature)
        return signature.decode(encoding)

    def __call__(self, r):
        r.headers.update({'Authorization': 'Basic %s:%s' % (self._key, self._signature)})
        return r


class EAAInvalidMoniker(Exception):
    pass


class EAAItem(object):
    """
    Representation of an EAA object.
    """
    _SEP = '://'

    class Type(Enum):

        Connector = "con"
        Application = "app"
        ApplicationGroupAssociation = "appgrp"
        Group = "group"
        User = "user"
        Directory = "dir"

        @property
        def scheme(self):
            "Full prefix: object://."
            return "%s%s" % (self.value, EAAItem._SEP)

        @classmethod
        def has_value(cls, value):
            return value in [m[1].value for m in cls.__members__.items()]

    def __init__(self, obj_url):
        if not isinstance(obj_url, six.string_types):
            raise TypeError('obj_url must be a string')
        if EAAItem._SEP not in obj_url:
            raise EAAInvalidMoniker('Invalid EAA Object URL %s' % obj_url)
        scanned_type, scanned_uuid = obj_url.split(EAAItem._SEP)
        if EAAItem.Type.has_value(scanned_type):
            self.objtype = EAAItem.Type(scanned_type)
        else:
            raise ValueError('Invalid type "%s"' % scanned_type)
        self.uuid = scanned_uuid

    def __repr__(self):
        return self.objtype.scheme + self.uuid

    def __str__(self):
        return self.__repr__()


class BaseAPI(object):

    class API_Version(Enum):
        "API backend, either Legacy or {OPEN} API (support introduced in 2020 for EAA)."
        Legacy = 1
        OpenAPI = 2

    def __init__(self, config=None, api=API_Version.Legacy):

        self._config = config
        edgerc = EdgeRc(config.edgerc)
        section = config.section

        if api == self.API_Version.Legacy:  # Prior to {OPEN} API
            self._api_ver = api
            self._content_type_json = {'content-type': 'application/json'}
            self._content_type_form = \
                {'content-type': 'application/x-www-form-urlencoded'}
            self._headers = None
            # self._baseurl = 'https://%s' % edgerc.get(section, 'host')
            self._baseurl = 'https://%s/api/v1/' % edgerc.get(section, 'eaa_api_host')
            self._session = requests.Session()
            self._session.auth = EAALegacyAuth(
                edgerc.get(section, 'eaa_api_key'),
                edgerc.get(section, 'eaa_api_secret')
            )
        else:  # {OPEN} API
            # TODO handle ambiguity when multiple contract ID are in use
            self._baseurl = 'https://%s/crux/v1/' % edgerc.get(section, 'host')
            self._session = requests.Session()
            self._session.auth = EdgeGridAuth.from_edgerc(edgerc, section)

        logging.info("Initialized with base_url %s" % self._baseurl)

    def build_params(self, params=None):
        final_params = {}
        if hasattr(self._config, 'contract_id') and self._config.contract_id:
            final_params.update({'contractId': self._config.contract_id})
        if isinstance(params, dict):
            final_params.update(params)
        return final_params

    def get(self, url_path, params=None):
        """
        Send a GET reques to the API.
        """
        url = urljoin(self._baseurl, url_path)
        response = self._session.get(url, params=self.build_params(params))
        logging.info("BaseAPI: GET response is HTTP %s" % response.status_code)
        if response.status_code != requests.status_codes.codes.ok:
            logging.info("BaseAPI: GET response body: %s" % response.text)
        return response

    def post(self, url_path, json=None, params=None):
        url = urljoin(self._baseurl, url_path)
        logging.info("API URL: %s" % url)
        response = self._session.post(url, json=json, params=self.build_params(params))
        logging.info("BaseAPI: POST response is HTTP %s" % response.status_code)
        if response.status_code != 200:
            logging.info("BaseAPI: POST response body: %s" % response.text)
        return response

    def put(self, url_path, json=None):
        url = urljoin(self._baseurl, url_path)
        logging.info("API URL: %s" % url)
        response = self._session.put(url, json=json)
        logging.info("BaseAPI: PUT response is HTTP %s" % response.status_code)
        if response.status_code != 200:
            logging.info("BaseAPI: PUT response body: %s" % response.text)
        return response

    def delete(self, url_path, json=None, params=None):
        url = urljoin(self._baseurl, url_path)
        logging.info("API URL: %s" % url)
        response = self._session.delete(url, json=json)
        logging.info("BaseAPI: DELETE response is HTTP %s" % response.status_code)
        if response.status_code != 200:
            logging.info("BaseAPI: DELETE response body: %s" % response.text)
        return response


class SearchAPI(BaseAPI):
    """
    Search EAA applications
    2020-08-07: migrated to {OPEN} API
    """

    SCAN_LIMIT = 1000

    def __init__(self, config):
        super(SearchAPI, self).__init__(config, api=BaseAPI.API_Version.OpenAPI)

    def search_app(self, search_pattern):
        url_params = {'limit': SearchAPI.SCAN_LIMIT, 'expand': 'true'}
        search_app = self.get('mgmt-pop/apps', params=url_params)
        self.process_response(search_app.json(), search_pattern)

    def process_response(self, data, search_pattern):
        app_found = 0
        if data.get("meta"):
            app_count = data.get("meta").get("total_count")
            # Header
            cli.print('#app_id,type,name,host,cname,status,reach')
            for a in data.get('objects', []):
                if not search_pattern or (
                    search_pattern and (
                        fnmatch.fnmatch(a.get('name') or "", "*%s*" % search_pattern) or
                        fnmatch.fnmatch(a.get('host') or "", "*%s*" % search_pattern) or
                        fnmatch.fnmatch(a.get('cname') or "", "*%s*" % search_pattern)
                    )
                ):
                    cli.print('{scheme}{app_id},{app_type},{name},{host},{cname},{status},{reach}'.format(
                        scheme=EAAItem.Type.Application.scheme,
                        app_id=a.get('uuid_url'),
                        app_type=ApplicationAPI.Type(a.get('app_type')).name,
                        name=a.get('name'),
                        host=a.get('host'),
                        cname=a.get('cname'),
                        status=ApplicationAPI.Status(a.get('app_status')).name,
                        reach=('Y' if a.get('resource_status', {}).get('host_reachable') else 'F'))
                    )
                    # cli.print(json.dumps(a))
                    app_found += 1
            # Footer
            if not config.batch:
                if app_found != app_count:
                    cli.footer("Found %s app(s), total %s app(s)" % (app_found, app_count))
                else:
                    cli.footer("%s app(s)" % app_count)


class DirectoryAPI(BaseAPI):
    """
    Interact with EAA directory configurations.
    """
    # class DirectoryType(Enum):
    #     CloudDirectory  7
    #     LDAP: 10,
    #     ActiveDirectory: 108

    class DirectoryStatus(Enum):
        Status3 = 3

    def __init__(self, config, directory_moniker=None):
        super(DirectoryAPI, self).__init__(config, BaseAPI.API_Version.OpenAPI)
        self._directory = None
        self._directory_id = None
        if directory_moniker:
            self._directory = EAAItem(directory_moniker)
            self._directory_id = self._directory.uuid

    def list_groups(self):
        url_params = {'limit': 0}
        url = 'mgmt-pop/directories/{directory_id}/groups'.format(directory_id=self._directory_id)
        if self._config.search_pattern:
            url_params = url_params.update({'q': self._config.search_pattern})
        resp = self.get(url, params=url_params)
        resj = resp.json()
        for u in resj.get('objects'):
            print('{scheme}{uuid},{name}'.format(
                scheme=EAAItem.Type.Group.scheme,
                uuid=u.get('uuid_url'),
                name=u.get('name')
            ))

    def list_users(self, search=None):
        logging.info("SEARCH %s" % search)
        url_params = {'limit': 0}
        url = 'mgmt-pop/users'
        if search:
            url_params.update({'q': search})
        resp = self.get(url, params=url_params)
        resj = resp.json()
        for u in resj.get('objects'):
            cli.print("{scheme}{uuid},{fn},{ln}".format(
                scheme=EAAItem.Type.User.scheme,
                uuid=u.get('uuid_url'),
                fn=u.get('first_name'),
                ln=u.get('last_name')
            ))

    def list_directories(self):
        if self._directory_id:
            if self._config.users:
                if self._config.search_pattern and not self._config.batch:
                    cli.header("# list users matching %s in %s" % (self._config.search_pattern, self._directory_id))
                self.list_users(self._config.search_pattern)
            elif self._config.groups:
                if self._config.search_pattern and not self._config.batch:
                    cli.header("# list groups matching %s" % self._config.search_pattern)
                self.list_groups()
        else:
            resp = self.get("mgmt-pop/directories")
            if resp.status_code != 200:
                logging.error("Error retrieve directories (%s)" % resp.status_code)
            resj = resp.json()
            # print(resj)
            if not self._config.batch:
                cli.header("#dir_id,dir_name,status,user_count")
            total_dir = 0
            for total_dir, d in enumerate(resj.get("objects"), start=1):
                cli.print("{scheme}{dirid},{name},{status},{user_count}".format(
                    scheme=EAAItem.Type.Directory.scheme,
                    dirid=d.get("uuid_url"),
                    name=d.get("name"),
                    status=d.get("directory_status"),
                    user_count=d.get("user_count"))
                )
            if total_dir == 0:
                cli.footer("No EAA Directory configuration found.")
            elif total_dir == 1:
                cli.footer("One EAA Directory configuration found.")
            else:
                cli.footer("%d EAA Directory configurations found." % total_dir)

    def delgroup(self, group_id):
        raise NotImplementedError("Group deletion is not implemented")

    def deloverlaygroup(self, group_id):
        raise NotImplementedError("Group Overlay deletion is not implemented")
        url = "mgmt-pop/directories/{directory_id}/groups/{group_id}".format(
                directory_id=self._directory_id,
                group_id=group_id
            )
        self.delete(url)

    def addoverlaygroup(self, groupname):
        url = "mgmt-pop/directories/{directory_id}/groups".format(directory_id=self._directory_id)
        resp = self.post(url, json={"status": 1, "group_type": 4, "name": groupname})
        if resp.status_code != 200:
            logging.error("Error adding group to directory %s" % self._directory_id)
        else:
            cli.footer("Overlay group %s added to directory %s" % (groupname, self._directory_id))

    @staticmethod
    def groupname_from_dn(dn):
        """
        Extract the group name from a full Distinguished Name string.
        Reference: https://regexr.com/3l4au
        """
        regexp = '^(?:(?P<cn>CN=(?P<name>[^,]*)),)?(?:(?P<path>(?:(?:CN|OU)=[^,]+,?)+),)?(?P<domain>(?:DC=[^,]+,?)+)$'
        matches = re.search(regexp, dn)
        if matches:
            return matches.group('name')
        else:
            return False

    def addgroup(self, dn):
        """
        Add a group to EAA Directory configuration.
        :param dn: Distiguished Name of the group
                   Example: CN=Print Operators,CN=Builtin,DC=AKAMAIDEMO,DC=NET
        """
        url = "mgmt-pop/directories/{directory_id}/groups".format(directory_id=self._directory_id)
        for scanned_dn in util.argument_tolist((dn,)):
            group = DirectoryAPI.groupname_from_dn(scanned_dn)
            if group:
                logging.debug("Adding group %s" % (scanned_dn))
                resp = self.post(url, json={"name": group, "dn": scanned_dn})
                if resp.status_code != requests.status_codes.codes.ok:
                    logging.error(resp.status_code)
            else:
                logging.warn("Invalid DN: %s" % scanned_dn)

    def synchronize(self):
        print("Synchronize directory %s..." % self._directory_id)
        response = self.post("mgmt-pop/directories/{dirId}/sync".format(dirId=self._directory_id))
        if response.status_code == 200 and not self._config.batch:
            print("Directory %s synchronization requested." % self._directory_id)


class ApplicationAPI(BaseAPI):
    """
    EAA Applications
    Only supported with {OPEN} API
    """

    class Status(Enum):
        NotReady = 1
        Ready = 2
        Pending = 3
        Deployed = 4
        Failed = 5
        CloudDeployed = 6
        ConnectorDeploy = 7

    class Type(Enum):
        Hosted = 1
        SaaS = 2
        Bookmark = 3
        Tunnel = 4
        ETP = 5

    class Profile(Enum):
        HTTP = 1
        SharePoint = 2
        JIRA = 3
        RDP = 4
        VNC = 5
        SSH = 6
        Jenkins = 7
        Confluence = 8
        TCP = 9

    class Domain(Enum):
        Custom = 1
        Akamai = 2

    def __init__(self, config):
        super(ApplicationAPI, self).__init__(config, api=BaseAPI.API_Version.OpenAPI)

    def process_command(self):
        """
        Process command passed from the CLI.
        """
        applications = list()
        appgroups = list()
        if self._config.application_id == '-':
            # nested if below because we don't do anything on create in this section
            if self._config.action != 'create':
                for line in sys.stdin:
                    scanned_items = line.split(',')
                    if len(scanned_items) == 0:
                        logging.warning("Cannot parse line: %s" % line)
                        continue
                    try:
                        scanned_obj = EAAItem(scanned_items[0])
                        if scanned_obj.objtype == EAAItem.Type.Application:
                            applications.append(scanned_obj)
                        elif scanned_obj.objtype == EAAItem.Type.ApplicationGroupAssociation:
                            appgroups.append(scanned_obj)
                    except EAAInvalidMoniker:
                        logging.warning("Invalid application moniker: %s" % scanned_items[0])
        else:
            logging.info("Single app %s" % config.application_id)
            applications.append(EAAItem(config.application_id))
            logging.info("%s" % EAAItem(config.application_id))

        if config.action == "deploy":
            for a in applications:
                self.deploy(a)
                cli.print("Application %s deployment requested, it may take a few minutes before it gets live." % a)
        elif config.action == "create":
            # new_config = json.load(sys.stdin)
            new_config = sys.stdin.read()
            self.create(new_config)
        elif config.action == "update":
            if len(applications) > 1:
                raise Exception("Batch operation not supported")
            app = applications[0]
            new_config = json.load(sys.stdin)
            self.update(app, new_config)
            cli.print("Configuration for application %s has been updated." % app)
        elif config.action == "delete":
            for a in applications:
                self.delete_app(a)
        elif config.action == "add_dnsexception":
            for a in applications:
                self.add_dnsexception(a)
        elif config.action == "del_dnsexception":
            for a in applications:
                self.del_dnsexception(a)
        elif config.action == 'viewgroups':
            for a in applications:
                self.loadgroups(a)
        elif config.action == 'delgroup':
            for ag in appgroups:
                self.delgroup(ag)
        else:  # view by default
            for a in applications:
                app_config = self.load(a)
                print(json.dumps(app_config))
                # print(app_config)

    def load(self, app_moniker):
        """Load application configuration."""
        # For the save/duplicate-type operations we need some extra data,
        # hence the two expand parameters
        url_params = {'expand': 'true', 'expand_sdk': 'true'}
        url = 'mgmt-pop/apps/{applicationId}'.format(applicationId=app_moniker.uuid)
        result = self.get(url, params=url_params)
        app_config = result.json()

        # Merge application groups info and align the view
        # to match the structure expected in save operation
        groups_url = 'mgmt-pop/apps/{applicationId}/groups'.format(applicationId=app_moniker.uuid)
        groups_result = self.get(groups_url, params={'limit': 0})
        groups = groups_result.json()
        app_config['groups'] = []
        for g in groups.get('objects', []):
            app_config['groups'].append(
                {
                    'name': g.get('group', {}).get('name'),
                    'enable_mfa': g.get('enable_mfa', 'inherit'),
                    'uuid_url': g.get('group', {}).get('group_uuid_url')
                }
            )

        # Merge URL path-based policies and align the view
        # to match the structure expected in save operation
        upp_url = 'mgmt-pop/apps/{applicationId}/urllocation'
        upp_result = self.get(upp_url.format(applicationId=app_moniker.uuid), params={'limit': 0})
        upp = upp_result.json()
        app_config['urllocation'] = []
        for upp_rule in upp.get('objects', []):
            app_config['urllocation'].append(upp_rule)

        return app_config

    def loadgroups(self, app_moniker):
        """Directory+Groups allowed to access this application."""
        url_params = {'limit': 0, 'expand': 'true', 'expand_sdk': 'true'}
        url = 'mgmt-pop/apps/{applicationId}/groups'.format(applicationId=app_moniker.uuid)
        result = self.get(url, url_params)
        count = 0
        allowed_groups = result.json().get('objects')
        if not self._config.batch:
            cli.header("# Allowed Groups to access app %s" % app_moniker)
            cli.header("# appgroup_id,group_id,group_name,dir_name,mfa")
        for count, group in enumerate(allowed_groups, start=1):
            association = group.get('resource_uri', {}).get('href')
            cli.print("{prefix1}{appgroup_id},{prefix2}{group_id},{name},{dir_name},{mfa}".format(
                prefix1=EAAItem.Type.ApplicationGroupAssociation.scheme,
                appgroup_id=association.split('/')[-1],
                prefix2=EAAItem.Type.Group.scheme,
                group_id=group.get('group').get('group_uuid_url'),
                name=group.get('group').get('name'),
                dir_name=group.get('group').get('dir_name'),
                mfa=group.get('enable_mfa')
            ))

        if not self._config.batch:
            cli.print("# %s groups configured to access application %s" % (count, app_moniker))
        return allowed_groups

    def delete_app(self, app_moniker):
        deletion = self.delete('mgmt-pop/apps/{applicationId}'.format(applicationId=app_moniker.uuid))
        if deletion.status_code == 200:
            cli.print("Application %s deleted." % app_moniker.uuid)

    def delgroup(self, appgroup_moniker):
        cli.print("Delete App-Group association %s..." % appgroup_moniker)
        deletion = self.post('mgmt-pop/appgroups', params={'method': 'DELETE'},
                             json={'deleted_objects': [appgroup_moniker.uuid]})
        if deletion.status_code == 200:
            cli.print("Association %s deleted." % appgroup_moniker)

    def cloudzone_lookup(self, name):
        """Lookup a cloud zone UUID based on it's name."""
        pops = self.get('mgmt-pop/pops?shared=true')
        for pop in pops.json().get('objects'):
            if pop.get('region') == name:
                return pop.get('uuid_url')
        return ""

    def certificate_lookup(self, cn):
        certs = self.get('mgmt-pop/certificates?limit=0')
        for cert in certs.json().get('objects'):
            if cert.get('cn') == cn:
                return cert.get('uuid_url')
        return ""

    def parse_template(self, raw_config):
        """
        Parse a template
        """
        t = Template(raw_config)
        t.globals['AppProfile'] = ApplicationAPI.Profile
        t.globals['AppDomainType'] = ApplicationAPI.Domain
        t.globals['cli_cloudzone'] = self.cloudzone_lookup
        t.globals['cli_certificate'] = self.certificate_lookup
        output = t.render()
        logging.debug("JSON Post-Template Render: %s" % output)
        return output

    def create(self, raw_app_config):
        """
        Create a new EAA application configuration.
        :param app_config: configuration as JSON string

        Note: the portal use the POST to create a new app with a minimal payload:
              {"app_profile":1,"app_type":1,"client_app_mode":1,"app_profile_id":"Fp3RYok1EeSE6AIy9YR0Dw",
              "name":"tes","description":"test"}
              We should do the same here
        """
        app_config = json.loads(self.parse_template(raw_app_config))
        logging.debug("Post Jinja parsing:\n%s" % json.dumps(app_config))
        app_config_create = {
            "app_profile": app_config.get('app_profile'),
            "app_type": app_config.get('app_type', ApplicationAPI.Type.Hosted.value),
            "name": app_config.get('name'),
            "description": app_config.get('description')
        }
        newapp = self.post('mgmt-pop/apps', json=app_config_create)
        logging.info("Create app core: %s %s" % (newapp.status_code, newapp.text))
        if newapp.status_code != 200:
            cli.exit(2)
        newapp_config = newapp.json()
        app_moniker = EAAItem("app://{}".format(newapp_config.get('uuid_url')))
        logging.info("UUID of the newapp: %s" % app_moniker)

        # Now we push everything else as a PUT
        self.put('mgmt-pop/apps/{applicationId}'.format(applicationId=app_moniker.uuid), json=app_config)

        # Sub-components of the application configuration definition

        # --- Connectors
        if app_config.get('agents'):
            self.attach_connectors(app_moniker, app_config.get('agents', []))

        # IdP, Directories, Groups
        self.create_auth(app_moniker, app_config)

        # --- ACL
        # TODO: implement

        # --- Services
        # TODO: implement

        # URL based policies
        self.create_urlbasedpolicies(app_moniker, app_config)

        # At the end we reload the app entirely
        cli.print(json.dumps(self.load(app_moniker)))

    def create_auth(self, app_moniker, app_config):
        """
        Create application authentication configuration
        IdP, Directories, Groups
        """
        # IdP
        # The "view" operation gives us the IdP in idp -> idp_id
        scanned_idp_uuid = app_config.get('idp', {}).get('idp_id')
        if scanned_idp_uuid:
            idp_app_payload = {"app": app_moniker.uuid, "idp": scanned_idp_uuid}
            idp_app_resp = self.post('mgmt-pop/appidp', json=idp_app_payload)
            logging.info("IdP-app association response: %s %s" % (idp_app_resp.status_code, idp_app_resp.text))

        # Directory
        # The view operation gives us the directories in directories[] -> uuid_url
        scanned_directories = app_config.get('directories', [])
        app_directories_payload = {"data": [{"apps": [app_moniker.uuid], "directories": scanned_directories}]}
        app_directories_resp = self.post('mgmt-pop/appdirectories', json=app_directories_payload)
        logging.info(
            "App directories association response: %s %s" %
            (app_directories_resp.status_code, app_directories_resp.text)
        )
        if app_directories_resp.status_code != 200:
            cli.exit(2)
        # Groups
        if len(app_config.get('groups', [])) > 0:
            app_groups_payload = {'data': [{'apps': [app_moniker.uuid], 'groups': app_config.get('groups', [])}]}
            app_groups_resp = self.post('mgmt-pop/appgroups', json=app_groups_payload)
            if app_groups_resp.status_code != 200:
                cli.exit(2)
        else:
            logging.debug("No group set")

    def create_urlbasedpolicies(self, app_moniker, app_config):
        if len(app_config.get('urllocation', [])) > 0:
            upp_url = 'mgmt-pop/apps/{applicationId}/urllocation'.format(applicationId=app_moniker.uuid)
            for upp_rule in app_config.get('urllocation', []):
                upp_create_payload = {
                    "rule_type": upp_rule.get("rule_type", 1),
                    "name": upp_rule.get("name"),
                    "url": upp_rule.get("url")
                }
                upp_create = self.post(upp_url, json=upp_create_payload)
                upp_create_data = upp_create.json()
                if upp_create_data.get('uuid_url'):
                    upp_update_url = 'mgmt-pop/apps/{applicationId}/urllocation/{ruleId}'
                    self.put(upp_update_url.format(
                        applicationId=app_moniker.uuid,
                        ruleId=upp_create_data.get('uuid_url')),
                        json=upp_rule
                    )
        else:
            logging.debug("No URL path-based policies set")

    def update(self, app_moniker, app_config):
        """
        Update an existing EAA application configuration.
        """
        update = self.put(
            'mgmt-pop/apps/{applicationId}'.format(applicationId=app_moniker.uuid),
            json=app_config
        )
        logging.info("Update app response: %s" % update.status_code)
        logging.info("Update app response: %s" % update.text)
        if update.status_code != 200:
            cli.exit(2)

    def attach_connectors(self, app_moniker, connectors):
        """
        Attach connector/s to an application.
        """
        # POST on mgmt-pop/apps/DBMcU6FwSjKa7c9sny4RLg/agents
        # Body
        # {"agents":[{"uuid_url":"cht3_GEjQWyMW9LEk7KQfg"}]}
        logging.info("Attaching {} connectors...".format(len(connectors)))
        api_resp = self.post(
            'mgmt-pop/apps/{applicationId}/agents'.format(applicationId=app_moniker.uuid),
            json={'agents': connectors}
        )
        logging.info("Attach connector response: %s" % api_resp.status_code)
        logging.info("Attach connector app response: %s" % api_resp.text)
        if api_resp.status_code not in (200, 201):
            cli.exit(2)

    def detach_connectors(self, app_moniker, connectors):
        """
        TODO: implement this function
        """
        pass

    def add_dnsexception(self, app_moniker):
        logging.info("Adding DNS exception: %s" % config.exception_fqdn)
        appcfg = self.load(app_moniker)
        dns_exceptions = set(appcfg.get('advanced_settings', {}).get('domain_exception_list').split(','))
        dns_exceptions |= set(config.exception_fqdn)
        appcfg["advanced_settings"]["domain_exception_list"] = ','.join(dns_exceptions)
        self.save(app_moniker, appcfg)

    def del_dnsexception(self, app_moniker):
        logging.info("Remove DNS exception: %s" % config.exception_fqdn)
        pass

    def deploy(self, app_moniker):
        """
        Deploy an EAA application.
        """
        if not isinstance(app_moniker, EAAItem):
            raise TypeError("Deploy expect and EAAItem, %s provided" % type(app_moniker))
        if app_moniker.objtype != EAAItem.Type.Application:
            raise ValueError("EAAItem object must be app, %s found" % app_moniker.objtype.value)
        deploy = self.post('mgmt-pop/apps/{applicationId}/deploy'.format(applicationId=app_moniker.uuid))
        logging.info("ApplicationAPI: deploy app response: %s" % deploy.status_code)
        if deploy.status_code != 200:
            logging.error(deploy.text)


class ReportingAPI(BaseAPI):
    def __init__(self, config):
        super(ReportingAPI, self).__init__(config, api=BaseAPI.API_Version.OpenAPI)

    def clients(self):
        now = time.time()
        params = {
            'limit': 0,
            'start': int((now - 30 * 24 * 60 * 60) * 1000),
            'end': int(now * 1000)
        }
        resp = self.get('mgmt-pop/clients', params=params)
        if resp.status_code != 200:
            logging.error(resp.text)
        data = resp.json()
        cli.header("#device_id,version,idp_user,idp_host,lastseen")
        for count, c in enumerate(data.get('objects', {})):
            cli.print("{device_id},{version},{idp_user},{idp_host},{lastseen}".format(
                device_id=c.get("device_id"),
                version=c.get("device_info", {}).get("version"),
                idp_user=c.get("idp_user"),
                idp_host=c.get("idp_host"),
                lastseen=c.get("timestamp")
            ))
        cli.footer("%s unique EAA Clients checked-in in the last 30 days" % count)


class EventLogAPI(BaseAPI):
    """
    EAA logs, this is using the legacy API
    Based on Splunk app
    """

    #: Pull interval when using the tail mode
    PULL_INTERVAL_SEC = 15
    COLLECTION_DELAY_MINUTES = 1

    class EventType(Enum):
        USER_ACCESS = "access"
        ADMIN = "admin"

    ADMINEVENT_API = "adminevents-reports/ops/splunk-query"
    ACCESSLOG_API = "analytics/ops"

    def __init__(self, config):
        super(EventLogAPI, self).__init__(config, api=BaseAPI.API_Version.Legacy)
        self._content_type_json = {'content-type': 'application/json'}
        self._content_type_form = {'content-type': 'application/x-www-form-urlencoded'}
        self._headers = None
        self._output = config.output
        if not self._output:
            self._output = sys.stdout
        self.line_count = 0

    def get_api_url(self, logtype):
        if logtype == self.EventType.ADMIN:
            return self.ADMINEVENT_API
        else:
            return self.ACCESSLOG_API

    def get_logs(self, drpc_args, logtype=EventType.USER_ACCESS, output=None):
        """
        Fetch the logs, by default the user access logs.
        """
        if not isinstance(logtype, self.EventType):
            raise ValueError("Unsupported log type %s" % logtype)

        scroll_id = None
        try:
            # Fetches the logs for given drpc args
            resp = self.post(self.get_api_url(logtype), json=drpc_args)
            if resp.status_code != requests.codes.ok:
                logging.error("Invalid API response status code: %s" % resp.status_code)
                return None

            resj = resp.json()
            logging.debug("JSON> %s" % json.dumps(resj, indent=2))

            if 'message' in resj:
                # Get msg and scroll_id based on the type of logs
                # Since it is two different API in the back-end
                if logtype == self.EventType.USER_ACCESS:
                    msg = resj.get('message')[0][1]
                    scroll_id = msg.get('scroll_id')
                elif logtype == self.EventType.ADMIN:
                    msg = resj.get('message')
                    if 'scroll_id' in msg.get('metadata'):
                        scroll_id = msg.get('metadata').get('scroll_id')
                else:
                    raise NotImplementedError("Doesn't support log type %s" % logtype)

                logging.debug("scroll_id: %s" % scroll_id)
                count = 0

                if logtype == self.EventType.USER_ACCESS:
                    for timestamp, response in six.iteritems(msg):
                        try:
                            if not timestamp.isdigit():
                                logging.debug("Ignored timestamp '%s': %s" % (timestamp, response))
                                continue
                            logging.debug("flog is %s" % type(response['flog']).__name__)
                            logging.debug("Scanned timestamp: %s" % timestamp)
                            local_time = datetime.fromtimestamp(int(timestamp)/1000)
                            if isinstance(response, dict) and 'flog' in response:
                                line = "%s\n" % ' '.join([local_time.isoformat(), response['flog']])
                                output.write(line)
                                logging.debug("### flog ## %s" % response['flog'])
                                self.line_count += 1
                                count += 1
                        except Exception:
                            logging.exception("Error parsing access log line")
                elif logtype == self.EventType.ADMIN:
                    for item in msg.get('data'):
                        try:
                            local_time = datetime.fromtimestamp(int(item.get('ts')/1000))
                            line = u"{},{}\n".format(local_time.isoformat(), item.get('splunk_line'))
                            output.write(line)
                            self.line_count += 1
                            count += 1
                        except Exception as e:
                            logging.exception('Error parsing admin log line: %s, content: %s' %
                                              (e, item.get('splunk_line')))
            else:
                logging.error('Error: no data(message) in response.')
                logging.error(drpc_args)
                logging.error(json.dumps(resj))
            resp.close()
        except Exception:
            if "resp" in locals():
                logging.debug("resp.status_code %s" % resp.status_code)
                logging.debug("resp.text %s" % resp.text)
            logging.error(drpc_args)
            logging.exception("Exception in get_logs")
        return scroll_id

    @staticmethod
    def date_boundaries():
        # end time in milliseconds, now minus collection delay
        ets = int(time.mktime(time.localtime()) * 1000 - (EventLogAPI.COLLECTION_DELAY_MINUTES * 60 * 1000))
        if not config.tail and config.end:
            ets = config.end * 1000
        # start time in milliseconds: end time minus poll interval
        sts = int(ets - (EventLogAPI.PULL_INTERVAL_SEC * 1000))
        if not config.tail and config.start:
            sts = config.start * 1000
        return ets, sts

    def fetch_logs(self, exit_fn, stop_event):
        """
        Fetch all logs
        :param exit_fn: function to call upon SIGTERM and SIGINT
        """

        log_type = self.EventType(config.log_type)
        logging.info(log_type)
        signal.signal(signal.SIGTERM, exit_fn)
        signal.signal(signal.SIGINT, exit_fn)

        logging.info("PID: %s" % os.getpid())
        logging.info("Poll interval: %s seconds" % EventLogAPI.PULL_INTERVAL_SEC)
        out = None
        try:
            if isinstance(self._output, str):
                logging.info("Output file: %s" % self._output)
                out = open(self._output, 'w+')
            elif hasattr(self._output, 'write'):
                out = self._output

            while True:
                ets, sts = EventLogAPI.date_boundaries()
                s = time.time()
                logging.info("Fetching log[%s] from %s to %s..." % (log_type, sts, ets))
                scroll_id = None
                while (True):
                    drpc_args = {
                        'sts': str(sts),
                        'ets': str(ets),
                        'metrics': 'logs',
                        'es_fields': 'flog',
                        'limit': '1000',
                        'sub_metrics': 'scroll',
                        'source': SOURCE,
                    }
                    if scroll_id is not None:
                        drpc_args.update({'scroll_id': str(scroll_id)})
                    scroll_id = self.get_logs(drpc_args, log_type, out)
                    out.flush()
                    if scroll_id is None:
                        break
                if not config.tail:
                    if not config.batch:
                        print("# Start: %s" % time.strftime('%m/%d/%Y %H:%M:%S UTC', time.gmtime(sts/1000.)))
                        print("# End: %s" % time.strftime('%m/%d/%Y %H:%M:%S UTC', time.gmtime(ets/1000.)))
                        print("# Total: %s event(s)" % self.line_count)
                    break
                else:
                    elapsed = time.time() - s
                    logging.debug("Now waiting %s seconds..." % (EventLogAPI.PULL_INTERVAL_SEC - elapsed))
                    stop_event.wait(EventLogAPI.PULL_INTERVAL_SEC - elapsed)
                    if stop_event.is_set():
                        break
        except Exception:
            logging.exception("General exception while fetching EAA logs")
        finally:
            if out and self._output != sys.stdout:
                logging.debug("Closing output file...")
                out.close()
            logging.info("%s log lines were fetched." % self.line_count)


class ConnectorAPI(BaseAPI):

    def __init__(self, config):
        super(ConnectorAPI, self).__init__(config, api=BaseAPI.API_Version.OpenAPI)

    def list(self):
        url_params = {'expand': 'true', 'limit': 0}
        data = self.get('mgmt-pop/agents', params=url_params)
        connectors = data.json()
        total_con = 0
        logging.info(json.dumps(data.json(), indent=4))
        cli.print('#Connector-id,name,reachable,status,version,privateip,publicip,debug')
        format_line = "{scheme}{con_id},{name},{reachable},{status},{version},{privateip},{publicip},{debugchan}"
        for total_con, c in enumerate(connectors.get('objects', {}), start=1):

            cli.print(format_line.format(
                scheme=EAAItem.Type.Connector.scheme,
                con_id=c.get('uuid_url'),
                name=c.get('name'),
                reachable=c.get('reach'),
                status=c.get('status'),
                version=c.get('agent_version').replace('AGENT-', '').strip(),
                privateip=c.get('private_ip'),
                publicip=c.get('public_ip'),
                debugchan='Y' if c.get('debug_channel_permitted') else 'N'
            ))
        cli.footer("Total %s connector(s)" % total_con)


if __name__ == "__main__":

    logging.basicConfig(filename=log_file, level=cli.log_level(), format=LOG_FMT)
    logging.debug("Python %s" % platform.python_version())

    try:
        if config.command == "version":
            print(__version__)
        elif config.command in ("log", "l"):
            events = EventLogAPI(config)
            events.fetch_logs(cli.exit_gracefully, stop_event)
        elif config.command in ("search", "s"):
            s = SearchAPI(config)
            s.search_app(config.pattern)
        elif config.command in ("dir", "d"):
            if config.action is None or config.action == "list":
                directory_id = None
                if hasattr(config, 'directory_id'):
                    directory_id = config.directory_id
                d = DirectoryAPI(config, directory_id)
                d.list_directories()
            else:
                d = DirectoryAPI(config, config.directory_id)
                if config.action == "sync":
                    d.synchronize()
                elif config.action == "addgroup":
                    d.addgroup(config.group)
                elif config.action == "addovlgroup":
                    d.addoverlaygroup(config.group)
                else:
                    raise Exception("Unsupported directory action: %s" % config.action)
        elif config.command in ("app", "a"):
            a = ApplicationAPI(config)
            a.process_command()
        elif config.command in ("connector", "c"):
            c = ConnectorAPI(config)
            c.list()
        elif config.command in ("report", "r"):
            r = ReportingAPI(config)
            if config.report_name == "clients":
                r.clients()
        else:
            config.parser.print_help()
            sys.exit(1)
    except KeyboardInterrupt:
        print("cli-eaa interrupted.")
        sys.exit(2)
    except Exception:
        logging.exception("cli-eaa general exception")
        sys.exit(200)

# end of file
