# -*- coding: utf-8 -*-
# #############################################################################
#    Copyright (C) 2018 manatlan manatlan[at]gmail(dot)com
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 2 only.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# https://github.com/manatlan/wuy
# #############################################################################

__version__ = "0.10.1"

from aiohttp import web, WSCloseCode
from multidict import CIMultiDict
import concurrent
import aiohttp
import asyncio
import json, sys, os
import webbrowser
import traceback
import uuid
import inspect
import types
import base64
import socket
import tempfile
import subprocess
import platform
from urllib.parse import urlparse
import inspect
import re
from datetime import datetime, date


"""
cef troubles, to fix (before 1.0 release):
    - FIX: set title don't work on *nix (Issue #252)
    - FIX: chain'able broken (test app3)
    - EVOL: make contextual menu (dev tools) optional
    - TEST: freezing with cef
"""

DEFAULT_PORT = 8080

application = None
currents = {}  # NEW
isLog = None
FULLSCREEN = "fullscreen"  # const !
PATH = os.path.dirname(os.path.abspath(os.path.realpath(sys.argv[0])))

try:
    if not getattr(
        sys, "frozen", False
    ):  # bypass uvloop in frozen app (wait pyinstaller hook)
        import uvloop

        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
except ImportError:
    pass

# helpers
#############################################################
def isFree(ip, port):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        s.bind((ip, int(port)))
        return True
    except socket.error as e:
        return False
    finally:
        s.close()


def serialize(obj):
    def toJSDate(d):
        assert type(d) in [datetime, date]
        d = datetime(d.year, d.month, d.day, 0, 0, 0, 0) if type(d) == date else d
        return d.isoformat() + "Z"

    if isinstance(obj, (datetime, date)):
        return toJSDate(obj)
    if isinstance(obj, bytes):
        return str(obj, "utf8")
    if hasattr(obj, "__dict__"):
        return obj.__dict__
    else:
        return str(obj)


def unserialize(obj):
    if type(obj) == str:
        if re.search("^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z$", obj):
            return datetime.strptime(obj, "%Y-%m-%dT%H:%M:%S.%fZ")
        elif re.search("^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ$", obj):
            return datetime.strptime(obj, "%Y-%m-%dT%H:%M:%SZ")
    elif type(obj) == list:
        return [unserialize(i) for i in obj]
    return obj


def jDumps(obj):
    return json.dumps(obj, default=serialize)


def jLoads(s):
    return unserialize(
        json.loads(s, object_pairs_hook=lambda obj: {k: unserialize(v) for k, v in obj})
    )


class JDict:
    def __init__(self, f: str):
        self.__f = os.path.join(PATH, f)
        try:
            with open(self.__f, "r+") as fid:
                self.__d = (
                    json.load(
                        fid,
                        object_pairs_hook=lambda obj: {
                            k: unserialize(v) for k, v in obj
                        },
                    )
                    or {}
                )
        except FileNotFoundError as e:
            self.__d = {}

    def set(self, k: str, v):
        self.__d[k] = v
        self.__save()

    def get(self, k: str = None):
        return self.__d.get(k, None) if k else self.__d

    def __save(self):
        with open(self.__f, "w+") as fid:
            json.dump(self.__d, fid, indent=4, sort_keys=True, default=serialize)


def path(f):
    if hasattr(sys, "_MEIPASS"):  # when freezed with pyinstaller ;-)
        return os.path.join(sys._MEIPASS, f)
    else:
        return os.path.join(PATH, f)


def wlog(*l):
    if isLog:
        s = " ".join([str(i) for i in l])
        if len(s) > 200:
            s = s[:200] + "..."
        print(s)


def find_chrome_win():
    import winreg  # TODO: pip3 install winreg

    reg_path = r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe"
    for install_type in winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE:
        try:
            with winreg.OpenKey(install_type, reg_path, 0, winreg.KEY_READ) as reg_key:
                return winreg.QueryValue(reg_key, None)
        except WindowsError:
            pass


def find_chrome_mac():
    default_dir = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
    if os.path.exists(default_dir):
        return default_dir


