#! /usr/bin/env python
'''test daemon-protocol interfacae.'''

from jmdaemon import MessageChannelCollection
from jmdaemon.orderbookwatch import OrderbookWatch
from jmdaemon.daemon_protocol import JMDaemonServerProtocol
from jmdaemon.protocol import NICK_HASH_LENGTH, NICK_MAX_ENCODED, JM_VERSION,\
    JOINMARKET_NICK_HEADER
from jmbase import get_log
from jmclient import (load_test_config, jm_single, get_mchannels)
from twisted.python.log import msg as tmsg
from twisted.python.log import startLogging
from twisted.internet import protocol, reactor, task
from twisted.internet.protocol import ServerFactory
from twisted.internet.error import (ConnectionLost, ConnectionAborted,
                                    ConnectionClosed, ConnectionDone)
from twisted.protocols.amp import UnknownRemoteError
from twisted.protocols import amp
from twisted.trial import unittest
from jmbase.commands import *
from msgdata import *
import base64
import sys
from dummy_mc import DummyMessageChannel

import pytest

pytestmark = pytest.mark.usefixtures("setup_regtest_bitcoind")

test_completed = False
end_early = False
jlog = get_log()

class DummyMC(DummyMessageChannel):
    #override run() for twisted compatibility
    def run(self):
        if self.on_welcome:
            reactor.callLater(1, self.on_welcome, self)

class JMProtocolError(Exception):
    pass

class JMBaseProtocol(amp.AMP):
    def checkClientResponse(self, response):
        """A generic check of client acceptance; any failure
        is considered criticial.
        """
        if 'accepted' not in response or not response['accepted']:
            reactor.stop()

    def defaultErrback(self, failure):
        failure.trap(ConnectionAborted, ConnectionClosed, ConnectionDone,
                     ConnectionLost, UnknownRemoteError)
        reactor.stop()

    def defaultCallbacks(self, d):
        d.addCallback(self.checkClientResponse)
        d.addErrback(self.defaultErrback)

