"""
homeassistant.observers
~~~~~~~~~~~~~~~~~~~~~~~

This module provides observers that can change the state or fire
events based on observations.

"""

import logging
import csv
import os
from datetime import datetime, timedelta
import threading
import re
import json

import requests

import homeassistant as ha

STATE_CATEGORY_SUN = "weather.sun"
STATE_ATTRIBUTE_NEXT_SUN_RISING = "next_rising"
STATE_ATTRIBUTE_NEXT_SUN_SETTING = "next_setting"
STATE_CATEGORY_ALL_DEVICES = 'all_devices'
STATE_CATEGORY_DEVICE_FORMAT = '{}'

SUN_STATE_ABOVE_HORIZON = "above_horizon"
SUN_STATE_BELOW_HORIZON = "below_horizon"

DEVICE_STATE_NOT_HOME = 'device_not_home'
DEVICE_STATE_HOME = 'device_home'

# After how much time do we consider a device not home if
# it does not show up on scans
TIME_SPAN_FOR_ERROR_IN_SCANNING = timedelta(minutes=1)

# Return cached results if last scan was less then this time ago
TOMATO_MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)

# Filename to save known devices to
KNOWN_DEVICES_FILE = "known_devices.csv"


def track_sun(eventbus, statemachine, latitude, longitude):
    """ Tracks the state of the sun. """
    logger = logging.getLogger(__name__)

    try:
        import ephem
    except ImportError:
        logger.exception(("TrackSun:Error while importing dependency ephem."))
        return False

    sun = ephem.Sun() # pylint: disable=no-member

    def update_sun_state(now):    # pylint: disable=unused-argument
        """ Method to update the current state of the sun and
            set time of next setting and rising. """
        observer = ephem.Observer()
        observer.lat = latitude
        observer.long = longitude

        next_rising = ephem.localtime(observer.next_rising(sun))
        next_setting = ephem.localtime(observer.next_setting(sun))

        if next_rising > next_setting:
            new_state = SUN_STATE_ABOVE_HORIZON
            next_change = next_setting

        else:
            new_state = SUN_STATE_BELOW_HORIZON
            next_change = next_rising

        logger.info("Sun:{}. Next change: {}".
                        format(new_state, next_change.strftime("%H:%M")))

        state_attributes = {
          STATE_ATTRIBUTE_NEXT_SUN_RISING: ha.datetime_to_str(next_rising),
          STATE_ATTRIBUTE_NEXT_SUN_SETTING: ha.datetime_to_str(next_setting)
        }

        statemachine.set_state(STATE_CATEGORY_SUN, new_state, state_attributes)

        # +10 seconds to be sure that the change has occured
        ha.track_time_change(eventbus, update_sun_state,
                          point_in_time=next_change + timedelta(seconds=10))

    update_sun_state(None)

    return True

class DeviceTracker(object):
    """ Class that tracks which devices are home and which are not. """

    def __init__(self, eventbus, statemachine, device_scanner):
        self.statemachine = statemachine
        self.eventbus = eventbus
        self.device_scanner = device_scanner
        self.logger = logging.getLogger(__name__)

        self.lock = threading.Lock()

        # Dictionary to keep track of known devices and devices we track
        self.known_devices = {}

        # Did we encounter a valid known devices file
        self.invalid_known_devices_file = False

        # Read known devices if file exists
        if os.path.isfile(KNOWN_DEVICES_FILE):
            with open(KNOWN_DEVICES_FILE) as inp:
                default_last_seen = datetime(1990, 1, 1)

                # Temp variable to keep track of which categories we use
                # so we can ensure we have unique categories.
                used_categories = []

                try:
                    for row in csv.DictReader(inp):
                        device = row['device']

                        row['track'] = True if row['track'] == '1' else False

                        # If we track this device setup tracking variables
                        if row['track']:
                            row['last_seen'] = default_last_seen

                            # Make sure that each device is mapped
                            # to a unique category name
                            name = row['name']

                            if not name:
                                name = "unnamed_device"

                            tries = 0
                            suffix = ""
                            while True:
                                tries += 1

                                if tries > 1:
                                    suffix = "_{}".format(tries)

                                category = STATE_CATEGORY_DEVICE_FORMAT.format(
                                            name + suffix)

                                if category not in used_categories:
                                    break

                            row['category'] = category
                            used_categories.append(category)

                        self.known_devices[device] = row

                except KeyError:
                    self.invalid_known_devices_file = False
                    self.logger.warning(("Invalid {} found. "
                        "We won't update it with new found devices.").
                        format(KNOWN_DEVICES_FILE))

        if len(self.device_state_categories()) == 0:
            self.logger.warning("No devices to track. Please update {}.".
                                                format(KNOWN_DEVICES_FILE))


        ha.track_time_change(eventbus,
          lambda time: self.update_devices(device_scanner.scan_devices()))

    def device_state_categories(self):
        """ Returns a list containing all categories
            that are maintained for devices. """
        return [self.known_devices[device]['category'] for device
                in self.known_devices if self.known_devices[device]['track']]

    def update_devices(self, found_devices):
        """ Update device states based on the found devices. """
        self.lock.acquire()

        temp_tracking_devices = [device for device in self.known_devices
                                 if self.known_devices[device]['track']]

        for device in found_devices:
            # Are we tracking this device?
            if device in temp_tracking_devices:
                temp_tracking_devices.remove(device)

                self.known_devices[device]['last_seen'] = datetime.now()

                self.statemachine.set_state(
                    self.known_devices[device]['category'], DEVICE_STATE_HOME)

        # For all devices we did not find, set state to NH
        # But only if they have been gone for longer then the error time span
        # Because we do not want to have stuff happening when the device does
        # not show up for 1 scan beacuse of reboot etc
        for device in temp_tracking_devices:
            if (datetime.now() - self.known_devices[device]['last_seen'] >
                TIME_SPAN_FOR_ERROR_IN_SCANNING):

                self.statemachine.set_state(
                    self.known_devices[device]['category'],
                    DEVICE_STATE_NOT_HOME)

        # Get the currently used statuses
        states_of_devices = [self.statemachine.get_state(category)['state']
                             for category in self.device_state_categories()]

        # Update the all devices category
        all_devices_state = (DEVICE_STATE_HOME if DEVICE_STATE_HOME
                             in states_of_devices else DEVICE_STATE_NOT_HOME)

        self.statemachine.set_state(STATE_CATEGORY_ALL_DEVICES,
                                    all_devices_state)

        # If we come along any unknown devices we will write them to the
        # known devices file but only if we did not encounter an invalid
        # known devices file
        if not self.invalid_known_devices_file:

            unknown_devices = [device for device in found_devices
                               if device not in self.known_devices]

            if len(unknown_devices) > 0:
                try:
                    # If file does not exist we will write the header too
                    is_new_file = not os.path.isfile(KNOWN_DEVICES_FILE)

                    with open(KNOWN_DEVICES_FILE, 'a') as outp:
                        self.logger.info(("DeviceTracker:Found {} new devices,"
                            " updating {}").format(len(unknown_devices),
                                                   KNOWN_DEVICES_FILE))

                        writer = csv.writer(outp)

                        if is_new_file:
                            writer.writerow(("device", "name", "track"))

                        for device in unknown_devices:
                            # See if the device scanner knows the name
                            temp_name = self.device_scanner.get_device_name(
                                                                        device)

                            name = temp_name if temp_name else "unknown_device"

                            writer.writerow((device, name, 0))
                            self.known_devices[device] = {'name':name,
                                                          'track': False}

                except IOError:
                    self.logger.exception(("DeviceTracker:Error updating {}"
                        "with {} new devices").format(KNOWN_DEVICES_FILE,
                                                      len(unknown_devices)))

        self.lock.release()


