import inspect
import os

from functools import wraps

from .exceptions import retry


def override_eq(f):
  """
  A decorator that converts the wrapped function into a class that will be used to determine if two arguments are equal
  when it is used as the left-side operand of an '==' expression. This can be useful for functions / statements that
  compare against an operand rather than allowing a custom comparator function.

  Paramters:
  - f: a single-argument function that will be passed the right side operand when this decorator is used in an equality
    comparison operation

  See the function '__eq_any__' for an example.

  Examples:

  @override_eq
  def is_positive(n):
    return n > 0
  is_positive == 1 # True
  is_positive == -1 # False

  override_eq(lambda i: i > 0) == 1 # True
  override_eq(lambda i: i > 0) == -1 # False
  """

  class OverrideEquals(object):

    def __eq__(self, other):
      return f(other)

  return OverrideEquals()


@override_eq
def __eq_any__(right_side):
  """
  A class that always returns True in an equality test (if it is the left argument)

  Examples:
  __eq_any__ == 1 # True
  __eq_any__ == 2 # True
  (1, __eq_any__) == (1, 2) # True
  """

  return True


class EventualAssertionError(AssertionError):
  pass


def assert_eventual(expected_output, delay_s=0.5, max=10, exp_backoff=1):
  """
  A decorator that retries the wrapped function until its output matches the 'expected_output'. If the retry conditions
  are exhausted, this function throws an EventualAssertionError (subclass of AssertionError), otherwise it returns the
  function output as normal.

  Note that you can also use this function inline to replace an assert statement:
  assert eventually_consistent_test_func() == 5 # May fail if the function takes a bit to return the expected
  assert_eventual(5)(eventually_consistent_test_func)() # Automatically retries

  Note that 'expected_output' will always be used as the left side operator when comparing equality against the output,
  so you could use a custom comparator class that overrides the '__eq__' function to do partial matching (see
  'override_eq' function).

  Parameters:
  - expected_output: the value against which to compare the actual function output

  Examples:

  i = 0
  @assert_eventual(5, delay_s=0)
  def test():
    global i
    i += 1
    return i
  test() # Returns 5 after running five times (with four retries)

  @assert_eventual(override_eq(lambda right_side: right_side['a'] == 1))
  def test():
    return { 'a': 1, 'b': 2 }
  test() # Returns { 'a': 1, 'b': 2 }

  assert_eventual(5)(lambda: 5)() # Returns 5
  """

  def decorator(f):
    @retry(EventualAssertionError, delay_s=delay_s, max=max, exp_backoff=exp_backoff)
    @wraps(f)
    def run(*args, **kwargs):
      output = f(*args, **kwargs)
      if expected_output != output:
        raise EventualAssertionError(output)
      return output
    return run
  return decorator


def exec_module_at_path(path):
  """
  Run the code in a module at the given path (either a file or directory containing an __init__.py), but don't import
  it into our current namespace. Relative paths will be resolved using the path of the calling function's module.

  Warning: unlike python import statements, this function does not prohibit circular execution.

  Example:

  /module_a.py
    print("module_a")

  /module_b/__init__.py
    print("module_b")

  /test_1.py
    load_path("module_a")
    load_path("module_b")

  /dir/test_2.py
    load_path("../module_a")
    load_path("../module_b")

  /test_3.py
    load_path("module_a")

  # Note that it doesn't matter from what directory I run these examples, since the path is relative to the module in
  # which the function is called
  $ cd / && python test_1.py
  > module_a
  > module_b
  $ cd /dir && python /test_1.py
  > module_a
  > module_b
  $ cd /dir && python test_2.py
  > module_a
  > module_b
  $ python /test_3.py
  > Exception
  """

  frame = inspect.stack()[1]
  module = inspect.getmodule(frame[0])
  dir = os.path.dirname(module.__file__)
  path = os.path.join(dir, path)

  if not os.path.isfile(path):
    path = os.path.join(path, "__init__.py")
  if not os.path.isfile(path):
    raise Exception("Module not found at path '%s'" % path)
  with open(path) as file:
    exec(file.read())
