import copy
import logging
from datetime import datetime, timedelta
from typing import Any

import pytz
import tornado
import tornado_eventsource.handler
from tornado import httputil
from tornado.web import Application

from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials


class APISpotsHandler(tornado.web.RequestHandler):
    """API request handler for /api/v2/spots"""

    def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
        self._spots = None
        self._web_server_metrics = None
        super().__init__(application, request, **kwargs)

    def initialize(self, spots, web_server_metrics):
        self._spots = spots
        self._web_server_metrics = web_server_metrics

    @staticmethod
    def _enrich(spots, credentials):
        enriched = []
        for spot in spots:
            spot_copy = copy.deepcopy(spot)
            spot_copy.infer_missing(credentials)
            enriched.append(spot_copy)
        return enriched

    def get(self):
        try:
            # Metrics
            self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
            self._web_server_metrics["api_access_counter"] += 1
            self._web_server_metrics["status"] = "OK"
            api_requests_counter.inc()

            # request.arguments contains lists for each param key because technically the client can supply multiple,
            # reduce that to just the first entry, and convert bytes to string
            query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}

            # Fetch all spots matching the query, then optionally enrich with online data
            credentials = extract_credentials(self.request.headers)
            data = get_spot_list_with_filters(self._spots, query_params)
            if credentials:
                data = self._enrich(data, credentials)
            self.write(safe_json_dumps(data))
            self.set_status(200)
        except ValueError as e:
            self.write(safe_json_dumps("Bad request - " + str(e)))
            self.set_status(400)
        except Exception as e:
            logging.error("Excedption when handling client request to spots API: %s", e, exc_info=True)
            self.write(safe_json_dumps("Error - an internal server error occurred."))
            self.set_status(500)
        self.set_header("Cache-Control", "no-store")
        self.set_header("Content-Type", "application/json")


