#!/usr/bin/env python3

import concurrent.futures
import csv
import logging
import re
import socket
import sqlite3
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET

try:
    with open('/etc/usm/config.json') as config:
        config = json.load(config)
except FileNotFoundError:
    # use local config for development
    with open(os.path.join('..', '..', 'etc', 'config.json')):
        config = json.load(config)

debug = config.get('debug', False)
if debug:
    level = logging.DEBUG
else:
    level = logging.WARNING

logging.basicConfig(level=level,
                    format='[%(asctime)s,%(filename)s,%(levelname)s] %(message)s',
                    datefmt='%Y-%m-%d %H:%M')


def get_tcp_client_socket(host, port, timeout=None):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
    sock.settimeout(timeout)
    sock.connect((host, port))
    return sock
    
get_socket = get_tcp_client_socket

def add_urllib_auth(host, username, password):
    mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
    mgr.add_password(None, host, username, password)
    auth = urllib.request.HTTPBasicAuthHandler(mgr)
    opener = urllib.request.build_opener(auth)
    urllib.request.install_opener(opener)

def try_to_number(value):
    try:
        value = int(value)
    except Exception:
        try:
            value = float(value)
        except Exception:
            return value

def get_mlc(timeout, system):
    base_keys = ('campus', 'building', 'room')
    base_params = {k: system[k] for k in base_keys}
    system_id = tuple(system[k] for k in base_keys)

    try:
        with MLC(timeout=timeout, **system) as mlc:
            config = mlc.get_config()
            properties = mlc.get_properties(config)
            properties = list(properties)

    except (PasswordError, socket.error, urllib.error.HTTPError) as e:
        logging.warning('Skipping ' + system['building'] + ' ' + system['room'] + ': ' + str(e))

    else:
        return system_id, base_params, properties


class PasswordError(ValueError): pass


