# -*- coding: utf-8 -*-
# vim: ts=2 sw=2 et ai
###############################################################################
# Copyright (c) 2012,2021 Andreas Vogel andreas@wellenvogel.net
#
#  Permission is hereby granted, free of charge, to any person obtaining a
#  copy of this software and associated documentation files (the "Software"),
#  to deal in the Software without restriction, including without limitation
#  the rights to use, copy, modify, merge, publish, distribute, sublicense,
#  and/or sell copies of the Software, and to permit persons to whom the
#  Software is furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included
#  in all copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
#  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
#  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
#  FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
#  DEALINGS IN THE SOFTWARE.
#
#  parts from this software (AIS decoding) are taken from the gpsd project
#  so refer to this BSD licencse also (see ais.py) or omit ais.py 
###############################################################################
import glob
import io
import itertools
import json
import posixpath
import shutil
import urllib.parse

import ctypes
import logging
import logging.handlers
import os
import re
import subprocess
import sys
import time
import traceback

import datetime
import math
import threading
from http.server import SimpleHTTPRequestHandler
from math import copysign
import zipfile
from os import path
from typing import Any


class Enum(set):
    def __getattr__(self, name):
        if name in self:
            return name
        raise AttributeError


class AvNavFormatter(logging.Formatter):

    def format(self, record):
        record.avthread = AVNLog.getThreadId()
        return super().format(record)


class LogFilter(logging.Filter):
    def __init__(self, filter):
        super().__init__()
        self.filterText = filter
        self.filterre = re.compile(filter, re.I)

    def filter(self, record):
        if (self.filterre.search(record.msg)):
            return True
        if (self.filterre.search(record.threadName)):
            return True
        for arg in record.args:
            if (self.filterre.search(str(arg))):
                return True
        return False


