# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.

"""
Tests for implementations of L{IReactorUDP} and the UDP parts of
L{IReactorSocket}.
"""


import socket

from zope.interface import implementer
from zope.interface.verify import verifyObject

from twisted.python import context
from twisted.python.log import ILogContext, err
from twisted.internet.test.reactormixins import ReactorBuilder
from twisted.internet.defer import Deferred, maybeDeferred
from twisted.internet.interfaces import (
    ILoggingContext,
    IListeningPort,
    IReactorUDP,
    IReactorSocket,
)
from twisted.internet.address import IPv4Address, IPv6Address
from twisted.internet.protocol import DatagramProtocol

from twisted.internet.test.connectionmixins import LogObserverMixin, findFreePort
from twisted.internet import defer, error
from twisted.test.test_udp import Server, GoodClient
from twisted.trial.unittest import SkipTest


def _has_ipv6():
    """Returns True if the system can bind an IPv6 address."""
    sock = None
    has_ipv6 = False

    try:
        sock = socket.socket(socket.AF_INET6)
        sock.bind(("::1", 0))
        has_ipv6 = True
    except OSError:
        pass

    if sock:
        sock.close()
    return has_ipv6


HAS_IPV6 = _has_ipv6()


def skipWithoutIPv6(f):
    if not HAS_IPV6:
        f.skip = "Does not work on systems without IPv6 support."
    return f


class DatagramTransportTestsMixin(LogObserverMixin):
    """
    Mixin defining tests which apply to any port/datagram based transport.
    """

    def test_startedListeningLogMessage(self):
        """
        When a port starts, a message including a description of the associated
        protocol is logged.
        """
        loggedMessages = self.observe()
        reactor = self.buildReactor()

        @implementer(ILoggingContext)
        class SomeProtocol(DatagramProtocol):
            def logPrefix(self):
                return "Crazy Protocol"

        protocol = SomeProtocol()

        p = self.getListeningPort(reactor, protocol)
        expectedMessage = "Crazy Protocol starting on %d" % (p.getHost().port,)
        self.assertEqual((expectedMessage,), loggedMessages[0]["message"])

    def test_connectionLostLogMessage(self):
        """
        When a connection is lost a message is logged containing an
        address identifying the port and the fact that it was closed.
        """
        loggedMessages = self.observe()
        reactor = self.buildReactor()
        p = self.getListeningPort(reactor, DatagramProtocol())
        expectedMessage = f"(UDP Port {p.getHost().port} Closed)"

        def stopReactor(ignored):
            reactor.stop()

        def doStopListening():
            del loggedMessages[:]
            maybeDeferred(p.stopListening).addCallback(stopReactor)

        reactor.callWhenRunning(doStopListening)
        self.runReactor(reactor)

        self.assertEqual((expectedMessage,), loggedMessages[0]["message"])

    def test_stopProtocolScheduling(self):
        """
        L{DatagramProtocol.stopProtocol} is called asynchronously (ie, not
        re-entrantly) when C{stopListening} is used to stop the datagram
        transport.
        """

        class DisconnectingProtocol(DatagramProtocol):

            started = False
            stopped = False
            inStartProtocol = False
            stoppedInStart = False

            def startProtocol(self):
                self.started = True
                self.inStartProtocol = True
                self.transport.stopListening()
                self.inStartProtocol = False

            def stopProtocol(self):
                self.stopped = True
                self.stoppedInStart = self.inStartProtocol
                reactor.stop()

        reactor = self.buildReactor()
        protocol = DisconnectingProtocol()
        self.getListeningPort(reactor, protocol)
        self.runReactor(reactor)

        self.assertTrue(protocol.started)
        self.assertTrue(protocol.stopped)
        self.assertFalse(protocol.stoppedInStart)


