"""CherryPy-based server for accessing NILM database via HTTP"""

# Need absolute_import so that "import nilmdb" won't pull in nilmdb.py,
# but will pull the nilmdb module instead.
from __future__ import absolute_import
import nilmdb

from nilmdb.printf import *

import cherrypy
import sys
import time
import os
import simplejson as json

try:
    import cherrypy
    cherrypy.tools.json_out
except: # pragma: no cover
    sys.stderr.write("Cherrypy 3.2+ required\n")
    sys.exit(1)

class NilmApp(object):
    def __init__(self, db):
        self.db = db

version = "1.1"

class Root(NilmApp):
    """Root application for NILM database"""

    def __init__(self, db, version):
        super(Root, self).__init__(db)
        self.server_version = version

    # /
    @cherrypy.expose
    def index(self):
        raise cherrypy.NotFound()

    # /favicon.ico
    @cherrypy.expose
    def favicon_ico(self):
        raise cherrypy.NotFound()

    # /version
    @cherrypy.expose
    @cherrypy.tools.json_out()
    def version(self):
        return self.server_version

    # /dbpath
    @cherrypy.expose
    @cherrypy.tools.json_out()
    def dbpath(self):
        return self.db.get_basepath()

    # /dbsize
    @cherrypy.expose
    @cherrypy.tools.json_out()
    def dbsize(self):
        return nilmdb.du.du(self.db.get_basepath())

