import json
import logging
from uuid import uuid4

from cached_property import cached_property
from marshmallow import ValidationError

from ops.framework import (
    StoredState,
    EventBase,
    EventSource,
    ObjectEvents,
)
from ops.model import ModelError

from .base import VersionedInterface


log = logging.getLogger(__name__)


class LBProviderAvailable(EventBase):
    pass


class LBResponseAvailable(EventBase):
    pass


class LBResponseChanged(EventBase):
    pass


class LBProviderEvents(ObjectEvents):
    available = EventSource(LBProviderAvailable)
    response_available = EventSource(LBResponseAvailable)
    response_changed = EventSource(LBResponseChanged)


class LBProvider(VersionedInterface):
    """API used to interact with the provider of loadbalancers."""

    state = StoredState()
    on = LBProviderEvents()

    def __init__(self, charm, relation_name):
        super().__init__(charm, relation_name)
        self.relation_name = relation_name
        # just call this to enforce that only one app can be related
        self.model.get_relation(relation_name)
        self.state.set_default(
            response_hashes={}, was_available=False, was_response_available=False
        )

        for event in (
            charm.on[relation_name].relation_created,
            charm.on[relation_name].relation_joined,
            charm.on[relation_name].relation_changed,
            charm.on[relation_name].relation_departed,
            charm.on[relation_name].relation_broken,
        ):
            self.framework.observe(event, self._check_provider)

    def _check_provider(self, event):
        if self.is_available:
            if not self.state.was_available:
                self.state.was_available = True
                self.on.available.emit()
            if not self.state.was_response_available:
                self.state.was_response_available = True
                self.on.response_available.emit()
            if self.is_changed:
                self.on.response_changed.emit()
        elif self.state.was_available:
            self.state.was_available = False
            self.state.was_response_available = False
            if self.state.response_hashes:
                self.state.response_hashes = {}
                self.on.response_changed.emit()

    @property
    def relation(self):
        return self.relations[0] if self.relations else None

    def get_request(self, name):
        """Get or create a Load Balancer Request object.

        May raise a ModelError if unable to create a request.
        """
        if not self.charm.unit.is_leader():
            raise ModelError("Unit is not leader")
        if not self.relation:
            raise ModelError("Relation not available")
        schema = self._schema(self.relation)
        local_data = self.relation.data[self.app]
        remote_data = self.relation.data[self.relation.app]
        request_key = "request_" + name
        response_key = "response_" + name
        request = None
        if request_key in local_data:
            try:
                request_sdata = local_data[request_key]
                response_sdata = remote_data.get(response_key)
                request = schema.Request.loads(request_sdata, response_sdata)
            except ValidationError:
                log.exception("Failed to load request {}".format(request_key))
        if not request:
            request = schema.Request()
            request.name = name
            request.id = uuid4().hex
        return request

    def get_response(self, name):
        """Get a specific Load Balancer Response by name.

        This is similar to `get_request(name).response`, except that it will return
        `None` if the response is not available and can be used by non-leaders.
        """
        if not self.is_available:
            return None
        schema = self._schema(self.relation)
        remote_data = self.relation.data[self.relation.app]
        response_key = "response_" + name
        if response_key not in remote_data:
            return None
        request = schema.Request()
        request.name = name
        response = schema.Response(request)
        response._update(json.loads(remote_data[response_key]))
        return response

    def send_request(self, request):
        """Send a specific request.

        May raise a ModelError if unable to send the request.
        """
        if not self.charm.unit.is_leader():
            raise ModelError("Unit is not leader")
        if not self.relation:
            raise ModelError("Relation not available")
        # The sent_hash is used to tell when the provider's response has been
        # updated to match our request. We can't used the request hash computed
        # on the providing side because it may not match due to default values
        # being filled in on that side (e.g., the backend addresses). We have to
        # clear the sent_hash field before calculating the hash to send so that
        # it doesn't cause the hash to change even if no other fields have.
        request.sent_hash = None
        request.sent_hash = request.hash
        key = "request_" + request.name
        self.relation.data[self.app][key] = request.dumps()

    def remove_request(self, name):
        """Remove a specific request.

        May raise a ModelError if unable to remove the request.
        """
        if not self.charm.unit.is_leader():
            raise ModelError("Unit is not leader")
        if not self.relation:
            return
        key = "request_" + name
        self.relation.data[self.app].pop(key, None)
        self.state.response_hashes.pop(name, None)

    @property
    def all_requests(self):
        """A list of all requests which have been made."""
        requests = []
        if self.relation and self.charm.unit.is_leader():
            for key in sorted(self.relation.data[self.app].keys()):
                if not key.startswith("request_"):
                    continue
                requests.append(self.get_request(key[len("request_") :]))
        return requests

    @property
    def revoked_responses(self):
        """A list of responses which are no longer available."""
        return [
            request.response
            for request in self.all_requests
            if not request.response and request.name in self.state.response_hashes
        ]

    @cached_property
    def all_responses(self):
        """A list of all responses which are available."""
        # NB: Non-leaders can't read the request data, but they should be able
        # to read the responses.
        responses = []
        if self.relation:
            for key in sorted(self.relation.data[self.relation.app].keys()):
                if not key.startswith("response_"):
                    continue
                responses.append(self.get_response(key[len("response_") :]))
        return responses

    @cached_property
    def complete_responses(self):
        """A list of all responses which are up to date with their associated
        request.
        """
        if not self.charm.unit.is_leader():
            # Ugh.  Non-leaders can't read the request data, so they can't
            # verify if the response is up-to-date with the latest request
            # data. Instead, just assume that if there's a response there, that
            # it's up-to-date so that the non-leaders can at least see the
            # responses.
            return self.all_responses
        return [
            request.response
            for request in self.all_requests
            if request.response.received_hash == request.sent_hash
        ]

    @property
    def new_responses(self):
        """A list of complete responses which have not yet been acknowledged as
        handled or which have changed.
        """
        acked_responses = self.state.response_hashes
        return [
            response
            for response in self.complete_responses
            if response.hash != acked_responses.get(response.name)
        ]

    def ack_response(self, response):
        """Acknowledge that a given response has been handled.

        Can be called on a revoked response as well to remove it
        from the revoked_responses list.
        """
        if response:
            self.state.response_hashes[response.name] = response.hash
        else:
            self.state.response_hashes.pop(response.name, None)
        if not self.is_changed:
            try:
                from charms.reactive import clear_flag

                prefix = "endpoint." + self.relation_name
                clear_flag(prefix + ".response.changed")
            except ImportError:
                pass  # not being used in a reactive charm

    @property
    def is_changed(self):
        return self.new_responses or self.revoked_responses

    @property
    def is_available(self):
        return bool(self.relation)

    @property
    def has_response(self):
        return bool(self.complete_responses)

    @property
    def can_request(self):
        return self.is_available and self.unit.is_leader()

    def manage_flags(self):
        """Used to interact with charms.reactive-base charms."""
        from charms.reactive import toggle_flag

        prefix = "endpoint." + self.relation_name
        toggle_flag(prefix + ".available", self.is_available)
        toggle_flag(prefix + ".response.available", self.has_response)
        toggle_flag(prefix + ".response.changed", self.is_changed)
