#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2025 The Electrum 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 asyncio
import json
import time
import ssl
import logging
import urllib.parse
from typing import TYPE_CHECKING, Optional, List, Tuple, Awaitable

import electrum_aionostr as aionostr
from electrum_aionostr.event import Event as nEvent
from electrum_aionostr.key import PrivateKey

from electrum.lnworker import PaymentDirection
from electrum.plugin import BasePlugin, hook
from electrum.logging import Logger
from electrum.util import log_exceptions, ca_path, OldTaskGroup, get_asyncio_loop, InvoiceError, \
    LightningHistoryItem, event_listener, EventListener, make_aiohttp_proxy_connector, \
    get_running_loop
from electrum.invoices import Invoice, Request, PR_UNKNOWN, PR_PAID, BaseInvoice, PR_INFLIGHT, PR_FAILED, PR_EXPIRED, PR_UNPAID
from electrum import constants
from electrum.lnutil import RECEIVED, PaymentFeeBudget

if TYPE_CHECKING:
    from aiohttp_socks import ProxyConnector

    from electrum.simple_config import SimpleConfig
    from electrum.wallet import Abstract_Wallet


class NWCServerPlugin(BasePlugin):
    URI_SCHEME = 'nostr+walletconnect://'

    def __init__(self, parent, config: 'SimpleConfig', name):
        BasePlugin.__init__(self, parent, config, name)
        self.config = config
        self.connections = None  # type: Optional[dict[str, dict]]  # pubkey_hex -> connection data
        self.nwc_server = None   # type: Optional[NWCServer]
        self.taskgroup = OldTaskGroup()
        self.initialized = False
        if not self.config.NWC_RELAY:  # type: ignore  # defined in __init__
            self.config.NWC_RELAY = self.config.NOSTR_RELAYS.split(',')[0]
        self.logger.debug(f"NWCServerPlugin created, waiting for wallet to load...")

    def start_plugin(self, wallet: 'Abstract_Wallet'):
        if not wallet.has_lightning():
            return
        if self.initialized:
            # this might be called for several wallets. only use one.
            return
        storage = self.get_storage(wallet)
        self.connections = storage.setdefault('connections', {})
        self.delete_expired_connections()
        self.nwc_server = NWCServer(self.config, wallet, self.connections)
        asyncio.run_coroutine_threadsafe(self.taskgroup.spawn(self.nwc_server.run()), get_asyncio_loop())
        self.initialized = True

    @hook
    def close_wallet(self, *args, **kwargs):
        async def close():
            if self.nwc_server and self.nwc_server.manager:
                self.nwc_server.do_stop = True
                await self.nwc_server.manager.close()
            await self.taskgroup.cancel_remaining()
        asyncio.run_coroutine_threadsafe(
            close(),
            get_asyncio_loop()
        )
        self.logger.debug(f"NWCServerPlugin closed, stopping taskgroup")

    def delete_expired_connections(self):
        if self.connections is None:
            return
        now = int(time.time())
        connections = list(self.connections.items())
        for pubkey, conn in connections:
            if 'valid_until' in conn and conn['valid_until'] <= now:
                del self.connections[pubkey]
                self.logger.info(f"Deleting expired NWC connection: {pubkey}")
        if len(self.connections) != len(connections) and self.nwc_server:
            self.nwc_server.restart_event_handler()

    def create_connection(self, name: str, daily_limit_sat: Optional[int], valid_for_sec: Optional[int]) -> str:
        assert self.connections is not None, f"Wallet not loaded yet"
        assert len(name) > 0, f"Invalid or missing connection name: {name}"

        for conn in self.connections.values():
            if conn['name'] == name:
                raise ValueError(f"Connection name already exists: {name}")

        our_connection_secret = PrivateKey()
        our_connection_pubkey: str = our_connection_secret.public_key.hex()

        client_secret = PrivateKey()
        client_pubkey: str = client_secret.public_key.hex()

        connection = {
            "name": name,
            "our_secret": our_connection_secret.hex(),
            "budget_spends": []
        }
        if daily_limit_sat is not None:
            connection['daily_limit_sat'] = daily_limit_sat
        if valid_for_sec:
            connection['valid_until'] = int(time.time()) + valid_for_sec
        connection_string = self.serialize_connection_uri(client_secret.hex(), our_connection_pubkey)
        self.connections[client_pubkey] = connection
        self.logger.debug(f"Added nwc connection: {name=}, {valid_for_sec=}, {daily_limit_sat=}")

        if self.nwc_server:
            self.nwc_server.restart_event_handler()

        return connection_string

    def remove_connection(self, name: str) -> None:
        assert self.connections is not None, f"Wallet not loaded yet"
        for pubkey, conn in self.connections.items():
            if conn['name'] == name:
                del self.connections[pubkey]
                return
        raise ValueError(f"Connection name not found: {name}")

    def list_connections(self) -> dict:
        assert self.connections is not None, f"Wallet not loaded yet"
        self.delete_expired_connections()
        connections_without_secrets = {}
        for client_pub, conn in self.connections.items():
            data = {
                'valid_until': conn.get('valid_until', "unset"),
                'daily_limit_sat': conn.get('daily_limit_sat', "unset"),
                'client_pub': client_pub,
            }
            connections_without_secrets[conn['name']] = data
        return connections_without_secrets

    def serialize_connection_uri(self, client_secret_hex: str, our_pubkey_hex: str) -> str:
        base_uri = f"{self.URI_SCHEME}{our_pubkey_hex}"

        # the NWC_RELAY is added first as this is the first relay parsed by clients
        query_params = [f"relay={urllib.parse.quote(self.config.NWC_RELAY)}"]  # type: ignore
        for relay in self.config.NOSTR_RELAYS.split(",")[:5]:
            if relay != self.config.NWC_RELAY:  # type: ignore
                query_params.append(f"relay={urllib.parse.quote(relay)}")

        query_params.append(f"secret={client_secret_hex}")

        # Construct the final URI
        query_string = "&".join(query_params)
        uri = f"{base_uri}?{query_string}"

        return uri


