"""
ORIGINAL GITHUB FOR MOST OF THE CODE:
    https://github.com/gryphius/port-forward

I used the above repo to automate portforwarding (still has some issues so
manual static setup is an option). Cleaned up the code to my own styling while
reading through it but it's mostly identical to the original.
All credit goes to the original owner.

* What is WANIPConnection service?
    It's a upnp service which allows internet gateways to communicate over the
    wan. In short terms, provided we know the service address, we can make calls
    to this service with the SOAP protocol to manage our connection to the
    network.

What this simply does is:
    - discover upnp capable devices on the local network
    - target the WANIPConnection service on the upnp device
    - communicate the portforwarding request
"""

from http.client import HTTPConnection
from random import randint
import re
from select import select
from socket import AF_INET, SOCK_STREAM, SOCK_DGRAM, socket, create_server
from struct import pack, unpack
import sys
from time import sleep
from threading import Thread
from urllib.parse import urlparse
import urllib.request, urllib.error
from xml.dom.minidom import parseString, Document

import engine


def discover():
    """
    Discover UPNP capable routers in the local network through SSDP.
    Returns a lit of urls with service descriptions
    SSDP --> https://tools.ietf.org/html/draft-cai-ssdp-v1-03
    """
    SSDP_ADDR = "239.255.255.250" #mutlicast address
    SSDP_PORT = 1900 # upnp discovery port
    SSDP_MX = 2
    SSDP_ST = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"

    WAIT = 1

    ssdpRequest = "M-SEARCH * HTTP/1.1\r\n" + \
                    f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n" + \
                    f"MAN: \"ssdp:discover\"\r\n" + \
                    f"MX: {SSDP_MX}\r\n" + \
                    f"ST: {SSDP_ST}\r\n" + "\r\n"

    sock = socket(AF_INET, SOCK_DGRAM)
    sock.setblocking(0)
    sock.sendto(ssdpRequest.encode(), (SSDP_ADDR, SSDP_PORT))
    sleep(WAIT)

    paths = []
    # expr = re.compile(r'(?P<name>.*?): (?P<value>.*?)\r\n') for full parse
    expr = re.compile(r'LOCATION: (?P<value>.*?)\r\n', re.IGNORECASE)

    for _ in range(10): # arbitrary number of ssdp lookups
        ready = select([sock], [], [], 0.01)
        if ready[0]:
            data, fromaddr = sock.recvfrom(1024)
            parsed = expr.findall(str(data,'utf-8'))
            # alternatively use urlparse for namedtuple url
            router_path = parsed[0]
            paths.append(router_path)

    return paths


def get_wanip_path(upnp_url):
    """Generate the path to WANIPConnection service on the UPNP device"""

    directory = urllib.request.urlopen(upnp_url).read() # upnp profile xml

    dom = parseString(directory) # look for required service in profile xml dom
    service_types = dom.getElementsByTagName('serviceType')

    # iterate over service_types until we get either WANIPConnection
    # (this should also check for WANPPPConnection, which, if I remember correctly
    # exposed a similar SOAP interface on ADSL routers.
    for service in service_types:
        # A 'serviceType' element contains a single text node, whose data can
        # be accessed by the 'data' attribute.
        # If right element is found, search for 'controlURL' in parent
        if service.childNodes[0].data.find('WANIPConnection') > 0:
            control_url = service.parentNode.getElementsByTagName('controlURL')
            path = control_url[0].childNodes[0].data
            return path


def open_port(
    service_url, external_port, internal_client, internal_port=None,
    protocol='TCP', duration=0, description=None, enabled=1
    ):
    """Creates XML for port opening request, returns status and response"""
    parsedurl = urlparse(service_url)

    if internal_port is None:
        internal_port = external_port

    if description is None:
        description = 'generated by port-forward.py'

    if not enabled:
        duration = 1

    doc = Document()

    # create the envelope element and set its attributes
    envelope = doc.createElementNS('', 's:Envelope')
    envelope.setAttribute('xmlns:s', 'http://schemas.xmlsoap.org/soap/envelope/')
    envelope.setAttribute('s:encodingStyle', 'http://schemas.xmlsoap.org/soap/encoding/')
    # create the body element
    body = doc.createElementNS('', 's:Body')
    # create the function element and set its attribute
    fn = doc.createElementNS('', 'u:AddPortMapping')
    fn.setAttribute('xmlns:u', 'urn:schemas-upnp-org:service:WANIPConnection:1')

    # xml arguments names/values. ordering is important!
    arguments = [
        ('NewRemoteHost', ""),                     # unused - but required
        ('NewExternalPort', external_port),        # specify port on router
        ('NewProtocol', protocol),                 # specify protocol
        ('NewInternalPort', internal_port),        # specify port on internal host
        ('NewInternalClient', internal_client),    # specify IP of internal host
        ('NewEnabled', enabled),                   # turn mapping ON
        ('NewPortMappingDescription', description),# add a description
        ('NewLeaseDuration', duration)             # forwarding duration
        ]

    # NewEnabled should be 1 by default, but better supply it.
    # NewPortMappingDescription Can be anything you want, even an empty string.
    # NewLeaseDuration can be any integer BUT some UPnP devices don't support it,
    # so set it to 0 for better compatibility.

    # create xml nodes from args list and append to function element
    for k, v in arguments:
        v = str(v)
        tmp_node = doc.createElement(k)
        tmp_text_node = doc.createTextNode(v)
        tmp_node.appendChild(tmp_text_node)
        fn.appendChild(tmp_node)

    # document->envelope->body->function
    body.appendChild(fn)
    envelope.appendChild(body)
    doc.appendChild(envelope)

    pure_xml = doc.toxml() # completed xml tree

    # connect via HTTP to service provider
    conn = HTTPConnection(parsedurl.hostname, parsedurl.port)
    # target WANIPConnection service, send xml payload and SOAP headers
    conn.request(
        'POST', parsedurl.path, pure_xml,
        {'SOAPAction': '"urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping"',
         'Content-Type': 'text/xml'}
    )
    resp = conn.getresponse()

    return resp.status,resp.read()