class ChromeApp:
    def __init__(self, url, size=None, chromeArgs=[]):
        self.__instance = None

        if sys.platform[:3] == "win":
            exe = find_chrome_win()
        elif sys.platform == "darwin":
            exe = find_chrome_mac()
        else:
            for i in ["chromium-browser", "chromium", "google-chrome", "chrome"]:
                try:
                    exe = webbrowser.get(i).name
                    break
                except webbrowser.Error:
                    exe = None

        if exe:
            args = [exe, "--app=" + url] + chromeArgs
            if size == FULLSCREEN:
                args.append("--start-fullscreen")
            if tempfile.gettempdir():
                args.append(
                    "--user-data-dir=%s"
                    % os.path.join(tempfile.gettempdir(), ".wuyapp")
                )
            # self.__instance = subprocess.Popen( args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) # make troubles on windows (freezd with noconsole don't start)
            self.__instance = subprocess.Popen(args)
        else:
            raise Exception("no browser")

    def wait(self):
        if self.__instance:
            return self.__instance.wait()

    def __del__(self):
        if self.__instance:
            self.__instance.kill()


###############################################################
## works with CefPython3
###############################################################
class ChromeAppCef:
    def __init__(self, url, size=None, chromeArgs=None):  # chromeArgs is not used
        import pkgutil

        assert pkgutil.find_loader("cefpython3"), "cefpython3 not available"

        def cefbrowser():
            from cefpython3 import cefpython as cef
            import ctypes

            isWin = platform.system() == "Windows"

            windowInfo = cef.WindowInfo()
            windowInfo.windowName = "CefPython3"
            if type(size) == tuple:
                w, h = size[0], size[1]
                windowInfo.SetAsChild(0, [0, 0, w, h])  # not win
            else:
                w, h = None, None

            sys.excepthook = cef.ExceptHook

            settings = {
                "product_version": "Wuy/%s" % __version__,
                "user_agent": "Wuy/%s (%s)" % (__version__, platform.system()),
                "context_menu": dict(
                    enabled=True,
                    navigation=False,
                    print=False,
                    view_source=False,
                    external_browser=False,
                    devtools=True,
                ),
            }
            cef.Initialize(settings, {})
            b = cef.CreateBrowserSync(windowInfo, url=url)

            if isWin and w and h:
                window_handle = b.GetOuterWindowHandle()
                SWP_NOMOVE = 0x0002  # X,Y ignored with SWP_NOMOVE flag
                ctypes.windll.user32.SetWindowPos(
                    window_handle, 0, 0, 0, w, h, SWP_NOMOVE
                )

            # ===---
            def wuyInit(width, height):
                if size == FULLSCREEN:
                    if isWin:
                        b.ToggleFullscreen()  # win only
                    else:
                        b.SetBounds(0, 0, width, height)  # not win

            bindings = cef.JavascriptBindings()
            bindings.SetFunction("wuyInit", wuyInit)
            b.SetJavascriptBindings(bindings)

            b.ExecuteJavascript("wuyInit(window.screen.width,window.screen.height)")
            # ===---

            class WuyClientHandler(object):
                def OnLoadEnd(self, browser, **_):
                    pass  # could serve in the future (?)

            class WuyDisplayHandler(object):
                def OnTitleChange(self, browser, title):
                    try:
                        cef.WindowUtils.SetTitle(browser, title)
                    except AttributeError:
                        print(
                            "**WARNING** : title changed '%s' not work on linux" % title
                        )

            b.SetClientHandler(WuyClientHandler())
            b.SetClientHandler(WuyDisplayHandler())

            cef.MessageLoop()
            cef.Shutdown()

        from threading import Thread

        t = Thread(target=cefbrowser)
        t.start()


###############################################################


# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
jar = aiohttp.CookieJar(unsafe=True)


class Request:  # just to transform aiohttp.Request in wuy.Request (abstraction)
    def __init__(self, req):
        self.method = req.method
        self.path = req.path
        self.headers = req.headers
        self.query = req.rel_url.query
        self.body = None


