from __future__ import unicode_literals

import re
import importlib

from django.core.exceptions import ImproperlyConfigured
from django.utils import six

from .handler import ViewConsumer
from .utils import name_that_thing


class Router(object):
    """
    Manages the available consumers in the project and which channels they
    listen to.

    Generally this is attached to a backend instance as ".router"
    """

    def __init__(self, routing):
        # Resolve routing into a list if it's a dict or string
        routing = self.resolve_routing(routing)
        # Expand those entries recursively into a flat list of Routes
        self.routing = []
        for entry in routing:
            self.routing.extend(entry.expand_routes())
        # Now go through that list and collect channel names into a set
        self.channels = {
            route.channel
            for route in self.routing
        }

    def add_route(self, route):
        """
        Adds a single raw Route to us at the end of the resolution list.
        """
        self.routing.append(route)
        self.channels.add(route.channel)

    def match(self, message):
        """
        Runs through our routing and tries to find a consumer that matches
        the message/channel. Returns (consumer, extra_kwargs) if it does,
        and None if it doesn't.
        """
        # TODO: Maybe we can add some kind of caching in here if we can hash
        # the message with only matchable keys faster than the search?
        for route in self.routing:
            match = route.match(message)
            if match is not None:
                return match
        return None

    def check_default(self, http_consumer=None):
        """
        Adds default handlers for Django's default handling of channels.
        """
        # We just add the default Django route to the bottom; if the user
        # has defined another http.request handler, it'll get hit first and run.
        self.add_route(Route("http.request", http_consumer or ViewConsumer()))

    @classmethod
    def resolve_routing(cls, routing):
        """
        Takes a routing - if it's a string, it imports it, and if it's a
        dict, converts it to a list of route()s. Used by this class and Include.
        """
        # If the routing was a string, import it
        if isinstance(routing, six.string_types):
            module_name, variable_name = routing.rsplit(".", 1)
            try:
                routing = getattr(importlib.import_module(module_name), variable_name)
            except (ImportError, AttributeError) as e:
                raise ImproperlyConfigured("Cannot import channel routing %r: %s" % (routing, e))
        # If the routing is a dict, convert it
        if isinstance(routing, dict):
            routing = [
                Route(channel, consumer)
                for channel, consumer in routing.items()
            ]
        return routing

    @classmethod
    def normalise_re_arg(cls, value):
        """
        Normalises regular expression patterns and string inputs to Unicode.
        """
        if isinstance(value, six.binary_type):
            return value.decode("ascii")
        else:
            return value


class Route(object):
    """
    Represents a route to a single consumer, with a channel name
    and optional message parameter matching.
    """

    def __init__(self, channel, consumer, **kwargs):
        # Get channel, make sure it's a unicode string
        self.channel = channel
        if isinstance(self.channel, six.binary_type):
            self.channel = self.channel.decode("ascii")
        # Get consumer, optionally importing it
        if isinstance(consumer, six.string_types):
            module_name, variable_name = consumer.rsplit(".", 1)
            try:
                consumer = getattr(importlib.import_module(module_name), variable_name)
            except (ImportError, AttributeError):
                raise ImproperlyConfigured("Cannot import consumer %r" % consumer)
        self.consumer = consumer
        # Compile filter regexes up front
        self.filters = {
            name: re.compile(Router.normalise_re_arg(value))
            for name, value in kwargs.items()
        }
        # Check filters don't use positional groups
        for name, regex in self.filters.items():
            if regex.groups != len(regex.groupindex):
                raise ValueError(
                    "Filter for %s on %s contains positional groups; "
                    "only named groups are allowed." % (
                        name,
                        self,
                    )
                )

    def match(self, message):
        """
        Checks to see if we match the Message object. Returns
        (consumer, kwargs dict) if it matches, None otherwise
        """
        # Check for channel match first of all
        if message.channel.name != self.channel:
            return None
        # Check each message filter and build consumer kwargs as we go
        call_args = {}
        for name, value in self.filters.items():
            if name not in message:
                return None
            match = value.match(Router.normalise_re_arg(message[name]))
            # Any match failure means we pass
            if match:
                call_args.update(match.groupdict())
            else:
                return None
        return self.consumer, call_args

    def expand_routes(self):
        """
        Expands this route into a list of just itself.
        """
        return [self]

    def add_prefixes(self, prefixes):
        """
        Returns a new Route with the given prefixes added to our filters.
        """
        new_filters = {}
        # Copy over our filters adding any prefixes
        for name, value in self.filters.items():
            if name in prefixes:
                if not value.pattern.startswith("^"):
                    raise ValueError("Cannot add prefix for %s on %s as inner value does not start with ^" % (
                        name,
                        self,
                    ))
                if "$" in prefixes[name]:
                    raise ValueError("Cannot add prefix for %s on %s as prefix contains $ (end of line match)" % (
                        name,
                        self,
                    ))
                new_filters[name] = re.compile(prefixes[name] + value.pattern.lstrip("^"))
            else:
                new_filters[name] = value
        # Now add any prefixes that are by themselves so they're still enforced
        for name, prefix in prefixes.items():
            if name not in new_filters:
                new_filters[name] = prefix
        # Return new copy
        return self.__class__(
            self.channel,
            self.consumer,
            **new_filters
        )

    def __str__(self):
        return "%s %s -> %s" % (
            self.channel,
            "" if not self.filters else "(%s)" % (
                ", ".join("%s=%s" % (n, v.pattern) for n, v in self.filters.items())
            ),
            name_that_thing(self.consumer),
        )


class Include(object):
    """
    Represents an inclusion of another routing list in another file.
    Will automatically modify message match filters to add prefixes,
    if specified.
    """

    def __init__(self, routing, **kwargs):
        self.routing = Router.resolve_routing(routing)
        self.prefixes = {
            name: Router.normalise_re_arg(value)
            for name, value in kwargs.items()
        }
        # Sanity check prefix regexes
        for name, value in self.prefixes.items():
            if not value.startswith("^"):
                raise ValueError("Include prefix for %s must start with the ^ character." % name)

    def expand_routes(self):
        """
        Expands this Include into a list of routes, first recursively expanding
        and then adding on prefixes to filters if specified.
        """
        # First, expand our own subset of routes, to get a list of Route objects
        routes = []
        for entry in self.routing:
            routes.extend(entry.expand_routes())
        # Then, go through those and add any prefixes we have.
        routes = [route.add_prefixes(self.prefixes) for route in routes]
        return routes


# Lowercase standard to match urls.py
route = Route
include = Include
