#!python
# -*- coding: utf-8 -*-


# Copyright (c) 2018 Wolfgang Rohdewald <wolfgang@rohdewald.de>
# See LICENSE for details.

"""implement a server using the mapmytracks protocol.

There is one notable difference:

https://github.com/MapMyTracks/api/blob/master/services/stop_activity.md
says stop_activity has no parameter activity_id. Our server needs it,
Oruxmaps delivers it. Maybe the MMT API definition is wrong.
See https://github.com/MapMyTracks/api/issues/25

"""

# PYTHON_ARGCOMPLETE_OK
# for command line argument completion, put this into your .bashrc:
# eval "$(register-python-argcomplete gpxdo)"
# or see https://argcomplete.readthedocs.io/en/latest/


import os
import sys
import base64
import datetime
import argparse
import logging
import logging.handlers
import traceback
import smtplib

from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs

from gpxpy import gpx as mod_gpx

GPX = mod_gpx.GPX
GPXTrack = mod_gpx.GPXTrack
GPXTrackSegment = mod_gpx.GPXTrackSegment
GPXTrackPoint = mod_gpx.GPXTrackPoint
GPXXMLSyntaxException = mod_gpx.GPXXMLSyntaxException

# This uses not the installed copy but the development files
_ = os.path.dirname(sys.path[0] or sys.path[1])
if os.path.exists(os.path.join(_, 'gpxity', '__init__.py')):
    sys.path.insert(0, _)
# pylint: disable=wrong-import-position

from gpxity import Track, ServerDirectory, Lifetrack, Mailer  # noqa pylint: disable=no-name-in-module

try:
    import argcomplete
    # pylint: disable=unused-import
    from argcomplete import ChoicesCompleter  # noqa
except ImportError:
    pass