def get_my_ip(routerip='10.255.255.255'):
    """
    Does not need network acces. (author's version queries google dns servers
    this uses the max values for ips used for private communication over the
    local network and adds better error handling)
    """
    s = socket(AF_INET, SOCK_DGRAM)
    try:
        s.connect((routerip, 1))
        IP = s.getsockname()[0]
    except Exception:
        IP = '127.0.0.1'
    finally:
        s.close()
    return IP


def forwardPort(
    eport, iport=None, router=None, lanip=None, protocol='TCP', duration=0,
    description=None, enabled=True, verbose=False
    ):
    """
     - eport/iport are the linked ports
     - router is the target router's ip in string form
     - lanip is the local ip the port is forwarded to. defaults to host machine
     - enabled=False disables the port
     - protocol is the port protocol
     - duration is timeout, duration=0 is permanent.
    """
    if verbose:
        print("Discovering routers...")

    res = discover()
    if not res:
        return False

    for path in res: # find selected router within SSDP responses
        discparsed = urlparse(path)
        routerip = discparsed.netloc.split(':')[0]
        if router is None or router == routerip:
            break

    # generate URL to WANIPConnection service provider (aka your router)
    service_path = get_wanip_path(path)
    service_url = f"{discparsed.scheme}://{discparsed.netloc}{service_path}"

    if lanip == None: # defaults to host machine
        lanip = get_my_ip(router)

    dis = ''
    if not enabled:
        dis = 'disable of '

    status, message = open_port(
        service_url, eport, internal_client=lanip, internal_port=iport,
        protocol=protocol, duration=duration, description=description,
        enabled=enabled
        )

    if status == 200:
        if verbose:
            print(f"{dis}port forward on {routerip} successful, \
                {eport}->{lanip}:{iport}")
        success = True
    else:
        sys.stderr.write(
            f"{dis}port forward on {routerip} failed, \
            status={status} message={message}\n")
        success = False

    return success


_default_port = 56969


def server_setup(port: int, router: str) -> socket:
    """
    Creates and binds server socket.
        - port=None automates portforwarding on default port. If no router is
          specified, the first found on the network will be used
        - if a port is selected, it must be manually portforwarded
    """
    if port is None:
        port = _default_port
        status = forwardPort(eport=port, router=router, description='py_chess')
    else:
        status = True

    if status:
        server = create_server(("", port), family=AF_INET, backlog=1)
        print(f"\nServer listening on socket {get_my_ip()}:{port} . . . ")
        connection_socket, address = server.accept()
        print(f"\nConnected to socket {address[0]}:{address[1]}")
        connection_socket.setblocking(False)

        return connection_socket
    else:
        raise Exception(f"\nCould not port forward on port: {port}")


def client_setup(port: int, ip: str) -> socket:
    """Creates client socket and connects to ip"""
    if port is None:
        port = _default_port

    client = socket(AF_INET, SOCK_STREAM)
    client.connect((ip, port))
    print(f"\nConnected to {ip}:{port}.")

    return client


def exchange_moves(socket: socket, game: "ChessGame") -> None:
    """Checks for inboud/outbound moves and updates game status"""
    while game.active:

        if game.player_move: # move is queued for delivery
            start, end = game.player_move
            msg = pack("<HH", start, end)
            socket.send(msg)
            game.player_move = None
        elif game.side != game.turn:
            ready = select([socket], [], [], 0.07) # waits for move inbound
            if ready[0]:
                opponent_move = unpack("<HH", socket.recv(100))
                engine.flip_board(game.match[0])
                engine.update_game(game.match, opponent_move)
                engine.flip_board(game.match[0])
                game.setup_board()
        sleep(0.07)


def server_start(socket: socket, game: "ChessGame") -> None:
    """Starts game, notify client socket and dispatch network thread"""
    choice = input("\nStart a new game? (y/n) . . . ")

    if choice == "y":
        side = randint(0, 1)
        game.start_new_game(side)
        msg = pack("<H", 200 + (not side))
        socket.send(msg) # sends start message to client

        daemon = Thread(
            target=exchange_moves, args=(socket, game)
            )
        daemon.start()
    else:
        msg = pack("<H", 500)
        socket.send(msg)
        socket.close()
        exit()


def client_start(socket: socket, game: "ChessGame") -> None:
    """Starts game, wait server start and dispatch network thread"""
    socket.setblocking(True) # waits for connection
    msg = unpack("<H", socket.recv(100))[0]

    if msg == 500:
        socket.close()
        exit()
    else:
        game.start_new_game(msg - 200)

        daemon = Thread(
            target=exchange_moves, args=(socket, game)
            )
        daemon.start()
        socket.setblocking(False)