class AVNLog(object):
    logger = logging.getLogger('avn')
    formatter = AvNavFormatter("%(asctime)s-%(process)d-%(avthread)s-%(threadName)s-%(levelname)s-%(message)s")
    consoleHandler = None
    consoleErrorHandler = logging.StreamHandler()
    fhandler = None
    debugToFile = False
    logDir = None
    SYS_gettid = 224
    hasNativeTid = False
    tempSequence = 0
    configuredLevel = 0
    consoleOff = False

    @classmethod
    def getSyscallId(cls):
        if hasattr(threading, 'get_native_id'):
            cls.hasNativeTid = True
            # newer python versions
            return
        if sys.platform == 'win32':
            return
        try:
            lines = subprocess.check_output("echo SYS_gettid | cc -include sys/syscall.h -E - ", shell=True)
            id = None
            for line in lines.splitlines():
                line = line.decode('utf-8', errors='ignore')
                line = line.rstrip()
                line = re.sub('#.*', '', line)
                if re.match('^ *$', line):
                    continue
                id = eval(line)
                if type(id) is int:
                    cls.SYS_gettid = id
                    break
        except:
            pass

    # 1st step init of logging - create a console handler
    # will be removed after parsing the cfg file
    @classmethod
    def initLoggingInitial(cls, level):
        cls.getSyscallId()
        try:
            numeric_level = level + 0
        except:
            numeric_level = getattr(logging, level.upper(), None)
            if not isinstance(numeric_level, int):
                raise ValueError('Invalid log level: %s' % level)
        cls.consoleHandler = logging.StreamHandler()
        cls.consoleHandler.setFormatter(cls.formatter)
        cls.consoleErrorHandler.setLevel(logging.INFO)
        cls.consoleErrorHandler.setFormatter(cls.formatter)
        cls.logger.propagate = False
        cls.logger.addHandler(cls.consoleHandler)
        cls.logger.setLevel(numeric_level)
        cls.filter = None
        cls.configuredLevel = numeric_level

    @classmethod
    def levelToNumeric(cls, level):
        try:
            numeric_level = int(level) + 0
        except:
            numeric_level = getattr(logging, level.upper(), None)
            if not isinstance(numeric_level, int):
                raise ValueError('Invalid log level: %s' % level)
        return numeric_level

    @classmethod
    def initLoggingSecond(cls, level, filename, debugToFile=False, consoleOff=False):
        numeric_level = level
        formatter = AvNavFormatter("%(asctime)s-%(process)d-%(avthread)s-%(threadName)s-%(levelname)s-%(message)s")
        cls.consoleOff = consoleOff
        if not cls.consoleHandler is None:
            cls.consoleHandler.setLevel(numeric_level if not consoleOff else logging.CRITICAL + 1)
        version = "2.7"
        try:
            version = sys.version.split(" ")[0][0:3]
        except:
            pass
        oldFiles = glob.glob("%s.[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]" % filename)
        for of in oldFiles:
            os.remove(of)
        if version != '2.6':
            # log files: 10M, 10 old files -> 110MB
            cls.fhandler = logging.handlers.RotatingFileHandler(filename=filename, maxBytes=10 * 1024 * 1024,
                                                                backupCount=10, delay=True)
            cls.fhandler.setFormatter(formatter)
            flevel = numeric_level
            if flevel < logging.DEBUG and not debugToFile:
                flevel = logging.INFO
            cls.fhandler.setLevel(flevel)
            cls.fhandler.doRollover()
            cls.logger.addHandler(cls.fhandler)
        cls.logger.setLevel(numeric_level)
        cls.debugToFile = debugToFile
        cls.logDir = os.path.dirname(filename)
        cls.configuredLevel = numeric_level

    @classmethod
    def setLogLevel(cls, level):
        if cls.consoleHandler and not cls.consoleOff:
            cls.consoleHandler.setLevel(level)
        if cls.fhandler:
            flevel = level
            if flevel < logging.DEBUG and not cls.debugToFile:
                flevel = logging.INFO
            cls.fhandler.setLevel(flevel)
        cls.logger.setLevel(level)

    @classmethod
    def resetRun(cls, sequence, timeout):
        start = time.time()
        while sequence == cls.tempSequence:
            time.sleep(0.5)
            now = time.time()
            if sequence != cls.tempSequence:
                return
            if now < start or now >= (start + timeout):
                cls.logger.info("resetting loglevel to %s", str(cls.configuredLevel))
                cls.logger.setLevel(cls.configuredLevel)
                cls.setFilter(None)
                if not cls.consoleHandler is None:
                    cls.consoleHandler.setLevel(cls.configuredLevel)
                if cls.debugToFile:
                    if cls.fhandler is not None:
                        cls.fhandler.setLevel(cls.configuredLevel)
                return

    @classmethod
    def getCurrentLevelAndFilter(cls):
        return (logging.getLevelName(cls.logger.getEffectiveLevel()),
                cls.filter.filterText if cls.filter is not None else '')

    @classmethod
    def startResetThread(cls, timeout):
        cls.tempSequence += 1
        sequence = cls.tempSequence
        thread = threading.Thread(target=cls.resetRun, args=(sequence, timeout))
        thread.setDaemon(True)
        thread.start()

    @classmethod
    def changeLogLevelAndFilter(cls, level, filter, timeout=None):
        try:
            numeric_level = cls.levelToNumeric(level)
            oldlevel = None
            if not cls.logger.getEffectiveLevel() == numeric_level:
                oldlevel = cls.logger.getEffectiveLevel()
                cls.logger.setLevel(numeric_level)
            if not cls.consoleHandler is None:
                cls.consoleHandler.setLevel(numeric_level)
            if cls.debugToFile:
                if cls.fhandler is not None:
                    cls.fhandler.setLevel(numeric_level)
                pass
            cls.setFilter(filter)
            if timeout is not None:
                cls.startResetThread(timeout)
            return True
        except:
            return False

    @classmethod
    def setFilter(cls, filter):
        oldFilter = cls.filter
        if cls.filter is not None:
            cls.consoleHandler.removeFilter(cls.filter)
            if cls.fhandler is not None:
                cls.fhandler.removeFilter(cls.filter)
            cls.filter = None
        if filter is None:
            return oldFilter
        cls.filter = LogFilter(filter)
        cls.consoleHandler.addFilter(cls.filter)
        if cls.fhandler is not None:
            cls.fhandler.addFilter(cls.filter)
        return oldFilter

    @classmethod
    def debug(cls, str, *args, **kwargs):
        cls.logger.debug(str, *args, **kwargs)

    @classmethod
    def warn(cls, str, *args, **kwargs):
        cls.logger.warn(str, *args, **kwargs)

    @classmethod
    def info(cls, str, *args, **kwargs):
        cls.logger.info(str, *args, **kwargs)

    @classmethod
    def error(cls, str, *args, **kwargs):
        cls.logger.error(str, *args, **kwargs)

    @classmethod
    def errorOut(cls, str, *args, **kwargs):
        logger = logging.getLogger('avnout')
        if cls.fhandler is not None:
            logger.addHandler(cls.fhandler)
        if cls.consoleErrorHandler is not None:
            logger.addHandler(cls.consoleErrorHandler)
        logger.error(str, *args, **kwargs)

    @classmethod
    def ld(cls, *parms):
        cls.logger.debug(' '.join(map(repr, parms)))

    @classmethod
    def getLogDir(cls):
        return cls.logDir

    # some hack to get the current thread ID
    # basically the constant to search for was
    # __NR_gettid - __NR_SYSCALL_BASE+224
    # taken from http://blog.devork.be/2010/09/finding-linux-thread-id-from-within.html
    # this definitely only works on the raspberry - but on other systems the info is not that important...
    @classmethod
    def getThreadId(cls):
        try:
            if cls.hasNativeTid:
                return str(threading.get_native_id())
        except:
            pass
        if sys.platform == 'win32':
            return "0"
        try:
            libc = ctypes.cdll.LoadLibrary('libc.so.6')
            tid = libc.syscall(cls.SYS_gettid)
            return str(tid)
        except:
            return "0"