class Stream(NilmApp):
    """Stream-specific operations"""

    # /stream/list
    # /stream/list?layout=PrepData
    # /stream/list?path=/newton/prep
    @cherrypy.expose
    @cherrypy.tools.json_out()
    def list(self, path = None, layout = None):
        """List all streams in the database.  With optional path or
        layout parameter, just list streams that match the given path
        or layout"""
        return self.db.stream_list(path, layout)

    # /stream/create?path=/newton/prep&layout=PrepData
    @cherrypy.expose
    @cherrypy.tools.json_out()
    def create(self, path, layout):
        """Create a new stream in the database.  Provide path
        and one of the nilmdb.layout.layouts keys.
        """
        try:
            return self.db.stream_create(path, layout)
        except Exception as e:
            message = sprintf("%s: %s", type(e).__name__, e.message)
            raise cherrypy.HTTPError("400 Bad Request", message)

    # /stream/get_metadata?path=/newton/prep
    # /stream/get_metadata?path=/newton/prep&key=foo&key=bar
    @cherrypy.expose
    @cherrypy.tools.json_out()
    def get_metadata(self, path, key=None):
        """Get metadata for the named stream.  If optional
        key parameters are specified, only return metadata
        matching the given keys."""
        try:
            data = self.db.stream_get_metadata(path)
        except nilmdb.nilmdb.StreamError as e:
            raise cherrypy.HTTPError("404 Not Found", e.message)
        if key is None:  # If no keys specified, return them all
            key = data.keys()
        elif not isinstance(key, list):
            key = [ key ]
        result = {}
        for k in key:
            if k in data:
                result[k] = data[k]
            else: # Return "None" for keys with no matching value
                result[k] = None
        return result

    # /stream/set_metadata?path=/newton/prep&data=<json>
    @cherrypy.expose
    @cherrypy.tools.json_out()
    def set_metadata(self, path, data):
        """Set metadata for the named stream, replacing any
        existing metadata.  Data should be a json-encoded
        dictionary"""
        try:
            data_dict = json.loads(data)
            self.db.stream_set_metadata(path, data_dict)
        except Exception as e:
            message = sprintf("%s: %s", type(e).__name__, e.message)
            raise cherrypy.HTTPError("400 Bad Request", message)
        return "ok"

    # /stream/update_metadata?path=/newton/prep&data=<json>
    @cherrypy.expose
    @cherrypy.tools.json_out()
    def update_metadata(self, path, data):
        """Update metadata for the named stream.  Data
        should be a json-encoded dictionary"""
        try:
            data_dict = json.loads(data)
            self.db.stream_update_metadata(path, data_dict)
        except Exception as e:
            message = sprintf("%s: %s", type(e).__name__, e.message)
            raise cherrypy.HTTPError("400 Bad Request", message)
        return "ok"

    # /stream/insert?path=/newton/prep
    @cherrypy.expose
    @cherrypy.tools.json_out()
    #@cherrypy.tools.disable_prb()
    def insert(self, path, old_timestamp = None):
        """
        Insert new data into the database.  Provide textual data
        (matching the path's layout) as a HTTP PUT.

        old_timestamp is used when making multiple, split-up insertions
        for a larger contiguous block of data.  The first insert
        will return the maximum timestamp that it saw, and the second
        insert should provide this timestamp as an argument.  This is
        used to extend the previous database interval rather than
        start a new one.
        """

        # Important that we always read the input before throwing any
        # errors, to keep lengths happy for persistent connections.
        # However, CherryPy 3.2.2 has a bug where this fails for GET
        # requests, so catch that. (issue #1134)
        try:
            body = cherrypy.request.body.read()
        except TypeError:
            raise cherrypy.HTTPError("400 Bad Request", "No request body")

        # Check path and get layout
        streams = self.db.stream_list(path = path)
        if len(streams) != 1:
            raise cherrypy.HTTPError("404 Not Found", "No such stream")
        layout = streams[0][1]

        # Parse the input data
        try:
            parser = nilmdb.layout.Parser(layout)
            parser.parse(body)
        except nilmdb.layout.ParserError as e:
            raise cherrypy.HTTPError("400 Bad Request",
                                     "Error parsing input data: " +
                                     e.message)

        # Now do the nilmdb insert, passing it the parser full of data.
        try:
            if old_timestamp:
                old_timestamp = float(old_timestamp)
            result = self.db.stream_insert(path, parser, old_timestamp)
        except nilmdb.nilmdb.NilmDBError as e:
            raise cherrypy.HTTPError("400 Bad Request", e.message)

        # Return the maximum timestamp that we saw.  The client will
        # return this back to us as the old_timestamp parameter, if
        # it has more data to send.
        return ("ok", parser.max_timestamp)

    # /stream/intervals?path=/newton/prep
    # /stream/intervals?path=/newton/prep&start=1234567890.0&end=1234567899.0
    @cherrypy.expose
    def intervals(self, path, start = None, end = None):
        """
        Get intervals from backend database.  Streams the resulting
        intervals as JSON strings separated by newlines.  This may
        make multiple requests to the nilmdb backend to avoid causing
        it to block for too long.
        """
        if start is not None:
            start = float(start)
        if end is not None:
            end = float(end)

        if start is not None and end is not None:
            if end < start:
                raise cherrypy.HTTPError("400 Bad Request",
                                         "end before start")

        streams = self.db.stream_list(path = path)
        if len(streams) != 1:
            raise cherrypy.HTTPError("404 Not Found", "No such stream")

        def content(start, end):
            # Note: disable response.stream below to get better debug info
            # from tracebacks in this subfunction.
            while True:
                (intervals, restart) = self.db.stream_intervals(path,start,end)
                response = ''.join([ json.dumps(i) + "\n" for i in intervals ])
                yield response
                if restart == 0:
                    break
                start = restart
        return content(start, end)
    intervals._cp_config = { 'response.stream': True } # chunked HTTP response

    # /stream/extract?path=/newton/prep&start=1234567890.0&end=1234567899.0
    @cherrypy.expose
    def extract(self, path, start = None, end = None, count = False):
        """
        Extract data from backend database.  Streams the resulting
        entries as ASCII text lines separated by newlines.  This may
        make multiple requests to the nilmdb backend to avoid causing
        it to block for too long.

        Add count=True to return a count rather than actual data.
        """
        if start is not None:
            start = float(start)
        if end is not None:
            end = float(end)

        # Check parameters
        if start is not None and end is not None:
            if end < start:
                raise cherrypy.HTTPError("400 Bad Request",
                                         "end before start")

        # Check path and get layout
        streams = self.db.stream_list(path = path)
        if len(streams) != 1:
            raise cherrypy.HTTPError("404 Not Found", "No such stream")
        layout = streams[0][1]

        # Get formatter
        formatter = nilmdb.layout.Formatter(layout)

        def content(start, end, count):
            # Note: disable response.stream below to get better debug info
            # from tracebacks in this subfunction.
            if count:
                matched = self.db.stream_extract(path, start, end, count)
                yield sprintf("%d\n", matched)
                return

            while True:
                (data, restart) = self.db.stream_extract(path, start, end)

                # Format the data and yield it
                yield formatter.format(data)

                if restart == 0:
                    return
                start = restart
        return content(start, end, count)
    extract._cp_config = { 'response.stream': True } # chunked HTTP response