class UDPPortTestsMixin:
    """
    Tests for L{IReactorUDP.listenUDP} and
    L{IReactorSocket.adoptDatagramPort}.
    """

    def test_interface(self):
        """
        L{IReactorUDP.listenUDP} returns an object providing L{IListeningPort}.
        """
        reactor = self.buildReactor()
        port = self.getListeningPort(reactor, DatagramProtocol())
        self.assertTrue(verifyObject(IListeningPort, port))

    def test_getHost(self):
        """
        L{IListeningPort.getHost} returns an L{IPv4Address} giving a
        dotted-quad of the IPv4 address the port is listening on as well as
        the port number.
        """
        host, portNumber = findFreePort(type=socket.SOCK_DGRAM)
        reactor = self.buildReactor()
        port = self.getListeningPort(
            reactor, DatagramProtocol(), port=portNumber, interface=host
        )
        self.assertEqual(port.getHost(), IPv4Address("UDP", host, portNumber))

    @skipWithoutIPv6
    def test_getHostIPv6(self):
        """
        L{IListeningPort.getHost} returns an L{IPv6Address} when listening on
        an IPv6 interface.
        """
        reactor = self.buildReactor()
        port = self.getListeningPort(reactor, DatagramProtocol(), interface="::1")
        addr = port.getHost()
        self.assertEqual(addr.host, "::1")
        self.assertIsInstance(addr, IPv6Address)

    def test_invalidInterface(self):
        """
        An L{InvalidAddressError} is raised when trying to listen on an address
        that isn't a valid IPv4 or IPv6 address.
        """
        reactor = self.buildReactor()
        self.assertRaises(
            error.InvalidAddressError,
            reactor.listenUDP,
            DatagramProtocol(),
            0,
            interface="example.com",
        )

    def test_logPrefix(self):
        """
        Datagram transports implement L{ILoggingContext.logPrefix} to return a
        message reflecting the protocol they are running.
        """

        class CustomLogPrefixDatagramProtocol(DatagramProtocol):
            def __init__(self, prefix):
                self._prefix = prefix
                self.system = Deferred()

            def logPrefix(self):
                return self._prefix

            def datagramReceived(self, bytes, addr):
                if self.system is not None:
                    system = self.system
                    self.system = None
                    system.callback(context.get(ILogContext)["system"])

        reactor = self.buildReactor()
        protocol = CustomLogPrefixDatagramProtocol("Custom Datagrams")
        d = protocol.system
        port = self.getListeningPort(reactor, protocol)
        address = port.getHost()

        def gotSystem(system):
            self.assertEqual("Custom Datagrams (UDP)", system)

        d.addCallback(gotSystem)
        d.addErrback(err)
        d.addCallback(lambda ignored: reactor.stop())

        port.write(b"some bytes", ("127.0.0.1", address.port))
        self.runReactor(reactor)

    def test_writeSequence(self):
        """
        Write a sequence of L{bytes} to a L{DatagramProtocol}.
        """

        class SimpleDatagramProtocol(DatagramProtocol):
            def __init__(self):
                self.defer = Deferred()

            def datagramReceived(self, data, addr):
                self.defer.callback(data)

        reactor = self.buildReactor()
        protocol = SimpleDatagramProtocol()
        defer = protocol.defer
        port = self.getListeningPort(reactor, protocol)
        address = port.getHost()
        dataToWrite = (b"some", b"bytes", b"to", b"write")

        def gotData(data):
            self.assertEqual(b"".join(dataToWrite), data)

        defer.addCallback(gotData)
        defer.addErrback(err)
        defer.addCallback(lambda ignored: reactor.stop())
        port.writeSequence(dataToWrite, ("127.0.0.1", address.port))
        self.runReactor(reactor)

    def test_str(self):
        """
        C{str()} on the listening port object includes the port number.
        """
        reactor = self.buildReactor()
        port = self.getListeningPort(reactor, DatagramProtocol())
        self.assertIn(str(port.getHost().port), str(port))

    def test_repr(self):
        """
        C{repr()} on the listening port object includes the port number.
        """
        reactor = self.buildReactor()
        port = self.getListeningPort(reactor, DatagramProtocol())
        self.assertIn(repr(port.getHost().port), str(port))

    @skipWithoutIPv6
    def test_writeToIPv6Interface(self):
        """
        Writing to an IPv6 UDP socket on the loopback interface succeeds.
        """
        reactor = self.buildReactor()
        server = Server()
        serverStarted = server.startedDeferred = defer.Deferred()
        self.getListeningPort(reactor, server, interface="::1")

        client = GoodClient()
        clientStarted = client.startedDeferred = defer.Deferred()
        self.getListeningPort(reactor, client, interface="::1")
        cAddr = client.transport.getHost()

        def cbClientStarted(ignored):
            """
            Send a datagram from the client once it's started.

            @param ignored: a list of C{[None, None]}, which is ignored
            @returns: a deferred which fires when the server has received a
                datagram.
            """
            client.transport.write(b"spam", ("::1", server.transport.getHost().port))
            serverReceived = server.packetReceived = defer.Deferred()
            return serverReceived

        def cbServerReceived(ignored):
            """
            Stop the reactor after a datagram is received.

            @param ignored: L{None}, which is ignored
            @returns: L{None}
            """
            reactor.stop()

        d = defer.gatherResults([serverStarted, clientStarted])
        d.addCallback(cbClientStarted)
        d.addCallback(cbServerReceived)
        d.addErrback(err)
        self.runReactor(reactor)

        packet = server.packets[0]
        self.assertEqual(packet, (b"spam", (cAddr.host, cAddr.port)))

    @skipWithoutIPv6
    def test_connectedWriteToIPv6Interface(self):
        """
        An IPv6 address can be passed as the C{interface} argument to
        L{listenUDP}. The resulting Port accepts IPv6 datagrams.
        """
        reactor = self.buildReactor()
        server = Server()
        serverStarted = server.startedDeferred = defer.Deferred()
        self.getListeningPort(reactor, server, interface="::1")

        client = GoodClient()
        clientStarted = client.startedDeferred = defer.Deferred()
        self.getListeningPort(reactor, client, interface="::1")
        cAddr = client.transport.getHost()

        def cbClientStarted(ignored):
            """
            Send a datagram from the client once it's started.

            @param ignored: a list of C{[None, None]}, which is ignored
            @returns: a deferred which fires when the server has received a
                datagram.
            """

            client.transport.connect("::1", server.transport.getHost().port)
            client.transport.write(b"spam")
            serverReceived = server.packetReceived = defer.Deferred()
            return serverReceived

        def cbServerReceived(ignored):
            """
            Stop the reactor after a datagram is received.

            @param ignored: L{None}, which is ignored
            @returns: L{None}
            """

            reactor.stop()

        d = defer.gatherResults([serverStarted, clientStarted])
        d.addCallback(cbClientStarted)
        d.addCallback(cbServerReceived)
        d.addErrback(err)
        self.runReactor(reactor)

        packet = server.packets[0]
        self.assertEqual(packet, (b"spam", (cAddr.host, cAddr.port)))

    def test_writingToHostnameRaisesInvalidAddressError(self):
        """
        Writing to a hostname instead of an IP address will raise an
        L{InvalidAddressError}.
        """
        reactor = self.buildReactor()
        port = self.getListeningPort(reactor, DatagramProtocol())
        self.assertRaises(
            error.InvalidAddressError, port.write, "spam", ("example.invalid", 1)
        )

    @skipWithoutIPv6
    def test_writingToIPv6OnIPv4RaisesInvalidAddressError(self):
        """
        Writing to an IPv6 address on an IPv4 socket will raise an
        L{InvalidAddressError}.
        """
        reactor = self.buildReactor()
        port = self.getListeningPort(reactor, DatagramProtocol(), interface="127.0.0.1")
        self.assertRaises(error.InvalidAddressError, port.write, "spam", ("::1", 1))

    @skipWithoutIPv6
    def test_writingToIPv4OnIPv6RaisesInvalidAddressError(self):
        """
        Writing to an IPv6 address on an IPv4 socket will raise an
        L{InvalidAddressError}.
        """
        reactor = self.buildReactor()
        port = self.getListeningPort(reactor, DatagramProtocol(), interface="::1")
        self.assertRaises(
            error.InvalidAddressError, port.write, "spam", ("127.0.0.1", 1)
        )

    def test_connectingToHostnameRaisesInvalidAddressError(self):
        """
        Connecting to a hostname instead of an IP address will raise an
        L{InvalidAddressError}.
        """
        reactor = self.buildReactor()
        port = self.getListeningPort(reactor, DatagramProtocol())
        self.assertRaises(error.InvalidAddressError, port.connect, "example.invalid", 1)

    def test_allowBroadcast(self):
        """
        L{IListeningPort.setBroadcastAllowed} sets broadcast to be allowed
        on the socket.
        """
        reactor = self.buildReactor()
        port = self.getListeningPort(reactor, DatagramProtocol())
        port.setBroadcastAllowed(True)
        self.assertTrue(port.getBroadcastAllowed())


