#!/usr/bin/env python

# Command-line script to print Bitcoin transactions.  Requirements:
# 
#   * Python 2.6 or higher (prior to 3).
#   
#   * A command called "bitcoin" which takes RPC arguments and prints
#     a JSON response.  (Or customize bitcoin_request(), below.)

# Public domain.

MTGOX_TICKER_URI = "https://mtgox.com/code/data/ticker.php"

import os, sys, json, subprocess, fractions, datetime, types, itertools, urllib2, traceback

def main (args):
    if len (args) == 0:
        account = None
    elif len (args) == 1:
        account, = args
    else:
        print >>sys.stderr, ("Usage: %s [ACCOUNT]" %
                             (os.path.basename (sys.argv[0]),))
        return 2
    transactions = get_transactions (account)
    conversions = [("BTC", 1, 8)]
    try:
        conversions.append (("USD", get_mtgox_price (), 2))
    except Exception, e:
        print >>sys.stderr, "While fetching MtGox USD/BTC rate:"
        for line in traceback.format_exception_only (type (e), e):
            sys.stderr.write (" " * 4 + line)
    balance_expected = print_transactions (sys.stdout, transactions, conversions)
    balance_bc = get_balance (account)
    if balance_expected != balance_bc:
        raise ValueError, \
            (("Available balance is expected to be %.8f, "
              "but Bitcoin reports it to be %.8f!") %
             (balance_expected, balance_bc))

def get_mtgox_price ():
    req = urllib2.Request (MTGOX_TICKER_URI, {})
    # Market rates are time-sensitive enough to justify no caching.
    req.add_header ("Cache-Control", "no-cache")
    file = None
    try:
        file = urllib2.urlopen (req)
        ticker = json.load (file)
    finally:
        if file is not None:
            file.close ()
    return (fractions.Fraction.from_float (ticker["ticker"]["buy"]) +
            fractions.Fraction.from_float (ticker["ticker"]["sell"])) / 2

def bitcoin_request (command):
    bitcoin = subprocess.Popen (["bitcoin"] + command, stdout = subprocess.PIPE)
    stdout, _ = bitcoin.communicate ()
    if bitcoin.returncode < 0:
        raise ValueError, \
            "bitcoin terminated by signal %d." % (-bitcoin.returncode,)
    elif bitcoin.returncode > 0:
        raise ValueError, "bitcoin exited with code %d." % (bitcoin.returncode,)
    return json.loads (stdout)

def get_transactions (account = None):
    transactions = bitcoin_request (["listtransactions",
                                     "" if account is None else account,
                                     "-1"])
    for trans in transactions:
        trans["amount"] = unf_amount (trans["amount"])
        if "fee" in trans:
            trans["fee"] = unf_amount (trans["fee"])
        trans["time"] = datetime.datetime.fromtimestamp (trans["time"])
    return transactions

def get_balance (account = None):
    return unf_amount (bitcoin_request (["getbalance",
                                            "" if account is None else account,
                                            "6"]))

def unf_amount (amount):
    """Unfudges a floating-point amount of BTC, returning the closest
    Fraction which is a multiple of 10e-8."""
    amount_int = fractions.Fraction.from_float (amount) * 10**8
    amount_int = sensible_round (amount_int)
    return fractions.Fraction (amount_int, 10**8)

def sensible_round (n):
    """Rounds a number, but not stupidly to a float!"""
    if n % 1 == 0:
        return long (n)
    
    if n < 0:
        floor_n = long (n) - 1
        ceiling_n = long (n)
    else:
        floor_n = long (n)
        ceiling_n = long (n) + 1
    
    if n % 1 < 0.5:
        return floor_n
    elif n % 1 > 0.5:
        return ceiling_n
    else:
        if floor_n % 2 == 0:
            assert ceiling_n % 2 == 1
            return floor_n
        else:
            assert ceiling_n % 2 == 0
            return ceiling_n

