import logging
import os
import pathlib
import shutil
import threading
import traceback
from typing import Dict

from flask_babel import lazy_gettext as _
from cryptoadvance.specter.rpc import BitcoinRPC

from cryptoadvance.specter.key import Key

from ..helpers import add_dicts, alias, is_liquid, load_jsons
from ..liquid.wallet import LWallet
from ..persistence import delete_folder
from ..rpc import RpcError, get_default_datadir
from ..specter_error import SpecterError, handle_exception
from ..wallet import (  # TODO: `purposes` unused here, but other files rely on this import
    Wallet,
    purposes,
)

logger = logging.getLogger(__name__)


class WalletManager:
    """Manages Wallets. Depending on the chain"""

    # chain is required to manage wallets when bitcoind is not running
    def __init__(
        self,
        bitcoin_core_version_raw,
        data_folder,
        rpc,
        chain,
        device_manager,
        path="specter",
        allow_threading=True,
    ):
        self.data_folder = data_folder
        self.chain = chain
        self.rpcs = {}
        self.rpc = rpc
        self.rpc_path = path
        self.device_manager = device_manager
        # sort of lock to prevent threads to update in parallel
        self.is_loading = False
        # key is the name of the wallet, value is the actual instance

        self.wallets = {}
        # A way to communicate failed wallets to the outside
        self.bitcoin_core_version_raw = bitcoin_core_version_raw
        self.allow_threading = allow_threading
        # define different wallet classes for liquid and bitcoin
        self.WalletClass = LWallet if is_liquid(chain) else Wallet
        self.update(data_folder, rpc, chain)

    def update(
        self,
        data_folder: str = None,
        rpc: BitcoinRPC = None,
        chain: str = None,
        use_threading=True,
    ):
        """Restructures the instance, specifically if chain/rpc changed
        The _update internal method will resync the internal status with Bitcoin Core
        use_threading : for the _update method which is heavily communicating with Bitcoin Core
        """
        if (chain is None and rpc is not None) or (chain is not None and rpc is None):
            raise Exception(
                f"Chain ({chain}) and rpc ({rpc}) can only be changed with one another"
            )
        if self.is_loading:
            return
        self.is_loading = True
        if chain is not None:
            self.chain = chain
        if data_folder is not None:
            self.data_folder = data_folder
            if data_folder.startswith("~"):
                data_folder = os.path.expanduser(data_folder)
            # creating folders if they don't exist
            if not os.path.isdir(data_folder):
                os.makedirs(data_folder, exist_ok=True)
        if rpc is not None and rpc.test_connection():
            self.rpc = rpc
        else:
            if rpc:
                logger.error(
                    f"Prevented Trying to update wallet_Manager with broken {rpc}"
                )
        # wallets_update_list is something like:
        # {'Specter': {'name': 'Specter', 'alias': 'pacman', ... }, 'another_wallet': { ... } }
        # It contains the same data as the JSON on disk
        wallets_update_list = {}
        if self.working_folder is not None and self.rpc is not None:
            wallets_files = load_jsons(self.working_folder, key="name")
            for wallet in wallets_files:
                wallet_name = wallets_files[wallet]["name"]
                wallets_update_list[wallet_name] = wallets_files[wallet]
                wallets_update_list[wallet_name]["is_multisig"] = (
                    len(wallets_files[wallet]["keys"]) > 1
                )
                wallets_update_list[wallet_name]["keys_count"] = len(
                    wallets_files[wallet]["keys"]
                )
            if self.allow_threading and use_threading:
                t = threading.Thread(
                    target=self._update,
                    args=(wallets_update_list,),
                )

                t.start()
            else:
                self._update(wallets_update_list)
        else:
            self.is_loading = False
            logger.info(
                "Specter seems to be disconnected from Bitcoin Core. Skipping wallets update."
            )

    def _update(self, wallets_update_list: Dict):
        """Effectively a three way sync. The three data sources are:
        * the json on disk (wallets_update_list)
        * the current wallet instances (existing_names)
        * the loaded wallets from Bitcoin Core (loaded_wallets)
        So, if we have data on disk
        * we get a list of loaded wallets from Bitcoin Core
        * the unloaded wallets are loaded in Bitcoin Core
        * and, on the Specter side, the wallet objects of those unloaded wallets are reinitialised
        """
        # list of wallets in the dict
        existing_names = list(self.wallets.keys())
        # list of wallet to keep
        self._failed_load_wallets = []
        try:
            if wallets_update_list:
                loaded_wallets = self.rpc.listwallets()
                # logger.info("Getting loaded wallets list from Bitcoin Core")
                for wallet in wallets_update_list:
                    wallet_alias = wallets_update_list[wallet]["alias"]
                    wallet_name = wallets_update_list[wallet]["name"]
                    # wallet from json not yet loaded in Bitcoin Core?!
                    if os.path.join(self.rpc_path, wallet_alias) not in loaded_wallets:
                        try:
                            logger.info(
                                "Loading %s to Bitcoin Core"
                                % wallets_update_list[wallet]["alias"]
                            )
                            self.rpc.loadwallet(
                                os.path.join(self.rpc_path, wallet_alias)
                            )
                            logger.info(
                                "Initializing %s Wallet object"
                                % wallets_update_list[wallet]["alias"]
                            )
                            loaded_wallet = self.WalletClass.from_json(
                                wallets_update_list[wallet],
                                self.device_manager,
                                self,
                            )
                            # Lock UTXO of pending PSBTs
                            logger.info(
                                "Re-locking UTXOs of wallet %s"
                                % wallets_update_list[wallet]["alias"]
                            )
                            if len(loaded_wallet.pending_psbts) > 0:
                                for psbt in loaded_wallet.pending_psbts:
                                    logger.info(
                                        f"lock {wallet_alias} {loaded_wallet.pending_psbts[psbt].utxo_dict()}"
                                    )
                                    loaded_wallet.rpc.lockunspent(
                                        False,
                                        loaded_wallet.pending_psbts[psbt].utxo_dict(),
                                    )
                            if len(loaded_wallet.frozen_utxo) > 0:
                                loaded_wallet.rpc.lockunspent(
                                    False,
                                    [
                                        {
                                            "txid": utxo.split(":")[0],
                                            "vout": int(utxo.split(":")[1]),
                                        }
                                        for utxo in loaded_wallet.frozen_utxo
                                    ],
                                )
                            self.wallets[wallet_name] = loaded_wallet
                            # logger.info(
                            #     "Finished loading wallet into Bitcoin Core and Specter: %s"
                            #     % wallets_update_list[wallet]["alias"]
                            # )
                        except RpcError as e:
                            logger.warning(
                                f"Couldn't load wallet {wallet_alias} into Bitcoin Core. Silently ignored! RPC error: {e}"
                            )
                            self._failed_load_wallets.append(
                                {
                                    **wallets_update_list[wallet],
                                    "loading_error": str(e).replace("'", ""),
                                }
                            )
                        except Exception as e:
                            logger.warning(
                                f"Couldn't load wallet {wallet_alias}. Silently ignored! Wallet error: {e}"
                            )
                            self._failed_load_wallets.append(
                                {
                                    **wallets_update_list[wallet],
                                    "loading_error": str(e).replace("'", ""),
                                }
                            )
                    else:
                        if wallet_name not in existing_names:
                            # ok wallet is not yet in the dict, create one
                            try:
                                loaded_wallet = self.WalletClass.from_json(
                                    wallets_update_list[wallet],
                                    self.device_manager,
                                    self,
                                )
                                self.wallets[wallet_name] = loaded_wallet
                            except Exception as e:
                                handle_exception(e)
                                self._failed_load_wallets.append(
                                    {
                                        **wallets_update_list[wallet],
                                        "loading_error": str(e).replace("'", ""),
                                    }
                                )
                        else:
                            # Wallet is already there
                            # we only need to update
                            self.wallets[wallet_name].update()
        # only ignore rpc errors
        except RpcError as e:
            logger.error(f"Failed updating wallet manager. RPC error: {e}")
        # logger.info("Done updating wallet manager")
        wallets_update_list = {}
        self.is_loading = False

    def get_by_alias(self, alias):
        for wallet_name in self.wallets:
            if self.wallets[wallet_name] and self.wallets[wallet_name].alias == alias:
                return self.wallets[wallet_name]
        raise SpecterError("Wallet %s does not exist!" % alias)

    @property
    def failed_load_wallets(self) -> list:
        """A list of wallets failed where not loaded"""
        if not hasattr(self, "_failed_load_wallets"):
            self._failed_load_wallets = []
        return self._failed_load_wallets

    @property
    def working_folder(self):
        if self.data_folder is None or self.chain is None:
            return None
        working_folder = os.path.join(self.data_folder, self.chain)
        pathlib.Path(working_folder).mkdir(parents=True, exist_ok=True)
        return working_folder

    @property
    def wallets_names(self):
        return sorted(self.wallets.keys())

    @property
    def rpc(self):
        """returns a BitcoinRpc depending on the chain"""
        return self.rpcs[self.chain]

    @rpc.setter
    def rpc(self, value):
        """sets a BitcoinRpc depending on the chain. This is an internal property. Don't use it from outside. ToDo: Refactor"""
        self.rpcs[self.chain] = value

    @property
    def wallets(self) -> Dict[str, Dict]:
        """returns wallets depending on the chain"""
        if not hasattr(self, "_wallets"):
            self._wallets = {}
        if not self._wallets.get(self.chain):
            self._wallets[self.chain] = {}
        # _wallets should look like something:
        # { "regtest" : {
        #       "wallet_name_1": {...},
        #       "wallet_name_2": {...},
        #    },
        #   "main": ...
        # }}
        return self._wallets[self.chain]

    @wallets.setter
    def wallets(self, value):
        if not hasattr(self, "_wallets"):
            self._wallets = {}
        if not self._wallets.get(self.chain):
            self._wallets[self.chain] = {}
        self._wallets[self.chain] = value

    def create_wallet(self, name, sigs_required, key_type, keys, devices, **kwargs):
        try:
            walletsindir = [
                wallet["name"] for wallet in self.rpc.listwalletdir()["wallets"]
            ]
        except:
            walletsindir = []
        self._check_duplicate_keys(keys)
        wallet_alias = alias(name)
        i = 2
        while (
            os.path.isfile(os.path.join(self.working_folder, "%s.json" % wallet_alias))
            or os.path.join(self.rpc_path, wallet_alias) in walletsindir
        ):
            wallet_alias = alias("%s %d" % (name, i))
            i += 1

        w = self.WalletClass.create(
            self.rpc,
            self.rpc_path,
            self.working_folder,
            self.device_manager,
            self,
            name,
            wallet_alias,
            sigs_required,
            key_type,
            keys,
            devices,
            self.bitcoin_core_version_raw,
            **kwargs,
        )
        # save wallet file to disk
        w.save_to_file()
        # get Wallet class instance
        if w:
            self.wallets[name] = w
            logger.info(f"Successfully created Wallet {name}")
            return w
        else:
            raise ("Failed to create new wallet")

    def delete_wallet(
        self, wallet, bitcoin_datadir=get_default_datadir(), chain="main"
    ):
        logger.info("Deleting {}".format(wallet.alias))
        wallet_rpc_path = os.path.join(self.rpc_path, wallet.alias)
        self.rpc.unloadwallet(wallet_rpc_path)
        # Try deleting wallet folder
        if bitcoin_datadir:
            if chain != "main":
                bitcoin_datadir = os.path.join(bitcoin_datadir, chain)
            candidates = [
                os.path.join(bitcoin_datadir, wallet_rpc_path),
                os.path.join(bitcoin_datadir, "wallets", wallet_rpc_path),
            ]
            for path in candidates:
                shutil.rmtree(path, ignore_errors=True)

        # Delete files
        wallet.delete_files()
        del self.wallets[wallet.name]
        self.update()

    def rename_wallet(self, wallet, name):
        logger.info("Renaming {}".format(wallet.alias))
        if wallet.name not in self.wallets_names:
            raise Exception(
                f"Wallet {wallet.name} is not managed by this WalletManager or the wallet is on a different chain than {self.chain}"
            )
        self.wallets.pop(wallet.name)
        wallet.name = name
        if self.working_folder is not None:
            wallet.save_to_file()
        self.wallets[name] = wallet

    def joined_balance(self):
        """
        Joined balance of all wallets.
        I don't call it full balance because
        full balance is different - see wallet.fullbalance...
        """
        balance = {}
        for wallet in self.wallets.values():
            add_dicts(balance, wallet.balance)
        return balance

    def full_txlist(
        self,
        fetch_transactions=True,
        validate_merkle_proofs=False,
        current_blockheight=None,
        service_id=None,
    ):
        """Returns a list of all transactions in all wallets loaded in the wallet_manager.
        #Parameters:
        #    fetch_transactions (bool): Update the TxList CSV caching by fetching transactions from the Bitcoin RPC
        #    validate_merkle_proofs (bool): Return transactions with validated_blockhash
        #    current_blockheight (int): Current blockheight for calculating confirmations number (None will fetch the block count from the RPC)
        #    service_id (str): Filters results for just the specified Service
        """
        # Nested comprehensions:
        txlists = [
            [
                # Inner comprehension: Return each tx and all its attrs as a list of dicts...
                # TODO: Simplify this by adding an `as_dict` option to `Wallet.txlist()`?
                {**tx, "wallet_alias": wallet.alias}
                for tx in wallet.txlist(
                    fetch_transactions=fetch_transactions,
                    validate_merkle_proofs=validate_merkle_proofs,
                    current_blockheight=current_blockheight,
                    service_id=service_id,
                )
            ]
            # Outer comprehension: ...from each wallet, each returning their own tx list.
            for wallet in self.wallets.values()
        ]
        result = []
        for txlist in txlists:
            for tx in txlist:
                result.append(tx)
        return list(reversed(sorted(result, key=lambda tx: tx["time"])))

    def full_utxo(self):
        """Returns a list of all UTXOs in all wallets loaded in the wallet_manager."""
        txlists = [
            [
                {
                    **utxo,
                    "label": wallet.getlabel(utxo["address"]),
                    "wallet_alias": wallet.alias,
                }
                for utxo in wallet.full_utxo
            ]
            for wallet in self.wallets.values()
        ]
        result = []
        for txlist in txlists:
            for tx in txlist:
                result.append(tx)
        return list(reversed(sorted(result, key=lambda tx: tx["time"])))

    def full_addresses_info(self, is_change: bool = False, service_id: str = None):
        """Mimics full_txlist in concept, but is really only expected to be used for
        retrieving all addresses across all Wallets that are associated with a
        Service.

        Not currently used yet."""
        addresses_info = []
        for wallet_alias, wallet in self.wallets.items():
            addresses_info.extend(
                wallet.addresses_info(
                    is_change=is_change,
                    service_id=service_id,
                    include_wallet_alias=True,
                )
            )
        return addresses_info

    def delete(self, specter):
        """Deletes all the wallets"""
        for w in list(self.wallets.keys()):
            wallet = self.wallets[w]
            self.delete_wallet(wallet, specter.bitcoin_datadir, specter.chain)
        delete_folder(self.data_folder)

    @classmethod
    def _check_duplicate_keys(cls, keys):
        """raise a SpecterError when a xpub in the passed KeyList is listed twice. Should prevent MultisigWallets where
        xpubs are used twice.
        """
        # normalizing xpubs in order to ignore slip132 differences
        xpubs = [Key.parse_xpub(key.original).xpub for key in keys]
        for xpub in xpubs:
            if xpubs.count(xpub) > 1:
                raise SpecterError(_(f"xpub {xpub} seem to be used at least twice!"))