class Response:
    def __init__(self, status, content, headers=None):
        self.status = status

        if headers is None:
            self.headers = CIMultiDict()
            if content is not None and type(content) == bytes:
                self.headers["Content-Type"] = "application/octet-stream"
            else:
                self.headers["Content-Type"] = "text/html"
        else:
            if type(headers) == str:
                self.headers = CIMultiDict([("Content-Type", headers)])
            else:
                self.headers = headers

        self.content = content


async def request(
    url, data=None, headers={}
):  # mimic urllib.Request() (GET & POST only)
    async with aiohttp.ClientSession(cookie_jar=jar) as session:
        try:
            if data:
                async with session.post(
                    url, data=data, headers=headers, ssl=False
                ) as resp:
                    return Response(
                        resp.status, await resp.text(), headers=resp.headers
                    )
            else:
                async with session.get(url, headers=headers, ssl=False) as resp:
                    return Response(
                        resp.status, await resp.text(), headers=resp.headers
                    )
        except aiohttp.client_exceptions.ClientConnectorError as e:
            return Response(None, str(e))


# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=


# Async aiohttp things (use current)
#############################################################
async def wsSend(ws, **kargs):
    if not ws.closed:
        wlog("< send:", kargs)
        await ws.send_str(jDumps(kargs))  # TODO: should remove ws from list ?


async def wsBroadcast(instance, event, args, exceptMe=None):
    for ws in instance._clients:
        if id(ws) != id(exceptMe):
            await wsSend(ws, event=event, args=args)


async def handleProxy(req):  # proxify "/_/<url>" with headers starting with "set-"
    url = req.match_info.get("url", None)
    if req.query_string:
        url = url + "?" + req.query_string
    headers = {k[4:]: v for k, v in req.headers.items() if k.lower().startswith("set-")}
    r = await request(
        url, data=req.can_read_body and (await req.text()), headers=headers
    )
    wlog(". serve proxy url", url, headers, ":", r.status)
    h = {"Server": "Wuy Proxified request (%s)" % __version__}
    for k, v in r.headers.items():
        if k.lower() in ["content-type", "date", "expires", "cache-control"]:
            h[k] = v
    return web.Response(status=r.status or 0, text=r.content, headers=h)


getname = lambda x: x.rsplit(".", 1)[0].replace("/", ".")


async def handleWeb(req):  # serve all statics from web folder
    ressource = req.match_info.get("path", "")
    if ressource == "" or ressource.endswith("/"):
        ressource += "index.html"
    if ressource.lower().endswith((".html", ".htm")):
        name = getname(ressource)
        if name in currents:
            html = currents[name]._render(path(os.path.dirname(ressource)))
            if html:  # the instance render its own html, go with it
                if re.findall(r"""<.*script.*src.*=.*['"]wuy.js['"].*>""", html):
                    return web.Response(status=200, body=html, content_type="text/html")
                else:
                    return web.Response(
                        status=200,
                        body='<script src="wuy.js"></script>\n' + html,
                        content_type="text/html",
                    )

    # classic serve static file or 404

    file = path(os.path.join("web", ressource))

    if os.path.isfile(file):
        wlog("- serve static file", file)
        return web.FileResponse(file)
    else:
        wreq = Request(req)
        if req.body_exists:  # override body
            wreq.body = await req.read()

        for name, instance in currents.items():
            ret = instance.request(wreq)
            if ret is not None:
                r = await ret if asyncio.iscoroutine(ret) else ret
                if r is not None:
                    if type(r) != Response:
                        r = Response(status=200, content=r)

                    wlog("- serve dynamic via", name, r)
                    return web.Response(
                        status=r.status, body=r.content, headers=r.headers
                    )

        wlog("! 404 on", file)
        return web.Response(status=404, body="file not found")


