##########
#
# This script goes through the whole process of verifying a Hashdate hash:
#  - Hashing your file (optionally)
#  - Querying the Hashdate database for your hash
#  - Downloading the Hashdate bundle file
#  - Checking that your hash is in the bundle file
#  - Generated the bundle file's hash
#  - Checking that the bundle file's hash is in the expected Ethereum block
#  - Printing the date of the Ethereum block
#
# It requires Python 3 but no other dependencies.
#
# NB. On macOS, you may need to run "Install Certificates.command" to fix a Python
# SSL problem. See: https://stackoverflow.com/a/70495761
#
# Simple usage:
#   python3 checkHash.py <name-of-your-file>
#
# Full usage:
#   python3 checkHash.py [--hash <your-64-character-hash>] [--save_bundle] [--etherscan_key <your-etherscan-api-key>] 
#       [--node_endpoint <your-ethereum-url>] [<name-of-your-file>]
#
# Example usage:
#   python3 checkHash.py vogelkop_lophorina.mp4
#   python3 checkHash.py --hash 0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff
#
##########

import sys
if sys.version_info[0] < 3:
    raise Exception("Python 3 is required.")

import argparse
import hashlib
import io
import json
import re
import time
import urllib.parse
import urllib.request

##########
# Config
##########

hashdate_endpoint = "https://api.hashdate.org"

# Our default service for getting info about Ethereum blocks.
# The API key belongs to Hashdate, and may easily reach its usage limit.
etherscan_endpoint = "https://api.etherscan.io/api"
etherscan_api_key = "EBG6IHYRHRTM3D98UU1444TJHHPIDM21G4"
etherscan_browser_tx_endpoint = "https://etherscan.io/tx"

# A fallback service for getting info about Ethereum blocks.
# Once again, the API key belongs to Hashdate, and may easily reach its usage limit.
node_endpoint = "https://mainnet.infura.io/v3/8e0b6ade5aac4e1ea28e28c4e09f88f1"

# The address of the contract which Hashdate uses to store bundle hashes in Ethereum blocks.
contract_address = "0x7F4bF89D96060634575Db20475Ef24C67892b8c0"

