import enum
from typing import TYPE_CHECKING, Optional, Union, Tuple, Sequence, Callable

from PyQt6.QtCore import pyqtSignal, Qt, QTimer
from PyQt6.QtGui import QIcon, QPixmap, QColor
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton
from PyQt6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView

from electrum_aionostr.util import from_nip19

from electrum.i18n import _
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, UserCancelled, trigger_callback
from electrum.bitcoin import DummyAddress
from electrum.transaction import PartialTxOutput, PartialTransaction
from electrum.fee_policy import FeePolicy
from electrum.submarine_swaps import NostrTransport

from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
from electrum.gui import messages

from . import util
from .util import (WindowModalDialog, Buttons, OkButton, CancelButton,
                   EnterButton, ColorScheme, WWLabel, read_QIcon, IconLabel, char_width_in_lineedit,
                   pubkey_to_q_icon)
from .amountedit import BTCAmountEdit
from .fee_slider import FeeSlider, FeeComboBox
from .my_treeview import create_toolbar_with_menu, MyTreeView

if TYPE_CHECKING:
    from .main_window import ElectrumWindow
    from electrum.submarine_swaps import SwapServerTransport, SwapOffer
    from electrum.lnchannel import Channel
    from electrum.simple_config import SimpleConfig

CANNOT_RECEIVE_WARNING = _(
"""The requested amount is higher than what you can receive in your currently open channels.
If you continue, your funds will be locked until the remote server can find a path to pay you.
If the swap cannot be performed after 24h, you will be refunded.
Do you want to continue?"""
)


ROLE_NPUB = Qt.ItemDataRole.UserRole + 1000

class InvalidSwapParameters(Exception): pass


class SwapProvidersButton(QPushButton):

    def __init__(
        self,
        transport_getter: Callable[[], Optional['SwapServerTransport']],
        config: 'SimpleConfig',
        main_window: 'ElectrumWindow',
    ):
        """parent must have a transport() method"""
        QPushButton.__init__(self)
        self.config = config
        self.transport_getter = transport_getter
        self.main_window = main_window
        self.clicked.connect(self.choose_swap_server)
        self.fetching = False
        self.update()

    def update(self):
        if self.fetching:
            self.setEnabled(False)
            self.setText(_("Fetching..."))
            self.setVisible(True)
            return

        transport = self.transport_getter()
        if not isinstance(transport, NostrTransport):
            # HTTPTransport or no Network, not showing server selection button
            self.setEnabled(False)
            self.setVisible(False)
            return
        self.setEnabled(True)
        self.setVisible(True)
        offer_count = len(transport.get_recent_offers())
        button_text = f' {offer_count} ' + (_('swap providers') if offer_count != 1 else _('swap provider'))
        self.setText(button_text)
        # update icon
        if self.config.SWAPSERVER_NPUB:
            pubkey = from_nip19(self.config.SWAPSERVER_NPUB)['object'].hex()
            self.setIcon(pubkey_to_q_icon(pubkey))

    def choose_swap_server(self) -> None:
        transport = self.transport_getter()
        assert isinstance(transport, NostrTransport), transport
        self.main_window.choose_swapserver_dialog(transport)  # type: ignore
        self.update()
        trigger_callback('swap_provider_changed')



