import argparse
import requests
import socket
import yaml
from flask import Flask, Response, request
from typing import Any, Dict, Optional
import urllib.parse as urlparse

from bemani.protocol import EAmuseProtocol, Node

# Application configuration
app = Flask(__name__)
config: Dict[str, Any] = {}


def modify_request(config: Dict[str, Any], req_body: Node) -> Optional[Node]:
    # Not sure if there's any reason to modify requests, but its plumbed
    return None


def modify_response(config: Dict[str, Any], resp_body: Node) -> Optional[Node]:
    # Figure out if we need to modify anything in the response
    if resp_body.name != 'response':
        # Not what we expected, bail
        return None

    # Now we get to the meat of packet detection
    body = resp_body.children[0]

    # Is this a services packet? We need to modify this to point the rest of the
    # game packets at this proxy :(
    if body.name == 'services':
        for child in body.children:
            if child.name == 'item':
                if child.attribute('name') == 'ntp':
                    # Don't override this
                    continue
                elif child.attribute('name') == 'keepalive':
                    # Completely rewrite to point at the proxy server, otherwise if we're proxying
                    # to a local backend, we will end up giving out a local address and we will get
                    # Network NG.
                    address = socket.gethostbyname(config['keepalive'])
                    child.set_attribute(
                        'url',
                        f'http://{address}/core/keepalive?pa={address}&ia={address}&ga={address}&ma={address}&t1=2&t2=10',
                    )
                else:
                    # Get netloc to replace
                    url = urlparse.urlparse(child.attribute('url'))
                    defaultport = {
                        'http': 80,
                        'https': 443,
                    }.get(url.scheme, 0)
                    if config['local_port'] != defaultport:
                        new_url = child.attribute('url').replace(url.netloc, f'{config["local_host"]}:{config["local_port"]}')
                    else:
                        new_url = child.attribute('url').replace(url.netloc, f'{config["local_host"]}')
                    child.set_attribute('url', new_url)

        return resp_body

    return None


@app.route('/', defaults={'path': ''}, methods=['GET'])
@app.route('/<path:path>', methods=['GET'])
def receive_healthcheck(path: str) -> Response:
    if '*' in config['remote']:
        remote_host = config['remote']['*']['host']
        remote_port = config['remote']['*']['port']
    else:
        return Response("No route for default PCBID", 500)

    actual_path = f'/{path}'
    if request.query_string is not None and len(request.query_string) > 0:
        actual_path = actual_path + f'?{request.query_string.decode("ascii")}'

    # Make request to foreign service, using the same parameters
    r = requests.get(
        f'http://{remote_host}:{remote_port}{actual_path}',
        timeout=config['timeout'],
        allow_redirects=False,
    )

    headers = {}
    for header in ['Location']:
        if header in r.headers:
            headers[header] = r.headers[header]

    return Response(r.content, r.status_code, headers)