class AVNUtil(object):
    NAVXML = "avnav.xml"
    NM = 1852.0  # convert nm into m
    R = 6371000  # earth radius in m
    NMEA_SERVICE = "_nmea-0183._tcp"  # avahi service for NMEA

    # convert a datetime UTC to a timestamp in seconds
    @classmethod
    def datetimeToTsUTC(cls, dt):
        if dt is None:
            return None
        # subtract the EPOCH
        td = (dt - datetime.datetime(1970, 1, 1, tzinfo=None))
        ts = ((td.days * 24 * 3600 + td.seconds) * 10 ** 6 + td.microseconds) / 1e6
        return ts

    # timedelta total_seconds that is not available in 2.6
    @classmethod
    def total_seconds(cls, td):
        return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10 ** 6) / 10 ** 6

    # now timestamp in utc
    @classmethod
    def utcnow(cls):
        return cls.datetimeToTsUTC(datetime.datetime.utcnow())

    @classmethod
    def utctomonotonic(cls, utcts):
        mn = time.monotonic()
        un = cls.utcnow()
        diff = mn - un
        return utcts + diff

    # check if a given position is within a bounding box
    # all in WGS84
    # ll parameters being tuples lat,lon
    # currently passing 180 is not handled...
    # lowerleft: smaller lat,lot
    @classmethod
    def inBox(cls, pos, lowerleft, upperright):
        if pos[0] < lowerleft[0]:
            return False
        if pos[1] < lowerleft[1]:
            return False
        if pos[0] > upperright[1]:
            return False
        if pos[1] > upperright[1]:
            return False
        return True

    # Haversine formula example in Python
    # Author: Wayne Dyck
    # distance in M
    @classmethod
    def distanceM(cls, origin, destination):
        lat1, lon1 = origin
        lat2, lon2 = destination

        dlat = math.radians(lat2 - lat1)
        dlon = math.radians(lon2 - lon1)
        a = math.sin(dlat / 2) * math.sin(dlat / 2) + math.cos(math.radians(lat1)) \
            * math.cos(math.radians(lat2)) * math.sin(dlon / 2) * math.sin(dlon / 2)
        c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
        d = (cls.R * c)
        return d

    @classmethod
    def distanceRhumbLineM(cls, origin, destination):
        lat1, lon1 = origin
        lat2, lon2 = destination
        lat1r = math.radians(lat1)
        lat2r = math.radians(lat2)
        dlatr = lat2r - lat1r
        dlonr = math.radians(math.fabs(lon2 - lon1))

        # if dLon over 180° take shorter rhumb line across the anti-meridian:
        if (math.fabs(dlonr) > math.pi):
            dlonr = -(2 * math.pi - dlonr) if dlonr > 0 else (2 * math.pi + dlonr)

        # on Mercator projection, longitude distances shrink
        # by latitude
        # q is the 'stretch factor'
        # q becomes ill - conditioned along E - W line(0 / 0)
        # use empirical tolerance to avoid it(note ε is too small)
        dcorr = math.log(math.tan(lat2r / 2 + math.pi / 4) / math.tan(lat1r / 2 + math.pi / 4))
        q = dlatr / dcorr if math.fabs(dcorr) > 10e-12 else math.cos(lat1r)

        # distance is pythagoras on 'stretched' Mercator projection, √(Δφ² + q²·Δλ²)
        d = math.sqrt(dlatr * dlatr + q * q * dlonr * dlonr)  # angular distance in radians
        d = d * cls.R
        return d

    # distance in NM
    @classmethod
    def distance(cls, origin, destination):
        rt = cls.distanceM(origin, destination)
        return rt / float(cls.NM)

    # XTE - originally from Dirk HH, crosschecked against
    # http://www.movable-type.co.uk/scripts/latlong.html
    # points are always tuples lat,lon
    @classmethod
    def calcXTE(cls, Pp, startWp, endWp):
        d13 = cls.distanceM(startWp, Pp)
        w13 = cls.calcBearing(startWp, Pp)
        w12 = cls.calcBearing(startWp, endWp)
        return math.asin(math.sin(d13 / cls.R) * math.sin(math.radians(w13) - math.radians(w12))) * cls.R

    @classmethod
    def calcXTERumbLine(cls, Pp, startWp, endWp):
        dstFromBrg = cls.calcBearingRhumbLine(endWp, startWp)
        dstCurBrg = cls.calcBearingRhumbLine(endWp, Pp)
        dstCurDst = cls.distanceRhumbLineM(endWp, Pp)
        alpha = dstFromBrg - dstCurBrg
        return dstCurDst * math.sin(math.radians(alpha))

    # bearing from one point the next originally by DirkHH
    # http://www.movable-type.co.uk/scripts/latlong.html
    @classmethod
    def calcBearing(cls, curP, endP):
        clat, clon = curP
        elat, elon = endP
        y = math.sin(math.radians(elon) - math.radians(clon)) * math.cos(math.radians(elat))
        x = math.cos(math.radians(clat)) * math.sin(math.radians(elat)) - \
            math.sin(math.radians(clat)) * math.cos(math.radians(elat)) * math.cos(
            math.radians(elon) - math.radians(clon))
        return ((math.atan2(y, x) * 180 / math.pi) + 360) % 360.0

    @classmethod
    def calcBearingRhumbLine(cls, curP, endP):
        clat, clon = curP
        elat, elon = endP
        clatr = math.radians(clat)
        elatr = math.radians(elat)
        dlonr = math.radians(elon - clon)
        # if dLon over 180° take shorter rhumb line across the anti-meridian:
        if math.fabs(dlonr) > math.pi:
            dlonr = -(2 * math.pi - dlonr) if dlonr > 0 else (2 * math.pi + dlonr)

        corr = math.log(math.tan(elatr / 2 + math.pi / 4) / math.tan(clatr / 2 + math.pi / 4))
        brg = math.atan2(dlonr, corr)
        brg = math.degrees(brg)
        return (brg + 360) % 360.0

    @classmethod
    def deg2rad(cls, v):
        if v is None:
            return v
        return v * math.pi / 180.0

    @classmethod
    def rad2deg(cls, value):
        '''
        format rad to deg, additionally mapping angles < 0 to 360-angle
        @param value:
        @return:
        '''
        if value is None:
            return None
        rt = value * 180 / math.pi
        if rt < 0:
            rt = 360 + rt
        return rt

    ais_converters = {
        "mmsi": str,
        "imo_id": int,
        "shiptype": int,
        "type": int,
        "epfd": int,
        "status": int,
        "month": int,  # ETA
        "day": int,  # ETA
        "hour": int,  # ETA
        "minute": int,  # ETA
        "second": int,  # timestamp
        "maneuver": int,
        "accuracy": int,
        "lat": lambda v: float(v) / 600000,
        "lon": lambda v: float(v) / 600000,
        "speed": lambda v: float(v) / 10 * AVNUtil.NM / 3600,
        "course": lambda v: float(v) / 10,
        "heading": int,
        "turn": lambda v: round(copysign((float(v) / 4.733) ** 2, float(v)))
        if abs(float(v)) < 128
        else None,
        "draught": lambda v: float(v) / 10,
        "to_bow": int,  # A
        "to_stern": int,  # B
        "to_port": int,  # C
        "to_starboard": int,  # D
    }

    @classmethod
    def convertAIS(cls, aisdata):
        "convert ais raw values to real values"
        rt = aisdata.copy()

        for k, f in cls.ais_converters.items():
            if k not in rt:  # only convert data that's actually present
                continue
            try:
                rt[k] = f(rt[k])
            except:
                rt[k] = None  # explicitly map invalid data to none

        try:
            rt["beam"] = rt["to_port"] + rt["to_starboard"]
            rt["length"] = rt["to_bow"] + rt["to_stern"]
        except:
            pass

        return rt

    # parse an ISO8601 t8ime string
    # see http://stackoverflow.com/questions/127803/how-to-parse-iso-formatted-date-in-python
    # a bit limited to what we write into track files or what GPSD sends us
    # returns a datetime object
    @classmethod
    def gt(cls, dt_str):
        dt, delim, us = dt_str.partition(".")
        if delim is None or delim == '':
            dt = dt.rstrip("Z")
        dt = datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S")
        if not us is None and us != "":
            us = int(us.rstrip("Z"), 10)
        else:
            us = 0
        return dt + datetime.timedelta(microseconds=us)

    # return a regex to be used to check for NMEA data
    @classmethod
    def getNMEACheck(cls):
        return re.compile("[!$][A-Z][A-Z][A-Z][A-Z]")

    # run an external command and and log the output
    # param - the command as to be given to subprocess.Popen
    @classmethod
    def runCommand(cls, param, threadName=None, timeout=None):
        try:
            cmd = subprocess.Popen(param, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=True)
            reader = threading.Thread(target=cls.cmdOut, args=[cmd.stdout], daemon=True)
            if threadName is not None:
                reader.setName(threadName or '')
            reader.start()
            start = time.monotonic()
            while True:
                rt = cmd.poll()
                if rt is not None:
                    return rt
                time.sleep(0.1)
                if timeout is None:
                    continue
                if (start + timeout) < time.monotonic():
                    AVNLog.error("timeout %f reached for command %s", timeout, " ".join(param))
                    cmd.kill()
                    time.sleep(0.1)
                    cmd.poll()
                    return -255
        except Exception as e:
            AVNLog.error("unable to start command %s:%s", " ".join(param), str(e))
            return -255

    @classmethod
    def cmdOut(cls, stream):
        AVNLog.debug("cmd reading started")
        while True:
            line = stream.readline()
            if not line:
                break
            AVNLog.debug("[cmd]%s", line.strip())
        AVNLog.debug("cmd reading finished")

    @classmethod
    def getHttpRequestParam(cls, requestparam, name, mantadory=False):
        if not requestparam:
            return None
        rt = requestparam.get(name)
        if rt is None:
            if mantadory:
                raise Exception("missing parameter %s" % name)
            return None
        if isinstance(rt, list):
            return rt[0]
        return rt

    @classmethod
    def getHttpRequestFlag(cls, requestparam, name, default=False, mantadory=False):
        flagString = cls.getHttpRequestParam(requestparam, name, mantadory)
        if flagString is None:
            return default
        return flagString.lower() == 'true'

    @classmethod
    def getReturnData(cls, error=None, **kwargs):
        if error is not None:
            rt = {'status': error}
        else:
            rt = {'status': 'OK'}
        for k in list(kwargs.keys()):
            if kwargs[k] is not None:
                rt[k] = kwargs[k]
        return rt

    @classmethod
    def replaceParam(cls, instr, param):
        if instr is None:
            return instr
        if param is None:
            return instr
        for k in list(param.keys()):
            instr = instr.replace("$" + k, param.get(k))
        return instr

    @classmethod
    def prependBase(cls, path, base):
        if path is None:
            return path
        if os.path.isabs(path):
            return path
        if base is None:
            return path
        if path.startswith(base):
            return path
        return os.path.join(base, path)

    @classmethod
    def getDirWithDefault(cls, parameters, name, defaultSub, belowData=True):
        value = parameters.get(name)
        if value is not None:
            if not isinstance(value, str):
                value = str(value, errors='ignore')

    @classmethod
    def clean_filename(cls, filename):
        replace = re.compile(r'[\u0000-\u001f\u007f"*/:<>?\\\|]')
        if filename is None:
            return None
        rt = replace.sub('', filename)
        if rt == '.' or rt == '..':
            return ''
        return rt

    @classmethod
    def pathQueryFromUrl(cls, url):
        (path, sep, query) = url.partition('?')
        path = path.split('#', 1)[0]
        path = urllib.parse.unquote(path)
        return (path, query)

    @classmethod
    def getBool(cls, v, default=False):
        if v is None:
            return default
        if type(v) is str:
            return v.upper() == 'TRUE'
        return v


