import inspect
import json
import logging
import math
import urllib.parse
import uuid
from datetime import date, datetime

import arrow
import flask

import sepiida.routing
from sepiida.errors import APIException, AppError, Error

LOGGER = logging.getLogger(__name__)

class UnpackageError(Exception):
    def __init__(self, errors):
        super().__init__()
        assert isinstance(errors, list)
        self.errors = errors

class PackageError(Exception):
    def __init__(self, errors):
        super().__init__()
        assert isinstance(errors, list)
        self.errors = errors
    def __str__(self):
        return "\n".join(self.errors)

TYPES_TO_NAME = {
    date        : 'a date',
    datetime    : 'a datetime',
    dict        : 'an object',
    list        : 'an array',
    int         : 'a number',
    float       : 'a number',
    bool        : 'a boolean',
    str         : 'a string',
    type(None)  : 'null',
    uuid.UUID   : 'a uuid',
    'URI'       : 'a uri',
    'URL'       : 'a URL',
}
def _unpackage_error_message(name, expected_type, provided_data):
    provided_type_name = TYPES_TO_NAME[type(provided_data)]
    expected_type_name = TYPES_TO_NAME[expected_type]
    return "The property '{}' must be {}. You provided {}".format(name, expected_type_name, provided_type_name)

def _raise_validation_error(title):
    errors = [Error('request-validation-error', title)]
    raise UnpackageError(errors)

def _raise_validation_type_error(name, expected_type, provided_data):
    title = _unpackage_error_message(name, expected_type, provided_data)
    errors = [Error('type-validation-error', title)]
    raise UnpackageError(errors)

def _maybe_raise_all_sub_errors(errors):
    if not errors:
        return

    sub_errors = []
    for e in errors:
        sub_errors += e.errors
    raise UnpackageError(errors=sub_errors)

class Unspecified(): # pylint: disable=too-few-public-methods
    pass

class Field(): # pylint: disable=too-many-instance-attributes
    TYPE_NAME = 'raw-field'
    def __init__(self, # pylint: disable=too-many-arguments
            choices    = None,
            default    = Unspecified,
            docs       = None,
            example    = Unspecified,
            filterable = True,
            methods    = None,
            nullable   = False,
            optional   = False,
            rename     = None,
        ):
        self.choices = choices
        if default != Unspecified:
            self.default = default
        self.docs           = docs
        self.filterable     = filterable
        self.methods        = methods if methods is not None else ['GET', 'POST', 'PUT']
        self.nullable       = nullable
        self.optional       = optional
        self.rename         = rename

        self._generate_example(example, choices)

    def _check_choices(self, name, data):
        if self.choices is None:
            return

        if data not in self.choices:
            choices = ["'{}'".format(choice) for choice in self.choices]
            choices = ", ".join(choices)
            error = Error(
                'property-value-not-in-allowed-values',
                "The property '{}' must be one of {}. You provided '{}'".format(name, choices, data)
            )
            raise UnpackageError([error])

    def _generate_example(self, example, choices):
        if example != Unspecified:
            self.example = example
        elif choices:
            self.example = '|'.join([str(choice) for choice in choices])

    def package(self, data):
        raise NotImplementedError

    def unpackage(self, name, package, method):
        raise NotImplementedError

    def get_options_description(self):
        description = {
            'type'          : self.TYPE_NAME,
            'choices'       : self.choices,
            'filterable'    : self.filterable,
            'methods'       : self.methods,
            'optional'      : self.optional
        }
        if hasattr(self, 'default'):
            description['default'] = self.default
        return description

    def get_filter_value(self, name, value): # pylint: disable=unused-argument,no-self-use
        return value

    def schema(self, method='POST'):
        my_schema = {
            'description'   : self.docs,
            'filterable'    : self.filterable,
            'nullable'      : self.nullable,
            'type'          : self.TYPE_NAME
        }
        if method == 'GET':
            my_schema['optional'] = self.optional
        if method == 'POST' and hasattr(self, 'default'):
            my_schema['default'] = self.default
        if self.choices:
            my_schema['enum'] = self.choices
        if hasattr(self, 'example'):
            my_schema['example'] = json.dumps(self.example)
        return my_schema