class MMTHandler(BaseHTTPRequestHandler):

    """handles all HTTP requests."""

    users = None
    login_user = None
    uniqueid = 123

    def log_info(self, format, *args):  # pylint: disable=redefined-builtin
        """Override: Redirect into logger."""
        self.server.logger.info(format % args)

    def log_error(self, format, *args):  # pylint: disable=redefined-builtin
        """Override: redirect into logger."""
        self.server.logger.error(format % args)

    def check_basic_auth_pw(self) ->bool:
        """basic http authentication.

        Returns:
            True if authentication succeeded

        """
        if self.users is None:
            self.load_users()
        for pair in self.users.items():
            expect = b'Basic ' + base64.b64encode(':'.join(pair).encode('utf-8'))
            expect = expect.decode('utf-8')
            if expect == self.headers['Authorization']:
                return True
        return False

    def load_users(self):
        """load legal user auth from serverdirectory/.users."""
        self.users = dict()
        with open(os.path.join(self.server.server_directory.url, '.users')) as user_file:
            for line in user_file:
                user, password = line.strip().split(':')
                self.users[user] = password

    def return_error(self, code, reason):
        """Answer the clint with an xml formatted error message."""
        self.server.logger.info('return_error: {} {}'.format(code, reason))
        self.send_response(code)
        xml = '<type>error</type><reason>{}</reason>'.format(reason)
        self.send_header('Content-Type', 'text/xml; charset=UTF-8')
        xml = '<?xml version="1.0" encoding="UTF-8"?><message>{}</message>'.format(xml)
        self.send_header('Content-Length', len(xml))
        self.end_headers()
        self.wfile.write(bytes(xml.encode('utf-8')))

    def parseRequest(self):  # noqa pylint: disable=invalid-name
        """Get interesting things.

        Returns:
            A dict with the parsed results or None

        """
        if 'Content-Length' in self.headers:
            data_length = int(self.headers['Content-Length'])
            data = self.rfile.read(data_length).decode('utf-8')
            parsed = parse_qs(data)
            self.server.logger.debug('got %s', parsed)
            for key, value in parsed.items():
                if len(value) != 1:
                    self.return_error(400, '{} must appear only once'.format(key))
                parsed[key] = parsed[key][0]
            return parsed
        return None

    def homepage(self):
        """Return what the client needs."""
        self.load_users()
        names = list(sorted(self.users.keys()))
        return """
            <input type="hidden" value="{}" name="mid" id="mid" />
            """.format(names.index(self.login_user))

    @staticmethod
    def answer_with_categories():
        """Return all categories."""
        all_cat = Track.legal_categories
        return ''.join('<li><input name="add-activity-x">&nbsp;{}</li>'.format(x) for x in all_cat)

    def cookies(self):
        """send cookies."""
        if hasattr(self, 'uniqueid'):
            self.send_header('Set-Cookie', 'exp_uniqueid={}'.format(self.uniqueid))

    def do_GET(self):  # noqa pylint: disable=invalid-name
        """Override standard."""
        self.server.logger.info('%s GET %s %s %s', self.server.second(), self.client_address[0], self.server.server_port, self.path)
        self.parseRequest()  # side effect: may output debug info
        self.send_response(200, 'OK')
        self.send_header('WWW-Authenticate', 'Basic realm="MMTracks API"')
        if self.path == '/':
            xml = self.homepage()
        elif self.path.endswith('/explore/wall'):
            # the client wants to find out legal categories
            xml = self.answer_with_categories()
        elif self.path.startswith('//assets/php/gpx.php'):
            parameters = self.path.split('?')[1]
            request = parse_qs(parameters)
            wanted_id = request['tid'][0]
            xml = self.server.server_directory[wanted_id].to_xml()
        else:
            xml = ''
        self.send_header('Content-Type', 'text/xml; charset=UTF-8')
        self.server.logger.info('%s  returning %s', self.server.second(), xml)
        self.send_header('Content-Length', len(xml))
        self.cookies()
        self.end_headers()
        self.wfile.write(bytes(xml.encode('utf-8')))

    def do_POST(self):  # noqa pylint: disable=invalid-name
        """override standard."""
        try:
            parsed = self.parseRequest()
            points = ' with {} points'.format(len(parsed['points'].split(' ')) // 4) if 'points' in parsed else ''
            ident=' id={}'.format(parsed['activity_id']) if 'activity_id' in parsed else ''
            self.server.logger.info(
                '%s POST from %s on port %s %s request=%s %s%s',
                self.server.second(), self.client_address[0], self.server.server_port, self.path, parsed.get('request'),
                ident, points)
            if self.path.endswith('/api/') or self.path == '/' or self.path == '//':
                try:
                    request = parsed['request']
                except KeyError:
                    self.return_error(401, 'No request given in {}'.format(parsed))
                try:
                    method = getattr(self, 'xml_{}'.format(request))
                except AttributeError:
                    self.return_error(401, 'Unknown request {}'.format(parsed['request']))
                try:
                    xml = method(parsed)
                except Exception:
                    self.server.logger.error(traceback.format_exc())
                    raise
                self.server.logger.info('%s   returning %s', self.server.second(), xml)
                if xml is None:
                    xml = ''
                xml = '<?xml version="1.0" encoding="UTF-8"?><message>{}</message>'.format(xml)
            else:
                xml = ''
            self.send_response(200, 'OK')
            self.send_header('WWW-Authenticate', 'Basic realm="MMTracks API"')
            self.send_header('Content-Type', 'text/xml; charset=UTF-8')
            self.send_header('Content-Length', len(xml))
            self.cookies()
            self.end_headers()
            self.wfile.write(bytes(xml.encode('utf-8')))
        except Exception as exc:
            if isinstance(exc, smtplib.SMTPRecipientsRefused):
                self.return_error(500, 'Mailer: Recipients refused: {}'.format(exc.recipients))
            else:
                self.return_error(500, 'Unknown error {}: {}'.format(type(exc), str(exc)))

    @staticmethod
    def xml_get_time(_) ->str:
        """Get server time as defined by the mapmytracks API.

        Returns:
            Our answer

        """
        return '<type>time</type><server_time>{}</server_time>'.format(
            int(datetime.datetime.now().timestamp()))

    def xml_get_tracks(self, parsed) ->str:
        """List all tracks as defined by the mapmytracks API.

        Returns:
            Our answer

        """
        a_list = list()
        if parsed['offset'] == '0':
            for idx, _ in enumerate(self.server.server_directory):
                a_list.append(
                    '<track{}><id>{}</id>'
                    '<title><![CDATA[ {} ]]></title>'
                    '<activity_type>{}</activity_type>'
                    '<date>{}</date>'
                    '</track{}>'.format(
                        idx + 1, _.id_in_backend, _.title, _.category,
                        int(_.time.timestamp()), idx + 1))
        return '<tracks>{}</tracks>'.format(''.join(a_list))

    def __points(self, raw):
        """convert raw data back into list(GPXTrackPoint).

        Returns:
            list(GPXTrackPoint)

        """
        values = raw.split()
        if len(values) % 4:
            self.return_error(401, 'Point element count {} is not a multiple of 4'.format(len(values)))
        result = list()
        for idx in range(0, len(values), 4):
            point = GPXTrackPoint(
                latitude=float(values[idx]),
                longitude=float(values[idx + 1]),
                elevation=float(values[idx + 2]),
                time=datetime.datetime.utcfromtimestamp(float(values[idx + 3])))
            result.append(point)
        return result

    def xml_upload_activity(self, parsed) ->str:
        """Upload an activity as defined by the mapmytracks API.

        Returns:
            Our answer

        """
        track = Track()
        track.parse(parsed['gpx_file'])
        self.server.server_directory.add(track)
        if self.server.mailer:
            self.server.mailer.add(track)
        return '<type>success</type><id>{}</id>'.format(track.id_in_backend)

    def xml_start_activity(self, parsed) ->str:
        """start Lifetrack server.

        Returns:
            Our answer

        """
        if 'title' in parsed:
            title = parsed['title']
            if parsed.get('source') == 'OruxMaps':
                # shorten monster title 2018-10-03 00:0020181003_0018
                title = title[:16]
        else:
            title = None
        if 'privicity' in parsed:
            parsed['privacy'] = parsed['privicity']
        public=parsed['privacy'] == 'public'
        # the MMT API example uses cycling instead of Cycling,
        # and Oruxmaps does so too.
        category = parsed['activity'].capitalize()
        points = self.__points(parsed['points'])

        life = Lifetrack(self.server.server_directory, self.server.mailer)
        new_ident = life.start(points, title, public, category)
        self.server.trackers[new_ident] = life
        return '<type>activity_started</type><activity_id>{}</activity_id>'.format(new_ident)

    def use_tracker(self, parsed):
        """Find matching tracker. Fail if no tracker is running at all.

        Returns: the wanted tracker or None.

        """
        if 'activity_id' in parsed:
            return self.server.trackers.get(parsed['activity_id'])

    def xml_update_activity(self, parsed) ->str:
        """Get new points.

        Returns:
            Our answer

        """
        updated = '<type>activity_updated</type>'
        tracker = self.use_tracker(parsed)
        if tracker is None:
            self.return_error(401, 'activity_id {} is not known'.format(parsed['activity_id']))
            return updated
        tracker.update(self.__points(parsed['points']))
        if tracker.done:
            self.return_error(401, 'update_activity: {} was already stopped'.format(tracker))
            return updated
        return updated

    def xml_stop_activity(self, parsed) ->str:  # pylint: disable=unused-argument
        """Client says stop.

        Returns:
            Our answer

        """
        trackers = [self.use_tracker(parsed)]
        if trackers == [None]:
            trackers = [x for x in self.server.trackers.values() if not x.done]
        for tracker in trackers:
            if tracker.done:
                self.return_error(401, 'stop_activity has already been called for {}'.format(tracker))
            else:
                tracker.end()
        return '<type>activity_stopped</type>'


class LifeServerMMT(HTTPServer):

    """A simple MMT server for life tracking.

    Currently supports only one logged in connection.

    This is not ready for production usage, several important
    parts are still unimplemented.

    Attributes:
        trackers: A dict of all Lifetrack instances, key is the id_in_backend (activity_id).
            No tracker is ever removed. At least for now. Change that some time after
            Oruxmaps got a fix for not doing update_activity after stop_activity.

    """

    def __init__(self, options, logger):
        """See class docstring."""
        super(LifeServerMMT, self).__init__((options.servername, options.port), MMTHandler)
        self.gpxdo_options = options
        self.logger = logger
        # define the directory in auth.cfg, using the Url=value
        self.server_directory = ServerDirectory(url=options.directory)
        self.start_second = datetime.datetime.now().timestamp()
        if options.mailto:
            auth = {
                'from': 'gpxity',
                'port': options.smtp_port,
                'Url': options.mailto}
            self.mailer = Mailer(auth=auth)
            self.mailer.min_interval = options.mail_interval
        else:
            self.mailer = None
        self.trackers = dict()

    def second(self):
        return '{:10.4f}'.format(datetime.datetime.now().timestamp() - self.start_second)

class Main:  # pylint: disable=too-few-public-methods

    """main."""

    def __init__(self):
        """See class docstring."""
        parser = argparse.ArgumentParser('gpxity_server')
        parser.add_argument(
            '--directory', required=True,
            help='Lookup the name of the server track directory in .config/Gpxity/auth.cfg')
        parser.add_argument('--servername', help='the name of this server', required=True)
        parser.add_argument('--port', help='listen on PORT', type=int, required=True)
        parser.add_argument('--smtp-port', help='PORT for mailer', type=int, default=25)
        parser.add_argument('--mailto', help='mail new tracks to MAILTO', default=None)
        parser.add_argument('--mail-interval', help='only mail every X seconds, default is 1800', type=int, default=1800)
        parser.add_argument('--loglevel', help='set the loglevel', choices=('debug', 'info', 'warning', 'error'), default='error')
        parser.add_argument('--timeout', help="""
            Timeout: Either one value in seconds or two comma separated values: The first one is the connection
            timeout, the second one is the read timeout. Default is to wait forever.""", type=str, default=None)

        try:
            argcomplete.autocomplete(parser)
        except NameError:
            pass

        options = parser.parse_args()

        logger = logging.getLogger()
        logger.setLevel(options.loglevel.upper())
        logfile = logging.FileHandler(os.path.join(options.directory,'gpxity_server.log'))
        logfile.setLevel(logging.DEBUG)
        logger.addHandler(logfile)
        logging.getLogger('urllib3').level = logging.ERROR

        LifeServerMMT(options, logger).serve_forever()

Main()
