#!/usr/bin/env python3
# -*- mode: python; coding: utf-8 -*-

import logging
from json import dumps as dump_json
from os import environ as env
from os.path import expanduser, join
from time import time
import string
from platform import node as hostname
import asyncio

import certifi
from amqtt.client import ClientException, ConnectException, MQTTClient

from .dashboard import Lock, Position, Sensor, BinarySensor, Switch
from .util import camel2slug, whitelisted, owntracks_encrypt

_LOGGER = logging.getLogger(__name__)

STATE_ON = "on"
STATE_OFF = "off"

STATE_ONLINE = "online"
STATE_OFFLINE = "offline"

STATE_LOCK = "lock"
STATE_UNLOCK = "unlock"

STATE_OPEN = "open"
STATE_CLOSE = "close"

STATE_SAFE = "safe"
STATE_UNSAFE = "unsafe"

DISCOVERY_PREFIX = "homeassistant"
TOPIC_PREFIX = "volvo"

CONF_OWNTRACKS_KEY = "owntracks_key"


TOPIC_WHITELIST = "_-" + string.ascii_letters + string.digits
TOPIC_SUBSTITUTE = "_"


def make_valid_hass_single_topic_level(s):
    """Transform a multi level topic to a single level.

    >>> make_valid_hass_single_topic_level('foo/bar/baz')
    'foo_bar_baz'

    >>> make_valid_hass_single_topic_level('hello å ä ö')
    'hello______'
    """
    return whitelisted(s, TOPIC_WHITELIST, TOPIC_SUBSTITUTE)


def make_topic(*levels):
    """Create a valid topic.

    >>> make_topic('foo', 'bar')
    'foo/bar'

    >>> make_topic(('foo', 'bar'))
    'foo/bar'
    """
    if len(levels) == 1 and isinstance(levels[0], tuple):
        return make_topic(*levels[0])
    return "/".join(levels)


def read_mqtt_config():
    """Read config from ~/.config/mosquitto_pub."""
    fname = join(
        env.get("XDG_CONFIG_HOME", join(expanduser("~"), ".config")),
        "mosquitto_pub",
    )
    try:
        with open(fname) as f:
            d = dict(
                line.replace("-", "").split() for line in f.read().splitlines()
            )
            return dict(
                host=d["h"],
                port=d["p"],
                username=d["username"],
                password=d["pw"],
            )
    except KeyError as error:
        exit("Could not parse MQTT config in %s: %s" % (fname, error))
    except FileNotFoundError:
        exit("Could not find MQTT config: %s" % fname)