########################
# Program starts here.
########################
def main():
    #-------------------------------------
    # 1. Process command line parameters
    #-------------------------------------
    if len(sys.argv) < 2:
        exit_with_message("Usage: python3 checkHash.py <name-of-your-file>")

    argv_parser = argparse.ArgumentParser(description="Checks whether a hash is registered with Hashdate. If it is, attempt to prove its age.")
    argv_parser.add_argument("file_name", type=str, nargs="?", help="The file whose hash you're checking")
    argv_parser.add_argument("--hash", type=str, help="The hash you're checking: a 64-character hex string")
    argv_parser.add_argument("--save-bundle", action="store_true", help="Set this flag to save the downloaded bundle file to the current directory")
    argv_parser.add_argument("--etherscan-key", type=str, metavar="KEY", help="An Etherscan API key to use instead of our default one")
    argv_parser.add_argument("--node-endpoint", type=str, metavar="URL", help="The full URL of an Ethereum node (including API key) to use instead of our default one")
    
    args = argv_parser.parse_args()
    
    if args.etherscan_key:
        global etherscan_api_key
        etherscan_api_key = args.etherscan_key

    if args.node_endpoint:
        global node_endpoint
        node_endpoint = args.node_endpoint

    if not args.hash and not args.file_name:
        exit_with_message("Error: you must provide either a file name or a hash.")

    if args.hash:
        if not is_valid_256_bit_hex_string(args.hash):
            exit_with_message("Error: invalid hash. Hash must be a 64-character hex string.\nFor example, '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'")
        test_hash = args.hash

    #-------------------------------------------------------------
    # 1. Hash the test file (if a hash wasn't provided).
    #-------------------------------------------------------------

    if not args.hash:
        try:
            with open(args.file_name, "rb") as f:
                print(f"\nHashing file '{args.file_name}'...")        
                test_hash = hashlib.sha3_256(f.read()).hexdigest()
                print(f"File hash: {test_hash}")
        except:
            # TODO
            exit_with_message(f"Error: couldn't read file '{args.file_name}'.")

    #-------------------------------------------------------------
    # 2. Query the Hashdate database to find out about the hash.
    #-------------------------------------------------------------
    print("\nQuerying Hashdate...")

    (success, dict) = get_hash_bundle_info(test_hash)
    if success:
        if dict:
            bundle_id = dict["id"]
            expected_bundle_hash = dict["bundle_hash"]
            block_number = dict["block"]
            transaction_hash = dict["transaction_hash"]
        else:    
            exit_with_message("Hash is present in database, but hasn't been bundled to the chain yet.")
    else:    
        exit_with_message("Hash not present in database.")

    print(f"Hash is present in Hashdate bundle {bundle_id}.")
    print(f"\nDownloading bundle {bundle_id}...")
    (bundle_file_name, bundle_bytes) = get_bundle(bundle_id)

    if not is_valid_bundle_data(bundle_bytes):
        exit_with_message(f"UNEXPECTED ERROR: Received bad data for bundle {bundle_id}.")

    if bytes(test_hash, "ascii") in bundle_bytes:
        print(f"Hash found in bundle {bundle_id}.")
    else:
        exit_with_message(f"UNEXPECTED ERROR: Hash not found in bundle {bundle_id}.")

    if args.save_bundle:
        with open(bundle_file_name, "wb") as f:
            f.write(bundle_bytes)
        print(f"Saved bundle as: {bundle_file_name}")

    bundle_hash = hashlib.sha3_256(bundle_bytes).hexdigest()

    print(f"Calculated bundle hash: {bundle_hash}")
    if bundle_hash != expected_bundle_hash:
        print(f"WARNING: Unexpected hash for bundle {bundle_id} - data may be corrupt.")

    #------------------------------------
    # 3. Query the Ethereum blockchain.
    #------------------------------------
    print("\nQuerying Ethereum...")

    block_number_hex_string = hex(block_number)

    try:
        block_timestamp = get_block_timestamp(block_number_hex_string)
        block_time_string = format_epoch_time(block_timestamp)
    except Exception as e:
        exit_with_message(f"Couldn't get epoch time: {e}.")

    try:
        event_logs = get_event_logs(block_number_hex_string, contract_address)
    except Exception as e:
        exit_with_message(f"Couldn't get event logs: {e}.")

    if len(event_logs) > 0:
        for event in event_logs:
            if ("0x" + bundle_hash) in event["topics"][1:]:
                print(f"Bundle hash found in Ethereum block {block_number}.")
                if args.file_name:
                    print(f"The contents of '{args.file_name}' existed before {block_time_string}.\n")
                else:
                    print(f"Hash {args.hash} was generated before {block_time_string}.\n")
                print("See:")
                print(f"{etherscan_browser_tx_endpoint}/0x{transaction_hash} (for timestamp)")
                print(f"{etherscan_browser_tx_endpoint}/0x{transaction_hash}#eventlog (for bundle hash)\n")
                exit()

    exit_with_message(f"UNEXPECTED ERROR: bundle hash not found in Ethereum block {block_number}.")

###############################################################################

def exit_with_message(message):
    print(message)
    exit()

def is_valid_256_bit_hex_string(string):
    return len(string) == 64 and set(string) <= set("0123456789abcdefABCDEF")

def is_valid_bundle_data(bytes):
    with io.BytesIO(bytes) as f:
        for line in f:
            hash = line.decode('ascii').strip()
            if not is_valid_256_bit_hex_string(hash):
                return False
    return True    

def format_epoch_time(epoch_time):
    # E.g. "2015-07-30 15:26:28 UTC"
    return time.strftime("%Y-%m-%d %T %Z", time.gmtime(epoch_time))

###############################################################################

# Ask Hashdate server for a hash's bundle ID, block number and transaction hash.
def get_hash_bundle_info(hash_string):
    send_url = f"{hashdate_endpoint}/query/{hash_string}"
    request = urllib.request.Request(send_url, method="GET")
    try:
        # Perform the web request
        with urllib.request.urlopen(request) as response:
            if response.status == 200:
                response_json = response.read().decode("utf-8")
                response_obj = json.loads(response_json)
                return (True, response_obj)
            if response.status == 204:
                return (True, None)
            exit_with_message(f"Hashdate hash query returned unexpected code {response.status}.")
    except urllib.error.HTTPError as e:
        if e.status == 404:
            return (False, None)
        exit_with_message(f"Hashdate hash query failed with unexpected code {e.status}.")
    except urllib.error.URLError as e:
        exit_with_message(f"Hashdate hash query failed to send: {e}")

