#!/usr/bin/python
# -*- coding: utf-8 -*-

import time
import subprocess
import argparse
import json
import hashlib
import textwrap
import smtplib
import logging
import shelve
from datetime import datetime
from email.message import EmailMessage
from pathlib import PosixPath
import yaml  # PyYAML
import requests  # requests
from sds011 import SDS011  # py-sds011
import aqi  # python-aqi
from xdg import xdg_config_home, xdg_config_dirs, xdg_state_home

NOTION_BASE_URL = "https://api.getnotion.com/api"

# The temperature that the purpleair sensor gathers is higher than the ambient
# temperature due to the heat generated by the sensor
# https://www.facebook.com/groups/purpleair/permalink/722201454903597/?comment_id=722420321548377
# https://www.reddit.com/r/PurpleAir/comments/imvepg/just_got_my_purpleair_to_measure_aqi_i_didnt/g45uonx
# pa.js widget has a "temperatureOffset" variable with a value I don't know yet
OBSERVED_ADDITIONAL_PURPLE_AIR_TEMP_OFFSET = 1
PURPLE_AIR_TEMP_OFFSET = -9.43 + OBSERVED_ADDITIONAL_PURPLE_AIR_TEMP_OFFSET

filename = 'monitor_air_quality_config.yaml'
dirs = [xdg_config_home()]
dirs.extend((xdg_config_dirs()))
config_files = [PosixPath(x, filename) for x in dirs if PosixPath(x, filename).exists()]
if not config_files:
    config = dict()
else:
    with config_files[0].open() as f:
        config = yaml.safe_load(f)

def celsius_to_fahrenheit(degrees_c):
    return 32.0 + (float(degrees_c) * 1.8)


def get_outdoor_data():
    # https://docs.google.com/document/d/15ijz94dXJ-YAZLi9iZ_RaBwrZ4KtYeCy08goGBwnbCU/edit
    result = requests.get(f"https://www.purpleair.com/json?key={config['PURPLE_AIR_KEY']}&show={config['PURPLE_AIR_DEVICE_ID']}")
    field_map = {
        'pm2_5_atm': 'pm25',
        'pm10_0_atm': 'pm10',
        'LastSeen': 'LastSeen',
        'humidity': 'humidity',
        'temp_f': 'temp_f',
        'pressure': 'pressure'
    }
    data = {}
    logging.debug(json.dumps(result.json(), indent=4))
    for record in result.json().get('results', []):
        # https://github.com/MazamaScience/AirSensor/blob/master/documents/PurpleAir_CF=ATM_vs_CF=1.md
        for field in field_map.keys():
            if field in record:
                if field_map[field] not in data:
                    data[field_map[field]] = record[field]
                else:
                    if field in ['pm2_5_atm', 'pm10_0_atm']:
                        # Average the existing measurement in data with the new
                        # second measurement in record
                        data[field_map[field]] = "{:.2f}".format(
                            sum([float(data[field_map[field]]), float(record[field])]) / 2)
                    # We'll assume LastSeen values are similar
                    # TODO : Check for very old LastSeen values and error out or something
    data['aqipm25'] = str(aqi.to_iaqi(aqi.POLLUTANT_PM25, str(data['pm25'])))
    data['aqipm10'] = str(aqi.to_iaqi(aqi.POLLUTANT_PM10, str(data['pm10'])))
    data['temp_f'] = f"{float(data['temp_f']) + PURPLE_AIR_TEMP_OFFSET:.2f}"

    return data


def get_notion_temperature(sensor_name):
    result = requests.post(
        url=f"{NOTION_BASE_URL}/users/sign_in",
        json={"sessions": {"email": config['NOTION_EMAIL'], "password": config['NOTION_PASSWORD']}})
    authentication_token = result.json()['session']['authentication_token']
    session = requests.Session()
    session.headers.update({"Authorization": f"Token token={authentication_token}"})

    result = session.get(url=f"{NOTION_BASE_URL}/sensors/")
    sensor_id = next(x['id'] for x in result.json()['sensors'] if x['name'] == sensor_name)

    result = session.get(url=f"{NOTION_BASE_URL}/tasks/")
    task = next(x for x in result.json()['tasks'] if x['sensor_id'] == sensor_id and x['task_type'] == 'temperature')
    return {'datetime': datetime.fromisoformat(task['status']['received_at'].replace("Z", "+00:00")),
            'temp_f': celsius_to_fahrenheit(task['status']['value'])}