class MLC:
    CONFIG_URL = 'http://{ip}/GC2/ExtronConfig.xml'
    RE_FORMAT_VALUE = '%\w'

    def __init__(self, host, port=23, username='', password='',
                       timeout=5, alarm_query='', **kwargs):
        self.host = host
        self.port = port
        self.username = username
        self.password = password
        self.timeout = timeout  # seconds
        
        self.alarm_query = alarm_query

    def __enter__(self):
        self.open()
        return self

    def __exit__(self, type, value, traceback):
        self.close()
        return not type

    def open(self, pass_prompt='Password', pass_accepted='Login'):
        # notice the convenience functions
        self.socket = get_socket(self.host, self.port, self.timeout)
        self.recv = self.socket.recv
        self.send = self.socket.sendall
        
        if self.password:        
            self._telnet_login(pass_prompt)
            self._check_login(pass_accepted)
        else:
            self.recv(1024) # flush prompt
        
        return self

    def close(self):
        self.socket.shutdown(socket.SHUT_RDWR)
        self.socket.close()
        return self

    def _telnet_login(self, password_prompt):
        prompt = ""
        
        try:
            while password_prompt not in prompt:
                prompt += self.read()
            else:
                self.write(self.password)
        
        except socket.timeout:
            raise PasswordError("Did not receive password prompt")
    
        return True
    
    def _check_login(self, password_accepted):
        prompt = ""
        try:
            while password_accepted not in prompt:
                prompt += self.read()
        
        except socket.timeout:
            raise PasswordError("Password not accepted")
        
        return True

    def read(self, bufsize=4096, encoding='utf-8'):
        bytes = self.recv(bufsize).strip()
        return bytes.decode(encoding)
     
    def write(self, string, suffix='\r\n', encoding='utf-8'):
        string += suffix
        bytes = string.encode(encoding)
        return self.send(bytes)

    def get_config(self):
        url = self.CONFIG_URL.format(ip=self.host)
        req = urllib.request.Request(url)
 
        try:
            # *must* run first before urlopen (race condition without try/else)
            if self.password:
                add_urllib_auth(self.host, self.username, self.password)
        except:
            raise
        else:
            response = urllib.request.urlopen(req, timeout=self.timeout)
        
        with response:
            config = ET.parse(response)

        for element in config.iter('*'):
            if element.text is not None:
                element.text = element.text.strip()

        return config

    def get_properties(self, config):
        prop_line = ('PROP',)

        for property in self._get_system_properties(config):
            yield prop_line + property

        for property in self._get_statistic_properties(config):
            yield prop_line + property

        for device_config in config.findall('./ports/*'):
            for property in self._get_device_properties(device_config):
                yield prop_line + property

        system_type = config.find('./system/sys_type').text
        if '226' in system_type:
            source_select = list(self._get_source_select(config))
        else:
            source_select = None

        # i.e. non-property lines
        for extra in self._get_extra(source_select):
            yield extra

        raise StopIteration

    def _get_system_properties(self, config):
        device = 'System'

        firmware_version = config.find('./system/sys_version').text
        yield device, 'Firmware Version', firmware_version, 'STRING'

        system_type = config.find('./system/sys_type').text
        yield device, 'Type', system_type, 'STRING'

        for url_config in config.findall('./urls/*'):
            url_name = url_config.find('name').text
            url_value = url_config.find('value').text

            # get rid of CDATA crap
            url_value = url_value.replace('<![CDATA[', '', 1)
            url_value = url_value[::-1].replace(']]>'[::-1], '', 1)[::-1]
            
            if url_name == 'Global Viewer':
                url_value = url_value.lstrip('http://')
                yield device, 'Net Address', url_value, 'TELNET'
            elif url_name == 'Default Web Page':
                yield device, 'Control Page', url_value, 'LINK'
            else:
                yield device, url_name, url_value, 'LINK'

        if self.alarm_query:
            self.write(self.alarm_query)
            yield device, 'Power Sense Pin', self.read(), 'NUMBER'

        if 'ipcp505' in system_type.lower():
            self.write('\x1b2CV')  # turn verbosity up to match sis_response
            self.read() # flush response
    
        raise StopIteration
    
    def _get_statistic_properties(self, config):
        device = 'Statistics'
        
        power_xpath = './ports/port/parameters/*[@type=\'power_status\']'
        source_xpath = './ports/port/parameters/*[@type=\'source_select\']'
        
        power_config = config.find(power_xpath)
        source_config = config.find(source_xpath)

        _, power, _ = self._get_value(power_config)
        _, source, _ = self._get_value(source_config)

        if source is None:
            source = 'Unknown'

        if power == 'On' or power == '1':
            yield device, source, '1', 'SOURCE'
            yield device, 'Display On Time', '1', 'SUM'
            
        raise StopIteration
        
    def _get_device_properties(self, device_config):  
        manufacturer = device_config.find('manufacturer').text
        model = device_config.find('model').text
            
        device = ' '.join((manufacturer, model))

        for property_config in device_config.findall('./parameters/*'):
            name, value, type = self._get_value(property_config)

            if name == 'Temperature':
                value = int(value)
                value = 1.8*value + 32    # celcius to fahrenheit
                value = str(int(value))
            
            yield device, name, value, type
            
        raise StopIteration

    def _get_value(self, property_config):
        name = property_config.find('name').text
        query = property_config.find('sis_request').text
        response_format = property_config.find('sis_response').text

        if query is None:
            return name, None, 'NONE'
        
        self.write(query)
        value = self.read()
        
        if response_format is not None:
            tokens = re.split(self.RE_FORMAT_VALUE, response_format)
            
            for token in tokens:
                start_idx = value.find(token)
                if start_idx >= 0:
                    end_idx = start_idx + len(token)
                    value = value[end_idx:]
                else:
                    value = value.lstrip(token)
        
        enums_config = property_config.find('enums')
        if enums_config is not None:
            try:
                value = int(value)
            except ValueError:
                pass
            else:
                enum_xpath = './*[@id=\'{value}\']'.format(value=value)
                enum = enums_config.find(enum_xpath)

                if enum is not None:
                    value = enum.text

        number = try_to_number(value)
        if number is None:
            type = 'STRING'
        else:
            type = 'NUMBER'
            value = number

        return name, value, type

    def _get_source_select(self, config):
        source_xpath = './ports/port/parameters/*[@type=\'source_select\']/enums'
        
        source_config = config.find(source_xpath)

        for enum in source_config:
            if enum.text is not None:
                yield (enum.text, enum.attrib['id'] + '!')

    def _get_extra(self, source_select=None):
        BUTTONS = [
            ('System', 'Power On', '1P'),
            ('System', 'Power Off', '0P'),
            ('System', 'Alarm Reset', '6!1!'),
            ('System', 'Audio Mute', '1Z'),
            ('System', 'Audio Unmute', '0Z'),
            ('System', 'Display Mute', '1M'),
            ('System', 'Display Unmute', '0M')
        ]

        if source_select is not None:
            SOURCE_SELECT = [('Sources',) + s for s in source_select]
            BUTTONS += SOURCE_SELECT
        
        CMD = '{} $$CMD'
        CMD_PASS = '{} --password {} $$CMD'
        PROC = 'telnet.py'
        
        if self.password:
            cmd = CMD_PASS.format(self.host, self.password)
        else:
            cmd = CMD.format(self.host)

        yield ('CP', PROC, cmd, None, None)
        
        for button in BUTTONS:
            yield ('BTN',) + button + (None,)