def print_transactions (file, transactions, conversions, starting_bal = 0):
    table = []
    actual_balance = available_balance = starting_bal
    header = ("Time", "Conf.", "", None)
    row = ("", "", "", "Initial balance")
    for conv_label, conv_rate, conv_max_digits in conversions:
        header += ("Amt. %s" % (conv_label,), "Bal. %s" % (conv_label,))
        row += ("", actual_balance * conv_rate)
    table.append (header)
    table.append (row)
    for transaction in transactions:
        if transaction["category"] == "receive":
            min_conf = 6
        elif transaction["category"] == "generate":
            min_conf = 120 # I guess?
        else:
            min_conf = 0
        blocks_rem = max (0, min_conf - transaction["confirmations"])
        
        actual_balance += transaction["amount"]
        if blocks_rem == 0 or transaction["amount"] < 0:
            available_balance += transaction["amount"]
        
        if transaction["category"] == "receive":
            desc = "Received with " + transaction["address"]
        elif transaction["category"] == "generate":
            desc = "Generated"
        elif transaction["category"] == "send":
            desc = "Sent to " + transaction["address"]
        else:
            desc = transaction["category"]
        
        row = (format_time (transaction["time"]),
               str (transaction["confirmations"]),
               "%s to go" % (blocks_rem,) if blocks_rem > 0 else "",
               desc)
        for conv_label, conv_rate, conv_max_digits in conversions:
            row += (transaction["amount"] * conv_rate,
                    actual_balance * conv_rate)
        table.append (row)
        
        fee = transaction.get ("fee", 0)
        if fee != 0:
            actual_balance += fee
            available_balance += fee
            row = row[:3] + ("Fee for above transaction",)
            for conv_label, conv_rate, conv_max_digits in conversions:
                row += (fee * conv_rate,
                        actual_balance * conv_rate)
            table.append (row)
    
    if actual_balance == available_balance:
        row = ("", "", "", "Final balance")
        for conv_label, conv_rate, conv_max_digits in conversions:
            row += ("", actual_balance * conv_rate)
        table.append (row)
    else:
        row = ("", "", "", "Final balance (actual)")
        for conv_label, conv_rate, conv_max_digits in conversions:
            row += ("", actual_balance * conv_rate)
        table.append (row)
        
        row = ("", "", "", "Final balance (available)")
        for conv_label, conv_rate, conv_max_digits in conversions:
            row += ("", available_balance * conv_rate)
        table.append (row)
    table_cols = zip (*table)
    for i, (conv_label, conv_rate, conv_max_digits) in enumerate (conversions):
        table_cols[4 + 2*i] = \
            format_amounts (table_cols[4 + 2*i], conv_max_digits, True)
        table_cols[4 + 2*i + 1] = \
            format_amounts (table_cols[4 + 2*i + 1], conv_max_digits)
    descriptions = table_cols[3]
    del table_cols[3]
    table_col_widths = [max (itertools.imap (len, col)) for col in table_cols]
    table = zip (*table_cols)
    is_first_row = True
    for row, description in itertools.izip (table, descriptions):
        if not is_first_row:
            print >>file
        is_first_col = True
        for col_width, datum in itertools.izip (table_col_widths, row):
            if (not is_first_col) and col_width > 0:
                file.write ("  ")
            file.write (datum.ljust (col_width))
            is_first_col = False
        file.write ("\n")
        if description is not None:
            print >>file, "  " + description
        is_first_row = False
    return available_balance

def format_time (dt):
    return dt.strftime ("%Y-%m-%dT%H:%M:%S")

def format_amounts (amounts, max_digits, signed = False):
    return format_amount_descriptors (map (amount_descriptor, amounts),
                                      max_digits, signed)

def amount_descriptor (amount):
    if isinstance (amount, basestring):
        return amount
    if not isinstance (amount, (types.IntType, types.LongType,
                                fractions.Fraction)):
        raise TypeError, "amount must be int, long, Fraction, or None."
    is_neg = amount < 0
    amount = abs (amount)
    int_part = str (long (amount))
    frac_part = ""
    amount %= 1
    for i in xrange (100): # Clamp at 100 digits, in case of decimals
                           # that can't be expressed finitely.
        if amount == 0:
            break
        amount *= 10
        frac_part += str (int (amount))
        amount %= 1
    return is_neg, int_part, frac_part

def format_amount_descriptors (descs, max_digits, signed):
    """Formats a list of amount descriptors to strings, returning a
    list."""
    int_len = frac_len = 0
    for desc in descs:
        if isinstance (desc, basestring): continue
        is_neg, int_part, frac_part = desc
        int_len = max (int_len, len (int_part))
        frac_len = max (frac_len, len (frac_part))
    frac_len = min (frac_len, max_digits)
    out = []
    for desc in descs:
        if isinstance (desc, basestring):
            out.append (desc)
        else:
            is_neg, int_part, frac_part = desc
            amt_f = int_part.rjust (int_len)
            if frac_len > 0:
                if frac_part != "":
                    amt_f += "." + frac_part[:max_digits].ljust (frac_len)
                else:
                    amt_f += " " * (frac_len + 1)
            if signed:
                amt_f = ("-" if is_neg else "+") + amt_f
            else:
                assert not is_neg
            out.append (amt_f)
    return out

if __name__ == "__main__":
    sys.exit (main (sys.argv[1:]))
