#!/usr/bin/env python3
# Description {{{1
"""Networth

Show a summary of the networth of the specified person.

Usage:
    networth [options] [<profile>]

Options:
    -u, --updated           show the account update date rather than breakdown
    -s, --sort              sort accounts by value rather than by name

{available_profiles}
Settings can be found in: {settings_dir}.
Typically there is one file for generic settings named 'config' and then one 
file for each profile whose name is the same as the profile name with a '.prof' 
suffix.  Each of the files may contain any setting, but those values in 'config' 
override those built in to the program, and those in the individual profiles 
override those in 'config'. The following settings are understood. The values 
are those before an individual profile is applied.

Profile values:
    default_profile = {default_profile}

Account values:
    avendesora_fieldname = {avendesora_fieldname}
    value_updated_subfieldname = {value_updated_subfieldname}
    date_formats = {date_formats}
    max_account_value_age = {max_account_value_age}  (in days)
    aliases = {aliases}
        (aliases is used to fix account names to make them more readable)

Cryptocurrency values:
    coins = {coins}
    securities = {securities}
    coin_prices_filename = {coin_prices_filename}
    security_prices_filename = {security_prices_filename}
    max_price_age = {max_price_age}  (in seconds)

Bar graph values:
    screen_width = {screen_width}
    asset_color = {asset_color}
    debt_color = {debt_color}

The prices and log files can be found in {cache_dir}.

A description of how to configure and use this program can be found at 
<https://avendesora.readthedocs.io/en/latest/api.html#example-net-worth>`_
"""

# Imports {{{1
from appdirs import user_config_dir, user_cache_dir
from avendesora import PasswordGenerator, PasswordError
from avendesora.gpg import PythonFile
from docopt import docopt
from inform import (
    conjoin, display, done, error, fatal, is_str, join, narrate, os_error, 
    render_bar, terminate, warn, Color, Error, Inform,
)
from operator import itemgetter
from quantiphy import Quantity, InvalidNumber
from pathlib import Path
import arrow

# Settings {{{1
# These can be overridden in ~/.config/networth/config
prog_name = 'networth'
config_filename = 'config'

# Avendesora settings {{{2
default_profile = 'me'
avendesora_fieldname = 'estimated_value'
value_updated_subfieldname = 'updated'
aliases = {}

# cryptocurrency settings (empty coins to disable cryptocurrency support) {{{2
proxy = None
coin_prices_filename = 'coins'
security_prices_filename = 'securities'
coins = None
securities = None
max_price_age = 86400  # refresh cache if older than this (seconds)

# bar settings {{{2
screen_width = 79
asset_color = 'green'
debt_color = 'red'
    # currently we only colorize the bar because ...
    # - it is the only way of telling whether value is positive or negative
    # - trying to colorize the value really messes with the column widths and is 
    #     not attractive

# date settings {{{2
date_formats = [
    'MMMM YYYY',
    'YYMMDD',
]
max_account_value_age = 120  # days

# Utility functions {{{1
# get the age of an account value {{{2
def get_age(date, profile):
    if date:
        for fmt in date_formats:
            try:
                then = arrow.get(date, fmt)
                age = arrow.now() - then
                return age.days
            except:
                pass
    warn(
        'could not compute age of account value',
        '(updated missing or misformatted).',
        culprit=profile
    )

# colorize text {{{2
def colorize(value, text = None):
    if text is None:
        text = str(value)
    return debt_color(text) if value < 0 else asset_color(text)

# colored bar {{{2
def colored_bar(value, width):
    return colorize(value, render_bar(abs(value), width))

# mortgage_balance {{{2
def mortgage_balance(description):
    try:
        params = dict(pair.split('=') for pair in description.split())
        principal = Quantity(params['principal'])
                                 # the principal
        start = arrow.get(params['date'], 'MM/DD/YYYY')
                                 # date for which principal was specified
        payment = Quantity(params['payment'])
                                 # the monthly payment
        interest = Quantity(params['rate'])
                                 # the interest rate in percent
        interest = interest/100 if interest.units == '%' else interest
        if 'share' in params:    # share of the loan that is mine (optional)
            share = Quantity(params['share'], '%')
            share = share/100 if share.units == '%' else share
            principal *= share
            payment *= share

        # useful values
        rate = interest/12       # the rate of growth per month
        unit_growth = 1 + rate   # the normalized growth per month
        now = arrow.now()        # the current date
        months = (now.year - start.year) * 12 + now.month - start.month
                                 # periods since the date of the principal

        # number of months until payoff
        #from math import log
        #periods_to_payoff = -(log(1 - principal*rate/payment))/log(unit_growth)
        #periods_to_payoff = Quantity(periods_to_payoff, units='months')

        # current balance
        balance = (payment + unit_growth**months*(rate*principal - payment))/rate
        return Quantity(-balance, units='$')
    except (AttributeError, KeyError, ValueError):
        raise Error('invalid mortgage description')