# https://towardsdatascience.com/sensing-the-air-quality-5ed5320f7a56
def get_air_quality():
    # sudo usermod -a -G dialout yourusername
    # https://cdn.sparkfun.com/assets/parts/1/2/2/7/5/Laser_Dust_Sensor_Control_Protocol_V1.3.pdf

    # Start in reporting mode : query/home/pi/.local/bin
    sensor = SDS011("/dev/ttyUSB0", use_query_mode=True)
    sensor.set_work_period(work_time=0)  # work_time is continuous
    logging.debug('waking sensor')
    sensor.sleep(sleep=False)  # wake sensor
    logging.debug('waiting 30 seconds')
    time.sleep(30)  # capture 30 seconds of data
    logging.debug('running sensor query')
    result = sensor.query()
    logging.debug('sleeping sensor')
    sensor.sleep()  # sleep sensor
    # print(f"    PMT2.5: {pm25} μg/m3    PMT10 : {pm10} μg/m3")
    if result is None:
        logging.error("Sensor returned None")
        return None
    pm25, pm10 = result
    data = {
        'pm25': str(pm25),
        'pm10': str(pm10),
        'aqipm25': str(aqi.to_iaqi(aqi.POLLUTANT_PM25, str(pm25))),
        'aqipm10': str(aqi.to_iaqi(aqi.POLLUTANT_PM10, str(pm10)))
    }
    return data


def post_data(data):
    hashed_key = hashlib.pbkdf2_hmac(
        'sha256',
        str.encode(config['KEY']),
        str.encode(config['SALT']),
        100000).hex()
    data.update({'key': hashed_key})
    result = requests.post(config['URL'], json=data)
    logging.debug(f"POSTed to {config['URL']} : {json.dumps(data)} and got back {result.text} {result.status_code}")
    return result.text


def send_email(location, metric, threshold, value):
    msg = EmailMessage()
    msg.set_content(
        f"The value for {metric} in location {location} is "
        f"{value} which has exceeded the "
        f"threshold of {threshold}")
    msg['Subject'] = f"{location} {metric} is at {value}"
    msg['From'] = config['ALERT_EMAIL_SOURCE']
    msg['To'] = config['ALERT_EMAIL_DESTINATION']
    s = smtplib.SMTP('localhost')
    s.send_message(msg)
    s.quit()


def alert_ifttt(message):
    event = config['IFTTT_EVENT_NAME']
    key = config['IFTTT_WEBHOOK_KEY']
    logging.info(message)
    if logging.root.level != logging.DEBUG:
        return requests.post(
            f"https://maker.ifttt.com/trigger/{event}/with/key/{key}",
            json={'value1': message})


def alert_on_temperature_inversion(parser, args, data, state):
    fields = args.alert_on_temperature_inversion.split(',')
    if len(fields) != 2:
        parser.error(
            f"alert-on-temperature-inversion arguments must contain 2 "
            f"comma delimited fields of indoor,outdoor location names, "
            f"you passed {args.alert_on_temperature_inversion}")
    bad_locations = set(fields) - set(data.keys())
    if len(bad_locations) > 0:
        parser.error(
            f"Unable to find location(s) {bad_locations} for "
            f"alert-on-temperature-inversion. You passed "
            f"{args.alert_on_temperature_inversion}")

    if not all('temp_f' in data[x] for x in data.keys() if x in fields):
        parser.error(
            f"The temp_f field isn't present in the data for all location "
            f"{fields}")

    inside_temp = float(data[fields[0]]['temp_f'])
    outside_temp = float(data[fields[1]]['temp_f'])
    outside_air_quality = float(data[fields[1]]['pm25'])

    if args.alert_on_temperature_inversion not in state['alerts']:
        state['alerts'][args.alert_on_temperature_inversion] = dict()
    if 'last_transition' not in state['alerts'][args.alert_on_temperature_inversion]:
        state['alerts'][args.alert_on_temperature_inversion]['last_transition'] = "outside warmed above inside"
    if state['alerts'][args.alert_on_temperature_inversion]['last_transition'] == "outside warmed above inside":
        # summer daytime cooler indoors than outdoors
        if inside_temp > outside_temp:
            logging.info(
                f"It's cooler outside {outside_temp} than inside "
                f"{inside_temp} and air quality is {outside_air_quality}")
            if outside_air_quality < 30.0:
                # hotter inside than outside and air is clean enough
                alert_ifttt(f"Open windows, it's cooler outside {outside_temp} than inside {inside_temp} and the air is ok {outside_air_quality}")
            else:
                logging.info(f"No alert to open windows because the air quality {outside_air_quality} is too poor")
            state['alerts'][args.alert_on_temperature_inversion]['last_transition'] = "outside cooled below inside"
        else:
            logging.info(f"It continues to be warmer outside {outside_temp} than inside {inside_temp}. No transition occurred")
    elif state['alerts'][args.alert_on_temperature_inversion]['last_transition'] == "outside cooled below inside":
        # summer nightime cooler outdoors than indoors
        if outside_temp > inside_temp:
            logging.info(
                f"It's warmer outside {outside_temp} than inside "
                f"{inside_temp}")
            # hotter outside than insie
            alert_ifttt(f"Close windows, it's warmer outside {outside_temp} than inside {inside_temp}")
            state['alerts'][args.alert_on_temperature_inversion]['last_transition'] = "outside warmed above inside"
        else:
            logging.info(f"It continues to be cooler outside {outside_temp} than inside {inside_temp}. No transition occurred")


