#!/usr/bin/python3 -sP
# -*- mode: python3 -*-
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2011 thomasv@gitorious
#
# Electron Cash - lightweight Bitcoin Cash client
# Copyright (C) 2017-2020 The Electron Cash Developers
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
import sys

# Note CashShuffle's .proto files have namespace conflicts with keepkey
# This is a workaround to force the python implementation versus the C++
# implementation which does more intelligent things with protobuf namespaces
os.environ['PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION'] = 'python'

if sys.version_info < (3, 7):
    sys.exit("*** Electron Cash support for Python 3.6 has been discontinued.\n"
             "*** Please run Electron Cash with Python 3.7 or above.")

# from https://gist.github.com/tito/09c42fb4767721dc323d
import threading
try:
    import jnius
except:
    jnius = None
if jnius:
    orig_thread_run = threading.Thread.run
    def thread_check_run(*args, **kwargs):
        try:
            return orig_thread_run(*args, **kwargs)
        finally:
            jnius.detach()
    threading.Thread.run = thread_check_run

script_dir = os.path.dirname(os.path.realpath(__file__))
is_pyinstaller = getattr(sys, 'frozen', False)
is_android = 'ANDROID_DATA' in os.environ
is_appimage = 'APPIMAGE' in os.environ
is_binary_distributable = is_pyinstaller or is_android or is_appimage
is_local = not is_binary_distributable and os.path.exists(os.path.join(script_dir, "electron-cash.desktop"))

if is_local:
    sys.path.insert(0, os.path.join(script_dir, 'packages'))


def check_imports():
    # pure-python dependencies need to be imported here for pyinstaller
    try:
        import dns
        import pyaes
        import ecdsa
        import requests
        import qrcode
        import google.protobuf
        import jsonrpclib
    except ImportError as e:
        sys.exit("Error: %s. Try 'sudo pip install <module-name>'"%str(e))
    # the following imports are for pyinstaller
    from google.protobuf import descriptor
    from google.protobuf import message
    from google.protobuf import reflection
    from google.protobuf import descriptor_pb2
    from jsonrpclib import SimpleJSONRPCServer
    # make sure that certificates are here
    assert os.path.exists(requests.utils.DEFAULT_CA_BUNDLE_PATH)


if not is_android:  # Avoid unnecessarily slowing down app startup.
    check_imports()


from electroncash import bitcoin, util
from electroncash import SimpleConfig, Network
from electroncash import networks
from electroncash.wallet import Wallet, ImportedPrivkeyWallet, ImportedAddressWallet
from electroncash.storage import WalletStorage
from electroncash.util import (print_msg, print_stderr, json_encode, json_decode,
                               set_verbosity, InvalidPassword)
from electroncash.i18n import _
from electroncash.commands import get_parser, known_commands, Commands, config_variables
from electroncash import daemon
from electroncash import keystore
from electroncash.mnemonic import Mnemonic, Mnemonic_Electrum
from electroncash.winconsole import create_or_attach_console  # Import ok on other platforms, won't be called.
import electroncash_plugins
import electroncash.web as web


def prompt_password(prompt, confirm=True):
    """ Get password routine """
    import getpass
    password = getpass.getpass(prompt, stream=None)
    if password and confirm:
        password2 = getpass.getpass("Confirm: ")
        if password != password2:
            sys.exit("Error: Passwords do not match.")
    if not password:
        password = None
    return password


def run_non_rpc(simple_config):
    """ Run non RPC commands """
    cmd_name = simple_config.get('cmd')

    storage = WalletStorage(simple_config.get_wallet_path())
    if storage.file_exists():
        sys.exit("Error: Remove the existing wallet first!")

    def password_dialog():
        return prompt_password(
            "Password (hit return if you do not wish to encrypt your wallet):"
        )

    if cmd_name == 'restore':
        wallet = restore_wallet(simple_config, password_dialog, storage)
    elif cmd_name == 'create':
        wallet = create_wallet(simple_config, password_dialog, storage)
    else:
        raise RuntimeError(f"run_non_rpc called with invalid cmd: {cmd_name}")

    wallet.storage.write()
    print_msg("Wallet saved in '%s'" % wallet.storage.path)
    sys.exit(0)