async def handleJs(req):  # serve the JS
    pp = urlparse(req.headers["Referer"]).path[
        1:
    ]  # TODO: what if browser hide its referer ????
    if pp.endswith("/") or pp == "":
        pp += "index.html"
    page = getname(pp)
    instance = currents[page]

    wlog(
        "- serve wuy.js to",
        page,
        instance._size and ("(with resize to " + str(instance._size) + ")") or "",
    )

    name = os.path.basename(sys.argv[0])
    if "." in name:
        name = name.split(".")[0]
    js = """
document.addEventListener("DOMContentLoaded", function(event) {
    %s
    %s
},true)

const dateFormat = /^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?Z$/;

function reviver(key, value) {
    if (typeof value === "string" && dateFormat.test(value)) {
        return new Date(value);
    }
    return value;
}

function setupWS( cbCnx ) {
    var url=window.location.origin.replace("http","ws")+"/_ws_%s"
    var ws=new WebSocket( url );

    ws.onmessage = function(evt) {
      var r = JSON.parse(evt.data, reviver );
      if(r.uuid) // that's a response from call py !
          document.dispatchEvent( new CustomEvent('wuy-'+r.uuid,{ detail: r} ) );
      else if(r.event){ // that's an event from anywhere !
          document.dispatchEvent( new CustomEvent(r.event,{ detail: r.args } ) );
      }
    };

    ws.onclose = function(evt) {
        console.log("disconnected");
        cbCnx(null);
        %s
    };
    ws.onerror = function(evt) {cbCnx(null);};
    ws.onopen=function(evt) {
        console.log("Connected",evt)
        cbCnx(ws);
    }

    return ws;
}

var wuy={
    _ws: setupWS( function(ws){wuy._ws = ws; document.dispatchEvent( new CustomEvent("init") )} ),
    on: function( evt, callback ) {     // to register an event on a callback
        var listener = function(e) { callback.apply(callback,e.detail) }
        document.addEventListener(evt, listener)
        return function() { document.removeEventListener(evt, listener) }
    },
    emit: function( evt, data) {        // to emit a event to all clients (except me), return a promise when done
        var args=Array.prototype.slice.call(arguments)
        return wuy._call("emit", args)
    },
    init: function( callback ) {
        function start() {
            document.removeEventListener("init", start)
            callback()
        }
        if(wuy._ws.readyState == wuy._ws.OPEN)
            start()
        else
            document.addEventListener("init", start)
    },
    _call: function( method, args ) {
        var cmd={
            command:    method,
            args:       args,
            uuid:       method+"-"+Math.random().toString(36).substring(2), // stamp the exchange, so the callback can be called back (thru customevent),
        };

        if(wuy._ws) {
            wuy._ws.send( JSON.stringify(cmd) );

            return new Promise( function (resolve, reject) {
                document.addEventListener('wuy-'+cmd.uuid, function handler(x) {
                    this.removeEventListener('wuy-'+cmd.uuid, handler);
                    var x=x.detail;
                    if(x && x.result!==undefined)
                        resolve(x.result)
                    else if(x && x.error!==undefined)
                        reject(x.error)
                });
            })
        }
        else
            return new Promise( function (resolve, reject) {
                reject("not connected");
            })
    },
    fetch: function(url,obj) {
        var h={"cache-control": "no-cache"};    // !!!
        if(obj && obj.headers)
            Object.keys(obj.headers).forEach( function(k) {
                h["set-"+k]=obj.headers[k];
            })
        var newObj = Object.assign({}, obj)
        newObj.headers=h;
        newObj.credentials= 'same-origin';
        return fetch( "/_/"+url,newObj )
    },
};
""" % (
        instance._size
        and "window.resizeTo(%s,%s);" % (instance._size[0], instance._size[1])
        or "",
        'if(!document.title) document.title="%s";' % name,
        page,
        instance._closeIfSocketClose
        and "window.close()"
        or "setTimeout( function() {setupWS(cbCnx)}, 1000);",
    )

    if instance._kwargs:
        for k, v in instance._kwargs.items():
            j64 = str(
                base64.b64encode(bytes(jDumps(v), "utf8")), "utf8"
            )  # thru b64 to avoid trouble with quotes or strangers chars
            js += """\nwuy.%s=JSON.parse(atob("%s"),reviver);""" % (k, j64)

    for k in instance._routes.keys():
        js += (
            """\nwuy.%s=function(_) {return wuy._call("%s", Array.prototype.slice.call(arguments) )};"""
            % (k, k)
        )

    return web.Response(status=200, text=js)