class ChartFile(object):
    def __init__(self):
        self._hasImporterLog = False

    def wakeUp(self):
        pass

    def getScheme(self):
        return None

    def close(self):
        pass

    def open(self):
        pass

    def changeScheme(self, schema, createOverview=True):
        raise Exception("not supported")

    def getChangeCount(self):
        return 0

    def getOriginalScheme(self):
        '''
        just return a schema if the user did not set it but it was found in the chart
        @return:
        '''
        return None

    def getAvnavXml(self):
        return None

    def setHasImporterLog(self, flag):
        self._hasImporterLog = flag

    def deleteFiles(self):
        pass

    def mergeAdditions(self, item: dict[str, Any]):
        item['hasImporterLog'] = self._hasImporterLog
        item['sequence'] = self.getChangeCount()
        item['scheme'] = self.getScheme()
        item['originalScheme'] = self.getOriginalScheme()

def plainUrlToPath(path:str)->str:
    path = re.sub('//*', '/', path)
    path = re.sub('^/*', '', path)
    parts = path.split('/')
    for part in parts:
        if AVNUtil.clean_filename(part) != part:
            raise Exception("invalid characters in path " + part)
    return posixpath.normpath(path)

class AVNDownload(object):
    '''
    generic response class for our HTTP request handling
    all HTTP requests will return instances of this class
    depending on the type of object they will adapt headers,
    encoding, seeking
    '''
    HEADERS = {
        'Cache-Control': 'no-store',
    }
    HEADERS_RANGE={
        'Accept-Ranges': 'bytes',
    }

    def __init__(self, size=None, stream=None, mimeType=None, mtime=None, dlname=None):
        self.size = size
        self.stream = stream
        self.mimeType = mimeType
        self.dlname = dlname
        self.mtime = None
        self.mtime = mtime
        self.responseCode=200

    def setError(self,code,error:str):
        error = error.encode(encoding='utf-8', errors="ignore")
        self.responseCode=code
        self.size=len(error)
        if self.stream:
            try:
                self.stream.close()
            except:
                pass
        self.stream = io.BytesIO(error)
        self.stream.seek(0)
        self.dlname=None
        self.mimeType="text/plain"

    def getSize(self):
        return self.size


    def getStream(self, noopen=False):
        return self.stream


    def getMimeType(self, handler=None):
        return self.mimeType

    def getResponseCode(self):
        return self.responseCode

    def getLastModified(self):
        return self.mtime
    def getHeaders(self):
        if self.canSeek():
            return dict(self.HEADERS,**self.HEADERS_RANGE)
        return self.HEADERS
    def canSeek(self):
        return False
    def passThru(self):
        return False
    def writeStream(self,fh,outfile,chunked=False):
        maxread = 1000000
        numread = 0
        maxlen=self.getSize() if not chunked else None
        while True:
            toread = maxread
            if maxlen is not None:
                if (maxlen - numread) > maxread:
                    toread = maxlen - numread
            buf = fh.read(toread) if toread else None
            if buf is None or len(buf) == 0:
                if chunked:
                    outfile.write(b'0\r\n\r\n')
                return
            l = len(buf)
            numread += l
            if chunked:
                outfile.write('{:X}\r\n'.format(l).encode('utf-8'))
                outfile.write(buf)
                outfile.write(b'\r\n')
            else:
                outfile.write(buf)
    def buildFinalHeaders(self,handler,filename=None,noattach:bool=False):
        rs=self.getHeaders().copy()
        code = self.getResponseCode()
        rs["Content-type"]=self.getMimeType(handler)
        if filename is None:
            filename = self.dlname
        if not noattach and filename is not None and 200 <= code < 300:
            rs["Content-Disposition"]= "attachment; %s" % self.fileToAttach(filename)
        mtime = self.getLastModified()
        if mtime is not None:
            rs["Last-Modified"]=handler.date_time_string(mtime)
        size = self.getSize()
        if size is not None:
            rs["Content-Length"]= size
        else:
            rs['Transfer-Encoding']='chunked'
        return rs
    def writeOut(self, handler: SimpleHTTPRequestHandler,
                 filename=None,
                 noattach: bool = False,
                 sendbody=True,
                 start=None,
                 end=None):
        stream=self.getStream(noopen=not sendbody)
        if sendbody and stream is None:
            self.setError(500,"no data in response")
        addHeaders = None
        if (start is not None or end is not None) and not self.passThru():
            try:
                if not self.canSeek() or self.size is None:
                    raise Exception("stream cannot seek")
                else:
                    starti=0
                    endi=self.size-1
                    if start is not None and start != '':
                        starti=int(start)
                        if starti < 0 or starti >= self.size:
                            raise Exception("invalid start")
                    if end is not None and end != '':
                        endi=int(end)
                        if endi < 0 or endi >= self.size or endi < starti:
                            raise Exception("invalid end")
                    reached=self.stream.seek(starti)
                    if reached != starti:
                        raise Exception("could not seek start")
                    self.responseCode=206
                    addHeaders={"Content-Range":f"bytes {starti}-{endi}/{self.size}"}
                    self.size = endi - starti + 1
            except Exception as e:
                self.setError(416,str(e))
                stream=self.stream
        code=self.getResponseCode()
        handler.send_response(code)
        headers = self.buildFinalHeaders(handler, filename, noattach=noattach)
        if addHeaders:
            headers.update(addHeaders)
        size = self.getSize()
        for k, v in headers.items():
            handler.send_header(k, v)
        handler.end_headers()
        if sendbody:
            if (size is None) and not self.passThru():
                self.writeStream(stream, handler.wfile,chunked=True)
            else:
                self.writeStream(stream,handler.wfile)
            stream.close()
        else:
            if stream:
                stream.close()

    def fileToAttach(self, filename):
        # see https://stackoverflow.com/questions/93551/how-to-encode-the-filename-parameter-of-content-disposition-header-in-http
        return 'filename="%s"; filename*=utf-8\'\'%s' % (filename, urllib.parse.quote(filename))