def restore_wallet(simple_config, password_dialog, storage):
    " Restore an existing wallet "
    text = simple_config.get('text').strip()
    passphrase = simple_config.get('passphrase', '')
    password = password_dialog() if keystore.is_private(text) else None
    if keystore.is_address_list(text):
        wallet = ImportedAddressWallet.from_text(storage, text)
    elif keystore.is_private_key_list(text):
        wallet = ImportedPrivkeyWallet.from_text(storage, text, password)
    else:
        if keystore.is_seed(text):
            # seed format will be auto-detected with preference order:
            # old, electrum, bip39
            k = keystore.from_seed(text, passphrase)
        elif keystore.is_master_key(text):
            k = keystore.from_master_key(text)
        else:
            sys.exit("Error: Seed or key not recognized")
        if password:
            k.update_password(None, password)
        storage.put('keystore', k.dump())
        storage.put('wallet_type', 'standard')
        storage.put('use_encryption', bool(password))
        seed_type = getattr(k, 'seed_type', None)
        if seed_type:
            # save to top-level storage too so it doesn't get lost if user
            # switches EC versions
            storage.put('seed_type', seed_type)
        storage.write()
        wallet = Wallet(storage)
    if not simple_config.get('offline'):
        network = Network(simple_config)
        network.start()
        wallet.start_threads(network)
        print_msg("Recovering wallet...")
        wallet.synchronize()
        wallet.wait_until_synchronized()
        if wallet.is_found():
            msg = "Recovery successful"
        else:
            msg = "Found no history for this wallet"
    else:
        msg = (
            "This wallet was restored offline. It may contain more addresses "
            "than displayed."
        )
    print_msg(msg)
    return wallet


def create_wallet(simple_config, password_dialog, storage):
    " Create a new wallet "
    password = password_dialog()
    passphrase = simple_config.get('passphrase', '')
    seed_type = simple_config.get('seed_type', 'bip39')
    if seed_type == 'bip39':
        seed = Mnemonic('en').make_seed()
    elif seed_type in ['electrum', 'standard']:
        seed_type = 'electrum'
        seed = Mnemonic_Electrum('en').make_seed()
    else:
        raise RuntimeError("Unknown seed_type " + str(seed_type))
    k = keystore.from_seed(seed, passphrase, seed_type=seed_type)
    storage.put('seed_type', seed_type)
    storage.put('keystore', k.dump())
    storage.put('wallet_type', 'standard')
    wallet = Wallet(storage)
    wallet.update_password(None, password, True)
    wallet.synchronize()
    print_msg("Your wallet generation seed is:\n    \"%s\"" % seed)
    print_msg("Wallet seed format:", seed_type)
    if k.has_derivation() and seed_type != "electrum":
        print_msg("Your wallet derivation path is:", str(k.derivation))
    print_msg(
        "Please keep your seed information in a safe place; if you lose it, "
        "you will not be able to restore your wallet."
    )
    return wallet


def init_daemon(config_options):
    config = SimpleConfig(config_options)
    storage = WalletStorage(config.get_wallet_path())
    if not storage.file_exists():
        print_msg("Error: Wallet file not found.")
        print_msg("Type 'electron-cash create' to create a new wallet, or provide a path to a wallet with the -w option")
        sys.exit(0)
    if storage.is_encrypted():
        if 'wallet_password' in config_options:
            print_msg('Warning: unlocking wallet with commandline argument \"--walletpassword\"')
            password = config_options['wallet_password']
        elif config.get('password'):
            password = config.get('password')
        else:
            password = prompt_password('Password:', False)
            if not password:
                print_msg("Error: Password required")
                sys.exit(1)
    else:
        password = None
    config_options['password'] = password