class SwapDialog(WindowModalDialog, QtEventListener):

    def __init__(
        self,
        window: 'ElectrumWindow',
        transport: 'SwapServerTransport',
        is_reverse: Optional[bool] = None,
        recv_amount_sat_or_max: Optional[Union[int, str]] = None,  # sat or '!'
        channels: Optional[Sequence['Channel']] = None,
    ):
        WindowModalDialog.__init__(self, window, _('Submarine Swap'))
        self.window = window
        self.config = window.config
        self.lnworker = self.window.wallet.lnworker
        self.swap_manager = self.lnworker.swap_manager
        self.network = window.network
        self.channels = channels
        self.is_reverse = is_reverse if is_reverse is not None else True
        vbox = QVBoxLayout(self)

        self.transport = transport
        self.server_button = SwapProvidersButton(lambda: self.transport, self.config, self.window)
        self.description_label = WWLabel(self.get_description())
        self.send_amount_e = BTCAmountEdit(self.window.get_decimal_point)
        self.recv_amount_e = BTCAmountEdit(self.window.get_decimal_point)
        self.max_button = EnterButton(_("Max"), self.spend_max)
        btn_width = 10 * char_width_in_lineedit()
        self.max_button.setFixedWidth(btn_width)
        self.max_button.setCheckable(True)
        self.toggle_button = QPushButton('  \U000021c4  ')  # whitespace to force larger min width
        self.toggle_button.setEnabled(is_reverse is None)
        # send_follows is used to know whether the send amount field / receive
        # amount field should be adjusted after the fee slider was moved
        self.send_follows = False
        self.send_amount_e.follows = False
        self.recv_amount_e.follows = False
        self.toggle_button.clicked.connect(self.toggle_direction)
        # textChanged is triggered for both user and automatic action
        self.send_amount_e.textChanged.connect(self.on_send_edited)
        self.recv_amount_e.textChanged.connect(self.on_recv_edited)
        # textEdited is triggered only for user editing of the fields
        self.send_amount_e.textEdited.connect(self.uncheck_max)
        self.recv_amount_e.textEdited.connect(self.uncheck_max)

        self.fee_policy = FeePolicy(self.config.FEE_POLICY)
        self.fee_slider = FeeSlider(parent=self, network=self.network, fee_policy=self.fee_policy, callback=self.fee_slider_callback)
        self.fee_combo = FeeComboBox(self.fee_slider)
        self.fee_target_label = QLabel()
        self._set_fee_slider_visibility(is_visible=not self.is_reverse)

        self.swap_limits_label = QLabel()
        self.fee_label = QLabel()
        self.server_fee_label = QLabel()
        self.last_server_mining_fee_sat = None
        h = QGridLayout()
        h.addWidget(self.description_label, 0, 0, 1, 3)
        h.addWidget(self.toggle_button, 0, 3)
        self.send_label = IconLabel(text=_('You send')+':')
        self.recv_label = IconLabel(text=_('You receive')+':')
        h.addWidget(self.send_label, 1, 0)
        h.addWidget(self.send_amount_e, 1, 1)
        h.addWidget(self.max_button, 1, 2)
        h.addWidget(self.recv_label, 2, 0)
        h.addWidget(self.recv_amount_e, 2, 1)
        h.addWidget(QLabel(_('Swap limits')+':'), 4, 0)
        h.addWidget(self.swap_limits_label, 4, 1, 1, 2)
        h.addWidget(QLabel(_('Server fee')+':'), 5, 0)
        h.addWidget(self.server_fee_label, 5, 1, 1, 2)
        h.addWidget(QLabel(_('Mining fee')+':'), 6, 0)
        h.addWidget(self.fee_label, 6, 1, 1, 2)
        h.addWidget(self.fee_slider, 7, 1)
        h.addWidget(self.fee_combo, 7, 2)
        h.addWidget(self.fee_target_label, 7, 0)
        h.addWidget(QLabel(''), 8, 0)
        vbox.addLayout(h)
        vbox.addStretch()
        self.ok_button = OkButton(self)
        self.ok_button.setDefault(True)
        self.ok_button.setEnabled(False)
        buttons = Buttons(CancelButton(self), self.ok_button)
        vbox.addLayout(buttons)
        buttons.insertWidget(0, self.server_button)
        if recv_amount_sat_or_max:
            assert isinstance(recv_amount_sat_or_max, (int, str)), f"invalid {type(recv_amount_sat_or_max)=}"
            self.init_recv_amount(recv_amount_sat_or_max)
        self.update()
        self.needs_tx_update = True

        self.timer = QTimer(self)
        self.timer.setInterval(500)
        self.timer.setSingleShot(False)
        self.timer.timeout.connect(self.timer_actions)
        self.timer.start()

        self.fee_slider.update()
        self.register_callbacks()

    def closeEvent(self, event):
        self.unregister_callbacks()
        event.accept()

    @qt_event_listener
    def on_event_fee_histogram(self, *args):
        self.update_send_receive()

    @qt_event_listener
    def on_event_fee(self, *args):
        self.update_send_receive()

    @qt_event_listener
    def on_event_swap_offers_changed(self, recent_offers: Sequence['SwapOffer']):
        self.server_button.update()
        if not self.ok_button.isEnabled():
            # only update the dialog with the new offer if the user hasn't entered an amount yet.
            # if the user has already entered an amount we prefer the swap to fail due to outdated
            # fees than the possibility of a swap happening with fees the user hasn't seen
            # due to an update happening just before the user initiated the swap
            self.update()

    @qt_event_listener
    def on_event_swap_provider_changed(self):
        self.update()
        self.update_send_receive()

    def timer_actions(self):
        if self.needs_tx_update:
            self.update_tx()
            self.update_ok_button()
            self.needs_tx_update = False

    def init_recv_amount(self, recv_amount_sat):
        if recv_amount_sat == '!':
            self.max_button.setChecked(True)
            self.spend_max()
        else:
            recv_amount_sat = max(recv_amount_sat, self.swap_manager.get_min_amount())
            self.recv_amount_e.setAmount(recv_amount_sat)

    def fee_slider_callback(self, fee_rate):
        self.config.FEE_POLICY = self.fee_policy.get_descriptor()
        if not self.is_reverse:
            self.fee_target_label.setText(self.fee_policy.get_target_text())
        self.update_send_receive()
        self.update()

    def _set_fee_slider_visibility(self, *, is_visible: bool):
        if is_visible:
            self.fee_slider.setEnabled(True)
            self.fee_combo.setEnabled(True)
            self.fee_target_label.setText(self.fee_policy.get_target_text())
        else:
            self.fee_slider.setEnabled(False)
            self.fee_combo.setEnabled(False)
            # show the eta of the swap claim
            self.fee_target_label.setText(FeePolicy(self.config.FEE_POLICY_SWAPS).get_target_text())

    def toggle_direction(self):
        self.is_reverse = not self.is_reverse
        self._set_fee_slider_visibility(is_visible=not self.is_reverse)
        self.send_amount_e.setAmount(None)
        self.recv_amount_e.setAmount(None)
        self.max_button.setChecked(False)
        self.update()

    def spend_max(self):
        if self.max_button.isChecked():
            if self.is_reverse:
                self._spend_max_reverse_swap()
            else:
                # spend_max_forward_swap will be called in update_tx
                pass
        else:
            self.send_amount_e.setAmount(None)
        self.needs_tx_update = True

    def uncheck_max(self):
        self.max_button.setChecked(False)
        self.update()

    def _spend_max_forward_swap(self, tx: Optional[PartialTransaction]) -> None:
        if tx:
            amount = tx.output_value_for_address(DummyAddress.SWAP)
            self.send_amount_e.setAmount(amount)
        else:
            self.send_amount_e.setAmount(None)
            self.max_button.setChecked(False)

    def _spend_max_reverse_swap(self) -> None:
        amount = min(self.lnworker.num_sats_can_send(), self.swap_manager.get_provider_max_forward_amount())
        amount = int(amount)  # round down msats
        self.send_amount_e.setAmount(amount)

    def on_send_edited(self):
        if self.send_amount_e.follows:
            return
        self.send_amount_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
        send_amount = self.send_amount_e.get_amount()
        recv_amount = self.swap_manager.get_recv_amount(send_amount, is_reverse=self.is_reverse)
        if self.is_reverse and send_amount and send_amount > self.lnworker.num_sats_can_send():
            # cannot send this much on lightning
            recv_amount = None
        if (not self.is_reverse) and recv_amount and recv_amount > self.lnworker.num_sats_can_receive():
            # cannot receive this much on lightning
            recv_amount = None
        self.recv_amount_e.follows = True
        self.recv_amount_e.setAmount(recv_amount)
        self.recv_amount_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
        self.recv_amount_e.follows = False
        self.send_follows = False
        self.needs_tx_update = True

    def on_recv_edited(self):
        if self.recv_amount_e.follows:
            return
        self.recv_amount_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
        recv_amount = self.recv_amount_e.get_amount()
        send_amount = self.swap_manager.get_send_amount(recv_amount, is_reverse=self.is_reverse)
        if self.is_reverse and send_amount and send_amount > self.lnworker.num_sats_can_send():
            send_amount = None
        self.send_amount_e.follows = True
        self.send_amount_e.setAmount(send_amount)
        self.send_amount_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
        self.send_amount_e.follows = False
        self.send_follows = True
        self.needs_tx_update = True

    def update_send_receive(self):
        self.on_recv_edited() if self.send_follows else self.on_send_edited()

    def update(self):
        sm = self.swap_manager
        w_base_unit = self.window.base_unit()
        send_icon = read_QIcon("lightning.png" if self.is_reverse else "bitcoin.png")
        self.send_label.setIcon(send_icon)
        recv_icon = read_QIcon("lightning.png" if not self.is_reverse else "bitcoin.png")
        self.recv_label.setIcon(recv_icon)
        self.description_label.setText(self.get_description())
        self.description_label.repaint()  # macOS hack for #6269
        min_swap_limit, max_swap_limit = self.get_client_swap_limits_sat()
        if max_swap_limit == 0:
            swap_name = _("reverse") if self.is_reverse else _("forward")
            swap_limit_str = _("No {} swap possible with this provider").format(swap_name)
        else:
            swap_limit_str = (f"{self.window.format_amount(min_swap_limit)} - "
                              f"{self.window.format_amount(max_swap_limit)} {w_base_unit}")
        self.swap_limits_label.setText(swap_limit_str)
        self.swap_limits_label.repaint()  # macOS hack for #6269
        self.last_server_mining_fee_sat = sm.mining_fee
        server_fee_str = '%.2f'%sm.percentage + '%  +  '  + self.window.format_amount(sm.mining_fee) + ' ' + w_base_unit
        self.server_fee_label.setText(server_fee_str)
        self.server_fee_label.repaint()  # macOS hack for #6269
        self.needs_tx_update = True

    def get_client_swap_limits_sat(self) -> Tuple[int, int]:
        """Returns the (min, max) client swap limits in sat."""
        sm = self.swap_manager

        if self.is_reverse:
            lower_limit = sm.get_min_amount()
            upper_limit = sm.client_max_amount_reverse_swap() or 0
        else:
            lower_limit = sm.get_send_amount(sm.get_min_amount(), is_reverse=False) or sm.get_min_amount()
            upper_limit = sm.client_max_amount_forward_swap() or 0

        if lower_limit > upper_limit:
            # if the max possible amount is below the lower limit no swap is possible
            lower_limit, upper_limit = 0, 0
        return lower_limit, upper_limit

    def update_fee(self, tx: Optional[PartialTransaction]) -> None:
        """Updates self.fee_label. No other side-effects."""
        if self.is_reverse:
            sm = self.swap_manager
            fee = sm.get_fee_for_txbatcher()
        else:
            fee = tx.get_fee() if tx else None
        fee_text = self.window.format_amount(fee) + ' ' + self.window.base_unit() if fee else _("no input")
        self.fee_label.setText(fee_text)
        self.fee_label.repaint()  # macOS hack for #6269

    def run(self, transport: 'SwapServerTransport') -> bool:
        """Can raise InvalidSwapParameters."""
        if not self.exec():
            return False
        if self.is_reverse:
            lightning_amount = self.send_amount_e.get_amount()
            onchain_amount = self.recv_amount_e.get_amount()
            if lightning_amount is None or onchain_amount is None:
                return False
            sm = self.swap_manager
            coro = sm.reverse_swap(
                transport=transport,
                lightning_amount_sat=lightning_amount,
                expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_fee_for_txbatcher(),
                prepayment_sat=2 * self.last_server_mining_fee_sat,
            )
            try:
                # we must not leave the context, so we use run_couroutine_dialog
                funding_txid = self.window.run_coroutine_dialog(coro, _('Initiating swap...'))
            except Exception as e:
                self.window.show_error(f"Reverse swap failed: {str(e)}")
                return False
            self.window.on_swap_result(funding_txid, is_reverse=True)
            return True
        else:
            lightning_amount = self.recv_amount_e.get_amount()
            onchain_amount = self.send_amount_e.get_amount()
            if lightning_amount is None or onchain_amount is None:
                return False
            if lightning_amount > self.lnworker.num_sats_can_receive():
                if not self.window.question(CANNOT_RECEIVE_WARNING):
                    return False
            self.window.protect(self.do_normal_swap, (transport, lightning_amount, onchain_amount))
            return True

    def update_tx(self) -> None:
        if self.is_reverse:
            self.update_fee(None)
            return
        is_max = self.max_button.isChecked()
        if is_max:
            tx = self._create_tx_safe('!')
            self._spend_max_forward_swap(tx)
        else:
            onchain_amount = self.send_amount_e.get_amount()
            tx = self._create_tx_safe(onchain_amount)
        self.update_fee(tx)

    def _create_tx(self, onchain_amount: Union[int, str, None]) -> PartialTransaction:
        assert not self.is_reverse
        if onchain_amount is None:
            raise InvalidSwapParameters("onchain_amount is None")
        coins = self.window.get_coins()
        if onchain_amount == '!':
            max_amount = sum(c.value_sats() for c in coins)
            max_swap_amount = self.swap_manager.client_max_amount_forward_swap()
            if max_swap_amount is None:
                raise InvalidSwapParameters("swap_manager.client_max_amount_forward_swap() is None")
            if max_amount > max_swap_amount:
                onchain_amount = max_swap_amount
        outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)]
        try:
            tx = self.window.wallet.make_unsigned_transaction(
                fee_policy=self.fee_policy,
                coins=coins,
                outputs=outputs,
                send_change_to_lightning=False,
            )
        except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
            raise InvalidSwapParameters(str(e)) from e
        return tx

    def _create_tx_safe(self, onchain_amount: Union[int, str, None]) -> Optional[PartialTransaction]:
        try:
            return self._create_tx(onchain_amount=onchain_amount)
        except InvalidSwapParameters:
            return None

    def update_ok_button(self):
        """Updates self.ok_button. No other side-effects."""
        send_amount = self.send_amount_e.get_amount()
        recv_amount = self.recv_amount_e.get_amount()
        self.ok_button.setEnabled(bool(send_amount) and bool(recv_amount))

    async def _do_normal_swap(self, transport, lightning_amount, onchain_amount, password):
        dummy_tx = self._create_tx(onchain_amount)
        assert dummy_tx
        sm = self.swap_manager
        swap, invoice = await sm.request_normal_swap(
            transport=transport,
            lightning_amount_sat=lightning_amount,
            expected_onchain_amount_sat=onchain_amount,
            channels=self.channels,
        )
        self._current_swap = swap
        tx = sm.create_funding_tx(swap, dummy_tx, password=password)
        txid = await sm.wait_for_htlcs_and_broadcast(transport=transport, swap=swap, invoice=invoice, tx=tx)
        return txid

    def do_normal_swap(self, transport, lightning_amount, onchain_amount, password):
        self._current_swap = None
        coro = self._do_normal_swap(transport, lightning_amount, onchain_amount, password)
        try:
            funding_txid = self.window.run_coroutine_dialog(coro, _('Awaiting swap payment...'))
        except UserCancelled:
            self.swap_manager.cancel_normal_swap(self._current_swap)
            self.window.show_message(_('Swap cancelled'))
            return
        except Exception as e:
            self.window.show_error(str(e))
            return
        self.window.on_swap_result(funding_txid, is_reverse=False)

    def get_description(self):
        onchain_funds = "onchain"
        lightning_funds = "lightning"

        return "Send {fromType}, receive {toType}.\nThis will increase your lightning {capacityType} capacity.\n".format(
            fromType=lightning_funds if self.is_reverse else onchain_funds,
            toType=onchain_funds if self.is_reverse else lightning_funds,
            capacityType="receiving" if self.is_reverse else "sending",
        )


