"""
This module contains functions designed for use with the 'input_format' parameter for the 'OPService.route' decorator.

Note that it's likely that this module will be imported using '*', so we're careful to obscure any globals using the
'_' prefix.
"""

import datetime as _datetime
import iso8601 as _iso8601
import json as _json
import re as _re
import uuid as _uuid

from fnmatch import fnmatch as _fnmatch
from starlette.datastructures import UploadFile as _UploadFile

from .type_validation import describe as _describe, validate as _validate


def rename(name):
  """
  A decorator for renaming functions - particularly useful for these input validation functions as the actual function
  name is exposed via the /explore route.
  """
  def decorator(f):
    f.__name__ = name
    return f
  return decorator


def regex(pattern):
  """
  An input validation helper for regular expressions. The regex must match the entire string (the '^' and '$' characters
  are implied).
  
  regex("[a-z]+")("apple") -> "apple"
  regex("[a-z]+")("appleJacksCereal") -> Exception
  """
  compiled_pattern = _re.compile(pattern + '$')
  @rename('regex(%s)' % pattern)
  def validate(value):
    match = compiled_pattern.match(value)
    if not match:
      raise Exception('Input does not match regular expression pattern %s' % pattern)
    return value
  return validate


def one_of(*args):
  """
  An input validation helper for enumerations. Uses the 'in' operator to compare the input to the argument
  list. Note that args should be comprised of primitives.
  
  one_of("apple", "ball")("apple") -> "apple"
  one_of("apple", "ball")("car") -> Exception
  """
  @rename('one_of(%s)' % ('%s' % list(args))[1:-1])
  def validate(value):
    if value not in args:
      raise Exception('Input is not in allowed values')
    return value
  return validate


def domain_name(value):
  match = _re.match('(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.)+[a-zA-Z]{2,63}$)', value)
  if not match:
    raise Exception('Input is not a valid domain name')
  return value


def _numeric_type_range(range_text, numeric_type_format):
  """
  Parses the input value as a numeric type (using 'numeric'), and makes sure that the value falls within the given
  range, obeying inclusivity vs. exclusivity as denoted by the brackets vs. parantheses.
  
  numeric_range("[0, 10]")(5) -> 5
  numeric_range("[0, 10]")("5") -> 5
  numeric_range("[0, 10]")(-1) -> Exception
  numeric_range("[0, 10]")(0) -> 0
  numeric_range("(0, 10]")(0) -> Exception
  numeric_range("[0, 10]")("not_a_number") -> Exception
  numeric_range("[0, ∞)")(10) -> 10
  numeric_range("[0, inf)")(10) -> 10
  numeric_range("[0, inf]")("inf") -> inf # This returns a float (python parses 'inf' correctly)
  numeric_range("[-∞, ∞]")(0) -> 0 # This useless range matches all numbers...
  """
  condensed_range_text = range_text.replace(' ', '')
  match = _re.match('^(\[|\()([-+]?(\d*\.\d+|\d+|inf|∞)),([-+]?(\d*\.\d+|\d+|inf|∞))(\]|\))$', condensed_range_text)
  min_inclusive = (match.group(1) == '[')
  from_min = numeric(match.group(2).replace('∞', 'inf'))
  to_max = numeric(match.group(4).replace('∞', 'inf'))
  max_inclusive = (match.group(6) == ']')

  @rename('%s(%s)' % (numeric_type_format.__name__, range_text))
  def validate(value):
    numeric_value = numeric_type_format(value)
    if not (min_inclusive and (numeric_value >= from_min) or (numeric_value > from_min)):
      raise Exception('Input value is too small')
    if not (max_inclusive and (numeric_value <= to_max) or (numeric_value < to_max)):
      raise Exception('Input value is too large')
    return numeric_value
  return validate


def int_range(range_text):
  return _numeric_type_range(range_text, int)


def numeric_range(range_text):
  return _numeric_type_range(range_text, numeric)


def numeric(value):
  """
  Parses this value as a numeric type, first trying 'int' then 'float'
  
  numeric(1.5) -> 1.5
  numeric("1.5") -> 1.0
  numeric(1) -> 1
  numeric(1.0) -> 1.0
  numeric("1.0") -> 1.0
  numeric("not_numeric") -> Exception
  """
  if isinstance(value, float):
    return value
  try:
    return int(value)
  except ValueError:
    return float(value)


