from __future__ import division, print_function


try:
    from psycopg2cffi import compat

    compat.register()
except ImportError:
    pass

import gc
import psycopg2
from twisted.internet.error import ReactorAlreadyInstalledError
from twisted.python import log
from txpostgres import reconnection
from txpostgres.txpostgres import Connection, ConnectionPool

try:
    from twisted.internet import epollreactor as reactor
except ImportError:
    from twisted.internet import kqreactor as reactor

try:
    reactor.install()
except ReactorAlreadyInstalledError:
    pass

from twisted.internet import defer
from txjsonrpc_ng.web.jsonrpc import with_request
from txjsonrpc_ng.web.data import CacheableResult

from zope.interface import Interface, implementer
from twisted.cred import portal, checkers, credentials, error as credential_error
from twisted.web import server
from twisted.application import service, internet
from twisted.python.log import ILogObserver, FileLogObserver
from twisted.python.logfile import DailyLogFile

from txjsonrpc_ng.web import jsonrpc

import os
import time
import datetime
import calendar
import pyproj
import statsd
import json
import collections

import platform
is_pypy = platform.python_implementation() == 'PyPy'

statsd_client = statsd.StatsClient('localhost', 8125, prefix='org.blitzortung.service')

import blitzortung.builder
import blitzortung.config
import blitzortung.cache
import blitzortung.data
import blitzortung.geom
import blitzortung.db
import blitzortung.db.mapper
import blitzortung.db.query
import blitzortung.db.query_builder
import blitzortung.service
from blitzortung.service.general import create_time_interval
from blitzortung.db.query import TimeInterval
from blitzortung.service.strike_grid import GridParameters


UTM_EU = pyproj.CRS('epsg:32633')  # UTM 33 N / WGS84
UTM_NORTH_AMERICA = pyproj.CRS('epsg:32614')  # UTM 14 N / WGS84
UTM_CENTRAL_AMERICA = pyproj.CRS('epsg:32614')  # UTM 14 N / WGS84
UTM_SOUTH_AMERICA = pyproj.CRS('epsg:32720')  # UTM 20 S / WGS84
UTM_OCEANIA = pyproj.CRS('epsg:32755')  # UTM 55 S / WGS84
UTM_ASIA = pyproj.CRS('epsg:32650')  # UTM 50 N / WGS84
UTM_AFRICA = pyproj.CRS('epsg:32633')  # UTM 33 N / WGS84
UTM_NORTH = pyproj.CRS('epsg:32631')  # UTM 31 N / WGS84
UTM_SOUTH = pyproj.CRS('epsg:32731')  # UTM 31 S / WGS84

FORBIDDEN_IPS = {}

def connection_factory(*args, **kwargs):
    kwargs['connection_factory'] = psycopg2.extras.DictConnection
    return psycopg2.connect(*args, **kwargs)


class LoggingDetector(reconnection.DeadConnectionDetector):
    def startReconnecting(self, f):
        print('[*] database connection is down (error: %r)' % f.value)
        return reconnection.DeadConnectionDetector.startReconnecting(self, f)

    def reconnect(self):
        print('[*] reconnecting...')
        return reconnection.DeadConnectionDetector.reconnect(self)

    def connectionRecovered(self):
        print('[*] connection recovered')
        return reconnection.DeadConnectionDetector.connectionRecovered(self)


class DictConnection(Connection):
    connectionFactory = staticmethod(connection_factory)

    def __init__(self, reactor=None, cooperator=None, detector=None):
        if not detector:
            detector = LoggingDetector()
        super(DictConnection, self).__init__(reactor, cooperator, detector)


class DictConnectionPool(ConnectionPool):
    connectionFactory = DictConnection

    def __init__(self, _ignored, *connargs, **connkw):
        super(DictConnectionPool, self).__init__(_ignored, *connargs, **connkw)


def create_connection_pool():
    config = blitzortung.config.config()
    db_connection_string = config.get_db_connection_string()

    created_connection_pool = DictConnectionPool(None, db_connection_string)

    d = created_connection_pool.start()
    d.addErrback(log.err)
    return created_connection_pool