class SignatureField(Field): # pylint: disable=abstract-method
    def __init__(self, s=None, **kwargs):
        super().__init__(**kwargs)
        self.signature = s
        root_signature = self.signature
        while isinstance(root_signature, SignatureField):
            root_signature = root_signature.signature

        if root_signature is None:
            return

        try:
            for k, v in root_signature.items():
                if inspect.isclass(v):
                    raise Exception(("You have provided a class '{}' as part of the signature for {}."
                        "Please specify the signature using instances of the class, not the class itself".format(v, k)))
        except AttributeError:
            pass

        if isinstance(root_signature, dict):
            fieldnames = set(root_signature.keys())
            for key, field in root_signature.items():
                if field.rename and field.rename in fieldnames:
                    raise Exception((
                        "You cannot rename field {} to {} because another "
                        "field is already using that name").format(key, field.rename))

        try:
            self._fieldnames = ', '.join(["'{}'".format(name) for name in sorted(root_signature.keys())])
            _filterable = [name for name, field in root_signature.items() if field.filterable]
            self._filternames = ', '.join(["'{}'".format(name) for name in sorted(_filterable)])
        except AttributeError:
            self._fieldnames = None


    def _process_against_signature(self, arguments):
        errors = []
        if arguments.fields and not self.signature:
            errors.append(Error(
                status_code=400,
                error_code='unable-to-specify-fields',
                title=("You specified return fields. This resource cannot return a subset of its fields"),
            ))

        if arguments.filters and not self.signature:
            errors.append(Error(
                status_code=400,
                error_code='unable-to-specify-filters',
                title=("You specified filters. This resource cannot be filtered"),
            ))
        return errors

    def _process_fields(self, arguments):
        if not self.signature:
            return []

        errors = []
        for field in (arguments.fields.values if arguments.fields else []):
            if field not in self.signature:
                msg = ("You specified the resource return '{}' but it is not a valid field for this resource. "
                       "Valid fields include {}").format(field, self._fieldnames)
                errors.append(Error('invalid-field-specified', msg))
        return errors

    def _process_filters(self, arguments):
        if not self.signature:
            return []

        errors = []
        for _filter in arguments.filters:
            try:
                field = self.signature[_filter.name]
            except KeyError:
                if self.signature:
                    title = ("Your requested filter property, '{}', is not a valid property for this endpoint. "
                            "Please choose one of {}".format(_filter.name, self._filternames))
                else:
                    title = "You requested a filter on property '{}'. This endpoint does not allow filtering".format(_filter.name)
                errors.append(Error('invalid-filter-property', title))
                continue

            if not field.filterable:
                title = ("Your requested filter property, '{}', is not a valid property for this endpoint. "
                         "Please choose one of {}").format(_filter.name, self._filternames)

                errors.append(Error(
                    status_code = 400,
                    error_code  = 'invalid-filter-property',
                    title       = title,
                ))
                continue

            new_values = []
            for value in _filter.values:
                try:
                    new_value = field.get_filter_value(_filter.name, value)
                    if field.choices and new_value not in field.choices:
                        title = ("You requested a filter on '{name}' to the value '{value}'. "
                                 "The property '{name}' only permits values from a particular "
                                 "set of options. Please use one of the following options for "
                                 "your filter: {values}").format(name=_filter.name, value=value, values=', '.join(field.choices))
                        errors.append(Error(
                            status_code = 400,
                            error_code  = 'invalid-filter-value',
                            title       = title,
                        ))
                        continue
                    new_values.append(new_value)
                except ValueError:
                    title = ("You requested a filter on '{name}' to the value '{value}'. "
                            "The property '{name}' is a {type} and so filters on it must "
                            "also be {type}").format(name=_filter.name, value=value, type=field.TYPE_NAME)
                    errors.append(Error(
                        status_code = 400,
                        error_code  = 'invalid-filter-value',
                        title       = title,
                    ))
                except AppError as e:
                    errors.append(Error(
                        status_code = 500,
                        error_code  = 'app-error',
                        title       = str(e)
                    ))
            _filter.values = new_values
        return errors

    def process_arguments(self, arguments):
        errors = self._process_against_signature(arguments)
        errors += self._process_fields(arguments)
        errors += self._process_filters(arguments)

        if errors:
            raise APIException(errors=errors)