def gather_data(parser, args, state):
    data = dict()
    data['dt'] = datetime.now().isoformat()

    if 'command' in args and args.command:
        for command in args.command:
            completed_process = subprocess.run(command, capture_output=True, text=True, shell=True)
            stdout = completed_process.stdout
            try:
                result = json.loads(stdout)
            except json.decoder.JSONDecodeError:
                logging.error(f"Command didn't return JSON : \"{command}\" with result \"{stdout}\"")
                result = dict()
            for location in result.keys():
                if type(result[location]) == dict:
                    if location not in data:
                        data[location] = {}
                    data[location].update(result[location])
            logging.info(f"Command executed : {command} : {result}")
    if 'fetch_local_aqi' in args and args.fetch_local_aqi:
        result = get_air_quality()
        if args.fetch_local_aqi not in data:
            data[args.fetch_local_aqi] = {}
        data[args.fetch_local_aqi].update(result)
        logging.info(f"Local air quality data fetched {result}")
    if 'fetch_notion_temperature' in args and args.fetch_notion_temperature:
        result = get_notion_temperature(args.notion_sensor)
        # We're ignoring the datestamp of the temperature reading, hoping it's near to now
        if args.fetch_notion_temperature not in data:
            data[args.fetch_notion_temperature] = {}
        data[args.fetch_notion_temperature].update({
            'temp_f': f"{result['temp_f']:.2f}"
        })
        logging.info(f"Notion temperature data fetched : {result['temp_f']:.2f}")
    if 'fetch_purpleair_data' in args and args.fetch_purpleair_data:
        result = get_outdoor_data()
        if args.fetch_purpleair_data not in data:
            data[args.fetch_purpleair_data] = {}
        data[args.fetch_purpleair_data].update(result)
        logging.info(f"Purpleair data fetched {result}")

    state['last_data'] = data
    if 'alerts' not in state:
        state['alerts'] = {}

    if 'alert' in args and args.alert:
        for alert in args.alert:
            fields = alert.split(',')
            if len(fields) > 3:
                parser.error(f"Alert arguments must contain 1 2 or 3 comma delimited fields, you passed {alert}")
            threshold = fields[2] if len(fields) == 3 else 12
            metric = fields[1] if len(fields) >= 2 else 'pm25'
            location = fields[0]
            if location not in data:
                parser.error(f"Location {location} from alert {alert} not found in data {data.keys()}")
            if metric not in data[location]:
                parser.error(f"Metric {metric} from alert {alert} not found in data {data['location'].keys()}")
            logging.debug(f"Checking on alert {alert} for {location} data is {data[location][metric]} and threshold is {threshold}")
            if alert not in state['alerts']:
                state['alerts'][alert] = dict()
            if 'last_transition' not in state['alerts'][alert]:
                state['alerts'][alert]['last_transition'] = "return below threshold"
            if state['alerts'][alert]['last_transition'] == "exceed threshold":
                if float(data[location][metric]) < float(threshold):
                    alert_ifttt(
                        f"The value for {metric} in {location} is "
                        f"{data[location][metric]} which has fallen back "
                        f"below the threshold of {threshold}")
                    state['alerts'][alert]['last_transition'] = "return below threshold"
                else:
                    logging.info(f"Metric {metric} {data[location][metric]} continues to exceed {threshold}. No transition occurred")
            elif state['alerts'][alert]['last_transition'] == "return below threshold":
                if float(data[location][metric]) > float(threshold):
                    alert_ifttt(
                        f"The value for {metric} in {location} is "
                        f"{data[location][metric]} which exceeds the threshold "
                        f"of {threshold}")
                    state['alerts'][alert]['last_transition'] = "exceed threshold"
                else:
                    logging.info(f"Metric {location} {metric} {data[location][metric]} continues to not exceed {threshold}. No transition occurred")
    if 'alert_on_temperature_inversion' in args and args.alert_on_temperature_inversion:
        alert_on_temperature_inversion(parser, args, data, state)
    if args.output == 'print':
        print(json.dumps(data, indent=4))
    elif args.output == 'post':
        result = post_data(data)
        logging.info(f"Response from API after POSTing data : {result}")