class JMTestClientProtocol(JMBaseProtocol):

    def connectionMade(self):
        self.clientStart()

    def clientStart(self):
        self.sigs_received = 0
        chan_configs = [get_mchannels()[0]]
        d = self.callRemote(JMInit,
                            bcsource="dummyblockchain",
                            network="dummynetwork",
                            chan_configs=chan_configs,
                            minmakers=2,
                            maker_timeout_sec=3,
                            dust_threshold=27300,
                            blacklist_location=".")
        self.defaultCallbacks(d)

    @JMInitProto.responder
    def on_JM_INIT_PROTO(self, nick_hash_length, nick_max_encoded,
                         joinmarket_nick_header, joinmarket_version):
        show_receipt("JMINITPROTO", nick_hash_length, nick_max_encoded,
                     joinmarket_nick_header, joinmarket_version)
        d = self.callRemote(JMStartMC,
                            nick="dummynick")
        self.defaultCallbacks(d)
        return {'accepted': True}

    @JMUp.responder
    def on_JM_UP(self):
        show_receipt("JMUP")
        d = self.callRemote(JMSetup,
                            role="TAKER",
                            initdata=None,
                            use_fidelity_bond=False)
        self.defaultCallbacks(d)
        return {'accepted': True}

    @JMSetupDone.responder
    def on_JM_SETUP_DONE(self):
        show_receipt("JMSETUPDONE")
        d = self.callRemote(JMRequestOffers)
        self.defaultCallbacks(d)
        return {'accepted': True}

    @JMFillResponse.responder
    def on_JM_FILL_RESPONSE(self, success, ioauth_data):
        show_receipt("JMFILLRESPONSE", success, ioauth_data)
        reactor.callLater(1, self.maketx, ioauth_data)
        return {'accepted': True}

    def maketx(self, ioauth_data):
        nl = list(ioauth_data)
        d = self.callRemote(JMMakeTx,
                            nick_list=nl,
                            tx=b"deadbeef")
        self.defaultCallbacks(d)

    @JMOffers.responder
    def on_JM_OFFERS(self, orderbook, fidelitybonds):
        if end_early:
            return {'accepted': True}
        jlog.debug("JMOFFERS" + str(orderbook))
        #Trigger receipt of verified privmsgs, including unverified
        nick = str(list(t_chosen_orders.keys())[0])
        b64tx = base64.b64encode(b"deadbeef").decode('ascii')
        d1 = self.callRemote(JMMsgSignatureVerify,
                             verif_result=True,
                             nick=nick,
                             fullmsg="!push " + b64tx + " abc def",
                             hostid="dummy")
        self.defaultCallbacks(d1)
        #unverified
        d2 = self.callRemote(JMMsgSignatureVerify,
                             verif_result=False,
                             nick=nick,
                             fullmsg="!push " + b64tx + " abc def",
                             hostid="dummy")
        self.defaultCallbacks(d2)
        d = self.callRemote(JMFill,
                            amount=100,
                            commitment="dummycommitment",
                            revelation="dummyrevelation",
                            filled_offers=t_chosen_orders)
        self.defaultCallbacks(d)
        return {'accepted': True}

    @JMSigReceived.responder
    def on_JM_SIG_RECEIVED(self, nick, sig):
        show_receipt("JMSIGRECEIVED", nick, sig)
        self.sigs_received += 1
        if self.sigs_received == 3:
            #end of test
            reactor.callLater(1, end_test)
        return {'accepted': True}

    @JMRequestMsgSig.responder
    def on_JM_REQUEST_MSGSIG(self, nick, cmd, msg, msg_to_be_signed, hostid):
        show_receipt("JMREQUESTMSGSIG", nick, cmd, msg, msg_to_be_signed, hostid)
        d = self.callRemote(JMMsgSignature,
                            nick=nick,
                            cmd=cmd,
                            msg_to_return="xxxcreatedsigxx",
                            hostid=hostid)
        self.defaultCallbacks(d)
        return {'accepted': True}

    @JMRequestMsgSigVerify.responder
    def on_JM_REQUEST_MSGSIG_VERIFY(self, msg, fullmsg, sig, pubkey, nick,
                                    hashlen, max_encoded, hostid):
        show_receipt("JMREQUESTMSGSIGVERIFY", msg, fullmsg, sig, pubkey,
                     nick, hashlen, max_encoded, hostid)
        d = self.callRemote(JMMsgSignatureVerify,
                            verif_result=True,
                            nick=nick,
                            fullmsg=fullmsg,
                            hostid=hostid)
        self.defaultCallbacks(d)
        return {'accepted': True}

class JMTestClientProtocolFactory(protocol.ClientFactory):
    protocol = JMTestClientProtocol


def show_receipt(name, *args):
    tmsg("Received msgtype: " + name + ", args: " + ",".join([str(x) for x in args]))

def end_test():
    global test_completed
    test_completed = True