class Object(SignatureField):
    TYPE_NAME = 'object'
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.signature:
            self.example = {k: v.example for k, v in self.signature.items() if hasattr(v, 'example')}
        else:
            self.example = None

    def package(self, response):
        package = {}
        if response is None:
            return None

        if self.signature is None:
            return dict(response)

        if response is None:
            return None

        errors = []
        for k, v in self.signature.items():
            try:
                if 'GET' in v.methods:
                    try:
                        newkey = k if v.rename is None else v.rename
                        package[k] = v.package(response[newkey])
                    except (TypeError, ValueError) as e:
                        errors.append("Programmer failed to provide the correct type for {}: {}".format(k, e))
            except KeyError:
                if not v.optional:
                    errors.append("Programmer failed to provide {}, which is a required value to return for this resource.".format(k))
        if errors:
            raise PackageError(errors=errors)
        return package

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None

        if not isinstance(data, dict):
            raise _raise_validation_type_error(name, dict, data)

        self._check_choices(name, data)

        if self.signature is None:
            return data

        package = {}
        errors = []
        for k, v in data.items():
            try:
                sub_name = "{}{}".format(name + "." if name else '', k)
                try:
                    sub_signature = self.signature[k]
                except KeyError:
                    raise _raise_validation_error("You provided '{}'. It is not a valid property for this resource".format(sub_name))
                value = sub_signature.unpackage(sub_name, v, method)
            except UnpackageError as e:
                errors.append(e)
                value = None
            package[k] = value
        for k, v in self.signature.items():
            sub_name = "{}{}".format(name + "." if name else '', k)
            if method not in v.methods and k in package:
                post_errors = [Error('cannot-set-property', "You provided '{}'. It cannot be directly set via {}".format(sub_name, method))]
                errors.append(UnpackageError(post_errors))
            if method == 'POST' and method in v.methods and k not in package:
                try:
                    package[k] = getattr(v, 'default')
                except AttributeError:
                    post_errors = [Error('missing-parameter',
                        "You did not provide the {} property. It is required when POSTing to this resource".format(sub_name)
                    )]
                    errors.append(UnpackageError(post_errors))
            if v.rename is not None:
                package[v.rename] = package.pop(k)
        _maybe_raise_all_sub_errors(errors)
        return package

    def get_options_description(self):
        base = super().get_options_description()
        if self.signature:
            base['fields'] = {k: v.get_options_description() for k, v in self.signature.items()}
        return base

    def schema(self, method='POST'):
        my_schema = super().schema(method)
        if not self.signature:
            return my_schema
        my_schema.update({
            'properties'    : {k: v.schema(method) for k, v in self.signature.items()},
        })
        if method == 'POST':
            my_schema['required'] = [k for k, v in self.signature.items() if not hasattr(v, 'default')]
        elif method == 'GET':
            my_schema['required'] = [k for k, v in self.signature.items() if not v.optional]
        return my_schema

