#!nix-shell python3 -p "python3.withPackages (ps: with ps; [requests])"

import datetime
import json
import requests
import statistics
import sys

# TODO
# - snowfall data
# - weather warnings

### CONSTANTS ###

# api key - get it at https://openweathermap.org/
API_KEY = "bfca28ca110b5595380d9aad164d5ee5"

# latitude and longitude of the city you want to query
# can be obtained through `./weather.py geocoding <city[,state,country]>`
LATITUDE  = 52.264149
LONGITUDE = 10.526420

# waybar colors
GRAY   = '#b7c1de'
DARK   = '#01012b'
GREEN  = '#65dc98'
YELLOW = '#ffe69d'
ORANGE = '#ff6e27'
RED    = '#ff124f'
PURPLE = '#7a04eb'
BLUE   = '#8386f5'
MOIST  = '#e96d5e'


### UTILITIES ###

def print_error(msg: str):
    '''
    print an error message with appropriate prefix.

    @param str: the message to print
    '''
    print('\x1b[90m[\x1b[31merr\x1b[90m]\x1b[0m', msg)


def print_help():
    '''
    print help message, to be used for the `--help` flag and as response to
    incorrect usage
    '''
    print('usage: \x1b[33m./weather.py <subcommand> [options]\x1b[0m')
    print()
    print('available subcommands:')
    print(' - \x1b[32mgeocoding\x1b[0m <city[,state][,country]> : search for city to get its coordinates')
    print(' - \x1b[32mcurrent\x1b[0m                            : print current weather information')
    print(' - \x1b[32mforecast[-daily]\x1b[0m                   : print forecast for the next ~5 days')
    print(' - \x1b[32mforecast-detail\x1b[0m                    : print detailed forecast in 3h intervals')
    print(' - \x1b[32mwaybar\x1b[0m                             : get output for usage with waybar')


def make_request(call: str) -> list | dict:
    '''
    make a request to an openweathermap api and returns the response as either
    a list or a dictionary. the api key is added automatically. quits on error.

    @param call: the api path, e.g. `data/2.5/weather?...`

    @return response of the api request as a list or dict
    '''
    try:
        req = requests.get(f'https://api.openweathermap.org/{call}&appid={API_KEY}')
        return req.json()
    except:
        print_error(f'failed to make request to `/{call}`')
        quit(1)


def get_wind_direction(deg: int) -> str:
    '''
    turn a wind direction specified by meteorological degrees into a
    human-readable form

    @param deg: degrees. expected to be in range [0..360]

    @return human-readable form (e.g. 'NE' for `deg == 45`)
    '''
    if deg < 22.5:  return 'N'
    if deg < 67.5:  return 'NE'
    if deg < 112.5: return 'E'
    if deg < 157.5: return 'SE'
    if deg < 202.5: return 'S'
    if deg < 247.5: return 'SW'
    if deg < 292.5: return 'W'
    if deg < 337.5: return 'NW'
    else:           return 'N'


def print_entry(label: str, content: str, indent: int = 0, label_width: int = 8):
    '''
    print an 'entry' that consists of a label (printed in gray) and some
    content. the labels are automatically filled with whitespace to align
    multiple lines properly.

    @param label:       the label of the line
    @param content:     content, printed after the label
    @param indent:      number of spaces to indent with
    @param label_width: width of label
    '''
    label_with_whitespace = label.ljust(label_width)
    print(f'{" " * indent}\x1b[90m{label_with_whitespace}\x1b[0m  {content}')


def get_weekday(date: str) -> str:
    '''
    get weekday from date

    @param date: date as ISO-8601-formatted string (YYYY-mm-dd)

    @return weekday as lowercase string (e.g. 'monday')
    '''
    return datetime.datetime.strptime(date, '%Y-%m-%d').strftime('%A').lower()


def colorize(text: str, color: str) -> str:
    '''
    wrap `text` with pango markup to colorize it for usage with waybar.

    @param text: the text to colorize
    @param color: the color as string ('#rrggbb')
    '''
    return f'<span foreground="{color}">{text}</span>'