async def wshandle(req):
    global currents
    page = req.match_info["page"]
    if page not in currents:
        return None
    instance = currents[page]

    ws = web.WebSocketResponse()
    await ws.prepare(req)
    instance._clients.append(ws)
    wlog("Socket connected", page)
    try:
        async for msg in ws:
            if msg.type == web.WSMsgType.text:
                try:
                    o = jLoads(msg.data)
                    wlog("> RECEPT", page, o)
                    if o["command"] == "emit":
                        event, *args = o["args"]
                        await wsBroadcast(
                            instance, event, args, ws
                        )  # emit to everybody except me
                        r = dict(
                            result=args
                        )  # but return the same content sended, thru the promise
                    else:
                        ret = instance._routes[o["command"]](*o["args"])
                        if ret and asyncio.iscoroutine(ret):
                            wlog(". ASync call", page, o["command"])

                            async def waitReturn(coroutine, uuid):
                                try:
                                    ret = await coroutine
                                    m = dict(result=ret, uuid=uuid)
                                except concurrent.futures._base.CancelledError as e:
                                    m = dict(error="task cancelled", uuid=uuid)
                                except Exception as e:
                                    m = dict(error=str(e), uuid=uuid)
                                    print("=" * 40, "in ASync", o["command"])
                                    print(traceback.format_exc().strip())
                                    print("=" * 40)
                                await wsSend(ws, **m)

                            asyncio.ensure_future(waitReturn(ret, o["uuid"]))
                            continue  # don't answer yet (the coroutine will do it)

                        r = dict(result=ret)
                except Exception as e:
                    r = dict(error=str(e))
                    print("=" * 40, "Exception on Recept", msg.data)
                    print(traceback.format_exc().strip())
                    print("=" * 79)

                if "uuid" in o:
                    r["uuid"] = o["uuid"]

                await wsSend(ws, **r)
            elif msg.type == web.WSMsgType.close or msg.tp == web.WSMsgType.error:
                break
    finally:
        wlog("Socket disconnected", page)
        instance._clients.remove(ws)

    if instance._closeIfSocketClose:
        _exit(instance)
    return ws


def _emit(instance, event, *args):  # sync version of emit for py side !
    asyncio.ensure_future(wsBroadcast(instance, event, args))


def _exit(instance=None):  # exit method
    global application

    if asyncio.get_event_loop().is_running():
        asyncio.get_event_loop().stop()

    if instance and hasattr(instance, "_browser") and instance._browser:
        del instance._browser
        instance._browser = None

    application = None
    wlog("exit")


async def on_shutdown(app):
    async def handle_exception(task):
        try:
            await task.cancel()
        except Exception:
            pass

    for task in asyncio.all_tasks():
        asyncio.ensure_future(handle_exception(task))