@app.route('/', defaults={'path': ''}, methods=['POST'])
@app.route('/<path:path>', methods=['POST'])
def receive_request(path: str) -> Response:
    # First, parse the packet itself
    client_proto = EAmuseProtocol()
    server_proto = EAmuseProtocol()
    remote_address = request.headers.get('X-Remote-Address', None)
    request_compression = request.headers.get('X-Compress', None)
    request_encryption = request.headers.get('X-Eamuse-Info', None)
    request_client = request.headers.get('User-Agent', None)

    actual_path = f'/{path}'
    if request.query_string is not None and len(request.query_string) > 0:
        actual_path = actual_path + f'?{request.query_string.decode("ascii")}'

    if config['verbose']:
        print(f"HTTP request for URI {actual_path}")
        print(f"Compression is {request_compression}")
        print(f"Encryption key is {request_encryption}")

    req = client_proto.decode(
        request_compression,
        request_encryption,
        request.data,
    )

    if req is None:
        # Nothing to do here
        return Response("Unrecognized packet!", 500)

    if config['verbose']:
        print("Original request to server:")
        print(req)

    # Grab PCBID for directing to mulitple servers
    pcbid = req.attribute('srcid')
    if pcbid in config['remote']:
        remote_host = config['remote'][pcbid]['host']
        remote_port = config['remote'][pcbid]['port']
    elif '*' in config['remote']:
        remote_host = config['remote']['*']['host']
        remote_port = config['remote']['*']['port']
    else:
        return Response(f"No route for PCBID {pcbid}", 500)

    modified_request = modify_request(config, req)
    if modified_request is None:
        # Return the original binary data instead of re-encoding it
        # to the exact same thing.
        req_binary = request.data
    else:
        if config['verbose']:
            print("Modified request to server:")
            print(modified_request)

        # Re-encode the modified packet
        req_binary = server_proto.encode(
            request_compression,
            request_encryption,
            modified_request,
            client_proto.last_text_encoding,
            client_proto.last_packet_encoding,
        )

    # Set up custom headers for remote request.
    headers = {
        # For lobby functionality, make sure the request receives
        # the original IP address
        'X-Remote-Address': remote_address or request.remote_addr,
        # Some remote servers can be somewhat buggy, so we make sure
        # to specify a range of encodings.
        'Accept-Encoding': 'identity, deflate, compress, gzip',
    }

    # Copy over required headers that are sent by game client.
    if request_compression:
        headers['X-Compress'] = request_compression
    else:
        headers['X-Compress'] = 'none'
    if request_encryption:
        headers['X-Eamuse-Info'] = request_encryption

    # Make sure to copy the user agent as well.
    if request_client is not None:
        headers['User-Agent'] = request_client

    # Make request to foreign service, using the same parameters
    prep_req = requests.Request(
        'POST',
        url=f'http://{remote_host}:{remote_port}{actual_path}',
        headers=headers,
        data=req_binary,
    ).prepare()
    sess = requests.Session()
    r = sess.send(prep_req, timeout=config['timeout'])

    if r.status_code != 200:
        # Failed on remote side
        return Response("Failed to get response!", 500)

    # Decode response, for modification if necessary
    response_compression = r.headers.get('X-Compress', None)
    response_encryption = r.headers.get('X-Eamuse-Info', None)
    resp = server_proto.decode(
        response_compression,
        response_encryption,
        r.content,
    )

    if resp is None:
        # Nothing to do here
        return Response("Unrecognized packet!", 500)

    if config['verbose']:
        print("Original response from server:")
        print(resp)

    modified_response = modify_response(config, resp)
    if modified_response is None:
        # Return the original response data instead of re-encoding it
        # to the exact same thing.
        resp_binary = r.content
    else:
        if config['verbose']:
            print("Modified response from server:")
            print(modified_response)

        # Re-encode the modified packet
        resp_binary = client_proto.encode(
            response_compression,
            response_encryption,
            modified_response,
        )

    # Some old clients are case sensitive, so be careful to capitalize
    # these responses here.
    flask_resp = Response(resp_binary)
    if response_compression is not None:
        flask_resp.headers['X-Compress'] = response_compression
    if response_encryption is not None:
        flask_resp.headers['X-Eamuse-Info'] = response_encryption
    return flask_resp


def load_proxy_config(filename: str) -> None:
    global config

    config_data = yaml.safe_load(open(filename))
    if 'pcbid' in config_data and config_data['pcbid'] is not None:
        for pcbid in config_data['pcbid']:
            remote_name = config_data['pcbid'][pcbid]
            remote_config = config_data['remote'][remote_name]
            config['remote'][pcbid] = remote_config


def load_config(filename: str) -> None:
    global config

    config_data = yaml.safe_load(open(filename))
    config.update({
        'local_host': config_data['local']['host'],
        'local_port': config_data['local']['port'],
        'verbose': config_data.get('verbose', False),
        'timeout': config_data.get('timeout', 30),
        'keepalive': config_data.get('keepalive', 'localhost'),
    })

    if 'default' in config_data:
        remote_config = config_data['remote'][config_data['default']]
        config.update({
            'remote': {
                '*': remote_config,
            },
        })
    else:
        config.update({
            'remote': {}
        })

    if 'pcbid' in config_data and config_data['pcbid'] is not None:
        load_proxy_config(filename)


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description="A utility to MITM non-SSL eAmusement connections.")
    parser.add_argument("-p", "--port", help="Port to listen on. Defaults to 9090", type=int, default=9090)
    parser.add_argument("-a", "--address", help="Address to listen on. Defaults to all addresses", type=str, default="0.0.0.0")
    parser.add_argument("-r", "--real-address", help="Real address we are listening on (for NAT and such)", type=str, default='127.0.0.1')
    parser.add_argument("-q", "--remote-port", help="Port to connect to.", type=int, required=True)
    parser.add_argument("-b", "--remote-address", help="Address to connect to.", type=str, required=True)
    parser.add_argument("-c", "--config", help="Configuration file for PCBID to remote server mapping.", type=str, default=None)
    parser.add_argument("-k", "--keepalive", help="Keepalive domain to advertise. Defaults to localhost", type=str, default='localhost')
    parser.add_argument("-v", "--verbose", help="Display verbose packet info.", action='store_true')
    parser.add_argument("-t", "--timeout", help="Timeout (in seconds) for proxy requests. Defaults to 30 seconds.", type=int, default=30)
    args = parser.parse_args()

    config.update({
        'local_host': args.real_address,
        'local_port': args.port,
        'remote': {
            '*': {
                'host': args.remote_address,
                'port': args.remote_port,
            },
        },
        'verbose': args.verbose,
        'timeout': args.timeout,
        'keepalive': args.keepalive,
    })

    # Fill in remote addresses for PCBIDs we should redirect to a non-default server
    if args.config is not None:
        load_proxy_config(args.config)

    app.run(host='0.0.0.0', port=args.port, debug=True)