class Exiter(object):
    """App that exits the server, for testing"""
    @cherrypy.expose
    def index(self):
        cherrypy.response.headers['Content-Type'] = 'text/plain'
        def content():
            yield 'Exiting by request'
            raise SystemExit
        return content()
    index._cp_config = { 'response.stream': True }

class Server(object):
    def __init__(self, db, host = '127.0.0.1', port = 8080,
                 stoppable = False,       # whether /exit URL exists
                 embedded = True,         # hide diagnostics and output, etc
                 fast_shutdown = False,   # don't wait for clients to disconn.
                 force_traceback = False  # include traceback in all errors
                 ):
        self.version = version

        # Need to wrap DB object in a serializer because we'll call
        # into it from separate threads.
        self.embedded = embedded
        self.db = nilmdb.serializer.WrapObject(db)
        cherrypy.config.update({
            'server.socket_host': host,
            'server.socket_port': port,
            'engine.autoreload_on': False,
            'server.max_request_body_size': 4*1024*1024,
            'error_page.default': self.json_error_page,
            })
        if self.embedded:
            cherrypy.config.update({ 'environment': 'embedded' })

        # Send tracebacks in error responses.  They're hidden by the
        # error_page function for client errors (code 400-499).
        cherrypy.config.update({ 'request.show_tracebacks' : True })
        self.force_traceback = force_traceback

        cherrypy.tree.apps = {}
        cherrypy.tree.mount(Root(self.db, self.version), "/")
        cherrypy.tree.mount(Stream(self.db), "/stream")
        if stoppable:
            cherrypy.tree.mount(Exiter(), "/exit")

        # Shutdowns normally wait for clients to disconnect.  To speed
        # up tests, set fast_shutdown = True
        if fast_shutdown:
            # Setting timeout to 0 triggers os._exit(70) at shutdown, grr...
            cherrypy.server.shutdown_timeout = 0.01
        else:
            cherrypy.server.shutdown_timeout = 5

    def json_error_page(self, status, message, traceback, version):
        """Return a custom error page in JSON so the client can parse it"""
        errordata = { "status" : status,
                      "message" : message,
                      "traceback" : traceback }
        # Don't send a traceback if the error was 400-499 (client's fault)
        try:
            code = int(status.split()[0])
            if not self.force_traceback:
                if code >= 400 and code <= 499:
                    errordata["traceback"] = ""
        except Exception as e: # pragma: no cover
            pass
        # Override the response type, which was previously set to text/html
        cherrypy.serving.response.headers['Content-Type'] = (
            "application/json;charset=utf-8" )
        # Undo the HTML escaping that cherrypy's get_error_page function applies
        # (cherrypy issue 1135)
        for k, v in errordata.iteritems():
            v = v.replace("&lt;","<")
            v = v.replace("&gt;",">")
            v = v.replace("&amp;","&")
            errordata[k] = v
        return json.dumps(errordata, separators=(',',':'))

    def start(self, blocking = False, event = None):

        if not self.embedded: # pragma: no cover
            # Handle signals nicely
            if hasattr(cherrypy.engine, "signal_handler"):
                cherrypy.engine.signal_handler.subscribe()
            if hasattr(cherrypy.engine, "console_control_handler"):
                cherrypy.engine.console_control_handler.subscribe()

        # Cherrypy stupidly calls os._exit(70) when it can't bind the
        # port.  At least try to print a reasonable error and continue
        # in this case, rather than just dying silently (as we would
        # otherwise do in embedded mode)
        real_exit = os._exit
        def fake_exit(code): # pragma: no cover
            if code == os.EX_SOFTWARE:
                fprintf(sys.stderr, "error: CherryPy called os._exit!\n")
            else:
                real_exit(code)
        os._exit = fake_exit
        cherrypy.engine.start()
        os._exit = real_exit

        if event is not None:
            event.set()
        if blocking:
            try:
                cherrypy.engine.wait(cherrypy.engine.states.EXITING,
                                     interval = 0.1, channel = 'main')
            except (KeyboardInterrupt, IOError): # pragma: no cover
                cherrypy.engine.log('Keyboard Interrupt: shutting down bus')
                cherrypy.engine.exit()
            except SystemExit: # pragma: no cover
                cherrypy.engine.log('SystemExit raised: shutting down bus')
                cherrypy.engine.exit()
                raise

    def stop(self):
        cherrypy.engine.exit()