# WUY routines
#############################################################
class Base:
    _routes = {}
    _closeIfSocketClose = False
    _size = None
    _kwargs = {}  # Window/Server only

    def __init__(self, log=True):
        global isLog
        isLog = log

        self._name = self.__class__.__name__

        self._routes = {
            n: v
            for n, v in inspect.getmembers(self, inspect.ismethod)
            if not v.__func__.__qualname__.startswith(("Base.", "Window.", "Server."))
        }
        self._routes.update(
            dict(set=self.set, get=self.get)
        )  # add get/set config methods
        if "init" in self._routes:
            del self._routes[
                "init"
            ]  # ensure that a server-side init() is not exposed on client-side
        self._clients = []

    def _render(
        self, folder="."
    ):  # override this, if you want to do more complex things
        html = self.__doc__
        if html is None:
            # create startpage if not present and no docstring
            startpage = path(
                os.path.join(folder, "web", self.__class__.__name__ + ".html")
            )
            if not os.path.isfile(startpage):
                if not os.path.isdir(os.path.dirname(startpage)):
                    os.makedirs(os.path.dirname(startpage))
                with open(startpage, "w+") as fid:
                    fid.write("""<script src="wuy.js"></script>\n""")
                    fid.write("""Hello Wuy'rld ;-)""")
                print("Create '%s', just edit it" % startpage)
        return html

    @classmethod
    def _start(cls, host, port, instances, appmode):
        global application, currents

        for i in instances:
            i.init()
        currents = {i._name: i for i in instances}

        wlog(
            "Will accept : %s"
            % ["%s: %s" % (k, list(v._routes.keys())) for k, v in currents.items()]
        )  # TODO: not neat

        if application is None:

            routes = [
                        web.get("/_ws_{page}", wshandle),
                        web.get("/{path:.*}wuy.js", handleJs),
                        web.get("/", handleWeb),
                        web.route("*", "/_/{url:.+}", handleProxy),
                        web.route("*", "/{path:.+}", handleWeb),
                     ]

            application = web.Application()
            try:
                application.add_routes(routes)
            except AttributeError:
                application.router.add_routes(routes)  # Py3.5 + aiohttp3.0b

            application.on_shutdown.append(on_shutdown)
            try:
                if (
                    appmode
                ):  # app-mode, don't shout "server started,  Running on, ctrl-c"
                    web.run_app(
                        application, host=host, port=port, print=lambda *a, **k: None
                    )
                else:
                    web.run_app(application, host=host, port=port)
            except concurrent.futures._base.CancelledError:
                pass  # die silently
            except RuntimeError:  # for tests stopping loop TODO check
                _exit()
            except KeyboardInterrupt:
                _exit()

            asyncio.set_event_loop(
                asyncio.new_event_loop()
            )  # renew, so multiple start are availables

    def emit(self, *a, **k):  # emit available for all
        _emit(self, *a, **k)

    def init(self):  # override this to make initializations
        pass

    def request(self, req):  # override to hook others web http requests
        pass

    def exit(self):  # available for ALL !!!
        _exit()

    def set(self, key, value, file="config.json"):
        c = JDict(file)
        c.set(key, value)

    def get(self, key=None, file="config.json"):
        c = JDict(file)
        return c.get(key)


class Window(Base):
    size = True  # or a tuple (wx,wy)
    _port = None
    chromeArgs = []

    def __init__(self, port=DEFAULT_PORT, log=True, **kwargs):
        super().__init__(log)
        self.__dict__.update(kwargs)
        self._kwargs = kwargs
        self._run(port=port)

    def _run(self, port):  # start method (app can be True, (width,height), ...)
        app = self.size

        self._closeIfSocketClose = True
        host = "localhost"
        if Window._port is None:
            while not isFree(host, port):
                port += 1
            Window._port = port
        else:
            port = Window._port

        url = "http://%s:%s/%s?%s" % (
            host,
            port,
            self._name + ".html",
            uuid.uuid4().hex,
        )

        if type(app) == tuple and len(app) == 2:  # it's a size tuple : set it !
            self._size = app

        try:
            # self._browser=ChromeAppCef(url,app)    # with CefPython3 !!!
            self._browser = ChromeApp(url, app, chromeArgs=self.chromeArgs)
        except Exception as e:
            print("Can't find Chrome on your desktop : %s" % e)
            sys.exit(-1)

        Base._start(host, port, [self], True)


class Server(Base):
    def __init__(self, port=DEFAULT_PORT, log=True, autorun=True, **kwargs):
        super().__init__(log)
        self.__dict__.update(kwargs)
        self._kwargs = kwargs
        if autorun:
            Base._start("0.0.0.0", port, [self], False)

    @classmethod
    def run(cls, port=DEFAULT_PORT, log=True, **kwargs):
        global isLog
        isLog = log
        allClasses = globals()[cls.__name__].__subclasses__()
        instances = [c(autorun=False, **kwargs) for c in allClasses]
        cls._start("0.0.0.0", port, instances, False)


if __name__ == "__main__":
    pass