class Array(SignatureField):
    TYPE_NAME = 'array'
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.example  = [self.signature.example if self.signature and hasattr(self.signature, 'example') else '...']

    def package(self, response):
        if response is None:
            return None

        _convert = (lambda x: self.signature.package(x)) if self.signature else (lambda x: x) # pylint: disable=unnecessary-lambda
        return [_convert(value) for value in response]

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None

        if not isinstance(data, list):
            raise _raise_validation_type_error(name, list, data)

        self._check_choices(name, data)

        if self.signature is None:
            return data

        package = []
        errors = []
        for i, d in enumerate(data):
            try:
                sub_name = "{}{}".format(name + "." if name else '', i)
                value = self.signature.unpackage(sub_name, d, method)
            except UnpackageError as e:
                errors.append(e)
                value = None
            package.append(value)
        _maybe_raise_all_sub_errors(errors)
        return package

    def schema(self, method='POST'):
        my_schema = super().schema(method)
        if not self.signature:
            return my_schema
        my_schema.update({
            'items'    : self.signature.schema(method=method)
        })
        return my_schema
# Backwards compatibility with previous sepiida versions
ist = Array

class String(Field):
    TYPE_NAME = 'string'
    def package(self, response):
        return str(response) if response is not None else None

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None

        if not isinstance(data, str):
            raise _raise_validation_type_error(name, str, data)

        self._check_choices(name, data)

        return data

class Integer(Field):
    TYPE_NAME = 'integer'
    def package(self, response):
        return int(response) if response is not None else None

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None

        if not isinstance(data, int) or isinstance(data, bool):
            raise _raise_validation_type_error(name, int, data)

        self._check_choices(name, data)

        return int(data)

    def get_filter_value(self, name, value):
        parsed = float(value)
        if int(parsed) != parsed:
            raise ValueError("It's a float but not an int")
        return int(parsed)

class Float(Field):
    TYPE_NAME = 'float'
    def package(self, response):
        if isinstance(response, float) and any([math.isnan(response), math.isinf(response)]):
            raise ValueError("Programmer provided '{}' which cannot be serialized correctly".format(response))
        return float(response) if response is not None else None

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None

        acceptable_types = [isinstance(data, x) for x in (int, float)]
        unacceptable_types = [isinstance(data, bool)]
        if not any(acceptable_types) or any(unacceptable_types):
            raise _raise_validation_type_error(name, float, data)

        self._check_choices(name, data)

        return float(data)

    def get_filter_value(self, name, value):
        return float(value)

class Boolean(Field):
    TYPE_NAME = 'boolean'
    def package(self, response):
        return bool(response) if response is not None else None

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None

        if not isinstance(data, bool):
            raise _raise_validation_type_error(name, bool, data)

        self._check_choices(name, data)

        return bool(data)

    def get_filter_value(self, name, value):
        lower = value.lower()
        if lower in ('f', 'false') or lower == '0':
            return False
        elif lower in ('t', 'true') or lower == '1':
            return True
        else:
            raise ValueError("{} does not parse to a bool".format(value))

class Date(Field):
    TYPE_NAME = 'iso-8601 date'
    def package(self, response):
        if response is None:
            return None

        if isinstance(response, date):
            return response.isoformat()
        else:
            raise TypeError("{} is not a valid date".format(response))

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None

        if not isinstance(data, str):
            raise _raise_validation_type_error(name, date, data)

        try:
            return arrow.get(data).date()
        except arrow.parser.ParserError:
            raise _raise_validation_type_error(name, date, data)

    def get_filter_value(self, name, value):
        try:
            return arrow.get(value).date()
        except arrow.parser.ParserError:
            raise ValueError("{} does not parse to a date".format(value))

class Datetime(Field):
    TYPE_NAME = 'iso-8601 datetime'
    def package(self, response):
        if response is None:
            return None

        if isinstance(response, datetime):
            return response.isoformat()
        else:
            raise TypeError("{} is not a valid datetime".format(response))

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None

        if not isinstance(data, str):
            raise _raise_validation_type_error(name, datetime, data)

        try:
            return arrow.get(data).datetime
        except arrow.parser.ParserError:
            raise _raise_validation_type_error(name, datetime, data)

    def get_filter_value(self, name, value):
        try:
            return arrow.get(value).datetime
        except arrow.parser.ParserError:
            raise ValueError("{} does not parse to a datetime".format(value))