class TomatoDeviceScanner(object):
    """ This class queries a wireless router running Tomato firmware
    for connected devices.

    A description of the Tomato API can be found on
    http://paulusschoutsen.nl/blog/2013/10/tomato-api-documentation/
    """

    def __init__(self, host, username, password, http_id):
        self.req = requests.Request('POST',
                                    'http://{}/update.cgi'.format(host),
                                    data={'_http_id': http_id,
                                          'exec': 'devlist'},
                                    auth=requests.auth.HTTPBasicAuth(
                                                username, password)).prepare()

        self.parse_api_pattern = re.compile(r"(?P<param>\w*) = (?P<value>.*);")

        self.logger = logging.getLogger(__name__)
        self.lock = threading.Lock()

        self.date_updated = None
        self.last_results = {"wldev": [], "dhcpd_lease": []}

        self.success_init = self._update_tomato_info()

    def scan_devices(self):
        """ Scans for new devices and return a
            list containing found device ids. """

        self._update_tomato_info()

        return [item[1] for item in self.last_results['wldev']]

    def get_device_name(self, device):
        """ Returns the name of the given device or None if we don't know. """

        # Make sure there are results
        if not self.date_updated:
            self._update_tomato_info()

        filter_named = [item[0] for item in self.last_results['dhcpd_lease']
                        if item[2] == device]


        if len(filter_named) == 0 or filter_named[0] == "":
            return None
        else:
            return filter_named[0]

    def _update_tomato_info(self):
        """ Ensures the information from the Tomato router is up to date.
            Returns boolean if scanning successful. """

        self.lock.acquire()

        # if date_updated is None or the date is too old we scan for new data
        if (not self.date_updated or datetime.now() - self.date_updated >
            TOMATO_MIN_TIME_BETWEEN_SCANS):

            self.logger.info("Tomato:Scanning")

            try:
                response = requests.Session().send(self.req, timeout=3)

                # Calling and parsing the Tomato api here. We only need the
                # wldev and dhcpd_lease values. For API description see:
                # http://paulusschoutsen.nl/blog/2013/10/tomato-api-documentation/
                if response.status_code == 200:

                    for param, value in self.parse_api_pattern.findall(
                                                                response.text):

                        if param == 'wldev' or param == 'dhcpd_lease':
                            self.last_results[param] = json.loads(value.
                                                            replace("'",'"'))

                    self.date_updated = datetime.now()

                    return True

                elif response.status_code == 401:
                    # Authentication error
                    self.logger.exception(("Tomato:Failed to authenticate, "
                        "please check your username and password"))

                    return False

            except requests.exceptions.ConnectionError:
                # We get this if we could not connect to the router or
                # an invalid http_id was supplied
                self.logger.exception(("Tomato:Failed to connect to the router"
                    " or invalid http_id supplied"))

                return False

            except requests.exceptions.Timeout:
                # We get this if we could not connect to the router or
                # an invalid http_id was supplied
                self.logger.exception(("Tomato:Connection to the router "
                    "timed out"))

                return False

            except ValueError:
                # If json decoder could not parse the response
                self.logger.exception(("Tomato:Failed to parse response "
                    "from router"))

                return False

            finally:
                self.lock.release()

        else:
            # We acquired the lock before the IF check,
            # release it before we return True
            self.lock.release()

            return True