def init_cmdline(config_options, server):
    config = SimpleConfig(config_options)
    cmdname = config.get('cmd')
    cmd = known_commands[cmdname]

    if cmdname == 'signtransaction' and config.get('privkey'):
        cmd.requires_wallet = False
        cmd.requires_password = False

    if cmdname in ['payto', 'paytomany'] and config.get('unsigned'):
        cmd.requires_password = False

    if cmdname in ['payto', 'paytomany'] and config.get('broadcast'):
        cmd.requires_network = True

    # instanciate wallet for command-line
    storage = WalletStorage(config.get_wallet_path())

    if cmd.requires_wallet and not storage.file_exists():
        print_msg("Error: Wallet file not found.")
        print_msg("Type 'electron-cash create' to create a new wallet, or provide a path to a wallet with the -w option")
        sys.exit(0)

    # important warning
    if cmd.name in ['getprivatekeys']:
        print_stderr("WARNING: ALL your private keys are secret.")
        print_stderr("Exposing a single private key can compromise your entire wallet!")
        print_stderr("In particular, DO NOT use 'redeem private key' services proposed by third parties.")

    if cmdname == 'gettransaction' and storage.file_exists() and not server:
        cmd.requires_wallet = True
        cmd.requires_network = False

    # commands needing password
    if  ( (cmd.requires_wallet and storage.is_encrypted() and not server)\
       or (cmdname == 'load_wallet' and storage.is_encrypted())\
       or (cmd.requires_password and (storage.is_encrypted() or storage.get('use_encryption')))):
        if config.get('password'):
            password = config.get('password')
        else:
            password = prompt_password('Password:', False)
            if not password:
                print_msg("Error: Password required")
                sys.exit(1)
    else:
        password = None

    config_options['password'] = password

    if cmd.name == 'password':
        new_password = prompt_password('New password:')
        config_options['new_password'] = new_password

    return cmd, password


def run_offline_command(config, config_options):
    cmdname = config.get('cmd')
    cmd = known_commands[cmdname]
    password = config_options.get('password')
    if cmd.requires_wallet:
        storage = WalletStorage(config.get_wallet_path())
        if storage.is_encrypted():
            storage.decrypt(password)
        wallet = Wallet(storage)
    else:
        wallet = None
    # check password
    if cmd.requires_password and storage.get('use_encryption'):
        try:
            seed = wallet.check_password(password)
        except InvalidPassword:
            print_msg("Error: This password does not decode this wallet.")
            sys.exit(1)
    if cmd.requires_network:
        print_msg("Warning: running command offline")
    # arguments passed to function
    args = [config.get(x) for x in cmd.params]
    # decode json arguments
    if cmdname not in ('setconfig',):
        args = list(map(json_decode, args))
    # options
    kwargs = {}
    for x in cmd.options:
        kwargs[x] = (config_options.get(x) if x in ['password', 'new_password'] else config.get(x))
    cmd_runner = Commands(config, wallet, None)
    func = getattr(cmd_runner, cmd.name)
    result = func(*args, **kwargs)
    # save wallet
    if wallet:
        wallet.storage.write()
    return result


def init_plugins(config, gui_name):
    from electroncash.plugins import Plugins
    return Plugins(config, gui_name)


def run_gui(config, config_options):
    """Run Electron Cash with GUI"""
    file_desc, server = daemon.get_fd_or_server(config)
    if file_desc is not None:
        plugins = init_plugins(config, config.get("gui", "qt"))
        daemon_thread = daemon.Daemon(config, file_desc, True, plugins)
        daemon_thread.start()
        try:
            daemon_thread.init_gui()
        finally:
            daemon_thread.stop()  # Cleans up lockfile gracefully
            daemon_thread.join(timeout=5.0)
        sys.exit(0)
    return server.gui(config_options)


