import urllib
import os
import time
import errno
import weakref
import base64
import json
import socket

#ASYNC
import asyncio
import aiohttp
from aiohttp import web

from urllib.parse import unquote

"""
HTTP Server interface
"""

def _get_viewer(ref):
    #Get from weak reference, if deleted raise exception
    lv = ref()
    if not lv:
        raise(Exception("Viewer not found"))
    return lv

def _execute(lv, cmds):
    if len(cmds) and cmds[0] == '_':
        #base64 encoded commands or JSON state
        cmds = str(base64.b64decode(cmds).decode('utf-8'))
        #cmds = str(base64.b64decode(cmds), 'utf-8')

    #Object to select can be provided in preceding angle brackets
    selobj = None
    if cmds[0] == '<':
        pos = cmds.find('>')
        selobj = lv.objects[cmds[1:pos]]
        cmds = cmds[pos+1:]

    #Execute commands via python API by preceding with '.'
    done = False
    if cmds[0] == '.':
        attr = cmds.split()[0][1:]
        pos = cmds.find(' ')
        params = cmds[pos+1:]
        if selobj:
            #Call on Object
            func = getattr(selobj, attr)
            if func and callable(func):
                func(params)
                done = True
        else:
            #Call on Viewer
            func = getattr(lv, attr)
            if func and callable(func):
                func(params)
                done = True
    elif cmds[0] == '$':
        #Requests prefixed by '$' are sent
        #from property collection controls
        #format is $ID KEY VALUE
        # - ID is the python id() of the properties object
        #    All properties collections are stored on their parent
        #    object using this id in the _collections dict
        # - KEY is the property name key to set
        # - VALUE is a json string containing the value to set
        S = cmds.split()
        target = S[0][1:]
        if target in lv._collections:
            #Get from _collections by id (weakref)
            props = lv._collections[target]()
            props[S[1]] = json.loads(S[2])
            #Check for callback - if provided, call with updated props
            func = getattr(props, 'callback')
            if func and callable(func):
                func(props)

    #Default, call via lv.commands() scripting API
    if not done:
        if selobj:
            selobj.select()
        lv.commands(cmds)

headers = {'Access-Control-Allow-Origin' : '*',
           'x-colab-notebook-cache-control' : 'no-cache'} #Colab: disable offline access cache      

def img_response(lv, query={}):
    global headers
    resp = None
    if 'width' in query and 'height' in query:
        resp = lv.jpeg(resolution=(int(query['width']), int(query['height'])))
    elif 'width' in query:
        resp = lv.jpeg(resolution=(int(query['width']), 0))
    else:
        resp = lv.jpeg()

    #Ensure the response is valid before serving
    if resp is not None:
        return web.Response(body=resp, content_type='image/jpeg', headers=headers)
    else:
        return web.Response(text='', headers=headers)

async def handle_get(request):
    global headers
    lv = _get_viewer(request.app['viewer'])

    #Default to empty OK 200 response
    response = web.Response(text='', headers=headers)

    #print(request.path)
    #for q in request.query:
    #    print(q, request.query[q])

    if request.path == '/':
        #Index request returns full screen interactive view
        lv = _get_viewer(request.app['viewer'])
        w = lv.control.Window(align=None, wrapper=None, fullscreen=True)
        code = lv.control.show(True, filename="")
        response = web.Response(text=code, headers=headers, content_type='text/html')

    elif request.path.startswith('/image'):
        response = img_response(lv, request.query)

    elif request.path.startswith('/command=') or request.path.startswith('/icommand='):
        pos1 = request.path.find('=')
        pos2 = request.path.find('?')
        if pos2 < 0: pos2 = len(request.path)
        cmds = unquote(request.path[pos1+1:pos2])

        #Run viewer commands
        _execute(lv, cmds)

        #Serve image or just respond 200
        if request.path.startswith('/icommand='):
            response = img_response(lv, request.query)

    elif request.path.startswith('/getstate'):
        state = lv.app.getState()
        response = web.Response(text=state, headers=headers, content_type='application/json')
    elif request.path.startswith('/connect'):
        if 'url' in request.query:
            #Save first valid connection URL on the viewer
            url = request.query['url']
            if len(lv._url) == 0:
                lv._url = url
        uid = id(lv)
        response = web.Response(text=str(uid), headers=headers)
    elif request.path.startswith('/key='):
        pos2 = request.path.find('&')
        cmds = unquote(request.path[1:pos2])
        lv.commands('key ' + cmds, True)
    elif request.path.startswith('/mouse='):
        pos2 = request.path.find('&')
        cmds = unquote(request.path[1:pos2])
        lv.commands('mouse ' + cmds, True)
    elif request.path.startswith('/db'):
        #Send the database
        db = bytes(lv.app.serialize())
        response = web.Response(body=db, headers=headers, content_type='application/octet-stream')
    else:
        #Serve other urls as files if available
        #print("UNKNOWN - ", request.path)
        path = request.path
        if os.path.exists(path):
            #OK to always serve files in cwd?
            response = web.FileResponse(path)
        else:
            #Serve files from lavavu html dir
            #print(' - not found in cwd')
            if path[0] == '/': path = path[1:]
            path2 = os.path.join(lv.htmlpath, path)
            if os.path.exists(path2) and os.path.isfile(path2):
                #print(' - found in htmlpath')
                response = web.FileResponse(path2)

    return response