class JMDaemonTestServerProtocol(JMDaemonServerProtocol):

    def __init__(self, factory):
        super().__init__(factory)
        #respondtoioauths should do nothing unless jmstate = 2
        self.respondToIoauths(True)
        #calling on_JM_MAKE_TX should also do nothing in wrong state
        assert super().on_JM_MAKE_TX(1, 2) == {'accepted': False}
        #calling on_JM_FILL with negative amount should reject
        assert super().on_JM_FILL(-1000, 2, 3, 4) == {'accepted': False}
        #checkutxos also does nothing for rejection at the moment
        self.checkUtxosAccepted(False)
        #None should be returned requesting a cryptobox for an unknown cp
        assert self.get_crypto_box_from_nick("notrealcp") == None
        #does nothing yet
        self.on_error("dummy error")

    @JMRequestOffers.responder
    def on_JM_REQUEST_OFFERS(self):
        for o in t_orderbook:
            #counterparty, oid, ordertype, minsize, maxsize,txfee, cjfee):
            self.on_order_seen(o["counterparty"], o["oid"], o["ordertype"],
                                 o["minsize"], o["maxsize"],
                                 o["txfee"], o["cjfee"])
        return super().on_JM_REQUEST_OFFERS()

    @JMInit.responder
    def on_JM_INIT(self, bcsource, network, chan_configs, minmakers,
                   maker_timeout_sec, dust_threshold, blacklist_location):
        self.maker_timeout_sec = maker_timeout_sec
        self.dust_threshold = int(dust_threshold)
        self.minmakers = minmakers
        mcs = [DummyMC(None)]
        self.mcc = MessageChannelCollection(mcs)
        #The following is a hack to get the counterparties marked seen/active;
        #note it must happen before callign set_msgchan for OrderbookWatch
        self.mcc.on_order_seen = None
        for c in [o['counterparty'] for o in t_orderbook]:
            self.mcc.on_order_seen_trigger(mcs[0], c, "a", "b", "c", "d", "e", "f")
        OrderbookWatch.set_msgchan(self, self.mcc)
        #register taker-specific msgchan callbacks here
        self.mcc.register_taker_callbacks(self.on_error, self.on_pubkey,
                                          self.on_ioauth, self.on_sig)
        self.mcc.set_daemon(self)
        self.restart_mc_required = True
        d = self.callRemote(JMInitProto,
                            nick_hash_length=NICK_HASH_LENGTH,
                            nick_max_encoded=NICK_MAX_ENCODED,
                            joinmarket_nick_header=JOINMARKET_NICK_HEADER,
                            joinmarket_version=JM_VERSION)
        self.defaultCallbacks(d)
        return {'accepted': True}

    @JMFill.responder
    def on_JM_FILL(self, amount, commitment, revelation, filled_offers):
        dummypub = "073732a7ca60470f709f23c602b2b8a6b1ba62ee8f3f83a61e5484ab5cbf9c3d"
        #trigger invalid on_pubkey conditions
        reactor.callLater(1, self.on_pubkey, "notrealcp", dummypub)
        reactor.callLater(2, self.on_pubkey, list(filled_offers)[0], dummypub + "deadbeef")
        #trigger invalid on_ioauth condition
        reactor.callLater(2, self.on_ioauth, "notrealcp", 1, 2, 3, 4, 5)
        #trigger msg sig verify request operation for a dummy message
        #currently a pass-through
        reactor.callLater(1, self.request_signature_verify, "1",
                          "!push abcd abc def", "3", "4",
                          str(list(filled_offers)[0]), 6, 7, self.mcc.mchannels[0].hostid)
        #send "valid" onpubkey, onioauth messages
        for k, v in filled_offers.items():
            reactor.callLater(1, self.on_pubkey, k, dummypub)
            reactor.callLater(2, self.on_ioauth, k, ['a', 'b'], "auth_pub",
                              "cj_addr", "change_addr", "btc_sig")
        return super().on_JM_FILL(amount, commitment, revelation, filled_offers)

    @JMMakeTx.responder
    def on_JM_MAKE_TX(self, nick_list, tx):
        for n in nick_list:
            reactor.callLater(1, self.on_sig, n, "dummytxsig")
        return super().on_JM_MAKE_TX(nick_list, tx)



class JMDaemonTestServerProtocolFactory(ServerFactory):
    protocol = JMDaemonTestServerProtocol

    def buildProtocol(self, addr):
        return JMDaemonTestServerProtocol(self)


class JMDaemonTest2ServerProtocol(JMDaemonServerProtocol):
    #override here to avoid actually instantiating IRCMessageChannels
    def init_connections(self, nick):
        self.mc_shutdown()

class JMDaemonTest2ServerProtocolFactory(ServerFactory):
    protocol = JMDaemonTest2ServerProtocol
    def buildProtocol(self, addr):
        return JMDaemonTest2ServerProtocol(self)

class TrialTestJMDaemonProto(unittest.TestCase):

    def setUp(self):
        startLogging(sys.stdout)
        load_test_config()
        jm_single().maker_timeout_sec = 1
        self.port = reactor.listenTCP(28184, JMDaemonTestServerProtocolFactory())
        self.addCleanup(self.port.stopListening)
        clientconn = reactor.connectTCP("localhost", 28184,
                                        JMTestClientProtocolFactory())
        self.addCleanup(clientconn.disconnect)

    def test_waiter(self):
        return task.deferLater(reactor, 12, self._called_by_deffered)

    def _called_by_deffered(self):
        pass