class MLC_Monitor: 
    def __init__(self, csv_path, db_path):
        self.csv_path = csv_path
        self.db_path = db_path

    def __enter__(self):
        self.open()
        return self

    def __exit__(self, type, value, traceback):
        is_exc = not type
        self.close()
        return is_exc

    def open(self):
        # TODO: change table name
        PROPERTY_TABLE = """
            CREATE TABLE IF NOT EXISTS property (campus TEXT, building TEXT,
                room TEXT, line TEXT, device TEXT, property TEXT,
                value TEXT, type TEXT, is_updated INTEGER DEFAULT 1,
            PRIMARY KEY(campus, building, room, line, device, property))
        """
        
        self.db = sqlite3.connect(self.db_path) 
        self.db.row_factory = sqlite3.Row
        self.db.execute(PROPERTY_TABLE)
 
        with open(self.csv_path) as systems:
            self.systems = list(csv.DictReader(systems))
            
        return self

    def close(self):
        self.db.commit()
        self.db.close()

    def update(self, timeout=5, max_workers=5):
        online_list, insert_params = [], []
        Executor = concurrent.futures.ThreadPoolExecutor
        
        with Executor(max_workers=max_workers) as executor:
            futures = [executor.submit(get_mlc, timeout, sys) for sys in self.systems]
            for future in concurrent.futures.as_completed(futures):
                result = future.result()
                if result is not None:
                    id, *params = result
                    online_list.append(id)
                    insert_params.append(params) 

        for params in insert_params:
            self.insert(*params)

        return online_list

    def insert(self, base_property, properties):
        PROPERTY_INSERT = """
            INSERT INTO property VALUES (:campus, :building, :room, :line,
                                         :device, :property, :value, :type, 1)
        """

        PROPERTY_UPDATE_ON_CHANGE = """ 
            UPDATE property SET value=:value, type=:type, is_updated=1
            WHERE campus=:campus AND building=:building AND room=:room AND
                  line=:line AND device=:device AND property=:property AND
                  (value!=:value OR (type='SUM' OR type='SOURCE'))
        """

        for property in properties:
            property_keys = ('line', 'device', 'property', 'value', 'type')
            params = dict(zip(property_keys, property))
            params.update(base_property)        

            # i.e. INSERT [...] ON DUPLICATE KEY UPDATE (not in sqlite)
            try:
                self.db.execute(PROPERTY_INSERT, params)
            except sqlite3.IntegrityError:       
                self.db.execute(PROPERTY_UPDATE_ON_CHANGE, params)
            
    def push(self, host, port, message_end='$$$END', timeout=None,
                   encoding='utf-8', online_list=None):   
        PROPERTY_SELECT_UPDATED = """
            SELECT campus, building, room, line, device, property, value, type
            FROM property WHERE is_updated=1
        """
        
        PROPERTY_CLEAR_UPDATED = "UPDATE property SET is_updated=0"

        properties = self.db.execute(PROPERTY_SELECT_UPDATED).fetchall()

        if properties:        
            messages = self._format(properties, message_end, online_list)
            message = '\n'.join(messages)

            logging.debug(message)

            server_sock = get_socket(host, port, timeout)
            payload = message.encode(encoding)
            server_sock.send(payload)
            server_sock.close()

        self.db.execute(PROPERTY_CLEAR_UPDATED)

    @staticmethod
    def _format(properties, message_end, online_list=None):
        last_id, messages = None, []
        
        def finalize_message(message, message_end=message_end):
            message.append([message_end])
            message = ['|'.join(line) for line in message]
            message = '\n'.join(message)
            return message
        
        key = lambda prop: tuple(p if p is not None else '' for p in tuple(prop))
        properties.sort(key=key)

        for property in properties:
            campus, building, room, *property = property
            
            if last_id != (campus, building, room) or last_id is None:
                if last_id is not None:
                    message = finalize_message(message)
                    messages.append(message)

                last_id = campus, building, room
                online_list.remove((campus, building, room))

                message = [['ID', campus, building, room]]
            
            line = property[0]
            if line == 'CP' or line == 'BTN':
                prop_line = [col for col in property if col]
            else:
                prop_line = [col if col else '' for col in property]
            
            message.append(prop_line)
        
        message = finalize_message(message)
        messages.append(message)

        for system in online_list:
            campus, building, room = system
            message = [['ID', campus, building, room]]
            message = finalize_message(message)
            messages.append(message)

        return messages


def main():
    monitor = MLC_Monitor(**config['extron_monitor'])
    with monitor:
        online_list = monitor.update(config['socket_timeout'])
        monitor.push(online_list=online_list, **config['python_server'])
        
if __name__ == '__main__':
    try:
        main()
    except Exception as e:
        logging.warning(e)