try:
    # Initialization {{{1
    settings_dir = Path(user_config_dir(prog_name))
    cache_dir = user_cache_dir(prog_name)
    Quantity.set_prefs(prec=2)
    Inform(logfile=Path(cache_dir, 'log'))
    display.log = False   # do not log normal output

    # Read generic settings {{{1
    config_filepath = Path(settings_dir, config_filename)
    if config_filepath.exists():
        narrate('reading:', config_filepath)
        settings = PythonFile(config_filepath)
        settings.initialize()
        locals().update(settings.run())
    else:
        narrate('not found:', config_filepath)

    # Read command line and process options {{{1
    available=set(p.stem for p in settings_dir.glob('*.prof'))
    available.add(default_profile)
    if len(available) > 1:
        choose_from = f'Choose <profile> from {conjoin(sorted(available))}.'
        default = f'The default is {default_profile}.'
        available_profiles = f'{choose_from} {default}\n'
    else:
        available_profiles = ''

    cmdline = docopt(__doc__.format(
        **locals()
    ))
    show_updated = cmdline['--updated']
    if cmdline['--sort']:
        sort_mode = dict(key=itemgetter(1), reverse=True)  # sort by value
    else:
        sort_mode = dict(key=itemgetter(0), reverse=False)  # sort by name
    profile = cmdline['<profile>'] if cmdline['<profile>'] else default_profile
    if profile not in available:
        fatal(
            'unknown profile.', choose_from, template=('{} {}', '{}'), 
            culprit=profile
        )

    # Read profile settings {{{1
    config_filepath = Path(user_config_dir(prog_name), profile + '.prof')
    if config_filepath.exists():
        narrate('reading:', config_filepath)
        settings = PythonFile(config_filepath)
        settings.initialize()
        locals().update(settings.run())
    else:
        narrate('not found:', config_filepath)

    # Process the settings
    if is_str(date_formats):
        date_formats = [date_formats]
    asset_color = Color(asset_color, enable=Color.isTTY())
    debt_color = Color(debt_color, enable=Color.isTTY())

    # Get cryptocurrency prices {{{1
    if coins:
        import requests

        cache_valid = False
        cache_dir = Path(cache_dir)
        cache_dir.mkdir(parents=True, exist_ok=True)
        prices_cache = Path(cache_dir, coin_prices_filename)
        if prices_cache and prices_cache.exists():
            now = arrow.now()
            age = now.timestamp - prices_cache.stat().st_mtime
            cache_valid = age < max_price_age
        if cache_valid:
            contents = prices_cache.read_text()
            coin_prices = Quantity.extract(contents)
            narrate('coin prices are current:', prices_cache)
        else:
            narrate('updating coin prices')
            # download latest asset prices from cryptocompare.com
            currencies = dict(
                fsyms=','.join(coins),     # from symbols
                tsyms='USD',               # to symbols
            )
            url_args = '&'.join(f'{k}={v}' for k, v in currencies.items())
            base_url = 'https://min-api.cryptocompare.com/data/pricemulti'
            url = '?'.join([base_url, url_args])
            try:
                r = requests.get(url, proxies=proxy)
                if r.status_code != requests.codes.ok:
                    r.raise_for_status()
            except KeyboardInterrupt:
                done()
            except Exception as e:
                # must catch all exceptions as requests.get() can generate 
                # a variety based on how it fails, and if the exception is not 
                # caught the thread dies.
                raise Error('cannot access cryptocurrency prices:', codicil=str(e))

            try:
                data = r.json()
            except:
                raise Error('cryptocurrency price download was garbled.')
            coin_prices = {k: Quantity(v['USD'], '$') for k, v in data.items()}

            if prices_cache:
                contents = '\n'.join('{} = {}'.format(k,v) for k,v in coin_prices.items())
                prices_cache.write_text(contents)
                narrate('updating coin prices:', prices_cache)
        coin_prices['USD'] = Quantity(1, '$')
    else:
        coin_prices = {}

    # Get security prices {{{1
    if securities:
        import requests

        cache_valid = False
        cache_dir = Path(cache_dir)
        cache_dir.mkdir(parents=True, exist_ok=True)
        prices_cache = Path(cache_dir, security_prices_filename)
        if prices_cache and prices_cache.exists():
            now = arrow.now()
            age = now.timestamp - prices_cache.stat().st_mtime
            cache_valid = age < max_price_age
        if cache_valid:
            contents = prices_cache.read_text()
            security_prices = Quantity.extract(contents)
            narrate('security prices are current:', prices_cache)
        else:
            narrate('updating security prices')
            # download latest security prices from iextrading.com
            symbols = ','.join(securities)
            url = f'https://api.iextrading.com/1.0/stock/market/batch?symbols={symbols}&types=quote'
            try:
                r = requests.get(url, proxies=proxy)
                if r.status_code != requests.codes.ok:
                    r.raise_for_status()
            except KeyboardInterrupt:
                done()
            except Exception as e:
                # must catch all exceptions as requests.get() can generate 
                # a variety based on how it fails, and if the exception is not 
                # caught the thread dies.
                raise Error('cannot access security prices:', codicil=str(e))

            try:
                data = r.json()
            except:
                raise Error('security price download was garbled.')
            security_prices = {
                s: Quantity(data[s]['quote']['close'], '$')
                #s: Quantity(data[s]['quote']['iexRealtimePrice'], '$')
                for s in securities
            }

            if prices_cache:
                contents = '\n'.join('{} = {}'.format(k,v) for k,v in security_prices.items())
                prices_cache.write_text(contents)
                narrate('updating security prices:', prices_cache)
    else:
        security_prices = {}

    # Build account summaries {{{1
    narrate('running avendesora')
    pw = PasswordGenerator()
    totals = {}
    accounts = []
    total_assets = Quantity(0, '$')
    total_debt = Quantity(0, '$')
    grand_total = Quantity(0, '$')
    width = 0
    for account in pw.all_accounts():

        # get data {{{2
        data = account.get_composite(avendesora_fieldname)
        if not data:
            continue
        if type(data) != dict:
            error(
                'expected a dictionary.',
                culprit=(account_name, avendesora_fieldname)
            )
            continue

        # get account name {{{2
        account_name = account.get_name()
        account_name = aliases.get(account_name, account_name)
        account_name = account_name.replace('_', ' ')
        width = max(width, len(account_name))

        # sum the data {{{2
        updated = None
        contents = {}
        total = Quantity(0, '$')
        odd_units = False
        for k, v in data.items():
            if k == value_updated_subfieldname:
                updated = v
                continue
            if k in coin_prices:
                value = Quantity(v*coin_prices[k], coin_prices[k])
                k = 'cryptocurrency'
            elif k in security_prices:
                value = Quantity(v*security_prices[k], security_prices[k])
                k = 'equities'
            else:
                try:
                    value = mortgage_balance(v)
                except Error as err:
                    try:
                        value = Quantity(v, '$')
                    except InvalidNumber as e:
                        raise Error(
                            str(e),
                            culprit=(account_name, avendesora_fieldname, k)
                        )
            if value.units == '$':
                total = total.add(value)
            else:
                odd_units = True
            contents[k] = value.add(contents.get(k, 0))
            width = max(width, len(k))
        for k, v in contents.items():
            totals[k] = v.add(totals.get(k, 0))

        # generate the account summary {{{2
        age = get_age(data.get(value_updated_subfieldname), account_name)
        if show_updated and updated:
            desc = updated
        else:
            desc = ', '.join('{}={}'.format(k, v) for k, v in contents.items() if v)
            if len(contents) == 1 and not odd_units:
                desc = k
            if age and age > max_account_value_age:
                desc += f' ({age//30} months old)'
        summary = join(
            total, desc.replace('_', ' '),
            template=('{:7q} {}', '{:7q}'), remove=(None,'')
        )
        accounts.append((account_name, total, summary))

        # sum assets and debts {{{2
        if total > 0:
            total_assets = total_assets.add(total)
        else:
            total_debt = total_debt.add(-total)
        grand_total = grand_total.add(total)

    # Summarize by account {{{1
    display('By Account:')
    for name, total, summary in sorted(accounts, **sort_mode):
        display(f'{name:>{width+2}s}: {summary}')

    # Summarize by investment type {{{1
    display('\nBy Type:')
    largest_share = max(abs(v) for v in totals.values() if v.units == '$')
    barwidth = screen_width - width - 18
    for asset_type in sorted(totals, key=lambda k: totals[k], reverse=True):
        value = totals[asset_type]
        if value.units != '$':
            continue
        share = value/grand_total
        bar = colored_bar(value/largest_share, barwidth)
        asset_type = asset_type.replace('_', ' ')
        display(f'{asset_type:>{width+2}s}: {value:>7s} ({share:>5.1%}) {bar}')
    display(
        f'\n{"TOTAL":>{width+2}s}:',
        f'{grand_total:>7s} (assets = {total_assets}, debt = {total_debt})'
    )

# Handle exceptions {{{1
except OSError as e:
    error(os_error(e))
except KeyboardInterrupt:
    terminate('Killed by user.')
except (PasswordError, Error) as e:
    e.terminate()
done()
