#!/usr/bin/python
"""
    Blockstack-client
    ~~~~~
    copyright: (c) 2014-2015 by Halfmoon Labs, Inc.
    copyright: (c) 2016 by Blockstack.org

    This file is part of Blockstack-client.

    Blockstack-client is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    Blockstack-client is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.
    You should have received a copy of the GNU General Public License
    along with Blockstack-client.  If not, see <http://www.gnu.org/licenses/>.
"""

import random
import json 
import time
import os 
import sys
import traceback
import subprocess
import pprint
import pybitcoin
import binascii
import logging 
import requests
import keychain 

DEBUG = True

from ConfigParser import SafeConfigParser

# Hack around absolute paths
current_dir = os.path.abspath(os.path.dirname(__file__))
parent_dir = os.path.abspath(current_dir + "/../")

sys.path.insert(0, parent_dir)

AVG_BLOCK_TIME = 600    # average number of seconds between blocks 
CONFIRM_DELAY = 10      # number of blocks to wait to pass before confirming that the name was registered
MAX_UNCONFIRMED = 50000000

def pretty_dump(json_str):
    """ pretty dump
    """
    return json.dumps(json_str, sort_keys=True, indent=4, separators=(',', ': '))


def send_checkpoint( chaincom_client, privkey_str, checkpoint_str="http://blockstack.org/summit" ):
    """
    Write an OP_RETURN to the blockchain, to checkpoint our progress.
    Should be written every Nth name.
    """
    
    nulldata = binascii.hexlify( checkpoint_str )
    return pybitcoin.embed_data_in_blockchain( nulldata, privkey_str, chaincom_client, 10000, format='hex')


def namecoin_to_bitcoin_address( nmc_address ):
    """
    Convert a namecoin address to a bitcoin address.
    The only difference is the version number.
    """
    
    return pybitcoin.b58check_encode( pybitcoin.b58check_decode( nmc_address ), version_byte=0 )


def get_chaincom_api_keys( path="./chaincom.ini" ):
    """
    Read the ini-formatted file given to find chain.com keys.
    """
    
    parser = SafeConfigParser()
    parser.read( path )
    
    return parser.get("chaincom", "api_key_id"), parser.get("chaincom", "api_key_secret")


def get_num_unconfirmed_txs( chaincom_api_key, chaincom_api_secret, address, count=500 ):
    """
    Get the number of unconfirmed transactions for an address.
    """
    r = requests.get("https://api.chain.com/v2/bitcoin/addresses/%s/transactions?limit=%s&api-key-id=%s&api-key-secret=%s" % (address, count, chaincom_api_key, chaincom_api_secret))
    data = None 
    
    if r.status_code != 200:
        return -1 
    
    try:
        data = r.json()
    except:
        print >> sys.stderr, "Failed to get transactions"
        return None 
    
    num_unconfirmed = 0
    for tx in data:
        
        try:
            if tx["confirmations"] == 0:
                num_unconfirmed += 1
        except:
            print tx 
            sys.exit(1)
            
    return num_unconfirmed


def get_balance( chaincom_api_key, chaincom_api_secret, address ):
    """
    Get the number of unconfirmed transactions for an address.
    """
    r = requests.get("https://api.chain.com/v2/bitcoin/addresses/%s?api-key-id=%s&api-key-secret=%s" % (address, chaincom_api_key, chaincom_api_secret))
    data = None 
    
    if r.status_code != 200:
        return -1 
    
    try:
        data = r.json()
    except:
        print >> sys.stderr, "Failed to get transactions"
        return None 
    
    return data[0]["total"]["balance"]
            

def confirm_name_imported( client, name ):
    """
    See if a name has been imported.
    """
    
    name_info = client.lookup( name )
    if 'error' in name_info:
        log.info( "confirm '%s'...no" % name )
        return False 
    
    # must be a full record 
    name_info = name_info[0]
    
    if name_info is None:
        log.info( "confirm '%s'...no" % name )
        return False 
    
    if 'address' not in name_info:
        log.info( "confirm '%s'...no" % name )
        return False 
    
    if 'value_hash' not in name_info:
        log.info( "confirm '%s'...no" % name )
        return False 
    
    if name_info['value_hash'] is None:
        log.info( "confirm '%s'...no" % name )
        return False 
    
    log.info( "confirm '%s'...yes" % name )
    return True
    

def find_imported( client, name_list ):
    """
    Find the list of names that have been imported
    """
    imported = []
    
    for name in name_list:
        if confirm_name_imported( client, name ):
            imported.append( name )
            
    return imported
        