def waybar_entry(label: str, content: str, indent: int = 2, label_width: int = 9):
    '''
    create an 'entry' for a waybar tooltip that consists of a label (printed in
    gray) and some content. the labels are automatically filled with whitespace
    to align multiple lines properly.

    @param label:       the label of the line
    @param content:     content, printed after the label
    @param indent:      number of spaces to indent with
    @param label_width: width of label

    @return the entry for use within waybar
    '''

    label_with_whitespace = label.ljust(label_width)
    return f'{" " * indent}{colorize(label_with_whitespace, GRAY)}  {content}\n'



### GEOCODING ###

def geocoding(search: str):
    '''
    call openweathermap's geocoding api to find the coordinates of cities. can
    be used to find the values required for the `LATITUDE` and `LONGITUDE`
    constants within this script. the results are printed.

    @param: search term in the format 'city[,state][,country]'
    '''

    res = make_request(f'geo/1.0/direct?q={search}&limit=5')
    num_results = len(res)

    if (num_results == 0):
        print_error('no results found')
    else:
        print(f'found {num_results} result{"" if num_results == 1 else "s"}:')
        for entry in res:
            # obtain data
            name      = entry['name']
            state     = entry['state'] if 'state' in entry.keys() else None
            country   = entry['country']
            latitude  = entry['lat']
            longitude = entry['lon']

            # print data
            print(f' - \x1b[32m{name}\x1b[90m',
                  f'({f"{state}, " if state else ""}{country})\x1b[0m:',
                  f'latitude = \x1b[35m{latitude}\x1b[0m,',
                  f'longitude = \x1b[35m{longitude}\x1b[0m')


### CURRENT WEATHER ###

def get_current_weather_data() -> dict:
    '''
    get current weather data from openweathermap api. when the `rain` is not
    set (due to there not being any rain), it will be added with a rain amount
    of 0 mm over the last hour.

    @return api response
    '''

    res = make_request(f'data/2.5/weather?lat={LATITUDE}&lon={LONGITUDE}&units=metric')

    if 'rain' not in res.keys():
        res['rain'] = { '1h': 0 }

    return res


def current_weather():
    '''
    print current weather information
    '''

    data = get_current_weather_data()

    # collect relevant data
    weather          = data['weather'][0]['description'].lower()
    temperature      = round(data['main']['temp'], 1)
    temperature_felt = round(data['main']['feels_like'], 1)
    humidity         = data['main']['humidity']
    wind_speed       = round(data['wind']['speed'], 1)
    wind_direction   = get_wind_direction(data['wind']['deg'])
    rainfall         = data['rain']['1h']

    # print data
    print_entry('weather',  f'\x1b[32m{weather}\x1b[0m')
    print_entry('temp',     f'\x1b[33m{temperature} °C\x1b[90m, feels like \x1b[33m{temperature_felt} °C\x1b[0m')
    print_entry('humidity', f'\x1b[31m{humidity} % RH\x1b[0m')
    print_entry('wind',     f'\x1b[35m{wind_speed} m/s\x1b[90m ({wind_direction})\x1b[0m')
    print_entry('rain',     f'\x1b[34m{rainfall} mm\x1b[0m')



### FORECAST ###

def get_forecast_data() -> dict:
    '''
    get forecast data for the next ~5 days from the openweathermap api, with
    data points separated by 3 hours. the data is grouped by date (the api does
    not group the data by default and instead sends it as one sequence).

    @return api response grouped by date
    '''

    res = make_request(f'data/2.5/forecast?lat={LATITUDE}&lon={LONGITUDE}&units=metric')

    days = dict()
    for i in res['list']:
        day = i['dt_txt'].split(' ')[0]
        if day not in days.keys():
            days[day] = list()
        if 'rain' not in i.keys():
            i['rain'] = { '3h': 0 }
        days[day].append(i)

    return days


