"""This platform enables the possibility to control a Jablotron alarm."""
import logging
import re
import time
import voluptuous as vol
import asyncio
import threading
import json
from datetime import timedelta, datetime

import homeassistant.components.alarm_control_panel as alarm
from homeassistant.const import (
    CONF_CODE, CONF_DEVICE, CONF_NAME, CONF_VALUE_TEMPLATE,
    STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
    STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_ALARM_TRIGGERED,
    ATTR_CODE_FORMAT)
from homeassistant.components.alarm_control_panel.const import (
    SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_TRIGGER, SUPPORT_ALARM_ARM_NIGHT
    )
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.components.sensor import PLATFORM_SCHEMA

_LOGGER = logging.getLogger(__name__)

CONF_SERIAL_PORT = 'serial_port'
CONF_CODE_PANEL_ARM_REQUIRED = 'code_panel_arm_required'
CONF_CODE_PANEL_DISARM_REQUIRED = 'code_panel_disarm_required'
CONF_CODE_ARM_REQUIRED = 'code_arm_required'
CONF_CODE_DISARM_REQUIRED = 'code_disarm_required'
CONF_CODE_SENSOR_NAMES = 'sensor_names'

DEFAULT_NAME = 'Jablotron Alarm'
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
    vol.Required(CONF_SERIAL_PORT): cv.string,
    vol.Optional(CONF_CODE): cv.string,
    vol.Optional(CONF_CODE_ARM_REQUIRED, default=False): cv.boolean,
    vol.Optional(CONF_CODE_DISARM_REQUIRED, default=True): cv.boolean,
    vol.Optional(CONF_CODE_PANEL_ARM_REQUIRED, default=False): cv.boolean,
    vol.Optional(CONF_CODE_PANEL_DISARM_REQUIRED, default=True): cv.boolean,
    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
    vol.Optional(CONF_CODE_SENSOR_NAMES, default={}): {int: cv.string},
})

ATTR_CHANGED_BY = "changed_by"
ATTR_CODE_ARM_REQUIRED = "code_arm_required"
ATTR_TRIGGERD_BY = "triggered_by"

JABLOTRON_KEY_MAP = {
    "0": b'\x80',
    "1": b'\x81',
    "2": b'\x82',
    "3": b'\x83',
    "4": b'\x84',
    "5": b'\x85',
    "6": b'\x86',
    "7": b'\x87',
    "8": b'\x88',
    "9": b'\x89',
    "#": b'\x8e',
    "?": b'\x8e',
    "*": b'\x8f'
}

async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
                               async_add_entities, discovery_info=None):

    async_add_entities([JablotronAlarm(hass,config)])

class JablotronAlarm(alarm.AlarmControlPanelEntity):
    """Representation of a Jabltron alarm status."""

    def __init__(self, hass, config):
        """Init the Alarm Control Panel."""
        self._state = None
        self._sub_state = None
        self._changed_by = None
        self._triggered_by = None
        self._name = config.get(CONF_NAME)
        self._file_path = config.get(CONF_SERIAL_PORT)
        self._available = False
        self._code = config.get(CONF_CODE)
        self._f = None
        self._hass = hass
        self._config = config
        self._model = 'Unknown'
        self._lock = threading.BoundedSemaphore()
        self._stop = threading.Event()
        self._updated = asyncio.Event()
        self._desired_state_updated = asyncio.Event()
        self._wait_task = None

        try:
            hass.bus.async_listen('homeassistant_stop', self.shutdown_threads)

            from concurrent.futures import ThreadPoolExecutor
            self._io_pool_exc = ThreadPoolExecutor(max_workers=5)
            #self._io_pool_exc.submit(self._startup_message)

            self._startup_message()

            self._read_loop_future = self._io_pool_exc.submit(self._read_loop)

            self.loop = asyncio.get_running_loop()

            self.loop.create_task(self.send_command_loop())

        except Exception as ex:
            _LOGGER.error('Unexpected error: %s', format(ex) )


    def shutdown_threads(self, event):

        _LOGGER.debug('handle_shutdown() called' )

        self._stop.set()
        if self._wait_task is not None:
            self._wait_task.cancel()

        _LOGGER.debug('exiting handle_shutdown()' )