class Entity:
    subscriptions = {}

    def __init__(self, client, instrument, config):
        self.client = client
        self.instrument = instrument
        self.config = config

    def __repr__(self):
        return self.instrument.name

    @classmethod
    def route_message(cls, topic, payload):
        entity = Entity.subscriptions.get(topic)
        if entity:
            entity.receive_command(payload)
        else:
            _LOGGER.warning("No subscriber to message on topic %s", topic)

    @property
    def vehicle(self):
        return self.instrument.vehicle

    @property
    def attr(self):
        return self.instrument.attr

    @property
    def name(self):
        return self.instrument.full_name

    @property
    def state(self):
        state = self.instrument.state
        if state is None:
            return None
        if self.is_lock:
            return (STATE_UNLOCK, STATE_LOCK)[state]
        elif self.is_switch:
            return (STATE_OFF, STATE_ON)[state]
        elif self.is_opening:
            return (STATE_CLOSE, STATE_OPEN)[state]
        elif self.is_safety:
            return (STATE_SAFE, STATE_UNSAFE)[state]
        elif self.is_binary_sensor:
            return (STATE_OFF, STATE_ON)[state]
        elif self.is_position:
            lat, lon, timestamp, speed, heading = state
            key = self.config.get(CONF_OWNTRACKS_KEY)
            res = dict(
                _type="location",
                tid="volvo",
                t="p",
                lat=lat,
                lon=lon,
                acc=1,
                tst=int(time()),
                now=int(time()),
            )
            if timestamp is not None:
                res["tst"] = int(timestamp.timestamp())
                res["tst_iso"] = timestamp.isoformat()
            if speed is not None:
                res["speed"] = speed
            if heading is not None:
                res["heading"] = heading
            return (
                dict(
                    _type="encrypted",
                    data=owntracks_encrypt(dump_json(res), key),
                )
                if key
                else res
            )
        else:
            return state

    @property
    def discovery_node_id(self):
        return make_valid_hass_single_topic_level(
            make_topic(TOPIC_PREFIX, self.vehicle.unique_id)
        )

    @property
    def object_id(self):
        return make_valid_hass_single_topic_level(camel2slug(self.attr))

    @property
    def discovery_topic(self):
        return make_topic(
            DISCOVERY_PREFIX,
            self.instrument.component,
            self.discovery_node_id,
            self.object_id,
            "config",
        )

    @property
    def topic(self):
        return make_topic(
            TOPIC_PREFIX,
            self.vehicle.unique_id,
            self.instrument.component,
            self.object_id,
        )

    def make_topic(self, *levels):
        return make_topic(self.topic, *levels)

    @property
    def state_topic(self):
        if self.is_position:
            return make_topic("owntracks", "volvo", self.vehicle.unique_id)
        else:
            return self.make_topic("state")

    @property
    def availability_topic(self):
        return self.make_topic("avail")

    @property
    def command_topic(self):
        return self.make_topic("cmd")

    @property
    def discovery_payload(self):
        instrument = self.instrument
        payload = dict(
            name=instrument.full_name,
            state_topic=self.state_topic,
            availability_topic=self.availability_topic,
            payload_available=STATE_ONLINE,
            payload_not_available=STATE_OFFLINE,
        )

        if self.is_mutable:
            payload.update(command_topic=self.command_topic)

        if self.is_sensor:
            return dict(
                payload,
                icon=instrument.icon,
                unit_of_measurement=instrument.unit,
            )
        elif self.is_opening:
            return dict(
                payload,
                payload_on=STATE_OPEN,
                payload_off=STATE_CLOSE,
                device_class=instrument.device_class,
            )
        elif self.is_safety:
            return dict(
                payload,
                payload_on=STATE_UNSAFE,
                payload_off=STATE_SAFE,
                device_class=instrument.device_class,
            )
        elif self.is_binary_sensor:
            return dict(
                payload,
                payload_on=STATE_ON,
                payload_off=STATE_OFF,
                device_class=instrument.device_class,
            )
        elif self.is_lock:
            return dict(
                payload,
                payload_lock=STATE_LOCK,
                payload_unlock=STATE_UNLOCK,
                optimistic=True,
            )
        elif self.is_switch:
            return dict(
                payload,
                payload_on=STATE_ON,
                payload_off=STATE_OFF,
                icon=instrument.icon,
                optimistic=True,
            )
        else:
            _LOGGER.error("Huh?")

    async def publish(self, topic, payload, retain=False):
        payload = (
            dump_json(payload) if isinstance(payload, dict) else str(payload)
        )
        _LOGGER.debug("Publishing on %s: %s", topic, payload)
        await self.client.publish(
            topic, payload.encode("utf-8"), retain=retain
        )
        _LOGGER.debug("Published on %s: %s", topic, payload)

    async def subscribe_to(self, topic):
        _LOGGER.debug("Subscribing to %s", topic)
        from amqtt.mqtt.constants import QOS_1

        await self.client.subscribe([(topic, QOS_1)])
        _LOGGER.debug("Subscribed to %s", topic)
        Entity.subscriptions[topic] = self

    def receive_command(self, command):
        run = asyncio.create_task  # pylint:disable=no-member

        if self.is_lock:
            if command == STATE_LOCK:
                run(self.instrument.lock())
            elif command == STATE_UNLOCK:
                run(self.instrument.unlock())
            else:
                _LOGGER.info("Skipping unknown payload %s", command)
        elif self.is_switch:
            if command == STATE_ON:
                run(self.instrument.turn_on())
            elif command == STATE_OFF:
                run(self.instrument.turn_off())
            else:
                _LOGGER.info("Skipping unknown payload %s", command)
        else:
            _LOGGER.warning("No command to execute for %s: %s", self, command)

    @property
    def is_mutable(self):
        return self.is_lock or self.is_switch

    @property
    def is_sensor(self):
        return isinstance(self.instrument, Sensor)

    @property
    def is_binary_sensor(self):
        return isinstance(self.instrument, BinarySensor)

    @property
    def is_opening(self):
        return self.is_binary_sensor and self.instrument.device_class in [
            "door",
            "window",
        ]

    @property
    def is_safety(self):
        return (
            self.is_binary_sensor and self.instrument.device_class == "safety"
        )

    @property
    def is_switch(self):
        return isinstance(self.instrument, Switch)

    @property
    def is_position(self):
        return isinstance(self.instrument, Position)

    @property
    def is_lock(self):
        return isinstance(self.instrument, Lock)

    async def publish_discovery(self):
        if self.is_position:
            return
        if self.is_mutable:
            await self.subscribe_to(self.command_topic)
        await self.publish(self.discovery_topic, self.discovery_payload)

    async def publish_availability(self, available):
        if self.is_position:
            return
        await self.publish(
            self.availability_topic,
            STATE_ONLINE
            if available and self.state is not None
            else STATE_OFFLINE,
        )

    async def publish_state(self):
        if self.state is not None:
            _LOGGER.debug("State for %s: %s", self.attr, self.state)
            await self.publish(self.state_topic, self.state)
        else:
            _LOGGER.warning("No state available for %s", self)