class AVNFileDownload(AVNDownload):
    def __init__(self,filename,dlname=None,mimeType=None):
        super().__init__(dlname=dlname,mimeType=mimeType)
        stat=os.stat(filename)
        self.size=stat.st_size
        self.mtime=stat.st_mtime
        self.filename=filename

    def getMimeType(self, handler=None):
        rt=super().getMimeType(handler)
        if rt is not None:
            return rt
        if handler is None:
            self.mimeType="application/octet-stream"
        else:
            self.mimeType=handler.guess_type(self.filename)
        return self.mimeType

    def _openStream(self):
        return open(self.filename, 'rb')

    def getStream(self, noopen=False):
        if noopen or self.stream is not None:
            return self.stream
        self.stream=self._openStream()
        return self.stream

    def canSeek(self):
        return True


class AVNFileDownloadLB(AVNFileDownload):
    def __init__(self,filename,lastBytes,dlname=None,mimeType=None):
        super().__init__(filename,mimeType=mimeType,dlname=dlname)
        self.lastBytes=int(lastBytes)

    def _openStream(self):
        stream=super()._openStream()
        if not stream:
            return
        if self.lastBytes >= 0 and self.lastBytes <= self.size:
            seekv=self.size-self.lastBytes
            if seekv > 0:
                stream.seek(seekv)
                self.size=self.size-seekv
        return stream

    def canSeek(self):
        return False