if __name__ == "__main__":
    
    names_json = None
    names = None 
    namespace_id = None
    privkey_str = None 
    pp = pprint.PrettyPrinter()

    if len(sys.argv) != 4:
        print >> sys.stderr, "Usage: %s [json_file] [namespace_id] [private_key]"
        sys.exit(1)

    # try to connect to our broadcast provider
    try:
        chaincom_id, chaincom_secret = get_chaincom_api_keys()
    except Exception, e:
        traceback.print_exc()
        print >> sys.stderr, "Failed to get Chain.com API keys"
        sys.exit(1)
        
    chaincom_client = pybitcoin.ChainComClient( chaincom_id, chaincom_secret )

    # get our namespace's names
    try:
        with open( sys.argv[1], "r" ) as f:
           names_json = f.read()

    except Exception, e:
        traceback.print_exc()
        print >> sys.stderr, "Failed to read '%s'" % sys.argv[1]
        sys.exit(1)

    privkey_str = sys.argv[3]
    namespace_id = sys.argv[2]
    
    print "--------------------------------------------------------------"
    print "WARN: you will need to populate these keys with BTC beforehand"
    print "--------------------------------------------------------------"
    
    total_balance = 0
    keyring = []
    unfunded = []
    keyring_path = "%s.keyring" % namespace_id 
    
    if os.path.exists(keyring_path):
        print "import from '%s'" % keyring_path
        try:
            tmp = []
            with open( keyring_path, "r" ) as f:
                tmp = f.readlines()
            
            keyring = []
            tmp2 = [k.strip() for k in tmp]
            
            for pk in tmp2:
                addr = pybitcoin.BitcoinPrivateKey( pk ).public_key().address()
                balance = get_balance( chaincom_id, chaincom_secret, addr )
                print "%s (%s) balance: %s" % (pk, addr, balance)
                
                total_balance += balance
                if balance > 54000:
                    keyring.append( pk )
                else:
                    unfunded.append(addr)
                
        except Exception, e:
            log.exception(e)
            pass
        
    if len(keyring) == 0:
        pk = pybitcoin.BitcoinPrivateKey( privkey_str )
        keyring_generator = keychain.PrivateKeychain.from_private_key( privkey_str )
        
        keyring = [ pk.to_hex() ]
        print "%s (%s) master" % (pk.to_wif(), pk.public_key().address())
        
        for i in xrange(0, 300):
            
            pk_hex = keyring_generator.child(i).private_key()
            pk_wif = pybitcoin.BitcoinPrivateKey( pk_hex ).to_wif()
            
            pk_addr = pybitcoin.BitcoinPrivateKey( pk_hex ).public_key().address()
            balance = get_balance( chaincom_id, chaincom_secret, pk_addr )
            
            print "%s (%s) balance: %s" % (pk_wif, pk_addr, balance)
            
            total_balance += balance
            if balance >= 54000:
                keyring.append( pk_wif )
            else:
                unfunded.append(pk_addr)
                
        try:
            with open(keyring_path, "w+") as f:
                for k in keyring:
                    f.write("%s\n" % k)
                
                f.flush()
        except Exception, e:
            log.exception(e)
            pass
    
    print "--------------------------------------------------------------"
    
    print "Unfunded:"
    for addr in unfunded:
        print addr
        
    print "--------------------------------------------------------------"
    print "Total: %s" % total_balance
    
    print "--------------------------------------------------------------"
    
    try:
        names = json.loads( names_json )
    except Exception, e:
        traceback.print_exc()
        print >> sys.stderr, "Invalid JSON file '%s'" % sys.argv[1]
        sys.exit(1)

    # record name status
    logfile_path = namespace_id + ".sent"
    failed_path = namespace_id + ".failed"
    confirmed_path = namespace_id + ".confirmed"
    
    sent_names_json = []
    sent_names = {}
    
    confirmed_names = []
    
    unconfirmed_names = {}      # map name to send time
    
    failed_lines = None
    failed = []
    
    num_sent_names = 0
    
    # resume from where we left off...
    try:
        # should contain newline-separated list of names we've processed so far
        if not os.path.exists( logfile_path ):
            sent_fd = open( logfile_path, "w+" )
        else:
            sent_fd = open( logfile_path, "r+" )
        
        if not os.path.exists( failed_path ):
            failed_fd = open( failed_path, "w+" )
        else:
            failed_fd = open( failed_path, "r+" )
        
        if not os.path.exists( confirmed_path ):
            confirmed_fd = open( confirmed_path, "w+" )
        else:
            confirmed_fd = open( confirmed_path, "r+" )
            
        sent_names_json = sent_fd.read().split("\n")
        confirmed_names = confirmed_fd.read().split("\n")
        
        failed = failed_fd.read().split("\n")
        
    except Exception, e:
        traceback.print_exc()
        print >> sys.stderr, "Failed to open '%s'" % logfile_path
        sys.exit(1)

    # get list of sent name records
    for name_json in sent_names_json:
        if len(name_json.strip()) == 0:
            continue
        
        name_rec = json.loads( name_json.strip() )
        sent_names[ name_rec['name'] ] = name_rec
    
    # connect to blockstack
    conf = config.get_config()
    proxy = client.session(conf['blockstackd_server'], conf['blockstackd_port'])
    
    time_of_last_confirmation = 0 
    
    # find any previously-sent-but-not-confirmed names 
    for sent_name in sent_names.keys():
        if sent_name not in confirmed_names:
            t = sent_names[sent_name].get('time', 0)
            unconfirmed_names[ sent_name ] = t
            
    # sanity check all names 
    for name in names:
        
        # must have BTC or NMC address 
        if not name.has_key('nmc_address') and not name.has_key('btc_address'):
            raise Exception("Name '%s' lacks an address" % name)
        
        # must have a profile hash 
        if not name.has_key('hash') and not name.has_key('profile_hash'):
            raise Exception("Name '%s' lacks a profile hash" % name )
    
    
    # some failed names might have actually gone through.  Find out which.
    new_confirmed = find_imported( client, failed )
    for confirmed_name in new_confirmed:
        confirmed_fd.write( "%s\n" % confirmed_name )
        confirmed_fd.flush()
        
        if confirmed_name in sent_names.keys():
            del sent_names[confirmed_name]
            
        confirmed_names.append( confirmed_name )
        failed.remove( confirmed_name )
        
    # new failed set
    new_failed_fd = open( failed_path + ".tmp", "w+" )
    new_failed_fd.write( "\n".join( failed ) )
    new_failed_fd.flush()
    new_failed_fd.close()
    os.rename( failed_path + ".tmp", failed_path )
    
    key_rr = 0
    
    # do all imports
    for name in names:
        
        username = name['username']
        
        if name.has_key('hash'):
            update_hash = str(name['hash'])
        elif name.has_key('profile_hash'):
            update_hash = str(name['profile_hash'])
            
        fqn = username + "." + namespace_id
        
        if fqn in confirmed_names or fqn in sent_names.keys():
            # already imported or sent
            num_sent_names += 1
            continue 
        
        # how long are we doing to wait?
        delay = 20
        try:
            with open("delay.txt", "r") as fd:
                delay_txt = fd.read()
            
            delay = float(delay_txt.strip())
        except Exception, e:
            print >> sys.stderr, "failed to read delay.txt; assuming %s" % delay
    
        """
        # every so often, see if we need to throttle ourselves
        if num_sent_names % len(keyring) == 0:
            
            total_unconfirmed = 0
            num_unconfirmed_txs = 10000000000
            
            while num_unconfirmed_txs > MAX_UNCONFIRMED:
                
                total_unconfirmed = 0
                
                for pk_str in keyring:
                    
                    addr = pybitcoin.BitcoinPrivateKey( pk_str ).public_key().address()
                    total_unconfirmed += get_num_unconfirmed_txs( chaincom_id, chaincom_secret, addr )
                    
                num_unconfirmed_txs = total_unconfirmed
                
                print >> sys.stderr, "%s unconfirmed transactions" % num_unconfirmed_txs
                
                if num_unconfirmed_txs <= MAX_UNCONFIRMED:
                    break
                
                time.sleep(60)
        """
        
        # every block (or on start-up), update the list of imported names
        if time_of_last_confirmation + AVG_BLOCK_TIME < time.time():
            
            log.info( "Check for confirmed names" )
            
            # get sent names that were sent more than AVG_BLOCK_TIME * CONFIRM_DELAY seconds ago 
            names_to_check = filter( lambda n: unconfirmed_names[n] + AVG_BLOCK_TIME * CONFIRM_DELAY < time.time(), unconfirmed_names.keys() )
            
            # which of our unconfirmed names have been confirmed?
            new_confirmed = find_imported( client, names_to_check )
            for confirmed_name in new_confirmed:
                
                confirmed_fd.write( "%s\n" % confirmed_name )
                confirmed_fd.flush()
                
                if confirmed_name in sent_names.keys():
                    del sent_names[confirmed_name]
                    
                confirmed_names.append( confirmed_name )
                
                del unconfirmed_names[ confirmed_name ]
                
            time_of_last_confirmation = time.time()
            
        btc_address = None 
        nmc_address = None 
        address = None
        
        if name.has_key('nmc_address'):
            nmc_address = str(name['nmc_address'])
            btc_address = namecoin_to_bitcoin_address( nmc_address )
            
        elif name.has_key("btc_address"):
            btc_address = name['btc_address']

        # ascii only 
        try:
           fqn = str(fqn)
        except:
           log.debug("non-ascii name '%s'" % fqn)
           continue
          
        count = 0
        MAX_COUNT = len(keyring)
        already_exists = False
        while count < MAX_COUNT:
 
           pk_str = keyring[ key_rr % len(keyring) ]
           pub_str = pybitcoin.BitcoinPrivateKey( pk_str ).public_key().address()
           key_rr += 1
           
           existing_name = client.lookup( fqn )
            
           try:
                if existing_name[0] is not None:
                    log.debug("Name '%s' already imported: %s" % (fqn, existing_name))
                
                    confirmed_fd.write( "%s\n" % fqn )
                    confirmed_fd.flush()
                    confirmed_names.append( fqn )
                    already_exists = True
                    break
                    
           except Exception, e:
                log.exception(e)
                sys.exit(0)
           
           log.debug( "name_import " + fqn + " " + btc_address + " " + update_hash + " (key " + pub_str + ")" )
           
           try:
               result = client.name_import( fqn, btc_address, update_hash, pk_str )
           except Exception, e:
               log.error( "register '%s' failed:\n%s\n" % (fqn, traceback.format_exc()) )
      
               if count == 0:      
                  failed_fd.write( "%s\n" % (fqn))
                  failed_fd.flush()

               count += 1
               continue 
        
           if type(result) == type([]):
               result = result[0]
        
           if 'error' in result.keys():
               log.error( "register '%s' failed:\n%s\n" % (fqn, pp.pformat(result)) )
            
               if result['error'] == 'Name already registered':
                   # it's because this is confirmed already!
                   log.debug("Confirmed: '%s'" % fqn)
                   confirmed_fd.write( "%s\n" % fqn)
                   confirmed_fd.flush()
                
                   if fqn in sent_names.keys():
                       del sent_names[fqn]
                
                   confirmed_names.append( fqn )
                
                   if fqn in unconfirmed_names[ fqn ]:
                       del unconfirmed_names[ fqn ]
            
               elif 'has non-base-38 characters' in result['error']:
                  break
                  
               else:
                  print >> sys.stderr, "import failed; retry count %s" % count
                  if count == 0:
                     failed_fd.write( "%s\n" % (fqn))
                     failed_fd.flush()
               
                  if count == MAX_COUNT - 1: 
                     # try again later
                     names.append( name )
                     log.error("result: %s" % result)
                
               count += 1
               continue 
        
           else:
               result['name'] = fqn
               result['time'] = time.time()
               result_str = json.dumps( result )
            
               sent_fd.write( "%s\n" % result_str)
               sent_fd.flush()
             
               print pretty_dump( result )
            
               unconfirmed_names[ fqn ] = result['time']
                
               # record progress
               num_sent_names += 1
               break 
        
        if not already_exists:
            time.sleep(delay)
        """
        if (num_sent_names % 20 == 0):
            
            checkpoint_tx = send_checkpoint( chaincom_client, privkey_str )
            print pretty_dump( checkpoint_tx )
            
            time.sleep(20)
        """

    # wait for all names to confirm 
    while len(unconfirmed_names.keys()) > 0:
        
        time.sleep( AVG_BLOCK_TIME )
    
        # get sent names that were sent more than AVG_BLOCK_TIME * CONFIRM_DELAY seconds ago 
        names_to_check = filter( lambda n: unconfirmed_names[n] + AVG_BLOCK_TIME * CONFIRM_DELAY < time.time(), unconfirmed_names.keys() )
        
        log.info( "Check for %d unconfirmed name(s)" % len(names_to_check) )
        
        # which of our unconfirmed names have been confirmed?
        new_confirmed = find_imported( client, names_to_check )
        for confirmed_name in new_confirmed:
            
            confirmed_fd.write( "%s\n" % confirmed_name )
            confirmed_fd.flush()
            
            if confirmed_name in sent_names.keys():
                del sent_names[confirmed_name]
                
            confirmed_names.append( confirmed_name )
            
            del unconfirmed_names[ confirmed_name ]