@implementer(checkers.ICredentialsChecker)
class PasswordDictChecker(object):
    credentialInterfaces = (credentials.IUsernamePassword,)

    def __init__(self, passwords):
        self.passwords = passwords

    def requestAvatarId(self, credentials):
        username = credentials.username
        if username in self.passwords:
            if credentials.password == self.passwords[username]:
                return defer.succeed(username)
        return defer.fail(credential_error.Unauthorized("invalid username/password"))


class IUserAvatar(Interface):
    """ should have attribute username """


@implementer(IUserAvatar)
class UserAvatar(object):

    def __init__(self, username):
        self.username = username


@implementer(portal.IRealm)
class TestRealm(object):

    def __init__(self, users):
        self.users = users

    def requestAvatar(self, avatarId, mind, *interfaces):
        if IUserAvatar in interfaces:
            logout = lambda: None
            return (IUserAvatar,
                    UserAvatar(avatarId),
                    logout)
        else:
            raise KeyError('none of the requested interfaces is supported')


grid = {
    1: blitzortung.geom.GridFactory(-25, 57, 27, 72, UTM_EU),
    2: blitzortung.geom.GridFactory(110, 180, -50, 0, UTM_OCEANIA),
    3: blitzortung.geom.GridFactory(-140, -50, 10, 60, UTM_NORTH_AMERICA),
    4: blitzortung.geom.GridFactory(85, 150, -10, 60, UTM_ASIA),
    5: blitzortung.geom.GridFactory(-100, -30, -50, 20, UTM_SOUTH_AMERICA),
    6: blitzortung.geom.GridFactory(-20, 50, -40, 40, UTM_AFRICA),
    7: blitzortung.geom.GridFactory(-115, -50, 0, 30, UTM_CENTRAL_AMERICA)
}

global_grid = blitzortung.geom.GridFactory(-180, 180, -90, 90, UTM_EU, 11, 48)

USER_AGENT_PREFIX = 'bo-android-'
NOT_ALLOWED_RESPONSE = {'e': 'not_allowed'}