class AVNStreamDownload(AVNDownload):
    def __init__(self,stream,size=None,dlname=None,mimeType=None,mtime=None):
        super().__init__(size=size,stream=stream,dlname=dlname,mimeType=mimeType,mtime=mtime)

class AVNProxyDownload(AVNDownload):
    def __init__(self, code, headers, stream,userData=None):
        super().__init__(stream=stream)
        self.headers=headers
        self.responseCode=code
        self.userData=userData

    def canSeek(self):
        return False

    def passThru(self):
        return True

    def buildFinalHeaders(self, handler, filename=None, noattach: bool = False):
        return self.headers


class AVNDownloadError(AVNDownload):
    def __init__(self, code,error: str):
        super().__init__(None)
        self.setError(code,error)

class AVNDataDownload(AVNDownload):
    def __init__(self,data,mimeType:str):
        super().__init__(None,mimeType=mimeType)
        self.size=len(data)
        self.stream=io.BytesIO(data)
        self.stream.seek(0)

class AVNStringDownload(AVNDataDownload):
    def __init__(self,string:str,mimeType=None):
        encoded = string.encode(encoding='utf-8', errors="ignore")
        super().__init__(encoded,mimeType or "text/plain")

class MultiReadStream:
    def __init__(self,prefix,stream,suffix):
        self.streams=[]
        ps=io.BytesIO(prefix)
        ps.seek(0)
        self.streams.append(ps)
        self.streams.append(stream)
        st=io.BytesIO(suffix)
        st.seek(0)
        self.streams.append(st)
        self.index=0
    def close(self):
        self.index=len(self.streams)+1
    def read(self,rlen=None):
        while self.index < len(self.streams):
            rt=self.streams[self.index].read(rlen)
            if rt is None or len(rt)==0:
                self.index+=1
            else:
                return rt
        return None