def get_daily_forecast_data() -> dict[dict]:
    '''
    obtain forecast data for the next ~5 days, where values are grouped by day.
    since the api only provides the data in 3h intervals, the properties of
    different data points are combined in order to provide appropriate data for
    each day.

    @return the processed data as a dict with key = date and value = data as
            another dict
    '''

    res = get_forecast_data()

    output = dict()

    for day in sorted(res):
        data = res[day]
        number_of_data_points = len(data)

        # collect relevant from data for each day
        temperatures         = [i['main']['temp'] for i in data]
        weather_descriptions = [i['weather'][0]['description'].lower() for i in data]
        humidity             = [i['main']['humidity'] for i in data]
        rainfall             = [i['rain']['3h'] for i in data]
        precipitation_prob   = [i['pop'] for i in data]
        wind_speeds          = [i['wind']['speed'] for i in data]
        weekday              = get_weekday(day)

        # min and max temperature for the day
        min_temperature = round(min(temperatures))
        max_temperature = round(max(temperatures))

        # obtain the average weather by finding the weather description with the highest number of occurances in `weather_descriptions`
        weather_count     = {i: weather_descriptions.count(i) for i in set(weather_descriptions)}
        weather_count_max = max(weather_count.values())
        weather_average   = tuple(filter(lambda x: weather_count[x] == weather_count_max, weather_count.keys()))[0]

        # humidiy
        humidity_average = round(statistics.mean(humidity))

        # total rainfall and probability of precipitation. also, estimate the total rainfall if not all data points for a day are available
        rainfall_total           = round(sum(rainfall), 1)
        rainfall_total_estimated = round(rainfall_total / number_of_data_points * 8, 1)
        max_precipitation_prob   = round(max(precipitation_prob) * 100)

        # average wind
        wind_average = round(statistics.mean(wind_speeds), 1)

        output[day] = {
            'number_of_data_points': number_of_data_points,
            'min_temperature': min_temperature,
            'max_temperature': max_temperature,
            'weather_average': weather_average,
            'humidity_average': humidity_average,
            'rainfall_total': rainfall_total,
            'rainfall_total_estimated': rainfall_total_estimated,
            'max_precipitation_prob': max_precipitation_prob,
            'wind_average': wind_average,
            'weekday': weekday,
        }

    return output


def daily_forecast():
    '''
    print forecast data for the next ~5 days
    '''

    daily_data = get_daily_forecast_data()

    for day in sorted(daily_data):
        data = daily_data[day]

        # only display data point if at least half of the data points are available
        if data['number_of_data_points'] < 4:
            continue

        # print data
        print(f'\x1b[1m{day}\x1b[0m ({data["weekday"]}):')
        print_entry('weather',  f'\x1b[32m{data["weather_average"]}\x1b[0m', indent = 2)
        print_entry('temp',     f'\x1b[33m{data["max_temperature"]} °C\x1b[0m / \x1b[33m{data["min_temperature"]} °C\x1b[0m', indent = 2)
        print_entry('humidity', f'\x1b[31m{data["humidity_average"]} % RH\x1b[0m', indent = 2)
        print_entry('wind',     f'\x1b[35m{data["wind_average"]} m/s\x1b[0m', indent = 2)
        print_entry('rain',     f'\x1b[34m{data["rainfall_total_estimated"]} mm\x1b[0m' +
                                f'\x1b[90m{f""" ({data["max_precipitation_prob"]}%)""" if data["max_precipitation_prob"] > 0 else ""}' +
                                f'{f" (estimated)" if data["rainfall_total"] != data["rainfall_total_estimated"] else ""}\x1b[0m\n', indent = 2)



### DETAILED FORECAST ###

def detailed_forecast():
    '''
    print forecast data for the next ~5 days, where values are printed for
    every 3h interval provided by the api. some data (e.g. humidity or wind
    speeds) are omitted.
    '''

    res = get_forecast_data()

    for day in sorted(res):
        weekday = get_weekday(day)
        print(f'\x1b[1m{day}\x1b[0m ({weekday})')

        for entry in res[day]:
            # collect data
            weather            = entry['weather'][0]['description'].lower()
            temperature        = round(entry['main']['temp'], 1)
            rainfall           = entry['rain']['3h']
            precipitation_prob = round(entry['pop'] * 100)
            time               = f"{int(entry['dt_txt'].split(' ')[1].split(':')[0]):2}h"

            # print data
            output = ''
            output += f'\x1b[33m{temperature:4} °C\x1b[90m, '
            output += f'\x1b[32m{weather}\x1b[0m'
            if rainfall > 0:
                output += f'\x1b[90m: \x1b[34m{rainfall} mm \x1b[90m({precipitation_prob}%)\x1b[0m'
            print_entry(time, output, indent = 2, label_width = 3)

        print()



### WAYBAR ###