def iso_date_str(value):
  if type(value) == _datetime.date:
    return value
  # The iso8601 module is a little too forgiving here, so first check against this regex:
  value = regex('\d{4}-\d{2}-\d{2}')(value)
  d = _iso8601.parse_date(value)
  return d.date()


def iso_datetime_str(value):
  # The input value is required to have a timezone, so this is really RFC 3339 instead of ISO 8601
  if type(value) == _datetime.datetime:
    d = value
  else:
    d = _iso8601.parse_date(value, default_timezone=None)
  if (d.tzinfo is None) or (d.tzinfo.utcoffset(d) is None):
    raise Exception('Missing timezone')
  return d


def non_empty_str(value):
  return truthy(str)(value)


def callable_with_arity(i):
  @rename('callable_with_arity(%i)' % i)
  def validate(value):
    if not callable(value):
      raise Exception('Value is not callable')
    arg_count = value.__code__.co_argcount
    if (arg_count != i):
      raise Exception('Expected callable to take %i arguments (got %i)' % (i, arg_count))
    return value
  return validate


def anything(value):
  return value


def uuid_str(value):
  # Value is either already a UUID or a string that can be parsed as one
  return value if type(value) == _uuid.UUID else _uuid.UUID(value)


@rename('JSON blob')
def json_blob(value):
  _json.loads(value)
  return value


def file_upload(content_types=[], max_size=None):
  conditions = [ content_types and 'content_types=%s' % content_types, max_size and 'max_size=%s' % max_size ]
  conditions_str = (', ').join([ i for i in conditions if i ])
  @rename('file_upload%s' % ('(%s)' % conditions_str if conditions_str else '') )
  def validate(value):
    if not isinstance(value, _UploadFile):
      raise Exception('Expected file')
    if content_types and not any([ _fnmatch(value.content_type, x) for x in content_types ]):
      raise Exception('Unexpected content-type')
    if max_size:
      value.file.seek(0, 2)
      if value.file.tell() > max_size:
        raise Exception('File is too large')
      value.file.seek(0)
    return value
  return validate


# Short for variable_dict
def v_dict(key=anything, value=anything):
  @rename('{ %s: %s, … }' % (_describe(key), _describe(value)))
  def validate(input_value):
    if type(input_value) is not dict:
      raise Exception('Expecting dictionary')
    output = {}
    for i, j in input_value.items():
      output[ _validate(i, key) ] = _validate(j, value)
    return output
  return validate


@rename('true')
def is_true(value):
  if value is True:
    return True
  raise Exception()


# The following functions cannot be used alone, but instead can be used to chain / compose various other input
# validation functions:


def truthy(condition):
  @rename('truthy(%s)' % _describe(condition))
  def validate(value):
    if not value:
      raise Exception('Input is not truthy')
    return _validate(value, condition)
  return validate


def nullable(condition):
  @rename('nullable(%s)' % _describe(condition))
  def validate(value):
    if value is None:
      return value
    return _validate(value, condition)
  return validate


def non_nullable(condition):
  @rename('non_nullable(%s)' % _describe(condition))
  def validate(value):
    if value is None:
      raise Exception('Input cannot be null')
    return _validate(value, condition)
  return validate


# It's not always constructive / informative to include the entire type validation as part of the input format because
# it can be hard to describe. The following functions are accessors for already-validated input that provide additional
# validation beyond what makes sense to describe in the input format.

def only_item(one_item_dict):
  """
  Extracts a single key / value pair as a tuple from the input dictionary

  Typical use would be with an input format that requires only one of a number of optional keys:
  { "[a]": str, "[b]": int, "[c]": { "x": int, "y": int } }

  only_item({ "a": "abc" }) -> ("a", "abc")
  only_item({ "b": 123 }) -> ("b", 123)
  only_item({ "d": { "x": 1, "y": 2 } }) -> ("d", { "x": 1, "y": 2 })
  only_item({ "a": "123" "b": 123 }) -> Exception
  """

  if (len(one_item_dict) != 1):
    raise Exception('Expected exactly one key')
  for item in one_item_dict.items():
    return item