async def handle_post(request):
    lv = _get_viewer(request.app['viewer'])
    #print("POST", request.path)
    #text = await request.text()
    text = ''
    #Always interpret post data as commands
    #(can perform other actions too based on path later if we want)
    if request.body_exists:
        body = await request.read()
        #body = await request.text()

        cmds = str(body, 'utf-8') #python3 only
        #from urllib.parse import unquote
        #data_string = unquote(body)
        #cmds = str(data_string.decode('utf-8'))

        #Run viewer commands
        _execute(lv, cmds)

    return web.Response(text='', headers=headers)


"""
HTTP Server manager class
"""
class Server(object):
    def __init__(self, viewer, port=8080, ipv6=False, retries=100):
        #Allows viewer to be garbage collected
        self.viewer = weakref.ref(viewer)
        self.ipv6 = ipv6
        self.retries = retries
        self._closing = False
        self._lock = asyncio.Lock()

        #Get port/socket before running server in synchronous code
        self.socket, self.port = _listen(port, self.ipv6, self.retries)

    async def run(self):
        #Server run!
        #Lock until the port is retrieved
        async with self._lock:
            await _serve(self.viewer, self.socket)

        #ONLY NEED THIS IF NO LOOP EXISTS AND WE ARE MANAGING OUR OWN
        #while not self._closing:
        #    await asyncio.sleep(3600)  # sleep forever
        #    #await asyncio.sleep(1)  # sleep forever
        #    print('_', end='')

        #To stop, call cleanup (TODO: put this somewhere in closedown code)
        #await runner.cleanup()

def _listen(port, ipv6, retries):
    #Get port/socket before running server in synchronous code
    #avoids race conditions over port number with subsequent code that 
    #tries to use server.port before it is confirmed/opened
    hostidx = 0
    for i in range(retries):
        try:
            hosts = []
            socktype = socket.AF_INET
            if ipv6:
                hosts = ['::', 'localhost', '::1']
                host = hosts[hostidx]
                socktype = socket.AF_INET6
            else:
                hosts = ['0.0.0.0', 'localhost', '127.0.0.1']
                host = hosts[hostidx]

            #https://github.com/aio-libs/aiohttp/issues/1987#issuecomment-309401600
            sock = socket.socket(socktype)
            sock.bind((host, port))
            # Aiohttp will call 'listen' inside.
            # But it must be called before we actually use the port,
            # any attempts to connect before the 'listen' call will
            # be rejected.
            sock.listen(128)
            params = sock.getsockname()
            port = params[1]
            #print("Socket ready on host %s port %s" % (host, port))
            return sock, port

        except (Exception) as e:
            #Try another port
            if e.errno == errno.EADDRINUSE: #98
                port += 1
                #Try again...
            elif e.errno == errno.EAFNOSUPPORT: #97 : Address family not supported by protocol
                #Try next host name/address
                hostidx += 1
                if hostidx > 2:
                    #Try again without ipv6?
                    if ipv6:
                        ipv6 = False
                    else:
                        ipv6 = True
                    hostidx = 0
                #Try again...
            else:
                print("Socket open failed: ", e, e.errno, host, port)

    print("Failed to open socket, max retries reached")
    return None, 0

async def _serve(viewer, sock):
    try:
        #Create web application manager
        app = web.Application()
        #Store viewer
        app["viewer"] = viewer
        #Add routes
        app.router.add_post('/{tail:.*}', handle_post),
        app.router.add_get('/{tail:.*}', handle_get)

        #Static routes? https://docs.aiohttp.org/en/stable/web_advanced.html
        #app.add_routes([web.static('/', path_to_static_folder)])
        #routes.static('/prefix', path_to_static_folder)

        #app.add_routes([web.get('/', handler),
        #        web.post('/post', post_handler),
        #        web.put('/put', put_handler)])

                #Can't use this, is blocking
                #web.run_app(app, sock=sock)

        # set up aiohttp - like run_app, but non-blocking - socket provided version
        runner = aiohttp.web.AppRunner(app, access_log=None)
        await runner.setup()
        site = aiohttp.web_runner.SockSite(runner, sock=sock)
        await site.start()

        """
        #This works but have to wait for the port to be allocated before using
        # passing socket as above gets port in synchronous code

        # set up aiohttp - like run_app, but non-blocking
        runner = aiohttp.web.AppRunner(app)
        await runner.setup()
        site = aiohttp.web.TCPSite(runner, host=host, port=port, reuse_address=False) 
        await site.start()

        #Get port from first entry in list of active connections
        for s in runner.sites:
            #print(s.name, s._port)
            return s._port #Get actual port allocated
        return 0
        """

    except (Exception) as e:
        print("Server start failed: ", e)

#Ignore SIGPIPE altogether (does not apply on windows)
#import sys
#if sys.platform != 'win32':
#    from signal import signal, SIGPIPE, SIG_IGN
#    signal(SIGPIPE, SIG_IGN)

def serve(viewer, port=None, ipv6=False, retries=100):
    s = Server(viewer, port, ipv6, retries)
    #Attach to event loop
    loop = asyncio.get_event_loop()
    loop.create_task(s.run())
    return s

"""
Main entry point - run server and open browser interface
"""
if __name__ == '__main__':
    import lavavu
    import asyncio
    lv = lavavu.Viewer()

    import logging
    logging.getLogger('aiohttp.server').setLevel(logging.CRITICAL)

    lv.browser()
    lv.app.loop()
    #lv.interactive()