def waybar_widget(data: dict) -> str:
    '''
    get the widget component of the waybar output. contains the current weather
    group and temperature.

    @param current weather data

    @return widget component
    '''

    weather = data['weather'][0]['main'].lower()
    temperature = round(data['main']['temp'])

    return f'{colorize(weather, MOIST)} {temperature}°'


def waybar_current(data: dict) -> str:
    '''
    get the current weather overview for the tooltip of the waybar output.

    @param current weather data

    @return formatted current weather overview
    '''

    # retrieve relevant data
    weather          = data['weather'][0]['description'].lower()
    temperature      = round(data['main']['temp'], 1)
    temperature_felt = round(data['main']['feels_like'], 1)
    humidity         = data['main']['humidity']
    wind_speed       = round(data['wind']['speed'], 1)
    wind_direction   = get_wind_direction(data['wind']['deg'])
    rainfall         = data['rain']['1h']

    # generate output
    output = ''
    output += waybar_entry('weather',  colorize(weather, YELLOW))
    output += waybar_entry('temp',     f'{colorize(f"{temperature} °C", ORANGE)}{colorize(", feels like ", GRAY)}{colorize(f"{temperature_felt} °C", ORANGE)}')
    output += waybar_entry('humidity', colorize(f'{humidity} % RH', RED))
    output += waybar_entry('wind',     f'{colorize(f"{wind_speed} m/s", PURPLE)} {colorize(f"({wind_direction})", GRAY)}')
    output += waybar_entry('rain',     colorize(f"{rainfall} mm", BLUE))
    return output


def waybar_forecast(data: dict) -> str:
    '''
    get the daily forecast for the tooltip of the waybar output.

    @param forecast weather data

    @return formatted daily forecast
    '''

    output = ''

    daily_data = get_daily_forecast_data()

    for day in sorted(daily_data):
        data = daily_data[day]

        line_content = colorize(f'{data["max_temperature"]:2}°', ORANGE) + \
                       colorize(' / ', GRAY) + \
                       colorize(f'{data["min_temperature"]:2}°', ORANGE) + \
                       colorize(', ', GRAY) + \
                       colorize(data['weather_average'], YELLOW)

        if data['rainfall_total_estimated'] > 0:
            line_content += colorize(': ', GRAY) + \
                            colorize(f'{data["rainfall_total_estimated"]} mm ', BLUE) + \
                            colorize(f'({data["max_precipitation_prob"]}%)', GRAY)

        output += waybar_entry(data['weekday'], line_content)

    return output.rstrip()


def waybar():
    '''
    get current and forecast weather data and output it formatted in a way that
    allows it to be included as a widget in waybar. only shows weather category
    and temperature in the widget, but reveals detailed weather information and
    a ~5 day forecast in the tooltip.
    '''

    current_data = get_current_weather_data()
    forecast_data = get_forecast_data()

    widget = waybar_widget(current_data)
    current = waybar_current(current_data)
    forecast = waybar_forecast(forecast_data)
    tooltip = colorize('current weather', GREEN) + '\n' + current + '\n' + \
              colorize('forecast', GREEN) + '\n' + forecast

    print(json.dumps({
        'text': widget,
        'tooltip': tooltip,
    }))



### MAIN ###

def main():
    '''
    main function
    '''

    # no parameters or `--help`
    if len(sys.argv) == 1 or sys.argv[1] in ('help', '-h', '--help'):
        print_help()

    # >= 1 parameter provided
    else:
        # geocoding
        if sys.argv[1] == 'geocoding':
            if len(sys.argv) == 3:
                geocoding(sys.argv[2])
            else:
                print_error('expected argument `<city[,state][,country]>`')
                print_help()
        # constants not set
        elif API_KEY is None or LATITUDE is None or LONGITUDE is None:
            print_error('please modify the constants within the script before use.')
        # current weather
        elif sys.argv[1] == 'current':
            current_weather()
        # daily forecast
        elif sys.argv[1] == 'forecast' or sys.argv[1] == 'forecast-daily':
            daily_forecast()
        # detailed forecast
        elif sys.argv[1] == 'forecast-detail':
            detailed_forecast()
        # waybar
        elif sys.argv[1] == 'waybar':
            waybar()
        # unknown command
        else:
            print_error(f'unknown command `{sys.argv[1]}`')
            print_help()

if __name__ == '__main__':
    main()