def main():
    epilog='''Examples:
    --command "ssh pi@203.0.113.20 'monitor_air_quality --fetch-local-aqi upstairs'"
    --fetch-local-aqi basement --alert basement,pm25,12
    --notion-sensor "Upstairs hall Sensor" --fetch-notion-temperature upstairs
    --fetch-purpleair-data outdoor
    --alert-on-temperature-inversion upstairs,outdoor --notion-sensor "Upstairs hall Sensor" --fetch-notion-temperature upstairs --fetch-purpleair-data outdoor
    --fetch-local-aqi basement --notion-sensor "Upstairs hall Sensor" --fetch-notion-temperature upstairs --fetch-purpleair-data outdoor --command "ssh -i /home/gene/Documents/monitor_air_quality/id_rsa pi@192.168.0.31 true" --alert upstairs,pm25,12 --alert-on-temperature-inversion upstairs,outdoor
    '''
    epilog = textwrap.dedent(epilog)
    usage_suffix = []
    parser = argparse.ArgumentParser(
        description='Gather and post or print air quality and temperature data',
        formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument(
        '--fetch-local-aqi', metavar='LOCATION',
        help='the local location to use when fetching local data')
    required_notion_config = {'NOTION_EMAIL', 'NOTION_PASSWORD', 'NOTION_SENSOR_NAME'}
    if len(set(config.keys()) & required_notion_config) < len(required_notion_config):
        usage_suffix.append(
            f"Notion temperature options disabled as the Notion configuration "
            f"settings, {required_notion_config} are missing.")
    else:
        parser.add_argument(
            '--fetch-notion-temperature', metavar='LOCATION',
            help='the location to use when fetching the notion API for temperature data')
        parser.add_argument(
            '--notion-sensor', metavar='SENSOR_NAME',
            help='the notion sensor name to fetch from')
    required_purpleair_config = {'PURPLE_AIR_KEY', 'PURPLE_AIR_DEVICE_ID'}
    if len(set(config.keys()) & required_purpleair_config) < len(required_purpleair_config):
        usage_suffix.append(
            f"Purple Air options disabled as the Purple Air configuration "
            f"settings, {required_purpleair_config} are missing.")
    else:
        parser.add_argument(
            '--fetch-purpleair-data', metavar='LOCATION',
            help='the location to use when fetching purpleair data')
    parser.add_argument(
        '--command', action='append',
        help='command to run to fetch remote data')
    required_ifttt_config = {'IFTTT_EVENT_NAME', 'IFTTT_WEBHOOK_KEY'}
    if len(set(config.keys()) & required_ifttt_config) < len(required_ifttt_config):
        usage_suffix.append(
            f"Alert options disabled as the IFTTT configuration "
            f"settings, {required_ifttt_config} are missing.")
    else:
        parser.add_argument(
            '--alert', action='append', metavar='LOCATION,METRIC,THRESHOLD',
            help='comma delimited string of location,metric,threshold')
        parser.add_argument(
            '--alert-on-temperature-inversion', metavar='LOCATION,LOCATION',
            help='comma delimited string of indoor,outdoor location names')
    required_post_config = {'URL', 'KEY', 'SALT'}
    choices=['print', 'debug', 'info']
    if len(set(config.keys()) & required_post_config) < len(required_post_config):
        usage_suffix.append(
            f"\"--output post\" option disabled as the POST configuration "
            f"settings, {required_post_config} are missing.")
    else:
        choices.append('post')
    parser.add_argument(
        '--output', choices=choices, default='print',
        help='whether to print or post the results or give debug or info output (default: print)')

    if usage_suffix:
        epilog += "\nMissing configuration options:\n" + "\n".join([f"    - {x}" for x in usage_suffix])
        if not config_files:
            epilog += (
                f"\n    Unable to find configuration file \"{filename}\" "
                f"either in any of {xdg_config_home()}, {', '.join([str(x) for x in xdg_config_dirs()])}. "
                f"Please create a config file first. See https://github.com/gene1wood/monitor_air_quality/blob/master/monitor_air_quality/monitor_air_quality_config.example.yaml")
    parser.epilog = epilog
    args = parser.parse_args()

    if args.output == 'post':
        log_level = logging.INFO
    elif args.output in ['debug', 'info']:
        log_level = getattr(logging, args.output.upper())
    else:  # print
        log_level = logging.CRITICAL
    logging.basicConfig(level=log_level)
    logging.debug(f"Argument passed in were {args}")

    xdg_state_home().mkdir(parents=True, exist_ok=True)
    with shelve.open(str(PosixPath(xdg_state_home(), 'monitor_air_quality.shelve')), writeback=True) as state:
        logging.debug(json.dumps({x: state[x] for x in state.keys()}, indent=4))
        gather_data(parser, args, state)


if __name__ == "__main__":
    main()