class UDPServerTestsBuilder(
    ReactorBuilder, UDPPortTestsMixin, DatagramTransportTestsMixin
):
    """
    Run L{UDPPortTestsMixin} tests using newly created UDP
    sockets.
    """

    requiredInterfaces = (IReactorUDP,)

    def getListeningPort(
        self, reactor, protocol, port=0, interface="", maxPacketSize=8192
    ):
        """
        Get a UDP port from a reactor.

        @param reactor: A reactor used to build the returned
            L{IListeningPort} provider.
        @type reactor: L{twisted.internet.interfaces.IReactorUDP}

        @see: L{twisted.internet.IReactorUDP.listenUDP} for other
            argument and return types.
        """
        return reactor.listenUDP(
            port, protocol, interface=interface, maxPacketSize=maxPacketSize
        )


class UDPFDServerTestsBuilder(
    ReactorBuilder, UDPPortTestsMixin, DatagramTransportTestsMixin
):
    """
    Run L{UDPPortTestsMixin} tests using adopted UDP sockets.
    """

    requiredInterfaces = (IReactorSocket,)

    def getListeningPort(
        self, reactor, protocol, port=0, interface="", maxPacketSize=8192
    ):
        """
        Get a UDP port from a reactor, wrapping an already-initialized file
        descriptor.

        @param reactor: A reactor used to build the returned
            L{IListeningPort} provider.
        @type reactor: L{twisted.internet.interfaces.IReactorSocket}

        @param port: A port number to which the adopted socket will be
            bound.
        @type port: C{int}

        @param interface: The local IPv4 or IPv6 address to which the
            adopted socket will be bound.  defaults to '', ie all IPv4
            addresses.
        @type interface: C{str}

        @see: L{twisted.internet.IReactorSocket.adoptDatagramPort} for other
            argument and return types.
        """
        if IReactorSocket.providedBy(reactor):
            if ":" in interface:
                domain = socket.AF_INET6
                address = socket.getaddrinfo(interface, port)[0][4]
            else:
                domain = socket.AF_INET
                address = (interface, port)
            portSock = socket.socket(domain, socket.SOCK_DGRAM)
            portSock.bind(address)
            portSock.setblocking(False)
            try:
                return reactor.adoptDatagramPort(
                    portSock.fileno(), portSock.family, protocol, maxPacketSize
                )
            finally:
                # The socket should still be open; fileno will raise if it is
                # not.
                portSock.fileno()
                # Now clean it up, because the rest of the test does not need
                # it.
                portSock.close()
        else:
            raise SkipTest("Reactor does not provide IReactorSocket")


globals().update(UDPServerTestsBuilder.makeTestCaseClasses())
globals().update(UDPFDServerTestsBuilder.makeTestCaseClasses())