class AVNJsDownload(AVNFileDownload):
    def __init__(self, filename, baseUrl, addCode=None):
        super().__init__(filename)
        self.mimeType="text/javascript"
        base = urllib.parse.quote(baseUrl)
        self.PREFIX = (f'''
            try{{
            let handler = {{
                get(target, key, descriptor) {{
                  if (key != 'avnav') return target[key];
                  return {{
                    api:target.avnavLegacy
                  }}
                }}
            }};
            let proxyWindow = new Proxy(window, handler);
            (function(window,avnav){{
             let AVNAV_BASE_URL="{base}";
            ''').encode('utf-8')
        self.SUFFIX=(f'''
            }}.bind(proxyWindow,proxyWindow).call(proxyWindow,{{api:window.avnavLegacy}}));
            }}catch(e){{
            window.avnavLegacy.showToast(e.message+"\\n"+(e.stack||e));
            }}
            ''').encode('utf-8')
        self.addCode=b''
        if addCode is not None:
            self.addCode=addCode.encode('utf-8')

    def canSeek(self):
        return False

    def getSize(self):
        return super().getSize()+len(self.PREFIX)+len(self.SUFFIX)+len(self.addCode)

    def _openStream(self):
        fs= super()._openStream()
        return MultiReadStream(self.PREFIX+self.addCode,fs,self.SUFFIX)



class Encoder(json.JSONEncoder):
  '''
  allow our objects to have a "serialize" method
  to make them json encodable in a generic manner
  '''
  def default(self, o):
    if hasattr(o,'serialize'):
      return o.serialize()
    return super(Encoder, self).default(o)

class AVNJsonDownload(AVNStringDownload):
    def __init__(self,data):
        super().__init__(json.dumps(data,cls=Encoder),mimeType="application/json")