class SwapServerDialog(WindowModalDialog, QtEventListener):

    class Columns(MyTreeView.BaseColumnsEnum):
        PUBKEY = enum.auto()
        FEE = enum.auto()
        MAX_FORWARD = enum.auto()
        MAX_REVERSE = enum.auto()
        LAST_SEEN = enum.auto()

    headers = {
        Columns.PUBKEY: _("Pubkey"),
        Columns.FEE: _("Fee"),
        Columns.MAX_FORWARD: _('Max Forward'),
        Columns.MAX_REVERSE: _('Max Reverse'),
        Columns.LAST_SEEN: _("Last seen"),
    }

    def __init__(self, window: 'ElectrumWindow', servers: Sequence['SwapOffer']):
        WindowModalDialog.__init__(self, window, _('Choose Swap Provider'))
        self.window = window
        self.config = window.config
        msg = '\n'.join([
            _("Please choose a provider from this list."),
            _("Note that fees and liquidity may be updated frequently.")
        ])
        self.servers_list = QTreeWidget()
        col_names = [self.headers[col_idx] for col_idx in sorted(self.headers.keys())]
        self.servers_list.setHeaderLabels(col_names)
        self.servers_list.header().setStretchLastSection(False)
        for col_idx in range(len(self.Columns)):
            sm = QHeaderView.ResizeMode.Stretch if col_idx == self.Columns.PUBKEY else QHeaderView.ResizeMode.ResizeToContents
            self.servers_list.header().setSectionResizeMode(col_idx, sm)
        self.update_servers_list(servers)
        vbox = QVBoxLayout()
        self.setLayout(vbox)
        vbox.addWidget(WWLabel(msg))
        vbox.addWidget(self.servers_list, stretch=1)
        vbox.addSpacing(10)
        self.ok_button = OkButton(self)
        vbox.addLayout(Buttons(CancelButton(self), self.ok_button))
        self.setMinimumWidth(650)
        self.register_callbacks()

    def run(self):
        if self.exec() != 1:
            return None
        if item := self.servers_list.currentItem():
            return item.data(self.Columns.PUBKEY, ROLE_NPUB)
        return None

    def closeEvent(self, event):
        self.unregister_callbacks()
        event.accept()

    @qt_event_listener
    def on_event_swap_offers_changed(self, recent_offers: Sequence['SwapOffer']):
        self.update_servers_list(recent_offers)

    def update_servers_list(self, servers: Sequence['SwapOffer']):
        self.servers_list.clear()
        from electrum.util import age
        items = []
        for x in servers:
            labels = [""] * len(self.Columns)
            labels[self.Columns.PUBKEY] = x.server_pubkey
            labels[self.Columns.FEE] = f"{x.pairs.percentage}% + {x.pairs.mining_fee} sats"
            labels[self.Columns.MAX_FORWARD] = self.window.format_amount(x.pairs.max_forward) + ' ' + self.window.base_unit()
            labels[self.Columns.MAX_REVERSE] = self.window.format_amount(x.pairs.max_reverse) + ' ' + self.window.base_unit()
            labels[self.Columns.LAST_SEEN] = age(x.timestamp)
            item = QTreeWidgetItem(labels)
            item.setData(self.Columns.PUBKEY, ROLE_NPUB, x.server_npub)
            item.setIcon(self.Columns.PUBKEY, pubkey_to_q_icon(x.server_pubkey))
            items.append(item)
        self.servers_list.insertTopLevelItems(0, items)