async def run(voc, config):
    logging.getLogger("amqtt.client.plugins.packet_logger_plugin").setLevel(
        logging.WARNING
    )

    client_id = "voc_{hostname}_{time}".format(
        hostname=hostname(), time=time()
    )

    mqtt = MQTTClient(client_id=client_id)
    url = config.get("mqtt_url")

    if url:
        _LOGGER.debug("Using MQTT url from voc.conf")
    else:
        _LOGGER.debug("Using MQTT url from mosquitto_pub")
        mqtt_config = read_mqtt_config()
        try:
            username = mqtt_config["username"]
            password = mqtt_config["password"]
            host = mqtt_config["host"]
            port = mqtt_config["port"]
            url = "mqtts://{username}:{password}@{host}:{port}".format(
                username=username, password=password, host=host, port=port
            )
        except Exception as e:
            exit(e)

    entities = {}

    async def mqtt_task():
        try:
            await mqtt.connect(url, cleansession=False, cafile=certifi.where())
            _LOGGER.info("Connected to MQTT server")
        except ConnectException as e:
            exit("Could not connect to MQTT server: %s" % e)
        while True:
            _LOGGER.debug("Waiting for messages")
            try:
                message = await mqtt.deliver_message()
                packet = message.publish_packet
                topic = packet.variable_header.topic_name
                payload = packet.payload.data.decode("ascii")
                _LOGGER.debug("got message on %s: %s", topic, payload)
                Entity.route_message(topic, payload)
            except ClientException as e:
                _LOGGER.error("MQTT Client exception: %s", e)

    asyncio.create_task(mqtt_task())  # pylint:disable=no-member

    interval = int(config["interval"])
    _LOGGER.info("Polling VOC every %d seconds", interval)
    while True:
        available = await voc.update(journal=True)
        wait_list = []
        for vehicle in voc.vehicles:
            if vehicle not in entities:
                _LOGGER.debug("creating vehicle %s", vehicle)

                dashboard = vehicle.dashboard(**config)

                entities[vehicle] = [
                    Entity(mqtt, instrument, config)
                    for instrument in dashboard.instruments
                ]
            for entity in entities[vehicle]:
                _LOGGER.debug(
                    "%s: %s", entity.instrument.full_name, entity.state
                )
                wait_list.append(entity.publish_discovery())
                wait_list.append(entity.publish_availability(available))
                if available:
                    wait_list.append(entity.publish_state())

        await asyncio.gather(*wait_list)
        _LOGGER.debug("Waiting for new VOC update in %d seconds", interval)
        await asyncio.sleep(interval)
