#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2015 Thomas Voegtlin
#
# 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.

from typing import Optional, List, Dict, Sequence, Set, TYPE_CHECKING
import enum
import copy

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QStandardItemModel, QStandardItem, QFont
from PyQt6.QtWidgets import QAbstractItemView, QMenu

from electrum.i18n import _
from electrum.bitcoin import is_address
from electrum.transaction import PartialTxInput, PartialTxOutput
from electrum.lnutil import MIN_FUNDING_SAT
from electrum.util import profiler
from electrum.plugin import run_hook

from .util import ColorScheme, MONOSPACE_FONT
from .my_treeview import MyTreeView, MySortModel
from .new_channel_dialog import NewChannelDialog
from ..messages import MSG_FREEZE_ADDRESS, MSG_FREEZE_COIN

if TYPE_CHECKING:
    from .main_window import ElectrumWindow


class UTXOList(MyTreeView):
    _spend_set: Set[str]  # coins selected by the user to spend from
    _utxo_dict: Dict[str, PartialTxInput]  # coin name -> coin

    class Columns(MyTreeView.BaseColumnsEnum):
        OUTPOINT = enum.auto()
        ADDRESS = enum.auto()
        LABEL = enum.auto()
        AMOUNT = enum.auto()
        PARENTS = enum.auto()

    headers = {
        Columns.OUTPOINT: _('Output point'),
        Columns.ADDRESS: _('Address'),
        Columns.PARENTS: _('Parents'),
        Columns.LABEL: _('Label'),
        Columns.AMOUNT: _('Amount'),
    }
    filter_columns = [Columns.ADDRESS, Columns.LABEL, Columns.OUTPOINT]
    stretch_column = Columns.LABEL

    ROLE_PREVOUT_STR = Qt.ItemDataRole.UserRole + 1000
    ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1001
    key_role = ROLE_PREVOUT_STR

    def __init__(self, main_window: 'ElectrumWindow'):
        super().__init__(
            main_window=main_window,
            stretch_column=self.stretch_column,
        )
        self._spend_set = set()
        self._utxo_dict = {}
        self.wallet = self.main_window.wallet
        self.std_model = QStandardItemModel(self)
        self.proxy = MySortModel(self, sort_role=self.ROLE_SORT_ORDER)
        self.proxy.setSourceModel(self.std_model)
        self.setModel(self.proxy)
        self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self.setSortingEnabled(True)

    def create_toolbar(self, config):
        toolbar, menu = self.create_toolbar_with_menu('')
        self.num_coins_label = toolbar.itemAt(0).widget()
        menu.addAction(_('Coin control'), lambda: self.add_selection_to_coincontrol())

        def cb():
            self.main_window.utxo_list.refresh_all()  # for coin frozen status
            self.main_window.update_status()  # frozen balance
        menu.addConfig(config.cv.WALLET_FREEZE_REUSED_ADDRESS_UTXOS, callback=cb)
        return toolbar

    @profiler(min_threshold=0.05)
    def update(self):
        # not calling maybe_defer_update() as it interferes with coincontrol status bar
        self.proxy.setDynamicSortFilter(False)  # temp. disable re-sorting after every change
        utxos = self.wallet.get_utxos()
        self._maybe_reset_coincontrol(utxos)
        self._utxo_dict = dict([(utxo.prevout.to_str(), utxo) for utxo in utxos])
        self.std_model.clear()
        self.update_headers(self.__class__.headers)
        for idx, utxo in enumerate(utxos):
            name = utxo.prevout.to_str()
            labels = [""] * len(self.Columns)
            amount_str = self.main_window.format_amount(
                utxo.value_sats(), whitespaces=True)
            amount_str_nots = self.main_window.format_amount(
                utxo.value_sats(), whitespaces=False, add_thousands_sep=False)
            labels[self.Columns.OUTPOINT] = str(utxo.short_id)
            labels[self.Columns.ADDRESS] = utxo.address
            labels[self.Columns.AMOUNT] = amount_str
            utxo_item = [QStandardItem(x) for x in labels]
            self.set_editability(utxo_item)
            utxo_item[self.Columns.OUTPOINT].setData(name, self.ROLE_PREVOUT_STR)
            utxo_item[self.Columns.AMOUNT].setData(amount_str_nots, self.ROLE_CLIPBOARD_DATA)
            utxo_item[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT))
            utxo_item[self.Columns.AMOUNT].setFont(QFont(MONOSPACE_FONT))
            utxo_item[self.Columns.PARENTS].setFont(QFont(MONOSPACE_FONT))
            utxo_item[self.Columns.OUTPOINT].setFont(QFont(MONOSPACE_FONT))
            self.std_model.insertRow(idx, utxo_item)
            self.refresh_row(name, idx)
        self.filter()
        self.proxy.setDynamicSortFilter(True)
        self.sortByColumn(self.Columns.OUTPOINT, Qt.SortOrder.DescendingOrder)
        self.update_coincontrol_bar()
        self.num_coins_label.setText(_('{} unspent transaction outputs').format(len(utxos)))

    def update_coincontrol_bar(self):
        # update coincontrol status bar
        if bool(self._spend_set):
            coins = [self._utxo_dict[x] for x in self._spend_set]
            coins = self._filter_frozen_coins(coins)
            amount = sum(x.value_sats() for x in coins)
            amount_str = self.main_window.format_amount_and_units(amount)
            num_outputs_str = _("{} outputs available ({} total)").format(len(coins), len(self._utxo_dict))
            self.main_window.set_coincontrol_msg(_("Coin control active") + f': {num_outputs_str}, {amount_str}')
        else:
            self.main_window.set_coincontrol_msg(None)

    def refresh_row(self, key, row):
        assert row is not None
        utxo = self._utxo_dict[key]
        utxo_item = [self.std_model.item(row, col) for col in self.Columns]
        txid = utxo.prevout.txid.hex()
        num_parents = self.wallet.get_num_parents(txid)
        utxo_item[self.Columns.PARENTS].setText('%6s'%num_parents if num_parents else '-')
        label = self.wallet.get_label_for_txid(txid) or ''
        utxo_item[self.Columns.LABEL].setText(label)
        sort_key = (
            self.wallet.adb.tx_height_to_sort_height(utxo.block_height),  # sort by block height
            str(utxo.short_id),                                           # order inside block (if mined), or just txid
        )
        utxo_item[self.Columns.OUTPOINT].setData(sort_key, self.ROLE_SORT_ORDER)
        SELECTED_TO_SPEND_TOOLTIP = _('Coin selected to be spent')
        if key in self._spend_set:
            tooltip = key + "\n" + SELECTED_TO_SPEND_TOOLTIP
            color = ColorScheme.GREEN.as_color(True)
        else:
            tooltip = key
            color = self._default_bg_brush
        for col in utxo_item:
            col.setBackground(color)
            col.setToolTip(tooltip)
        if self.wallet.is_frozen_address(utxo.address):
            utxo_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True))
            utxo_item[self.Columns.ADDRESS].setToolTip(_('Address is frozen'))
        if self.wallet.is_frozen_coin(utxo):
            utxo_item[self.Columns.OUTPOINT].setBackground(ColorScheme.BLUE.as_color(True))
            utxo_item[self.Columns.OUTPOINT].setToolTip(f"{key}\n{_('Coin is frozen')}")

    def get_selected_outpoints(self) -> List[str]:
        if not self.model():
            return []
        items = self.selected_in_column(self.Columns.OUTPOINT)
        return [x.data(self.ROLE_PREVOUT_STR) for x in items]

    def _filter_frozen_coins(self, coins: List[PartialTxInput]) -> List[PartialTxInput]:
        coins = [utxo for utxo in coins
                 if (not self.wallet.is_frozen_address(utxo.address) and
                     not self.wallet.is_frozen_coin(utxo))]
        return coins

    def are_in_coincontrol(self, coins: List[PartialTxInput]) -> bool:
        return all([utxo.prevout.to_str() in self._spend_set for utxo in coins])

    def add_to_coincontrol(self, coins: List[PartialTxInput]):
        assert all(utxo.prevout.to_str() in self._utxo_dict for utxo in coins) # see issue 10206
        coins = self._filter_frozen_coins(coins)
        for utxo in coins:
            self._spend_set.add(utxo.prevout.to_str())
        self._refresh_coincontrol()

    def remove_from_coincontrol(self, coins: List[PartialTxInput]):
        for utxo in coins:
            self._spend_set.remove(utxo.prevout.to_str())
        self._refresh_coincontrol()

    def clear_coincontrol(self):
        self._spend_set.clear()
        self._refresh_coincontrol()

    def add_selection_to_coincontrol(self):
        if bool(self._spend_set):
            self.clear_coincontrol()
            return
        selected = self.get_selected_outpoints()
        coins = [self._utxo_dict[name] for name in selected]
        if not coins:
            self.main_window.show_error(_('You need to select coins from the list first.\nUse ctrl+left mouse button to select multiple items'))
            return
        self.add_to_coincontrol(coins)

    def _refresh_coincontrol(self):
        self.refresh_all()
        self.update_coincontrol_bar()
        self.selectionModel().clearSelection()

    def get_spend_list(self) -> Optional[Sequence[PartialTxInput]]:
        if not bool(self._spend_set):
            return None
        utxos = [self._utxo_dict[x] for x in self._spend_set]
        return copy.deepcopy(utxos)  # copy so that side-effects don't affect utxo_dict

    def _maybe_reset_coincontrol(self, current_wallet_utxos: Sequence[PartialTxInput]) -> None:
        if not self._spend_set and not self._currently_open_menu:
            return
        utxo_set = {utxo.prevout.to_str() for utxo in current_wallet_utxos}
        if self._currently_open_menu:
            # if we spent one of the qt-highlighted UTXOs, close context-menu
            if not all(prevout_str in utxo_set for prevout_str in self.get_selected_outpoints()):
                self.close_menu()
        if self._spend_set:
            # if we spent one of the green-marked UTXOs, just reset selection
            if not all([prevout_str in utxo_set for prevout_str in self._spend_set]):
                self._spend_set.clear()

    def can_swap_coins(self, coins):
        # fixme: min and max_amounts are known only after first request
        if self.wallet.lnworker is None:
            return False
        value = sum(x.value_sats() for x in coins)
        min_amount = self.wallet.lnworker.swap_manager.get_min_amount()
        max_amount = self.wallet.lnworker.swap_manager.client_max_amount_forward_swap()
        if min_amount is None or max_amount is None:
            # we need to fetch data from swap server
            return True
        if value < min_amount:
            return False
        if max_amount is None or value > max_amount:
            return False
        return True

    def swap_coins(self, coins: list[PartialTxInput]) -> None:
        assert coins, "no coins selected?"
        self.main_window.run_swap_dialog(is_reverse=False, recv_amount_sat_or_max='!', get_coins=lambda *args, **kwargs: coins)

    def can_open_channel(self, coins):
        if self.wallet.lnworker is None:
            return False
        value = sum(x.value_sats() for x in coins)
        return value >= MIN_FUNDING_SAT and value <= self.config.LIGHTNING_MAX_FUNDING_SAT

    def open_channel_with_coins(self, coins: list[PartialTxInput]) -> None:
        assert coins, "no coins selected?"
        # todo : use a single dialog in new flow
        d = NewChannelDialog(self.main_window, get_coins=lambda *args, **kwargs: coins)
        d.max_button.setChecked(True)
        d.max_button.setEnabled(False)
        d.min_button.setEnabled(False)
        d.clear_button.setEnabled(False)
        d.amount_e.setFrozen(True)
        d.spend_max()
        d.run()

    def clipboard_contains_address(self) -> bool:
        text = self.main_window.app.clipboard().text()
        return is_address(text)

    def pay_to_clipboard_address(self, coins: list[PartialTxInput]) -> None:
        assert coins, "no coins selected?"
        if not self.clipboard_contains_address():
            self.main_window.show_error(_('Clipboard doesn\'t contain a valid address'))
            return
        addr = self.main_window.app.clipboard().text()
        outputs = [PartialTxOutput.from_address_and_value(addr, '!')]

        self.main_window.send_tab.pay_onchain_dialog(outputs, get_coins=lambda *args, **kwargs: coins)

    def on_double_click(self, idx):
        outpoint = idx.sibling(idx.row(), self.Columns.OUTPOINT).data(self.ROLE_PREVOUT_STR)
        utxo = self._utxo_dict[outpoint]
        self.main_window.show_utxo(utxo)

    def create_menu(self, position):
        selected = self.get_selected_outpoints()
        coins = [self._utxo_dict[name] for name in selected]

        if not coins:
            return

        unfrozen_coins = self._filter_frozen_coins(coins)
        menu = QMenu()
        menu.setSeparatorsCollapsible(True)  # consecutive separators are merged together

        if len(coins) == 1:
            idx = self.indexAt(position)
            if not idx.isValid():
                return
            utxo = coins[0]
            txid = utxo.prevout.txid.hex()
            # "Details"
            tx = self.wallet.adb.get_transaction(txid)
            if tx:
                label = self.wallet.get_label_for_txid(txid)
                menu.addAction(_("Privacy analysis"), lambda: self.main_window.show_utxo(utxo))
            cc = self.add_copy_menu(menu, idx)
            cc.addAction(_("Long Output point"), lambda: self.place_text_on_clipboard(utxo.prevout.to_str(), title="Long Output point"))
        # fully spend
        m = menu_spend = menu.addMenu(_("Fully spend") + '…')
        m.setEnabled(bool(unfrozen_coins))
        m = menu_spend.addAction(_("send to address in clipboard"), lambda: self.pay_to_clipboard_address(unfrozen_coins))
        m.setEnabled(self.clipboard_contains_address())
        m = menu_spend.addAction(_("in new channel"), lambda: self.open_channel_with_coins(unfrozen_coins))
        m.setEnabled(self.can_open_channel(unfrozen_coins))
        m = menu_spend.addAction(_("in submarine swap"), lambda: self.swap_coins(unfrozen_coins))
        m.setEnabled(self.can_swap_coins(unfrozen_coins))
        # coin control
        if self.are_in_coincontrol(coins):
            menu.addAction(_("Remove from coin control"), lambda: self.remove_from_coincontrol(coins))
        else:
            m = menu.addAction(_("Add to coin control"), lambda: self.add_to_coincontrol(coins))
            m.setEnabled(bool(unfrozen_coins))
        # Freeze menu
        if len(coins) == 1:
            utxo = coins[0]
            addr = utxo.address
            menu_freeze = menu.addMenu(_("Freeze"))
            menu_freeze.setToolTipsVisible(True)
            if not self.wallet.is_frozen_coin(utxo):
                act = menu_freeze.addAction(_("Freeze Coin"), lambda: self.main_window.set_frozen_state_of_coins([utxo], True))
            else:
                act = menu_freeze.addAction(_("Unfreeze Coin"), lambda: self.main_window.set_frozen_state_of_coins([utxo], False))
            act.setToolTip(MSG_FREEZE_COIN)
            if not self.wallet.is_frozen_address(addr):
                act = menu_freeze.addAction(_("Freeze Address"), lambda: self.main_window.set_frozen_state_of_addresses([addr], True))
            else:
                act = menu_freeze.addAction(_("Unfreeze Address"), lambda: self.main_window.set_frozen_state_of_addresses([addr], False))
            act.setToolTip(MSG_FREEZE_ADDRESS)
        elif len(coins) > 1:  # multiple items selected
            menu.addSeparator()
            addrs = [utxo.address for utxo in coins]
            is_coin_frozen = [self.wallet.is_frozen_coin(utxo) for utxo in coins]
            is_addr_frozen = [self.wallet.is_frozen_address(utxo.address) for utxo in coins]
            menu_freeze = menu.addMenu(_("Freeze"))
            menu_freeze.setToolTipsVisible(True)
            if not all(is_coin_frozen):
                act = menu_freeze.addAction(_("Freeze Coins"), lambda: self.main_window.set_frozen_state_of_coins(coins, True))
                act.setToolTip(MSG_FREEZE_COIN)
            if any(is_coin_frozen):
                act = menu_freeze.addAction(_("Unfreeze Coins"), lambda: self.main_window.set_frozen_state_of_coins(coins, False))
                act.setToolTip(MSG_FREEZE_COIN)
            if not all(is_addr_frozen):
                act = menu_freeze.addAction(_("Freeze Addresses"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, True))
                act.setToolTip(MSG_FREEZE_ADDRESS)
            if any(is_addr_frozen):
                act = menu_freeze.addAction(_("Unfreeze Addresses"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, False))
                act.setToolTip(MSG_FREEZE_ADDRESS)

        run_hook('qt_utxo_menu', menu, coins, self.wallet)
        self.open_menu(menu, position)

    def get_filter_data_from_coordinate(self, row, col):
        if col == self.Columns.OUTPOINT:
            return self.get_role_data_from_coordinate(row, col, role=self.ROLE_PREVOUT_STR)
        return super().get_filter_data_from_coordinate(row, col)