class UUID(Field):
    TYPE_NAME = 'uuid'
    def package(self, response):
        if response is None:
            return None

        if isinstance(response, uuid.UUID):
            return str(response)
        else:
            raise ValueError("{} is not a valid UUID".format(response))

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None

        if not isinstance(data, str):
            raise _raise_validation_type_error(name, uuid.UUID, data)

        try:
            return uuid.UUID(data)
        except ValueError:
            raise _raise_validation_type_error(name, uuid.UUID, data)

    def get_filter_value(self, name, value):
        return uuid.UUID(value)

def _app_has_endpoint(app, endpoint):
    for rule in app.url_map.iter_rules():
        if rule.endpoint == endpoint:
            return True
    return False

class URI(String):
    TYPE_NAME = 'URI'
    def __init__(self, endpoint, **kwargs):
        super().__init__(**kwargs)
        self.endpoint = endpoint

    def package(self, response):
        if response is None:
            return None
        elif isinstance(response, (int, uuid.UUID)):
            return sepiida.routing.uri(self.endpoint, response)
        elif isinstance(response, str):
            endpoint, _ = sepiida.routing.extract_parameters(flask.current_app, method='GET', url=response)
            assert endpoint == self.endpoint, "URI is valid but for the wrong endpoint"
            return response
        else:
            return sepiida.routing.uri(self.endpoint, **response)

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None
        try:
            route, parameters = sepiida.routing.extract_parameters(flask.current_app, method='GET', url=data)
            if not route:
                raise _raise_validation_error("The URI you provided for {} - {} - is not recognized as a URI".format(name, data))
            if route != self.endpoint:
                msg = (
                    "The URI you provided for {property_name} - {data} - "
                    "appears to be a URI for a {bad_route} resource, not a {good_route} resource".format(
                        property_name   = name,
                        data            = data,
                        bad_route       = route,
                        good_route      = self.endpoint,
                ))
                raise _raise_validation_error(msg)
        except AttributeError:
            raise _raise_validation_type_error(name, 'URI', data)
        parameters = parameters or {}
        parameters['uri'] = data
        return parameters

    def get_filter_value(self, name, value):
        _, params = sepiida.routing.extract_parameters(flask.current_app, method='GET', url=value)
        _uuid = params.get('uuid') or params.get('_id')
        if not _uuid:
            if _app_has_endpoint(flask.current_app, self.endpoint):
                raise ValueError("{} is not a recognized URI for {}".format(value, self.endpoint))
            else:
                raise AppError("The endpoint '{}' is not a valid endpoint for this application".format(self.endpoint))
        return _uuid

class URL(String):
    TYPE_NAME = 'URL'
    def package(self, response):
        if response is None:
            return response
        if not self._valid_url(response):
            message = "The URL supplied by the programmer '{}' is not a valid URL".format(response)
            LOGGER.error(message)
        return self._strip_credentials(response)

    def unpackage(self, name, data, method):
        if self.nullable and data is None:
            return None
        if not self._valid_url(data):
            raise _raise_validation_type_error(name, 'URL', data)
        return data

    @staticmethod
    def _valid_url(url):
        try:
            parsed = urllib.parse.urlparse(url)
            return all((
                bool(parsed.netloc),
                bool(parsed.scheme),
            ))
        except AttributeError:
            return False

    @staticmethod
    def _strip_credentials(url):
        parsed = urllib.parse.urlparse(url)
        netloc = parsed.netloc
        if parsed.username is not None:
            netloc = netloc[len(parsed.username) + 1:]
        if parsed.password is not None:
            netloc = netloc[len(parsed.password) + 1:]
        return urllib.parse.urlunparse((parsed[0], netloc, parsed[2], parsed[3], parsed[4], parsed[5]))

    def get_filter_value(self, name, value):
        if not self._valid_url(value):
            raise ValueError("{} is not a URL".format(value))
        return value