class AVNZipDownload(AVNDownload):
    '''
    see https://github.com/pR0Ps/zipstream-ng
    and https://stackoverflow.com/questions/6657820/how-to-convert-an-iterable-to-a-stream
    '''

    def zipGenerator(self, path, prefix=None, filter=None):
        class WStream(io.RawIOBase):
            """An unseekable stream for the ZipFile to write to"""

            def __init__(self):
                self._buffer = bytearray()
                self._closed = False

            def close(self):
                self._closed = True

            def write(self, b):
                if self._closed:
                    raise ValueError("Can't write to a closed stream")
                self._buffer += b
                return len(b)

            def hasData(self):
                return len(self._buffer) > 0

            def readall(self):
                chunk = bytes(self._buffer)
                self._buffer.clear()
                return chunk

        def iter_files(path):
            for dirpath, _, files in os.walk(path, followlinks=True):
                if not files:
                    if filter is not None and not filter(dirpath):
                        return
                    yield dirpath  # Preserve empty directories
                for f in files:
                    fn = os.path.join(dirpath, f)
                    if filter is not None and not filter(fn):
                        continue
                    yield fn

        def read_file(path):
            with open(path, "rb") as fp:
                while True:
                    buf = fp.read(1024 * 64)
                    if not buf:
                        break
                    yield buf

        stream = WStream()
        with zipfile.ZipFile(stream, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
            for f in iter_files(path):
                # Use the basename of the path to set the arcname
                rpath = os.path.relpath(f, path)
                arcname = os.path.join(prefix, rpath) if prefix is not None else rpath
                zinfo = zipfile.ZipInfo.from_file(f, arcname)
                zinfo.compress_type = zipfile.ZIP_DEFLATED

                # Write data to the zip file then yield the stream content
                with zf.open(zinfo, mode="w") as fp:
                    if zinfo.is_dir():
                        continue
                    for buf in read_file(f):
                        fp.write(buf)
                        if stream.hasData():
                            yield stream.readall()
                        else:
                            debug = 1
            zf.close()
        yield stream.readall()

    class IteratorStream(object):
        def __init__(self, iterable):
            self.iter = iter(iterable)

        def read(self, size):
            '''
            we just ignore the size
            to avoid double buffering
            @param size:
            @return:
            '''
            return next(self.iter, None)

        def close(self):
            pass

    def __init__(self, baseDir, prefix=None, filter=None):
        super().__init__(None)
        self.baseDir = baseDir
        self.prefix = prefix
        self.stream = None
        self.filter = filter

    def getSize(self):
        return None

    def getStream(self,noopen=False):
        if self.stream is None:
            if noopen:
                return
            self.stream = self.IteratorStream(self.zipGenerator(self.baseDir, self.prefix, filter=self.filter))
        return self.stream

    def getMimeType(self, handler=None):
        return "application/x-zip"


class MovingSum:
    def __init__(self, num=10):
        self._values = list(itertools.repeat(0, num))
        self._num = num
        self._sum = 0
        self._last = None
        self._idx = 0
        self._lastUpdate = None

    def clear(self):
        self._sum = 0
        self._idx = 0
        for i in range(0, self._num):
            self._values[i] = 0

    def num(self):
        return self._num

    def val(self):
        return self._sum

    def avg(self):
        if self._num <= 0:
            return 0
        return self._sum / self._num

    def add(self, v=0):
        now = int(time.monotonic())
        if self._last is None:
            self._last = now
        diff = now - self._last
        self._last = now
        rt = False
        if diff > 0:
            rt = True
            if diff > self._num:
                # fast empty
                self.clear()
            else:
                while diff > 0:
                    # fill intermediate seconds with 0
                    self._idx += 1
                    diff -= 1
                    if self._idx >= self._num:
                        self._idx = 0
                    self._sum -= self._values[self._idx]
                    self._values[self._idx] = 0
            self._values[self._idx] = v
            self._sum += v
        else:
            self._values[self._idx] += v
            self._sum += v
        return rt

    def shouldUpdate(self, iv=1):
        now = int(time.monotonic())
        if self._lastUpdate is None or (now - self._lastUpdate) >= iv:
            self._lastUpdate = now
            self.add(0)
            return True
        return False


if __name__ == '__main__':
    testsets = [
        {
            "name": 'q1',
            "waypoints": [
                {"lon": 13.46481754474968, "lat": 54.10810325512469, "name": "WP 1"},
                {"lon": 13.468166666666667, "lat": 54.11538104291324, "name": "WP 2"}
            ],
            "testpoints": [
                [54.111, 13.469],
                [54.11216666666667, 13.463666666666667],
                [54.10666666666667, 13.471333333333334],
                [54.10433333333334, 13.469],
                [54.117666666666665, 13.474],
                [54.112, 13.4665],
                [54.10816666666667, 13.464833333333333],
                [54.11183333333334, 13.466333333333333],
                [54.11533333333333, 13.468166666666667]
            ]
        },
        {
            "name": 'q2',
            "waypoints": [
                {"lon": 13.46481754474968, "lat": 54.10810325512469, "name": "WP 1"},
                {"lon": 13.472969266821604, "lat": 54.105126060954404, "name": "WP 2"}
            ],
            "testpoints": [
                [54.108333333333334, 13.469666666666667],
                [54.105666666666664, 13.466333333333333],
                [54.107, 13.468],
                [54.10583333333334, 13.478833333333334],
                [54.102333333333334, 13.474],
                [54.112, 13.464333333333334],
                [54.106833333333334, 13.460833333333333]
            ]
        },
        {
            "name": 'q3',
            "waypoints": [
                {"lon": 13.46481754474968, "lat": 54.10810325512469, "name": "WP 1"},
                {"lon": 13.458224892739876, "lat": 54.10384632131624, "name": "WP 2"}
            ],
            "testpoints": [
                [54.105333333333334, 13.463333333333333],
                [54.106833333333334, 13.460166666666666],
                [54.10166666666667, 13.459166666666667],
                [54.1045, 13.452166666666667],
                [54.108666666666664, 13.47],
                [54.111, 13.464],
                [54.106, 13.461500000000001]
            ]
        },
        {
            "name": 'q4',
            "waypoints": [
                {"lon": 13.464728465190955, "lat": 54.10854720253275, "name": "WP 1"},
                {"lon": 13.458937624792682, "lat": 54.11408311247243, "name": "WP 2"}
            ],
            "testpoints": [
                [54.11066666666667, 13.458833333333333],
                [54.11233333333333, 13.464],
                [54.114666666666665, 13.453333333333333],
                [54.11683333333333, 13.4595],
                [54.10583333333334, 13.463666666666667],
                [54.107166666666664, 13.472833333333334],
                [54.1115, 13.461666666666666]
            ]
        },
        {
            "name": 'cross',
            "waypoints": [
                {"lon": 13.464728465190955, "lat": 54.10854720253275, "name": "WP 1"},
                {"lon": 13.470786574851976, "lat": 54.103924671947794, "name": "WP 2"}
            ],
            "testpoints": [
                [54.1085, 13.4755]
            ]
        },
        {
            "name": 'long50',
            "waypoints": [
                {"lon": 14.14261292205835, "lat": 55.35112111609973, "name": "WP 1"},
                {"lon": 13.470786574851976, "lat": 54.103924671947794, "name": "WP 2"}
            ],
            "testpoints": [
                [54.4455, 13.608],
                [54.6215, 12.981666666666667]
            ],
            "percent": 10
        }
    ]
    for ts in testsets:
        print("Testset %s" % (ts['name']))
        p1 = [ts['waypoints'][0]['lat'], ts['waypoints'][0]['lon']]
        p2 = [ts['waypoints'][1]['lat'], ts['waypoints'][1]['lon']]
        print("Points: %s , %s" % (str(p1), str(p2)))
        dst = AVNUtil.distanceM(p1, p2)
        dstRl = AVNUtil.distanceRhumbLineM(p1, p2)
        print("dst=%f, dstRL=%f" % (dst, dstRl))
        brg = AVNUtil.calcBearing(p1, p2)
        brgRl = AVNUtil.calcBearingRhumbLine(p1, p2)
        print("brg=%f, brgRl=%f" % (brg, brgRl))
        tps = ts['testpoints']
        for tp in tps:
            xte = AVNUtil.calcXTE(tp, p1, p2)
            xteRl = AVNUtil.calcXTERumbLine(tp, p1, p2)
            print("latlon=%f,%f xte=%f, xteRl=%f" % (tp[0], tp[1], xte, xteRl))