#    @property
#    def unique_id(self):
#        """Return a unique ID."""
#        return 'alarm_control_panel.jablotron.test'

    @property
    def should_poll(self):
        """No polling needed."""
        return False

    @property
    def name(self):
        """Return the name of the device."""
        return self._name

    @property
    def state(self):
        """Return the state of the device."""
        return self._state

    @property
    def changed_by(self):
        """Return the last source of state change."""
        return self._changed_by
        
    @property
    def triggered_by(self):
        """Return the sensor which triggered the alarm"""
        return self._triggered_by

    @property
    def available(self):
        return self._available

    @property
    def code_format(self):
        """Return one or more digits/characters."""
        code = self._code
        if code is None:
            return None

        # Return None if no code needed in HA
        if not self._config[CONF_CODE_ARM_REQUIRED] and not self._config[CONF_CODE_DISARM_REQUIRED]:
            return None

        if isinstance(code, str) and re.search('^\\d+$', code):
            return alarm.FORMAT_NUMBER
        return alarm.FORMAT_TEXT

    @property
    def supported_features(self) -> int:
        """Return the list of supported features."""
        return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_TRIGGER | SUPPORT_ALARM_ARM_NIGHT
    
    @property
    def state_attributes(self):
        """Return the state attributes."""
        state_attr = {
            ATTR_CODE_FORMAT: self.code_format,
            ATTR_CHANGED_BY: self.changed_by,
            ATTR_CODE_ARM_REQUIRED: self.code_arm_required,
            ATTR_TRIGGERD_BY: self.triggered_by,
        }
        return state_attr

    async def _update(self):

        #_LOGGER.debug('_update called, state: %s', self._state )
        self._updated.set()
        self.async_schedule_update_ha_state()
        #_LOGGER.debug('_update exited, state: %s', self._state )

    def _read_loop(self):

        try:
            self._f = open(self._file_path, 'rb', 64)

            lastTriggerTime = None

            while not self._stop.is_set():

                time.sleep(1) # read state once every second, no need for more!

                #self._lock.acquire()

                new_state = self._read()

                if new_state != self._state:
                    _LOGGER.info("Jablotron state change detected: %s to %s", self._state, new_state)
                    if new_state == STATE_ALARM_TRIGGERED and self._triggered_by is None:
                        _LOGGER.debug("Alarm triggered but source not known yet")

                        # wait for _triggered_by to be set before returning triggered state, but not more that 10 seconds
                        if lastTriggerTime is not None and (datetime.now() - lastTriggerTime).seconds < 10: 
                            continue 
                        elif lastTriggerTime is None:
                            lastTriggerTime = datetime.now()
                            continue

                    elif new_state == STATE_ALARM_DISARMED:
                        lastTriggerTime = None # clear last trigger time
                        self._triggered_by = None # clear triggered_by
                    
                    # Update state & notify home assistant
                    self._state = new_state
                    asyncio.run_coroutine_threadsafe(self._update(), self._hass.loop)
                        
                #self._lock.release()

        except Exception as ex:
            _LOGGER.error('Unexpected error: %s', format(ex) )

        finally:
            self._f.close()
            _LOGGER.debug('exiting read_loop()' )

    def _read(self):

        state = None

        try:
            while True:

                self._lock.acquire()
                packet = self._f.read(64)
                self._lock.release()

                if not packet:
                    _LOGGER.warn("No packets")
                    self._available = False
                    return 'No Signal'   

                self._available = True

                if packet[:1] == b'\x82': # all JA-82 packets begin x82 

                    self._model = 'Jablotron JA-80 Series'
                    byte_two = int.from_bytes(packet[1:2], byteorder='big', signed=False)
                    
                    # Startup packet
                    if byte_two == 62: # '>' symbol is received on startup
                        _LOGGER.info("Startup response packet is: %s", packet[1:8])

                    # Status packet
                    elif byte_two == 1: 
                        # and byte_two <= 8: # and byte_two != 2: # all 2nd packets I have seen are between 1 and 8, but 2 packets sometimes have trigger message 

                        state_byte = packet[2:3]
                    
                        # heartbeats or null
                        if state_byte in (b'\xed', b'\xff', b'\x00', b'z'):
                            state = "ignore" # no change 
                        # Stable states
                        elif state_byte == b'@': 
                            state = STATE_ALARM_DISARMED
                        elif state_byte in (b'Q', b'R', b'S') and self._state == STATE_ALARM_DISARMED:
                            state = STATE_ALARM_ARMING # Zone A; A&B; A&B&C
                        elif state_byte == b'A':
                            state = STATE_ALARM_ARMED_HOME
                        elif state_byte == b'B':
                            state = STATE_ALARM_ARMED_NIGHT
                        elif state_byte == b'C':
                            state = STATE_ALARM_ARMED_AWAY
                        elif state_byte == b'D' :
                            state = STATE_ALARM_TRIGGERED # via '24 hour' sensor when unset
                        elif state_byte in (b'E', b'G') :
                            state = STATE_ALARM_TRIGGERED # when zone A is set, via standard sensor when set
                        # Temporary states
                        elif state_byte == b'K' and self._state == STATE_ALARM_ARMED_AWAY:
                            state = STATE_ALARM_PENDING
                        elif state_byte in (b'\xa1', b'$') and self._state == STATE_ALARM_ARMING:
                            state = STATE_ALARM_ARMING # during arm & arm away (beeps?)
                        elif state_byte in (b'\x02', b'\xe8', b'=') and self._state == STATE_ALARM_TRIGGERED:
                            state = STATE_ALARM_TRIGGERED # during alarm & alarm night
                        elif state_byte in (b'\xa4', b'\xa0', b'\xb8') and self._state != STATE_ALARM_DISARMED:
                            state = STATE_ALARM_DISARMING
                        # Keypress 
                        elif state_byte in (b'\x80', b'\x81', b'\x82', b'\x83', b'\x84', b'\x85', b'\x86', b'\x87', b'\x88', b'\x89', b'\x8e', b'\x8f'):
                            state = "ignore" # no change

                        # Ignore change from disarmed to triggered
                        if state == STATE_ALARM_TRIGGERED and self._state == STATE_ALARM_DISARMED:
                            _LOGGER.info("Ignoring triggered state when disarmed")
                            state = "ignore" 

                        if state == "ignore":
                            pass
                        elif state is not None:
                            return state
                        else:
                            _LOGGER.debug("Unknown status packet is %s", packet[2:8])

                    # Packets x07?F*\x1* contains the id of the device which triggered the alarm
                    elif (byte_two == 7 and self._triggered_by is None and 
                            (
                                (packet[2:4] == b'GF' and packet[5:6] == b'\x1f') or # when away                                
                                (packet[2:4] == b'G\x06' and packet[5:6] == b'\x1f') or # when away
                                (packet[2:4] == b'EF' and packet[5:6] == b'\x19') or # when home
                                (packet[2:4] == b'E\x06' and packet[5:6] == b'\x19') # when home
                            )): 
                        _LOGGER.debug("Sensor status packet is: %s", packet[1:8])
                        sensor_id = int.from_bytes(packet[4:5], byteorder='big', signed=False)
                        triggered_sensor = "%s: %s" % (sensor_id, self._config[CONF_CODE_SENSOR_NAMES].get(sensor_id, '?'))
                        _LOGGER.info("Trigger of sensor %s detected", triggered_sensor)
                        self._triggered_by = triggered_sensor

                    # Packets x07G*\x1* contains the id of the device which have been sabotaged
                    elif (byte_two == 7 and self._triggered_by is None and 
                            (
                                (packet[2:3] == b'G' and packet[4:5] == b'\x1f')  or # when away
                                (packet[2:3] == b'G' and packet[4:5] == b'\x19') # when home
                            )): 
                        _LOGGER.debug("Sensor status packet is: %s", packet[1:8])
                        sensor_id = int.from_bytes(packet[3:4], byteorder='big', signed=False)
                        triggered_sensor = "%s: %s (sabotage)" % (sensor_id, self._config[CONF_CODE_SENSOR_NAMES].get(sensor_id, '?'))
                        _LOGGER.info("Sabotage of sensor %s detected", triggered_sensor)
                        self._triggered_by = triggered_sensor

                    else:
                        # FOR REVERSE ENGINEERING ONLY: will produce A LOT of logs
                        #_LOGGER.debug("Unknown packet when triggered %s", packet[1:8])
                        pass

                else:
                    _LOGGER.error("The data stream is not recongisable as a JA-82 control panel. Please raise an issue at https://github.com/mattsaxon/HASS-Jablotron80/issues with this packet info [%s]", packet)
                    self._stop.set()

        except (IndexError, FileNotFoundError, IsADirectoryError,
                UnboundLocalError, OSError):
            _LOGGER.warning("File or data not present at the moment: %s",
                            self._file_path)
            return 'Failed'

        except Exception as ex:
            _LOGGER.error('Unexpected error: %s', format(ex) )
            return 'Failed'

    async def async_alarm_disarm(self, code=None):
        """Send disarm command.

        This method is a coroutine.
        """

        if self._config[CONF_CODE_DISARM_REQUIRED] and not self._validate_code(code, 'disarming'):
            return

        send_code = ""

        if self._config[CONF_CODE_PANEL_DISARM_REQUIRED]:
            # Use code from config if and onlfy is none is entered by user and setup as not required
            if code is None and not self._config[CONF_CODE_DISARM_REQUIRED]:
                code = self._code
            send_code = code

        action = "*0"

        await self._sendCommand(send_code, action, STATE_ALARM_DISARMED)


    async def async_alarm_arm_home(self, code=None):
        """Send arm home command.

        This method is a coroutine.
        """
        if self._config[CONF_CODE_ARM_REQUIRED] and not self._validate_code(code, 'arming home'):
            return

        send_code = ""
        if self._config[CONF_CODE_PANEL_ARM_REQUIRED]:
            send_code = code

        action = "*2"

        await self._sendCommand(send_code, action, STATE_ALARM_ARMED_HOME)


    async def async_alarm_arm_away(self, code=None):
        """Send arm away command.

        This method is a coroutine.
        """
        if self._config[CONF_CODE_ARM_REQUIRED] and not self._validate_code(code, 'arming away'):
            return

        send_code = ""
        if self._config[CONF_CODE_PANEL_ARM_REQUIRED]:
            send_code = code

        action = "*1"

        await self._sendCommand(send_code, action, STATE_ALARM_ARMED_AWAY)


    async def async_alarm_arm_night(self, code=None):
        """Send arm night command.

        This method is a coroutine.
        """
        if self._config[CONF_CODE_ARM_REQUIRED] and not self._validate_code(code, 'arming night'):
            return

        send_code = ""
        if self._config[CONF_CODE_PANEL_ARM_REQUIRED]:
            send_code = code

        action = "*3"

        await self._sendCommand(send_code, action, STATE_ALARM_ARMED_NIGHT)


    async def _sendCommand(self, code, action, desired_state):

        payload = action

        if code is not None:
            payload += code

        self._payload = payload
        self._desired_state = desired_state
        self._changed_by = "hass"

        self._desired_state_updated.set()

        if self._wait_task is not None:
            self._wait_task.cancel()


    async def send_command_loop(self):

        _LOGGER.debug('send_command_loop() enter')

        while not self._stop.is_set():

            retrying = False

            await self._desired_state_updated.wait()
            self._desired_state_updated.clear()

            _LOGGER.debug('command received: %s', self._payload)

            while self.state != self._desired_state:

                self._updated.clear()

                if not retrying or (self.state != STATE_ALARM_ARMING and self.state != STATE_ALARM_DISARMING and self.state != STATE_ALARM_PENDING) :
                    await self._send_keys(self._payload)

                try:

                    if self._desired_state == STATE_ALARM_DISARMED:
                        timeout = 10
                    else:
                        timeout = 40

                    self._wait_task = self.loop.create_task(self._updated.wait())
                    await asyncio.wait_for(self._wait_task, timeout)
                    self._updated.clear()

                except asyncio.TimeoutError:
                    _LOGGER.warn('Timed out waiting for change of state, retry')

                except asyncio.CancelledError:
                    _LOGGER.debug('New desired state set, wait has been cancelled, wait for next command')
                    break

                except Exception as ex:
                    _LOGGER.error('Unexpected error: %s', format(ex) )
                    break

                retrying = True

                _LOGGER.debug('state: %s', self.state)

        _LOGGER.debug('send_command_loop(): exit')


    async def _send_keys(self, payload):
        """Send via serial port."""

        _LOGGER.debug("sending %s", payload)

        try:
            self._lock.acquire()

            packet_no = 0
            for c in payload:
                packet_no +=1
                packet = b'\x00\x02\x01' + JABLOTRON_KEY_MAP.get(c)
                _LOGGER.debug('sending packet %i, message: %s', packet_no, packet)
                self._send_packet(packet)
                await asyncio.sleep(0.1) # lower reliability without this delay

        except Exception as ex:
            _LOGGER.error('Unexpected error: %s', format(ex) )

        finally:
            self._lock.release()

    def _send_packet(self, packet):
        f = open(self._file_path, 'wb')
        f.write(packet)
        f.close()        

    def _startup_message(self):
        """ Send Start Message to panel"""

        _LOGGER.debug('Sending startup message')
        self._send_packet(b'\x00\x00\x01\x01')
        _LOGGER.debug('Successfully sent startup message')

    def _validate_code(self, code, state):
        """Validate given code."""
        conf_code = self._code
        check = conf_code is None or code == conf_code
        if not check:
            _LOGGER.warning('Wrong code entered for %s', state)
        return check