# Ask Hashdate server for a bundle.
def get_bundle(bundle_id_string):
    send_url = f"{hashdate_endpoint}/bundle/{bundle_id_string}"
    request = urllib.request.Request(send_url, method="GET")
    try:
        # Perform the web request
        with urllib.request.urlopen(request) as response:
            if response.status == 200:
                # Extract filename from Content-Disposition header if present
                content_disposition_header_value = response.info().get("Content-Disposition")
                bundle_file_name = "bundle.txt"
                if content_disposition_header_value:
                    match = re.search(r'filename="?([^";]+)"?', content_disposition_header_value)
                    if match:
                        bundle_file_name = match.group(1)
                bundle_bytes = response.read()
                return (bundle_file_name, bundle_bytes)
            exit_with_message(f"Hashdate bundle request returned unexpected code {response.status}.")
    except urllib.error.HTTPError as e:
        if e.status == 404:
            exit_with_message(f"UNEXPECTED ERROR: Hashdate bundle request failed: bundle not found.")
        exit_with_message(f"Hashdate bundle request failed with unexpected code {e.status}.")
    except urllib.error.URLError as e:
        exit_with_message(f"Hashdate bundle request failed to send: {e}")

# Ask an Ethereum server for a block's timestamp.
def get_block_timestamp(block_number_hex_string):
    result = make_ethereum_api_request("eth_getBlockByNumber", [("tag", block_number_hex_string), ("boolean", False)])
    if result:
        return int(result["timestamp"], 16)
    else:
        raise Exception("No such block")

# Ask an Ethereum server for all event logs generated by a given contract in a given block.
def get_event_logs(block_number_hex_string, contract_address):
    param_object = {
        "fromBlock": block_number_hex_string,
        "toBlock": block_number_hex_string,
        "address": contract_address
    }
    return make_ethereum_api_request("eth_getLogs", [("object", param_object)])

###############################################################################

def make_ethereum_api_request(rpc_name, param_pairs):
    try:
        return make_etherscan_request(rpc_name, param_pairs)
    except Exception as e:
        print(f"Etherscan request failed: {e}")
        print(f"\nQuerying fallback node...")
        params = [pair[1] for pair in param_pairs]
        return make_node_request(rpc_name, params)

# Make a request via Etherscan, which has its own API, and supports some of Ethereum's
# JSON RPC endpoints but not others.
def make_etherscan_request(rpc_name, param_pairs):
    if rpc_name == "eth_getLogs":
        # Convert RPC params to an Etherscan 'getLogs' request.
        param_dict = param_pairs[0][1].copy()
        param_dict["fromBlock"] = int(param_dict["fromBlock"], 16)
        param_dict["toBlock"] = int(param_dict["toBlock"], 16)
        qs_params = urllib.parse.urlencode(param_dict)
        send_url = f"{etherscan_endpoint}?module=logs&apikey={etherscan_api_key}&action=getLogs&{qs_params}"
    else:
        # Proxy request, no conversion required.
        qs_params = urllib.parse.urlencode(param_pairs)
        send_url = f"{etherscan_endpoint}?module=proxy&apikey={etherscan_api_key}&action={rpc_name}&{qs_params}"
    request = urllib.request.Request(send_url, method="GET")
    try:
        # Perform the web request
        with urllib.request.urlopen(request) as response:
            if response.status == 200:
                response_json = response.read().decode("utf-8")
                response_obj = json.loads(response_json)
                if "status" not in response_obj or response_obj["status"] != "0":
                    # success
                    return response_obj["result"]
                else:
                    raise IOError(f"Request '{rpc_name}' failed: {response_obj['result']}")
            else:
                raise IOError(f"Request '{rpc_name}' failed with {response.status}.")
    except urllib.error.URLError as e:
        raise IOError(f"Request '{rpc_name}' failed to send: {e}")

# Make a direct request to an Ethereum node.
def make_node_request(rpc_name, param_values):
    url = node_endpoint
    payload_obj = {
        "jsonrpc": "2.0", 
        "method": rpc_name, 
        "params": param_values, 
        "id": 1
    }
    payload_json = json.dumps(payload_obj).encode("utf-8")
    print(url)
    print(payload_json)
    request = urllib.request.Request(url, data=payload_json, headers={"Content-Type": "application/json"}, method="POST")
    try:
        # Perform the web request
        with urllib.request.urlopen(request) as response:
            if response.status == 200:
                response_json = response.read().decode("utf-8")
                response_obj = json.loads(response_json)
                if "error" not in response_obj:
                    # success
                    return response_obj["result"]
                else:
                    raise IOError(f"Request '{rpc_name}' failed: {response_obj['error']}")
            else:
                raise IOError(f"Request '{rpc_name}' failed with {response.status}.")
    except urllib.error.URLError as e:
        raise IOError(f"Request '{rpc_name}' failed to send: {e}")

###

main()