class Blitzortung(jsonrpc.JSONRPC):
    """
    An example object to be published.
    """

    def __init__(self, db_connection_pool, log_directory):
        super().__init__()
        self.connection_pool = db_connection_pool
        self.log_directory = log_directory
        self.strike_query = blitzortung.service.strike_query()
        self.strike_grid_query = blitzortung.service.strike_grid_query()
        self.global_strike_grid_query = blitzortung.service.global_strike_grid_query()
        self.histogram_query = blitzortung.service.histogram_query()
        self.check_count = 0
        cache_cleanup_period = 300
        self.strikes_grid_cache = blitzortung.cache.ObjectCache(ttl_seconds=20, cleanup_period=cache_cleanup_period)
        self.strikes_history_grid_cache = blitzortung.cache.ObjectCache(ttl_seconds=60, cleanup_period=cache_cleanup_period)
        self.global_strikes_grid_cache = blitzortung.cache.ObjectCache(ttl_seconds=20, cleanup_period=cache_cleanup_period)
        self.global_strikes_history_grid_cache = blitzortung.cache.ObjectCache(ttl_seconds=60, cleanup_period=cache_cleanup_period)
        self.local_strikes_grid_cache = blitzortung.cache.ObjectCache(ttl_seconds=20, size=100, cleanup_period=cache_cleanup_period)
        self.local_strikes_history_grid_cache = blitzortung.cache.ObjectCache(ttl_seconds=60, size=400, cleanup_period=cache_cleanup_period)
        self.histogram_cache = blitzortung.cache.ObjectCache(ttl_seconds=60, cleanup_period=cache_cleanup_period)
        self.test = None
        self.current_period = self.__current_period()
        self.current_data = collections.defaultdict(list)
        self.next_memory_info = 0.0

    addSlash = True

    def __get_epoch(self, timestamp):
        return calendar.timegm(timestamp.timetuple()) * 1000000 + timestamp.microsecond

    def __current_period(self):
        return datetime.datetime.now(datetime.UTC).replace(second=0, microsecond=0)

    def __check_period(self):
        if self.current_period != self.__current_period():
            self.current_data['timestamp'] = self.__get_epoch(self.current_period)
            if log_directory:
                with open(os.path.join(log_directory, self.current_period.strftime("%Y%m%d-%H%M.json")),
                          'w') as output_file:
                    output_file.write(json.dumps(self.current_data))
            self.__restart_period()

    def __restart_period(self):
        self.current_period = self.__current_period()
        self.current_data = collections.defaultdict(list)

    @staticmethod
    def __force_min(number, min_number):
        return max(min_number, number)

    @staticmethod
    def __force_max(number, max_number):
        return min(max_number, number)

    def __force_range(self, number, min_number, max_number):
        return self.__force_min(self.__force_max(number, max_number), min_number)

    def jsonrpc_check(self):
        self.check_count += 1
        return {'count': self.check_count}

    @with_request
    def jsonrpc_get_strikes(self, request, minute_length, id_or_offset=0):
        minute_length = self.__force_range(minute_length, 0, 24 * 60)

        client = self.get_request_client(request)
        user_agent = request.getHeader("User-Agent")
        # if client in FORBIDDEN_IPS or user_agent != 'bo-android-195':
        print('get_strikes(%d, %d) %s %s BLOCKED' % (minute_length, id_or_offset, client, user_agent))
        return None

        minute_offset = self.__force_range(id_or_offset, -24 * 60 + minute_length, 0) if id_or_offset < 0 else 0
        time_interval = create_time_interval(minute_length, minute_offset)
        strikes_result, state = self.strike_query.create(id_or_offset, minute_length, minute_offset,
                                                         self.connection_pool, statsd_client)

        histogram_result = self.get_histogram(time_interval)

        combined_result = self.strike_query.combine_result(strikes_result, histogram_result, state)

        print('get_strikes(%d, %d) %s %s' % (minute_length, id_or_offset, client, user_agent))
        return combined_result

    def jsonrpc_get_strikes_around(self, longitude, latitude, minute_length, min_id=None):
        pass

    def get_strikes_grid(self, minute_length, grid_baselength, minute_offset, region, count_threshold):
        grid_parameters = GridParameters(grid[region].get_for(grid_baselength), grid_baselength, region, count_threshold=count_threshold)
        time_interval = create_time_interval(minute_length, minute_offset)

        grid_result, state = self.strike_grid_query.create(grid_parameters, time_interval, self.connection_pool, statsd_client)

        histogram_result = self.get_histogram(time_interval, envelope=grid_parameters.grid) \
            if minute_length > 10 else self.deferred_with([])

        combined_result = self.strike_grid_query.combine_result(grid_result, histogram_result, state)

        combined_result.addCallback(lambda value: CacheableResult(value))

        return combined_result

    def get_global_strikes_grid(self, minute_length, grid_baselength, minute_offset, count_threshold):
        grid_parameters = GridParameters(global_grid.get_for(grid_baselength), grid_baselength, count_threshold=count_threshold)
        time_interval = create_time_interval(minute_length, minute_offset)

        grid_result, state = self.global_strike_grid_query.create(grid_parameters, time_interval, self.connection_pool, statsd_client)

        histogram_result = self.get_histogram(time_interval) if minute_length > 10 else self.deferred_with([])

        combined_result = self.strike_grid_query.combine_result(grid_result, histogram_result, state)

        combined_result.addCallback(lambda value: CacheableResult(value))

        return combined_result

    def get_local_strikes_grid(self, x, y, grid_baselength, minute_length, minute_offset, count_threshold, data_area=5):
        data_area_size_factor = 3
        size = data_area * data_area_size_factor
        reference_longitude = (x - 1) * data_area
        reference_latitude = (y - 1) * data_area
        center_latitude = reference_latitude + size / 2.0
        longitude_extension = abs(center_latitude) / 15.0
        utm_longitude = 3
        local_grid = blitzortung.geom.GridFactory(
            reference_longitude - longitude_extension,
            reference_longitude + size + longitude_extension,
            reference_latitude,
            reference_latitude + size,
            UTM_NORTH if reference_latitude >= 0 else UTM_SOUTH,
            utm_longitude,
            reference_latitude + size / 2.0
        )
        grid_parameters = GridParameters(local_grid.get_for(grid_baselength), grid_baselength, count_threshold=count_threshold)
        time_interval = create_time_interval(minute_length, minute_offset)

        grid_result, state = self.strike_grid_query.create(grid_parameters, time_interval, self.connection_pool, statsd_client)

        histogram_result = self.get_histogram(time_interval, envelope=grid_parameters.grid) \
            if minute_length > 10 else self.deferred_with([])

        combined_result = self.strike_grid_query.combine_result(grid_result, histogram_result, state)

        combined_result.addCallback(lambda value: CacheableResult(value))

        return combined_result

    @staticmethod
    def deferred_with(result):
        deferred = defer.Deferred()
        deferred.result = result
        deferred.called = True
        return deferred

    @with_request
    def jsonrpc_get_strikes_raster(self, request, minute_length, grid_base_length=10000, minute_offset=0, region=1):
        return self.jsonrpc_get_strikes_grid(request, minute_length, grid_base_length, minute_offset, region)

    @with_request
    def jsonrpc_get_strokes_raster(self, request, minute_length, grid_base_length=10000, minute_offset=0, region=1):
        return self.jsonrpc_get_strikes_grid(request, minute_length, grid_base_length, minute_offset, region)

    @with_request
    def jsonrpc_get_global_strikes_grid(self, request, minute_length, grid_base_length=10000, minute_offset=0,
                                        count_threshold=0):
        self.memory_info()
        client = self.get_request_client(request)
        user_agent, user_agent_version = self.parse_user_agent(request)

        if client in FORBIDDEN_IPS or user_agent_version == 0 or request.getHeader(
                'content-type') != 'text/json' or request.getHeader(
            'referer') == '' or grid_base_length < 5000 or grid_base_length == 1000001:
            print(
                f"FORBIDDEN - client: {client}, user agent: {user_agent_version}, content type: {request.getHeader('content-type')}, referer: {request.getHeader('referer')}")
            print('get_global_strikes_grid(%d, %d, %d, >=%d) BLOCKED %.1f%% %s %s' % (
                minute_length, grid_base_length, minute_offset, count_threshold,
                self.global_strikes_grid_cache.get_ratio() * 100, client, user_agent))
            return {}

        original_grid_base_length = grid_base_length
        grid_base_length = self.__force_min(grid_base_length, 10000)
        minute_length = self.__force_range(minute_length, 0, 24 * 60)
        minute_length = 60 if minute_length == 0 else minute_length
        minute_offset = self.__force_range(minute_offset, -24 * 60 + minute_length, 0)
        count_threshold = self.__force_min(count_threshold, 0)

        cache = self.global_strikes_grid_cache if minute_offset == 0 else self.global_strikes_history_grid_cache
        response = cache.get(self.get_global_strikes_grid, minute_length=minute_length,
                             grid_baselength=grid_base_length,
                             minute_offset=minute_offset,
                             count_threshold=count_threshold)
        self.fix_bad_accept_header(request, user_agent)

        print('get_global_strikes_grid(%d, %d, %d, >=%d) %.1f%% %s %s' % (
            minute_length, grid_base_length, minute_offset, count_threshold,
            self.global_strikes_grid_cache.get_ratio() * 100, client, user_agent))

        self.__check_period()
        self.current_data['get_strikes_grid'].append(
            (self.__get_epoch(datetime.datetime.now(datetime.UTC)), minute_length, original_grid_base_length, minute_offset,
             0, count_threshold, client, user_agent))

        statsd_client.incr('strikes_grid.total_count')
        statsd_client.incr('global_strikes_grid.total_count')
        statsd_client.gauge('global_strikes_grid.cache_hits', self.global_strikes_grid_cache.get_ratio())
        if minute_length == 10:
            statsd_client.incr('strikes_grid.bg_count')
            statsd_client.incr('global_strikes_grid.bg_count')

        return response

    @with_request
    def jsonrpc_get_local_strikes_grid(self, request, x, y, grid_base_length=10000, minute_length=60, minute_offset=0,
                                       count_threshold=0, data_area=5):
        self.memory_info()
        client = self.get_request_client(request)
        user_agent, user_agent_version = self.parse_user_agent(request)

        if client in FORBIDDEN_IPS or request.getHeader(
                'content-type') != 'text/json' or request.getHeader(
            'referer') == '' or grid_base_length < 5000 or grid_base_length == 1000001:
            print(
                f"FORBIDDEN - client: {client}, user agent: {user_agent_version}, content type: {request.getHeader('content-type')}, referer: {request.getHeader('referer')}")
            print('get_local_strikes_grid(%d, %d, %d, %d, %d, >=%d, %d) BLOCKED %.1f%% %s %s' % (
                x, y, grid_base_length, minute_length, minute_offset, count_threshold, data_area,
                self.local_strikes_grid_cache.get_ratio() * 100, client, user_agent))
            return {}

        original_grid_base_length = grid_base_length
        grid_base_length = self.__force_min(grid_base_length, 5000)
        minute_length = self.__force_range(minute_length, 0, 24 * 60)
        minute_length = 60 if minute_length == 0 else minute_length
        minute_offset = self.__force_range(minute_offset, -24 * 60 + minute_length, 0)
        count_threshold = self.__force_min(count_threshold, 0)
        data_area = round(self.__force_min(data_area, 5))

        cache = self.local_strikes_grid_cache if minute_offset == 0 else self.local_strikes_history_grid_cache

        response = cache.get(self.get_local_strikes_grid, x=x, y=y,
                             grid_baselength=grid_base_length,
                             minute_length=minute_length,
                             minute_offset=minute_offset,
                             count_threshold=count_threshold,
                             data_area=data_area)

        print('get_local_strikes_grid(%d, %d, %d, %d, %d, >=%d, %d) %.1f%% %d# %s %s' % (
            x, y, minute_length, grid_base_length, minute_offset, count_threshold, data_area,
            self.local_strikes_grid_cache.get_ratio() * 100, self.local_strikes_grid_cache.get_size(), client,
            user_agent))

        self.__check_period()
        self.current_data['get_strikes_grid'].append(
            (
                self.__get_epoch(datetime.datetime.now(datetime.UTC)), minute_length, original_grid_base_length, minute_offset,
                -1, count_threshold, client, user_agent, x, y, data_area))

        statsd_client.incr('strikes_grid.total_count')
        statsd_client.incr('local_strikes_grid.total_count')
        statsd_client.incr(f'local_strikes_grid.data_area.{data_area}')
        statsd_client.gauge('local_strikes_grid.cache_hits', self.local_strikes_grid_cache.get_ratio())
        if minute_length == 10:
            statsd_client.incr('strikes_grid.bg_count')
            statsd_client.incr('local_strikes_grid.bg_count')

        return response

    @with_request
    def jsonrpc_get_strikes_grid(self, request, minute_length, grid_base_length=10000, minute_offset=0, region=1,
                                 count_threshold=0):
        self.memory_info()
        client = self.get_request_client(request)
        user_agent, user_agent_version = self.parse_user_agent(request)

        if client in FORBIDDEN_IPS or user_agent_version == 0 or request.getHeader(
                'content-type') != 'text/json' or request.getHeader(
            'referer') == '' or grid_base_length < 5000 or grid_base_length == 1000001:
            print(
                f"FORBIDDEN - client: {client}, user agent: {user_agent_version}, content type: {request.getHeader('content-type')}, referer: {request.getHeader('referer')}")
            print('get_strikes_grid(%d, %d, %d, %d, >=%d) BLOCKED %.1f%% %s %s' % (
                minute_length, grid_base_length, minute_offset, region, count_threshold,
                self.strikes_grid_cache.get_ratio() * 100, client, user_agent))
            return {}

        original_grid_base_length = grid_base_length
        grid_base_length = self.__force_min(grid_base_length, 5000)
        minute_length = self.__force_range(minute_length, 0, 24 * 60)
        minute_length = 60 if minute_length == 0 else minute_length
        minute_offset = self.__force_range(minute_offset, -24 * 60 + minute_length, 0)
        region = self.__force_min(region, 1)
        count_threshold = self.__force_min(count_threshold, 0)

        cache = self.strikes_grid_cache if minute_offset == 0 else self.strikes_history_grid_cache
        response = cache.get(self.get_strikes_grid, minute_length=minute_length,
                             grid_baselength=grid_base_length,
                             minute_offset=minute_offset, region=region,
                             count_threshold=count_threshold)
        self.fix_bad_accept_header(request, user_agent)

        print('get_strikes_grid(%d, %d, %d, %d, >=%d) %.1f%% %s %s' % (
            minute_length, grid_base_length, minute_offset, region, count_threshold,
            self.strikes_grid_cache.get_ratio() * 100, client, user_agent))

        self.__check_period()
        self.current_data['get_strikes_grid'].append(
            (self.__get_epoch(datetime.datetime.now(datetime.UTC)), minute_length, original_grid_base_length, minute_offset,
             region,
             count_threshold, client, user_agent))

        statsd_client.incr('strikes_grid.total_count')
        statsd_client.incr('strikes_grid.total_count.{}'.format(region))
        statsd_client.gauge('strikes_grid.cache_hits', self.strikes_grid_cache.get_ratio())
        if minute_length == 10:
            statsd_client.incr('strikes_grid.bg_count')
            statsd_client.incr('strikes_grid.bg_count.{}'.format(region))

        return response

    def parse_user_agent(self, request):
        user_agent = request.getHeader("User-Agent")
        if user_agent and user_agent.startswith(USER_AGENT_PREFIX):
            user_agent_parts = user_agent.split(' ')[0].rsplit('-', 1)
            version_string = user_agent_parts[1] if len(user_agent_parts) > 1 else None
            user_agent_version = int(version_string) if user_agent_parts[0] == 'bo-android' else 0
        else:
            user_agent_version = 0
        return user_agent, user_agent_version

    @staticmethod
    def fix_bad_accept_header(request, user_agent):
        if user_agent is not None:
            user_agent_parts = user_agent.split(' ')[0].rsplit('-', 1)
            version_string = user_agent_parts[1] if len(user_agent_parts) > 1 else None
            if user_agent_parts[0] == 'bo-android':
                version = int(version_string)
            else:
                version = None
            if version and version <= 177:
                request.requestHeaders.removeHeader("Accept-Encoding")

    def get_histogram(self, time_interval: TimeInterval, region=None, envelope=None):
        return self.histogram_cache.get(self.histogram_query.create,
                                        time_interval=time_interval,
                                        connection=self.connection_pool,
                                        region=region,
                                        envelope=envelope)

    @with_request
    def jsonrpc_get_clusters(self, minute_length, minute_offset, interval_length, interval_offset):
        pass

    @with_request
    def jsonrpc_get_stations(self, request):
        stations_db = blitzortung.db.station()

        reference_time = time.time()
        stations = stations_db.select()
        query_time = time.time()
        statsd_client.timing('stations.query', max(1, int((query_time - reference_time) * 1000)))

        station_data = tuple(
            (
                station.number,
                station.name,
                station.country,
                station.x,
                station.y,
                station.timestamp.strftime("%Y%m%dT%H:%M:%S.%f")[:-3] if station.timestamp else ''
            )
            for station in stations
        )

        response = {'stations': station_data}

        full_time = time.time()

        client = self.get_request_client(request)
        user_agent = request.getHeader("User-Agent")
        print('get_stations() #%d %.2fs %s %s' % (len(stations), query_time - reference_time, client, user_agent))
        statsd_client.incr('stations')
        statsd_client.timing('stations', max(1, int((full_time - reference_time) * 1000)))

        return response

    def get_request_client(self, request):
        forward = request.getHeader("X-Forwarded-For")
        if forward:
            return forward.split(', ')[0]
        return request.getClientIP()

    def memory_info(self):
        now = time.time()
        if now > self.next_memory_info:
            print("### MEMORY INFO ###")
            print(gc.get_stats(True) if is_pypy else gc.get_stats())
            self.next_memory_info = now + 300


users = {'test': 'test'}

# Set up the application and the JSON-RPC resource.
application = service.Application("Blitzortung.org JSON-RPC Server")

log_directory = "/var/log/blitzortung"
if os.path.exists(log_directory):
    logfile = DailyLogFile("webservice.log", log_directory)
    application.setComponent(ILogObserver, FileLogObserver(logfile).emit)
else:
    log_directory = None

connection_pool = create_connection_pool()
root = Blitzortung(connection_pool, log_directory)

# With the wrapped root, we can set up the server as usual.
# site = server.Site(resource=wrappedRoot)
config = blitzortung.config.config()
site = server.Site(root)
site.displayTracebacks = False
jsonrpc_server = internet.TCPServer(config.get_webservice_port(), site, interface='127.0.0.1')
jsonrpc_server.setServiceParent(application)