class NWCServer(Logger, EventListener):
    INFO_EVENT_KIND: int        = 13194
    REQUEST_EVENT_KIND: int     = 23194
    RESPONSE_EVENT_KIND: int    = 23195
    NOTIFICATION_EVENT_KIND: int = 23196
    SUPPORTED_SPENDING_METHODS: set[str] = {'pay_invoice'}
    SUPPORTED_METHODS: set[str] = {'make_invoice', 'lookup_invoice', 'get_balance', 'get_info',
                                   'list_transactions', 'notifications'}.union(SUPPORTED_SPENDING_METHODS)
    SUPPORTED_NOTIFICATIONS: list[str] = ["payment_sent", "payment_received"]
    SUPPORTED_ENCRYPTION_SCHEMES: set[str] = {'nip04'}
    INFO_EVENT_REBROADCAST_INTERVAL_SEC = 60 * 60 * 24

    def __init__(
        self,
        config: 'SimpleConfig',
        wallet: 'Abstract_Wallet',
        connection_storage: dict,
    ):
        Logger.__init__(self)
        self.config = config  # type: 'SimpleConfig'
        self.wallet = wallet  # type: 'Abstract_Wallet'
        self.connections = connection_storage  # type: dict[str, dict]  # client hex pubkey -> connection data
        self.relays = config.NOSTR_RELAYS.split(",") or []  # type: List[str]
        self.do_stop = False
        self.taskgroup = None  # type: Optional[OldTaskGroup]
        self.ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path)
        self.manager = None  # type: Optional[aionostr.Manager]
        self.register_callbacks()

    def get_relay_manager(self) -> aionostr.Manager:
        assert get_asyncio_loop() == get_running_loop(), "NWCServer must run in the aio event loop"
        nostr_logger = self.logger.getChild('aionostr')
        nostr_logger.setLevel(logging.INFO)
        network = self.wallet.lnworker.network
        if network.proxy and network.proxy.enabled:
            proxy = make_aiohttp_proxy_connector(network.proxy, self.ssl_context)
        else:
            proxy: Optional['ProxyConnector'] = None
        return aionostr.Manager(
            # ensure that we also connect to NWC_RELAY, even if it's not in the NOSTR_RELAYS
            relays=set(self.config.NOSTR_RELAYS.split(",")) | {self.config.NWC_RELAY},  # type: ignore
            private_key=PrivateKey().hex(),  # use random private key
            log=nostr_logger,
            ssl_context=self.ssl_context,
            proxy=proxy
        )

    @log_exceptions
    async def run(self) -> None:
        while True:
            # wait until connections have been set up and network is available
            while (not self.connections
                        or not self.relays
                        or not self.wallet.network
                        or not self.wallet.network.is_connected()
                        or not self.wallet.lnworker):
                await asyncio.sleep(5)

            if not await self.refresh_manager():
                await asyncio.sleep(30)
                continue

            try:
                async with OldTaskGroup() as tg:
                    self.taskgroup = tg
                    await tg.spawn(self.publish_info_event_loop())
                    await tg.spawn(self.handle_requests())
            except asyncio.CancelledError:
                if self.do_stop:
                    return
                self.logger.debug("Restarting nwc event handler")
            except Exception as e:
                self.logger.exception(f"Restarting nwc event handler after exception: {e}")
                if self.manager:  # close the manager so refresh_manager() will recreate it
                    await self.manager.close()
                    self.manager = None
                await asyncio.sleep(60)
            finally:
                self.taskgroup = None

    async def refresh_manager(self) -> bool:
        """Checks if manager is still connected to relays, if not recreates it and reconnects"""
        if self.manager is None:
            # on startup and proxy change
            self.manager = self.get_relay_manager()

        if len(self.manager.relays) <= 0 < len(self.relays):
            # manager lost all connections (relays)
            # setup new manager so relays are populated again
            await self.manager.close()
            self.manager = self.get_relay_manager()

        if not self.manager.connected:
            # not set in new manager instances
            await self.manager.connect()

        if len(self.manager.relays) <= 0:
            # manager should still have relays after connecting
            self.logger.warning(f"Could not connect to any relays!")
            return False

        return True

    def restart_event_handler(self) -> None:
        """To be called when the connections change so we restart with a new filter"""
        if tg := self.taskgroup:
            asyncio.run_coroutine_threadsafe(tg.cancel_remaining(), get_asyncio_loop())

    @event_listener
    def on_event_proxy_set(self, *args):
        async def restart_manager():
            if self.manager:
                await self.manager.close()
                self.manager = None
            await asyncio.sleep(5)
            self.restart_event_handler()
            self.logger.info("proxy changed, restarting nwc plugin nostr transport")
        asyncio.run_coroutine_threadsafe(restart_manager(), get_asyncio_loop())

    async def handle_requests(self) -> None:
        query = {
            "authors": list(self.connections.keys()),  # the pubkeys of the client connections
            "kinds": [self.REQUEST_EVENT_KIND],
            "limit": 0,  # requests only new events after creating this subscription
            "since": int(time.time())
        }
        async for event in self.manager.get_events(query, single_event=False, only_stored=False):
            await self._handle_single_request(event)

    async def _handle_single_request(self, event: nEvent) -> None:
        if event.pubkey not in self.connections.keys():
            return

        # check if the connection is expired, if so we delete it and send an error
        valid_until: Optional[int] = self.connections[event.pubkey].get('valid_until')
        if valid_until and valid_until <= int(time.time()):
            await self.send_error(event, "UNAUTHORIZED", "Connection expired")
            del self.connections[event.pubkey]
            self.logger.info(f"Deleting expired NWC connection: {event.pubkey}")
            self.restart_event_handler()
            return

        if event.kind != self.REQUEST_EVENT_KIND:
            self.logger.debug(f"Unknown nwc request event kind: {event.kind}")
            await self.send_error(event, "NOT_IMPLEMENTED")
            return

        # if the request has an explicitly set expiration tag, ignore it if it is expired
        # otherwise ignore requests older than 30 sec to not handle requests the user may
        # already expect to have timed out
        if event.expires_at() is not None:
            if event.is_expired():
                self.logger.debug(f"expired nwc request event: {event.content}")
                return
        elif event.created_at < int(time.time()) - 30:
            self.logger.debug(f"old nwc request event: {event.content}")
            await self.send_error(event, "OTHER", f"not handling too old request")
            return

        # check encryption scheme
        for tag in event.tags:
            if len(tag) == 2 and tag[0] == 'encryption':
                if tag[1] not in self.SUPPORTED_ENCRYPTION_SCHEMES:
                    await self.send_error(event, "UNSUPPORTED_ENCRYPTION", " ".join(self.SUPPORTED_ENCRYPTION_SCHEMES))
                    return
                break

        # decrypt the requests content
        our_secret: str = self.connections[event.pubkey]['our_secret']
        our_connection_secret = PrivateKey(raw_secret=bytes.fromhex(our_secret))
        try:
            content = our_connection_secret.decrypt_message(event.content, event.pubkey)
            content = json.loads(content)
            if not isinstance(content, dict):
                raise Exception("malformed content, not dict")
            params: dict = content.get('params') or {}  # some clients send 'params: null' or no params key at all
            if not isinstance(params, dict):
                raise Exception(f"malformed params, not dict: {content=}")
        except Exception:
            self.logger.debug(f"Invalid request event content: {event.content}", exc_info=True)
            return

        # run the according method
        method: str = content.get('method')
        self.logger.debug(f"got request: {method=}, {params=}")
        task: Optional[Awaitable] = None
        if method == "pay_invoice" and not self.is_receive_only(event.pubkey):
            task = self.handle_pay_invoice(event, params)
        elif method == "make_invoice":
            task = self.handle_make_invoice(event, params)
        elif method == "lookup_invoice":
            task = self.handle_lookup_invoice(event, params)
        elif method == "get_balance":
            task = self.handle_get_balance(event)
        elif method == "get_info":
            task = self.handle_get_info(event)
        elif method == "list_transactions":
            task = self.handle_list_transactions(event, params)
        else:
            self.logger.debug(f"Unsupported nwc method requested: {method}")
            await self.send_error(event, "NOT_IMPLEMENTED", f"{method} not supported", error_restype=method)
            return

        if task:
            await self.taskgroup.spawn(self.run_request_task(task, request_event=event, request_method=method))

    async def run_request_task(self, task: Awaitable, *, request_event: nEvent, request_method: str = None) -> None:
        """Catches request handling exceptions and send an error response"""
        try:
            await task
        except Exception as e:
            self.logger.exception("Error handling nwc request")
            await self.send_error(
                request_event, "INTERNAL", f"Error handling request: {str(e)[:100]}",
                error_restype=request_method,
            )

    async def send_error(
        self,
        causing_event: nEvent,
        error_type: str,
        error_msg: str = "",
        *,
        error_restype: str = None,
    ) -> None:
        """Sends an error as response to the passed nEvent, containing the error type and message"""
        to_pubkey_hex = causing_event.pubkey
        response_to_id = causing_event.id
        content = self.get_error_response(error_type, error_msg, error_restype)
        await self.send_encrypted_response(to_pubkey_hex, json.dumps(content), response_to_id)

    @staticmethod
    def get_error_response(error_type: str, error_msg: str = "", method: Optional[str] = None) -> dict:
        content = {
            "error": {
                "code": error_type,
                "message": error_msg
            }
        }
        if method:
            content['result_type'] = method
        return content

    async def send_encrypted_response(
            self,
            to_pubkey_hex: str,
            content: str,
            response_event_id: str,
            *,
            add_tags: Optional[List] = None
    ) -> None:
        """Encrypts content for the given pubkey and sends it as response to the given event id"""
        our_secret: str = self.connections[to_pubkey_hex]['our_secret']
        tags = [['p', to_pubkey_hex], ['e', response_event_id]]
        if add_tags:
            tags.extend(add_tags)

        await self.taskgroup.spawn(aionostr._add_event(
            self.manager,
            kind=self.RESPONSE_EVENT_KIND,
            tags=tags,
            content=self.encrypt_to_pubkey(content, to_pubkey_hex),
            # use the private key we generated for this specific client
            private_key=our_secret
            )
        )

    @log_exceptions
    async def handle_pay_invoice(self, request_event: nEvent, params: dict) -> None:
        """
        Handler for pay_invoice method
        https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#pay_invoice
        """
        invoice: str = params.get('invoice', "")
        amount_msat: Optional[int] = params.get('amount')
        pay_task = asyncio.create_task(self.pay_invoice(invoice, amount_msat, request_event.pubkey))
        # prevent the payment attempt from getting canceled during taskgroup restart
        response = await asyncio.shield(pay_task)
        response['result_type'] = 'pay_invoice'
        await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id)

    @log_exceptions
    async def handle_make_invoice(self, request_event: nEvent, params: dict):
        """
        Handler for make_invoice method.
        https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#make_invoice
        """
        amount_msat = params.get('amount', 0)  # type: Optional[int]
        description = params.get('description', params.get('description_hash', ""))  # type: str
        expiry = params.get('expiry', 3600)  # type: int
        # create payment request
        key: str = self.wallet.create_request(
            amount_sat=amount_msat // 1000,
            message=description,
            exp_delay=expiry,
            address=None
        )
        req: Request = self.wallet.get_request(key)
        info = self.wallet.lnworker.get_payment_info(req.payment_hash, direction=RECEIVED)
        try:
            lnaddr, b11 = self.wallet.lnworker.get_bolt11_invoice(
                payment_info=info,
                message=description,
                fallback_address=None
            )
        except Exception:
            self.logger.exception(f"failed to create invoice")
            response = self.get_error_response("INTERNAL", "Failed to create invoice", "make_invoice")
            return await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id)
        response = {
            "result_type": "make_invoice",
            "result": {
                "type": "incoming",
                "state": "pending",
                "invoice": b11,
                "description": description,
                "payment_hash": lnaddr.paymenthash.hex(),
                "amount": amount_msat,
                "created_at": lnaddr.date,
                "expires_at": req.get_expiration_date(),
                "metadata": {},
                "fees_paid": 0  # the spec wants this??
            }
        }
        self.logger.debug(f"make_invoice response: {response}")
        await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id)

    @log_exceptions
    async def handle_lookup_invoice(self, request_event: nEvent, params: dict):
        """
        https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#lookup_invoice
        """
        invoice = params.get('invoice')
        payment_hash = params.get('payment_hash')
        if invoice:
            invoice = Invoice.from_bech32(invoice)
        elif payment_hash:
            invoice = self.wallet.get_invoice(payment_hash) or self.wallet.get_request(payment_hash)
        else:
            response = self.get_error_response("NOT_FOUND", "Missing invoice or payment_hash")
            return await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id)

        status = None
        if invoice and invoice.is_lightning():
            status = self.wallet.get_invoice_status(invoice)
        if not invoice or status is None or status == PR_UNKNOWN:
            response = self.get_error_response("NOT_FOUND", "Invoice not found")
            return await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id)

        direction = None
        b11 = None
        if self.wallet.get_invoice(invoice.rhash):
            direction = "outgoing"
            b11 = invoice.lightning_invoice
        elif self.wallet.get_request(invoice.rhash):
            direction = "incoming"
            info = self.wallet.lnworker.get_payment_info(bytes.fromhex(invoice.rhash), direction=RECEIVED)
            _, b11 = self.wallet.lnworker.get_bolt11_invoice(
                payment_info=info,
                message=invoice.message,
                fallback_address=None
            )

        response = {
            "result_type": "lookup_invoice",
            "result": {
                "description": invoice.message,
                "payment_hash": invoice.rhash,
                "amount": invoice.get_amount_msat(),
                "created_at": invoice.time,
                "expires_at": invoice.get_expiration_date(),
                "fees_paid": 0,
                "invoice": b11,
                "metadata": {}
            }
        }
        if nip47_status := self.invoice_status_to_nip47_state(status):
            response['result']['state'] = nip47_status

        info = self.get_payment_info(invoice.rhash)
        if info:
            _, _, fee_msat, settled_at = info
            if fee_msat:
                response['result']['fees_paid'] = fee_msat
            response['result']['settled_at'] = settled_at

        if direction:
            response['result']['type'] = direction
        if status == PR_PAID:
            response['result']['preimage'] = self.wallet.lnworker.get_preimage_hex(invoice.rhash) or "not found"
        self.logger.debug(f"lookup_invoice response: {response}")
        await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id)

    @log_exceptions
    async def handle_get_balance(self, request_event: nEvent):
        """
        https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#get_balance
        """
        balance = int(self.wallet.lnworker.get_balance())
        response = {
            "result_type": "get_balance",
            "result": {
                "balance": balance * 1000,
            }
        }
        await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id)

    @log_exceptions
    async def handle_get_info(self, request_event: nEvent):
        """
        https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#get_info
        """
        height = self.wallet.lnworker.network.blockchain().height()
        blockhash = self.wallet.lnworker.network.blockchain().get_hash(height)
        supported_methods = self.SUPPORTED_METHODS.copy()
        if self.is_receive_only(request_event.pubkey):
            supported_methods -= self.SUPPORTED_SPENDING_METHODS
        response = {
            "result_type": "get_info",
            "result": {
                "alias": self.config.LIGHTNING_NODE_ALIAS,
                "color": self.config.LIGHTNING_NODE_COLOR_RGB,
                "pubkey": self.wallet.lnworker.node_keypair.pubkey.hex(),
                "network": constants.net.NET_NAME,
                "block_height": height,
                "block_hash": blockhash,
                "methods": list(supported_methods),
            }
        }
        if self.SUPPORTED_NOTIFICATIONS:
            response['result']['notifications'] = self.SUPPORTED_NOTIFICATIONS
        await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id)

    @log_exceptions
    async def handle_list_transactions(self, request_event: nEvent, params: dict):
        """
        https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#list_transactions
        Lists invoices and payments. If type is not specified, both invoices and payments are returned.
        The from and until parameters are timestamps in seconds since epoch.
        If from is not specified, it defaults to 0. If until is not specified, it defaults to the current time.
        Transactions are returned in descending order of creation time.
        """
        t0 = time.time()
        from_ts = int(params.get('from', 0))
        until_ts = int(params.get('until', time.time()))
        limit: Optional[int] = params.get('limit')
        offset: Optional[int] = params.get('offset')
        include_unpaid_reqs = bool(params.get('unpaid', False))
        # this is not in spec but alby go requests it
        include_unpaid_outgoing = bool(params.get('unpaid_outgoing', False))
        req_type = params.get('type', "undefined")

        lightning_history = self.wallet.lnworker.get_lightning_history()
        lightning_history = lightning_history.values()

        if req_type == "incoming":
            lightning_history = [tx for tx in lightning_history if tx.direction == PaymentDirection.RECEIVED]
        elif req_type == "outgoing":
            lightning_history = [tx for tx in lightning_history if tx.direction == PaymentDirection.SENT]
        else:
            directions = [PaymentDirection.SENT, PaymentDirection.RECEIVED]
            lightning_history = [tx for tx in lightning_history if tx.direction in directions]

        if include_unpaid_reqs:
            requests = self.wallet.get_unpaid_requests()
            for req in requests:
                if not req.is_lightning() or not (from_ts <= req.time <= until_ts):
                    continue
                lightning_history.append(
                    # append the payment request as LightingHistoryItem so they can be filtered
                    # together with the real lightning history items
                    LightningHistoryItem(
                        type='unpaid',
                        payment_hash=req.rhash,
                        preimage=None,
                        amount_msat=req.get_amount_msat() or 0,
                        fee_msat=None,
                        timestamp=req.time,
                        direction=PaymentDirection.RECEIVED,
                        group_id=None,
                        label=None
                    )
                )

        if include_unpaid_outgoing:
            """Alby Go requests unpaid_outgoing (out of nip47 spec) but then shows them as sent in the tx history.
            So we only return PR_INFLIGHT here so its not totally misleading in the history."""
            invoices = self.wallet.get_invoices()
            for inv in invoices:
                if (not inv.is_lightning()
                        or not (from_ts <= inv.time <= until_ts)
                        or not self.wallet.get_invoice_status(inv) == PR_INFLIGHT):
                    continue
                lightning_history.append(
                    LightningHistoryItem(
                        type='pending',
                        payment_hash=inv.rhash,
                        preimage=None,
                        amount_msat=inv.get_amount_msat() or 0,
                        fee_msat=None,
                        timestamp=inv.time,
                        direction=PaymentDirection.SENT,
                        group_id=None,
                        label=None
                    )
                )

        if from_ts > 0 or until_ts < time.time() - 50:
            # filter out transactions that are not in the time range
            lightning_history = [tx for tx in lightning_history if from_ts <= tx.timestamp <= until_ts]

        lightning_history = sorted(lightning_history, key=lambda tx: tx.timestamp, reverse=True)
        if offset and offset > 0:
            lightning_history = lightning_history[offset:]
        if limit and limit > 0:
            lightning_history = lightning_history[:limit]
        transactions = []
        for history_tx in lightning_history:
            tx = {
                "payment_hash": history_tx.payment_hash,
                "amount": abs(history_tx.amount_msat),
                "metadata": {},
                "fees_paid": 0
            }
            payment: Optional[BaseInvoice] = None
            if history_tx.direction == PaymentDirection.RECEIVED:
                tx['type'] = "incoming"
                payment = self.wallet.get_request(history_tx.payment_hash)
            elif history_tx.direction == PaymentDirection.SENT:
                tx['type'] = "outgoing"
                payment = self.wallet.get_invoice(history_tx.payment_hash)
            if not payment:
                # don't include txs with semi complete information as this will cause some clients
                # to fail displaying any transaction at all
                continue
            if include_unpaid_outgoing and history_tx.type == 'pending':
                tx['description'] = f"pending! {payment.message}"
            else:
                tx['description'] = payment.message
            tx['expires_at'] = payment.get_expiration_date()
            tx['created_at'] = payment.time
            status = self.wallet.get_invoice_status(payment)
            if nip47_status := self.invoice_status_to_nip47_state(status):
                tx['state'] = nip47_status
            if (not include_unpaid_reqs and not include_unpaid_outgoing) or history_tx.type == 'payment':
                tx['settled_at'] = history_tx.timestamp
                tx['preimage'] = history_tx.preimage
            if history_tx.fee_msat:
                tx['fees_paid'] = history_tx.fee_msat
            transactions.append(tx)

        response = {
            "result_type": "list_transactions",
            "result": {
                "transactions": transactions,
            }
        }
        self.logger.debug(f"list_transactions: returning {len(transactions)} txs in {time.time() - t0:.2f}s")
        await self.send_encrypted_response(request_event.pubkey, json.dumps(response), request_event.id)

    @event_listener
    def on_event_request_status(self, wallet, key, status):
        if wallet != self.wallet:
            return
        request: Optional[Request] = self.wallet.get_request(key)
        if not request or not request.is_lightning() or not status == PR_PAID:
            return
        info = self.wallet.lnworker.get_payment_info(request.payment_hash, direction=RECEIVED)
        _, b11 = self.wallet.lnworker.get_bolt11_invoice(
            payment_info=info,
            message=request.message,
            fallback_address=None
        )

        payment_info = self.get_payment_info(request.rhash)
        if payment_info:
            _, _, _, settled_at = payment_info
        else:
            settled_at = None

        notification = {
            "type": "incoming",
            "invoice": b11,
            "description": request.message,
            "payment_hash": request.rhash,
            "amount": request.get_amount_msat(),
            "created_at": request.time,
            "expires_at": request.get_expiration_date(),
            "preimage": self.wallet.lnworker.get_preimage_hex(request.rhash) or "not found",
            "metadata": {},
            "fees_paid": 0
        }
        if settled_at:
            notification['state'] = 'settled'
            notification['settled_at'] = settled_at

        self.publish_notification_event({
            "notification_type": "payment_received",
            "notification": notification,
        })

    @event_listener
    def on_event_payment_succeeded(self, wallet, key):
        if wallet != self.wallet:
            return
        invoice: Optional[Invoice] = self.wallet.get_invoice(key)
        if not invoice or not invoice.is_lightning():
            return

        payment_info = self.get_payment_info(key)
        if not payment_info:
            return
        _, _, fee_msat, settled_at = payment_info

        assert key == invoice.rhash, f"{key=!r} != {invoice.rhash=!r}"
        notification = {
            "type": "outgoing",
            "invoice": invoice.lightning_invoice or "",
            "description": invoice.message,
            "preimage": self.wallet.lnworker.get_preimage_hex(invoice.rhash) or "not found",
            "payment_hash": invoice.rhash,
            "amount": invoice.get_amount_msat(),
            "created_at": invoice.time,
            "expires_at": invoice.get_expiration_date(),
            "metadata": {}
        }
        if fee_msat:
            notification['fees_paid'] = fee_msat
        if settled_at:
            notification['state'] = 'settled'
            notification['settled_at'] = settled_at
        content = {
            "notification_type": "payment_sent",
            "notification": notification,
        }
        self.publish_notification_event(content)

    async def pay_invoice(self, b11: str, amount_msat: Optional[int], request_pub: str) -> dict:
        try:
            invoice: Invoice = Invoice.from_bech32(b11)
        except InvoiceError:
            return self.get_error_response("INTERNAL", "Invalid invoice")

        if invoice.get_amount_msat() is None and not amount_msat:
            return self.get_error_response("INTERNAL", "Missing amount")
        elif invoice.get_amount_msat() is None:
            invoice.set_amount_msat(amount_msat)

        # add the maximum allowed fee to the budget and update it with the actual fee once the payment succeeds
        # to prevent exceeding the budget through high fees
        payment_amount_msat = amount_msat or invoice.get_amount_msat()
        fee_budget = PaymentFeeBudget.from_invoice_amount(
            config=self.wallet.config,
            invoice_amount_msat=payment_amount_msat,
        )
        budget_amount_msat = payment_amount_msat + fee_budget.fee_msat
        try:
            budget_item = self.add_to_budget(request_pub, msat_requested=budget_amount_msat)
        except ValueError as e:
            return self.get_error_response("QUOTA_EXCEEDED", str(e))

        self.wallet.save_invoice(invoice)
        success = None
        try:
            success, log = await self.wallet.lnworker.pay_invoice(
                invoice=invoice,
                amount_msat=amount_msat,
                budget=fee_budget,
            )
        except Exception as e:
            self.logger.exception(f"failed to pay nwc invoice")
            return self.get_error_response("PAYMENT_FAILED", str(e))
        finally:
            # note: if the user shuts down or the application crashes before the payment ends, it will not
            # get deducted from the budget, even if the htlcs later get failed on wallet restart.
            if success:
                # replace the spend in the budget, this time using the actual (lower) fees that have been paid
                info = self.get_payment_info(invoice.rhash)
                assert info, "info should exist after successful payment"
                _, total_amount_msat, _, _ = info
                assert payment_amount_msat <= total_amount_msat <= budget_amount_msat
                self._update_budget_item_amount(request_pub, old_budget_item=budget_item, new_msat=total_amount_msat)
            else:
                self.remove_from_budget(request_pub, budget_item)

        preimage: bytes = self.wallet.lnworker.get_preimage(bytes.fromhex(invoice.rhash))
        response = {}
        if not success or not preimage:
            return self.get_error_response("PAYMENT_FAILED", str(log))
        response['result'] = {
            'preimage': preimage.hex(),
        }
        if success:
            self.logger.info(f"paid invoice request from NWC for {invoice.get_amount_sat()} sat")
        else:
            self.logger.info(f"failed to pay invoice request from NWC: {log}")
        return response

    def remove_from_budget(self, client_pub: str, budget_item: list[int]) -> None:
        assert len(budget_item) == 2, budget_item
        budget_spends = self.connections[client_pub].get('budget_spends', [])
        try:
            budget_spends.remove(budget_item)
            self.logger.debug(f"removed {budget_item=} from {client_pub[:4]}...{client_pub[-4:]} budget")
        except ValueError:
            self.logger.debug(f"failed to remove {budget_item=} from {client_pub[:4]}...{client_pub[-4:]} budget: not found")
            pass

    def get_used_budget_msat(self, client_pub: str) -> int:
        """
        Returns the used budget for the given client_pubkey in millisatoshi.
        """
        if 'budget_spends' not in self.connections[client_pub]:
            return 0
        used_budget: int = 0
        budget_spends = self.connections[client_pub]['budget_spends']
        for amount_msat, timestamp in list(budget_spends):
            if timestamp > int(time.time()) - 24 * 3600:
                used_budget += amount_msat
            elif timestamp < int(time.time()) - 24 * 3600:
                # remove old expense
                try:
                    budget_spends.remove([amount_msat, timestamp])
                except ValueError:
                    self.logger.debug("", exc_info=True)
                    continue  # could happen if there is a race
        return used_budget

    def add_to_budget(self, client_pub: str, *, msat_requested: int) -> list[int]:
        if 'budget_spends' not in self.connections[client_pub]:
            self.connections[client_pub]['budget_spends'] = []

        # check if budget allows this spend
        client_budget_sat: Optional[int] = self.connections[client_pub].get('daily_limit_sat')
        if client_budget_sat is not None:
            used_budget_msat: int = self.get_used_budget_msat(client_pub)
            if used_budget_msat + msat_requested > client_budget_sat * 1000:
                raise ValueError("spend exceeds daily budget")

        self.logger.debug(f"adding {msat_requested=} msat to {client_pub[:4]}...{client_pub[-4:]} budget")
        # tuples don't work because jsondb converts them to lists on reload
        budget_item = [msat_requested, int(time.time())]
        self.connections[client_pub]['budget_spends'].append(budget_item)
        return budget_item

    def _update_budget_item_amount(self, client_pub: str, *, old_budget_item: list[int], new_msat: int) -> None:
        assert len(old_budget_item) == 2, old_budget_item
        budget_spends = self.connections[client_pub].setdefault('budget_spends', [])
        try:
            budget_spends.remove(old_budget_item)
        except ValueError:
            self.logger.debug(
            f"failed to rm and update {old_budget_item=} from {client_pub[:4]}...{client_pub[-4:]} budget: not found")
            return
        # tuples don't work because jsondb converts them to lists on reload
        new_budget_item = [new_msat, int(time.time())]
        budget_spends.append(new_budget_item)

    async def publish_info_event_loop(self):
        """
        Publishes the info event according to spec, announcing the supported methods.
        We publish one info event for each client connection.
        We re-publish info events regularly as some relays drop them after a couple days.
        https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#example-nip-47-info-event
        """
        tags = []
        if self.SUPPORTED_NOTIFICATIONS:
            tags.append(['notifications', ' '.join(self.SUPPORTED_NOTIFICATIONS)])
        if self.SUPPORTED_ENCRYPTION_SCHEMES:
            tags.append(['encryption', ' '.join(self.SUPPORTED_ENCRYPTION_SCHEMES)])
        while True:
            for client_pubkey, connection in list(self.connections.items()):
                if client_pubkey not in self.connections:
                    continue  # might was removed during sleep
                supported_methods = self.SUPPORTED_METHODS.copy()
                if self.is_receive_only(client_pubkey):
                    supported_methods -= self.SUPPORTED_SPENDING_METHODS
                content = ' '.join(supported_methods)
                event_id = await aionostr._add_event(
                    self.manager,
                    kind=self.INFO_EVENT_KIND,
                    tags=tags or None,
                    content=content,
                    private_key=connection['our_secret']
                )
                self.logger.debug(f"Published info event {event_id} to {client_pubkey}")
                await asyncio.sleep(3)  # try not to blast every event at once so they don't get rate limited
            await asyncio.sleep(self.INFO_EVENT_REBROADCAST_INTERVAL_SEC)

    def publish_notification_event(self, content: dict):
        """
        https://github.com/nostr-protocol/nips/blob/75f246ed987c23c99d77bfa6aeeb1afb669e23f7/47.md#notification-events
        """
        if not self.taskgroup or not self.manager:
            return
        self.logger.debug(f"Publishing notification event: {content}")
        for client_pubkey, connection in list(self.connections.items()):
            coro = self.taskgroup.spawn(aionostr._add_event(
                self.manager,
                kind=self.NOTIFICATION_EVENT_KIND,
                tags=[['p', client_pubkey]],
                content=self.encrypt_to_pubkey(json.dumps(content), client_pubkey),
                private_key=connection['our_secret']
                )
            )
            asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())

    def encrypt_to_pubkey(self, msg: str, pubkey: str) -> str:
        """
        Encrypts the given message to the given pubkey using the connection secret.
        """
        our_secret: str = self.connections[pubkey]['our_secret']
        our_secret_key = PrivateKey(raw_secret=bytes.fromhex(our_secret))
        encrypted_content: str = our_secret_key.encrypt_message(msg, pubkey)
        return encrypted_content

    def get_payment_info(self, payment_hash: str) \
        -> Optional[Tuple[PaymentDirection, int, Optional[int], int]]:
        payment_hash: bytes = bytes.fromhex(payment_hash)
        payments = self.wallet.lnworker.get_payments(status=None)
        plist = payments.get(payment_hash)
        if plist and any(htlc.status == 'settled' for htlc in plist):
            direction = plist[0].direction
            info = self.wallet.lnworker.get_payment_info(payment_hash, direction=direction)
            if info:
                # assumes inflight htlcs will get settled and counts them into the payment values
                active_htlcs = [htlc for htlc in plist if htlc.status in ('settled', 'inflight')]
                dir, amount, fee, ts = self.wallet.lnworker.get_payment_value(info, active_htlcs)
                fee = abs(fee) if fee else None
                return dir, abs(amount), fee, ts
        return None

    def is_receive_only(self, pubkey: str) -> bool:
        return self.connections[pubkey].get('daily_limit_sat') == 0

    @staticmethod
    def invoice_status_to_nip47_state(status) -> Optional[str]:
        if status == PR_EXPIRED:
            return "expired"
        elif status == PR_FAILED:
            return "failed"
        elif status == PR_PAID:
            return "settled"
        elif status == PR_UNPAID:
            return "pending"
        return None