def run_start(config, config_options, subcommand):
    """Start Electron Cash"""
    file_desc, server = daemon.get_fd_or_server(config)
    if file_desc is not None:
        if subcommand == "start":
            if sys.platform == "darwin":
                sys.exit(
                    "MacOS does not support this usage due to the way the "
                    "platform libraries work.\n"
                    "Please run the daemon without the 'start' option and "
                    "manually detach/background the process."
                )
            pid = os.fork()
            if pid:
                print_stderr("starting daemon (PID %d)" % pid)
                # exit without calling atexit handlers, in case there are any
                # from e.g. a plugin, etc.
                os._exit(0)  # pylint: disable=W0212
        plugins = init_plugins(config, "cmdline")
        daemon_thread = daemon.Daemon(config, file_desc, False, plugins)
        daemon_thread.start()
        if config.get("websocket_server"):
            from electroncash import websockets  # pylint: disable=C0415
            websockets.WebSocketServer(config, daemon_thread.network).start()
        if config.get("requests_dir"):
            requests_path = os.path.join(config.get("requests_dir"), "index.html")
            if not os.path.exists(requests_path):
                print("Requests directory not configured.")
                print(
                    "You can configure it using https://github.com/spesmilo/electrum-merchant"
                )
                sys.exit(1)
        daemon_thread.join()
        sys.exit(0)
    else:
        return server.daemon(config_options)


def run_daemon(config, config_options):
    """Run the daemon"""
    subcommand = config.get("subcommand")
    if subcommand in ["load_wallet"]:
        init_daemon(config_options)
    if subcommand in [None, "start"]:
        return run_start(config, config_options, subcommand)
    server = daemon.get_server(config)
    if server is not None:
        return server.daemon(config_options)
    print_msg("Daemon not running")
    sys.exit(1)


def run_cmdline(config, config_options, cmdname):
    """Run Electron Cash in command line mode"""
    server = daemon.get_server(config)
    init_cmdline(config_options, server)
    if server is not None:
        result = server.run_cmdline(config_options)
    else:
        cmd = known_commands[cmdname]
        if cmd.requires_network:
            print_msg("Daemon not running; try 'electron-cash daemon start'")
            sys.exit(1)
        else:
            init_plugins(config, "cmdline")
            result = run_offline_command(config, config_options)
    return result


def process_config_options(args):
    """config is an object passed to the various constructors (wallet,
    interface, gui)"""
    config_options = args.__dict__

    def filter_func(key):
        return (
            config_options[key] is not None
            and key not in config_variables.get(args.cmd, {}).keys()
        )

    config_options = {
        key: config_options[key] for key in filter(filter_func, config_options.keys())
    }
    if config_options.get("server"):
        config_options["auto_connect"] = False
    config_options["cwd"] = os.getcwd()

    if not config_options.get("portable"):
        # auto detect whether we are started from the portable bundle
        # we only do this when the user has not already specified portable mode
        # fixme: this can probably be achieved with a runtime hook (pyinstaller)
        meipass = getattr(sys, "_MEIPASS", None)
        if meipass:
            tmp_folder = os.path.join(meipass, "is_portable")
            config_options["portable"] = is_pyinstaller and os.path.exists(tmp_folder)

    if config_options.get("portable"):
        if is_local:
            # Running from git clone or local source: put datadir next to main script
            portable_dir = os.path.dirname(os.path.realpath(__file__))
        else:
            # Running a binary or installed source
            if is_pyinstaller:
                # PyInstaller sets sys.executable to the bundle file
                portable_dir = os.path.dirname(os.path.realpath(sys.executable))
            elif is_appimage:
                # AppImage sets the APPIMAGE environment variable to the bundle file
                portable_dir = os.path.dirname(os.path.realpath(os.environ["APPIMAGE"]))
            else:
                # We fall back to getcwd in case nothing else can be used
                portable_dir = os.getcwd()
        config_options['electron_cash_path'] = os.path.join(portable_dir, 'electron_cash_data')

    set_verbosity(config_options.get("verbose"))

    have_testnet = config_options.get("testnet", False)
    have_testnet4 = config_options.get("testnet4", False)
    have_scalenet = config_options.get("scalenet", False)
    have_chipnet = config_options.get("chipnet", False)
    have_regtest = config_options.get("regtest", False)
    if have_testnet + have_testnet4 + have_scalenet + have_chipnet + have_regtest > 1:
        sys.exit(
            "Invalid combination of --testnet, --testnet4, --scalenet, --chipnet and/or --regtest"
        )
    elif have_testnet:
        networks.set_testnet()
    elif have_testnet4:
        networks.set_testnet4()
    elif have_scalenet:
        networks.set_scalenet()
    elif have_chipnet:
        networks.set_chipnet()
    elif have_regtest:
        networks.set_regtest()

    # check uri
    uri = config_options.get("url")
    if uri:
        lc_uri = uri.lower()
        if not any(
            lc_uri.startswith(scheme + ":") for scheme in web.parseable_schemes()
        ):
            print_stderr("unknown command:", uri)
            sys.exit(1)
        config_options["url"] = uri
    return config_options