class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
    """API request handler for /api/v2/spots/stream"""

    def __init__(self, application, request, **kwargs: Any):
        self._sse_spot_broadcaster = None
        self._web_server_metrics = None
        self._query_params = None
        self._credentials = None
        super().__init__(application, request, **kwargs)

    def initialize(self, sse_spot_broadcaster, web_server_metrics):
        self._sse_spot_broadcaster = sse_spot_broadcaster
        self._web_server_metrics = web_server_metrics

    def custom_headers(self):
        """Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""

        return {"Cache-Control": "no-store",
                "X-Accel-Buffering": "no"}

    def open(self):
        """Called once on the client opening a connection, set things up"""

        try:
            # Metrics
            self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
            self._web_server_metrics["api_access_counter"] += 1
            self._web_server_metrics["status"] = "OK"
            api_requests_counter.inc()

            # request.arguments contains lists for each param key because technically the client can supply multiple,
            # reduce that to just the first entry, and convert bytes to string
            self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
            self._credentials = extract_credentials(self.request.headers)

            # Flush headers immediately so nginx doesn't time out waiting for a response
            self.write_message("keepalive", "")

            # Register to handle new spots arriving. The callback() method will get called with the new spot as an
            # argument.
            self._sse_spot_broadcaster.register(self)

        except Exception as e:
            logging.warning("Exception when serving SSE socket: %s", e, exc_info=True)
            self.close()

    def close(self):
        """When the user closes the socket, deregister ourselves from the spot broadcaster"""

        self._sse_spot_broadcaster.unregister(self)
        super().close()

    def callback(self, spot):
        """Callback when a new spot arrives"""

        try:
            # If the new spot matches our param filters, send it to the client. If not, ignore it.
            if spot_allowed_by_query(spot, self._query_params):
                if self._credentials:
                    spot = copy.deepcopy(spot)
                    spot.infer_missing(self._credentials)
                self.write_message(msg=safe_json_dumps(spot))
        except Exception as e:
            logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True)
            self.close()


def get_spot_list_with_filters(all_spots, query):
    """Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in
    the main "spots" GET call."""

    # Create a shallow copy of the spot list, ordered by spot time, then filter the list to reduce it only to spots
    # that match the filter parameters in the query string. Finally, apply a limit to the number of spots returned.
    # The list of query string filters is defined in the API docs.
    spot_ids = all_spots.keys()
    spots = []
    for k in spot_ids:
        s = all_spots.get(k)
        if s is not None:
            spots.append(s)
    spots = sorted(spots, key=lambda spot: (spot.time if spot and spot.time else 0), reverse=True)
    spots = list(filter(lambda spot: spot_allowed_by_query(spot, query), spots))
    if "limit" in query.keys():
        spots = spots[:int(query.get("limit"))]

    # Ensure only the latest spot of each callsign-SSID combo is present in the list. This relies on the
    # list being in reverse time order, so if any future change allows re-ordering the list, that should
    # be done *after* this. SSIDs are deliberately included here (see issue #68) because e.g. M0TRT-7
    # and M0TRT-9 APRS transponders could well be in different locations, on different frequencies etc.
    # This is a special consideration for the geo map and band map views (and Field Spotter) because while
    # duplicates are fine in the main spot list (e.g. different cluster spots of the same DX) this doesn't
    # work well for the other views.
    if "dedupe" in query.keys():
        dedupe = query.get("dedupe").upper() == "TRUE"
        if dedupe:
            spots_temp = []
            already_seen = []
            for s in spots:
                call_plus_ssid = s.dx_call + (s.dx_ssid if s.dx_ssid else "")
                if call_plus_ssid not in already_seen:
                    spots_temp.append(s)
                    already_seen.append(call_plus_ssid)
            spots = spots_temp

    return spots


def spot_allowed_by_query(spot, query):
    """Given URL query params and a spot, figure out if the spot "passes" the requested filters or is rejected. The list
    of query parameters and their function is defined in the API docs."""

    for k in query.keys():
        match k:
            case "since":
                since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
                if not spot.time or spot.time <= since:
                    return False
            case "max_age":
                max_age = int(query.get(k))
                since = (datetime.now(pytz.UTC) - timedelta(seconds=max_age)).timestamp()
                if not spot.time or spot.time <= since:
                    return False
            case "received_since":
                since = datetime.fromtimestamp(float(query.get(k)), pytz.UTC).timestamp()
                if not spot.received_time or spot.received_time <= since:
                    return False
            case "source":
                sources = query.get(k).split(",")
                if not spot.source or spot.source not in sources:
                    return False
            case "sig":
                # If a list of sigs is provided, the spot must have a sig and it must match one of them.
                # The special "sig" "NO_SIG", when supplied in the list, mathches spots with no sig.
                sigs = query.get(k).split(",")
                include_no_sig = "NO_SIG" in sigs
                if not spot.sig and not include_no_sig:
                    return False
                if spot.sig and spot.sig not in sigs:
                    return False
            case "needs_sig":
                # If true, a sig is required, regardless of what it is, it just can't be missing. Mutually
                # exclusive with supplying the special "NO_SIG" parameter to the "sig" query param.
                needs_sig = query.get(k).upper() == "TRUE"
                if needs_sig and not spot.sig:
                    return False
            case "needs_sig_ref":
                # If true, at least one sig ref is required, regardless of what it is, it just can't be missing.
                needs_sig_ref = query.get(k).upper() == "TRUE"
                if needs_sig_ref and (not spot.sig_refs or len(spot.sig_refs) == 0):
                    return False
            case "band":
                bands = query.get(k).split(",")
                if not spot.band or spot.band not in bands:
                    return False
            case "mode":
                modes = query.get(k).split(",")
                if not spot.mode or spot.mode not in modes:
                    return False
            case "mode_type":
                mode_types = query.get(k).split(",")
                if not spot.mode_type or spot.mode_type not in mode_types:
                    return False
            case "dx_continent":
                dxconts = query.get(k).split(",")
                if not spot.dx_continent or spot.dx_continent not in dxconts:
                    return False
            case "de_continent":
                deconts = query.get(k).split(",")
                if not spot.de_continent or spot.de_continent not in deconts:
                    return False
            case "comment_includes":
                comment_includes = query.get(k).strip()
                if not spot.comment or comment_includes.upper() not in spot.comment.upper():
                    return False
            case "dx_call_includes":
                dx_call_includes = query.get(k).strip()
                if not spot.dx_call or dx_call_includes.upper() not in spot.dx_call.upper():
                    return False
            case "text_includes":
                text_includes = query.get(k).strip()
                if (not spot.dx_call or text_includes.upper() not in spot.dx_call.upper()) \
                        and (not spot.comment or text_includes.upper() not in spot.comment.upper()):
                    return False
            case "allow_qrt":
                # If false, spots that are flagged as QRT are not returned.
                prevent_qrt = query.get(k).upper() == "FALSE"
                if prevent_qrt and spot.qrt:
                    return False
            case "needs_good_location":
                # If true, spots require a "good" location to be returned
                needs_good_location = query.get(k).upper() == "TRUE"
                if needs_good_location and not spot.dx_location_good:
                    return False
    return True