class TestJMDaemonProtoInit(unittest.TestCase):

    def setUp(self):
        global end_early
        end_early = True
        load_test_config()
        jm_single().maker_timeout_sec = 1
        self.port = reactor.listenTCP(28184, JMDaemonTest2ServerProtocolFactory())
        self.addCleanup(self.port.stopListening)
        clientconn = reactor.connectTCP("localhost", 28184,
                                        JMTestClientProtocolFactory())
        self.addCleanup(clientconn.disconnect)

    def test_waiter(self):
        return task.deferLater(reactor, 5, self._called_by_deffered)

    def _called_by_deffered(self):
        global end_early
        end_early = False

def test_check_utxo_blacklist_case_and_whitespace(tmp_path):
    """Regression test: a commitment that has already been used must not
      be accepted again, regardless of hex-digit case or surrounding
      whitespace (well; the line protocol doesn't allow whitespace in or around,
      but, why not be strict).
      We're gong to test canonicalize_commitment_str and check_utxo_blacklist.
    """
    from jmdaemon.daemon_protocol import (check_utxo_blacklist,
                                            canonicalize_commitment_str,
                                            set_blacklist_location,
                                            blacklist_location)

    old_blacklist_location = blacklist_location
    try:
        # Fresh, isolated commitmentlist file per test run, courtesy of the
        # pytest tmp_path fixture.
        commitmentlist = tmp_path / "commitmentlist"
        set_blacklist_location(str(commitmentlist))

        # A plausible 32-byte (64 hex char) commitment.
        lc = "deadbeef" * 8
        uc = lc.upper()
        mixed = "DeAdBeEf" * 8
        padded = "  " + lc + "\n"

        # First use: accepted, and persisted to disk.
        assert check_utxo_blacklist(lc, persist=True) is True
        assert commitmentlist.is_file()

        # Exact reuse: rejected.
        assert check_utxo_blacklist(lc) is False

        # Case-variant reuse: must also be rejected. This is the regression
        # the fix targets — before the fix these returned True.
        assert check_utxo_blacklist(uc) is False
        assert check_utxo_blacklist(mixed) is False

        # Whitespace-variant reuse: also rejected.
        assert check_utxo_blacklist(padded) is False

        # A genuinely new commitment is still accepted.
        other = "cafebabe" * 8
        assert check_utxo_blacklist(other) is True

        # Malformed input is rejected outright.
        assert canonicalize_commitment_str("not-a-hex-string!") is False
        assert check_utxo_blacklist("not-a-hex-string!") == "invalid_format"
        assert canonicalize_commitment_str("abc") is False
        assert check_utxo_blacklist("abc") == "invalid_format"  # odd length; not even valid hex

        # As explained elsewhere, whitespace inside the commitment is not actually
        # possible, but it's sensible to check the effect in case the protocol is different.
        # The current effect is to treat it as valid hex and canonicalize it.
        # Hence if the corresponding canonical form has been used, we should get False,
        # and otherwise True.
        assert check_utxo_blacklist("dead beef" * 8) is False
        assert check_utxo_blacklist("de ad be ef" * 8) is False
        assert check_utxo_blacklist("de ad be ef" * 7 + "cafe babe") is True

        # Legacy-on-disk path: simulate a pre-fix commitmentlist that was
        # written in uppercase (e.g. from an !hp2 broadcast that arrived in
        # uppercase under the old code). A canonical lowercase query must
        # still detect the reuse after the fix normalizes entries on read.
        legacy = tmp_path / "legacy_commitmentlist"
        legacy.write_bytes(("F00DF00D" * 8 + "\n").encode("ascii"))
        set_blacklist_location(str(legacy))
        assert check_utxo_blacklist("f00df00d" * 8) is False
    finally:
          set_blacklist_location(old_blacklist_location)