def print_result(result):
    """Print result of the execution of main"""
    if isinstance(result, str):
        print_msg(result)
    elif isinstance(result, dict) and result.get("error"):
        print_stderr(result.get("error"))
    elif result is not None:
        print_msg(json_encode(result))


def main():
    """ Main entry point into this script """

    # The hook will only be used in the Qt GUI right now
    util.setup_thread_excepthook()

    # On windows, allocate a console if needed
    if sys.platform.startswith('win'):
        require_console = '-v' in sys.argv or '--verbose' in sys.argv
        console_title = _("Electron Cash - Verbose Output") if require_console else None
        # Attempt to attach to ancestor process console. If create=True we will
        # create a new console window if no ancestor process console exists.
        # (Presumably if user ran with -v, they expect console output).
        # The below is required to be able to get verbose or console output
        # if running from cmd.exe (see spesmilo#2592, Electron-Cash#1295).
        # The below will be a no-op if the terminal was msys/mingw/cygwin, since
        # there will already be a console attached in that case.
        # Worst case: The below will silently ignore errors so that startup
        # may proceed unimpeded.
        create_or_attach_console(create=require_console, title=console_title)

    # on osx, delete Process Serial Number arg generated for apps launched in Finder
    sys.argv = list(filter(lambda x: not x.startswith('-psn'), sys.argv))

    # old 'help' syntax
    if len(sys.argv) > 1 and sys.argv[1] == 'help':
        sys.argv.remove('help')
        sys.argv.append('-h')

    # read arguments from stdin pipe and prompt
    for i, arg in enumerate(sys.argv):
        if arg == '-':
            if sys.stdin.isatty():
                raise BaseException('Cannot get argument from stdin')
            sys.argv[i] = sys.stdin.read()
            break
        if arg == '?':
            sys.argv[i] = input("Enter argument:")
        elif arg == ':':
            sys.argv[i] = prompt_password('Enter argument (will not echo):', False)

    # parse command line
    parser = get_parser()
    args = parser.parse_args()

    config_options = process_config_options(args)

    # todo: defer this to gui
    config = SimpleConfig(config_options)
    cmdname = config.get('cmd')

    # run non-RPC commands separately
    if cmdname in ['create', 'restore']:
        run_non_rpc(config)
        sys.exit(0)

    if cmdname == "gui":
        result = run_gui(config, config_options)
    elif cmdname == "daemon":
        result = run_daemon(config, config_options)
    else:
        result = run_cmdline(config, config_options, cmdname)

    # print result
    print_result(result)
    sys.exit(0)


if __name__ == '__main__':
    main()
