import ctypes
import mock
import unittest
import sys
import warnings
from contextlib import contextmanager
from nose import SkipTest

import nifpga
from nifpga.statuscheckedlibrary import (check_status,
                                         NamedArgtype,
                                         LibraryFunctionInfo,
                                         LibraryNotFoundError,
                                         StatusCheckedLibrary)

python_version = 3 if sys.version_info >= (3, 0) else 2


def raise_an_exception():
    """
    A helper for NiFpgaStatusExceptionTest
    """
    session = ctypes.c_int32(0x0000beef)
    fifo = ctypes.c_uint32(0x0000f1f0)
    data = ctypes.c_uint64(0x0000da7a)
    number_of_elements = ctypes.c_size_t(0x100)
    timeout_ms = ctypes.c_size_t(0x200)
    elements_remaining = ctypes.c_size_t(0x300)
    bogus_string_argument = ctypes.c_char_p(b"I am a string")
    exception = nifpga.FifoTimeoutError(
        function_name="Dummy Function Name",
        argument_names=["session",
                        "fifo",
                        "data",
                        "number of elements",
                        "timeout ms",
                        "elements remaining",
                        "a bogus string argument"],
        function_args=(session,
                       fifo,
                       data,
                       number_of_elements,
                       timeout_ms,
                       elements_remaining,
                       bogus_string_argument))
    raise exception


class NiFpgaStatusExceptionTest(unittest.TestCase):
    def test_autogenerated_status_warning_and_error_classes_exist(self):
        nifpga.FifoTimeoutWarning
        nifpga.FifoTimeoutError

    def test_can_get_arguments_from_exception(self):
        try:
            raise_an_exception()
            self.fail("An exception should have been raised")
        except nifpga.FifoTimeoutError as e:
            self.assertEqual(-50400, e.get_code())
            self.assertEqual("FifoTimeout", e.get_code_string())
            self.assertEqual("Dummy Function Name", e.get_function_name())

            args = e.get_args()
            self.assertEqual(args["session"], 0x0000beef)
            self.assertEqual(args["fifo"], 0x0000f1f0)
            self.assertEqual(args["data"], 0x0000da7a)
            self.assertEqual(args["number of elements"], 0x100)
            self.assertEqual(args["timeout ms"], 0x200)
            self.assertEqual(args["elements remaining"], 0x300)
            self.assertEqual(args["a bogus string argument"], b"I am a string")

            # Spot check a couple different types of args in the
            # printed string that should be helpful for readability
            exception_str = str(e)
            # numbers in hex!
            self.assertIn("session: 0xbeef", exception_str)
            # strings have single quotes around them
            if python_version == 2:
                self.assertIn("a bogus string argument: 'I am a string'", exception_str)
            else:
                self.assertIn("a bogus string argument: b'I am a string'", exception_str)

    def test_status_exceptions_can_be_pickled_across_processes(self):
        try:
            import jobrunner
        except ImportError:
            raise SkipTest("jobrunner not installed, skipping")
        runner = jobrunner.JobRunner(jobrunner.JobRunner.RUN_MODE_MULTIPROCESS,
                                     runnables=[raise_an_exception],
                                     auto_assert=False)
        result = runner.run()[0]
        self.assertTrue(result.exception_occured())
        self.assertEqual(str(result.err_type), str(nifpga.FifoTimeoutError))
        self.assertIn("session: 0xbeef", result.err_class)
        if python_version == 2:
            self.assertIn("a bogus string argument: 'I am a string'", result.err_class)
        else:
            self.assertIn("a bogus string argument: b'I am a string'", result.err_class)


@check_status(function_name="Fake Function Name", argument_names=["code"])
def return_a_checked_status(code):
    """
    A helper for CheckStatusTest
    """
    return code


@contextmanager
def assert_warns(warning):
    with warnings.catch_warnings(record=True) as w:
        warnings.simplefilter("always")
        yield
        # verify the warning occured
        assert len(w) == 1
        assert isinstance(w[0].message, warning)


class CheckStatusTest(unittest.TestCase):
    def test_success(self):
        return_a_checked_status(0)

    def test_get_known_error(self):
        with self.assertRaises(nifpga.FifoTimeoutError):
            return_a_checked_status(-50400)

    def test_get_known_warning(self):
        with assert_warns(nifpga.FifoTimeoutWarning):
            return_a_checked_status(50400)

    def test_get_unknown_error(self):
        with self.assertRaises(nifpga.UnknownError):
            return_a_checked_status(-1)

    def test_get_unknown_warning(self):
        with assert_warns(nifpga.UnknownWarning):
            return_a_checked_status(1)


class StatusCheckedLibraryTestCRunTime(unittest.TestCase):
    """
    Since we can't load NiFpga on a dev machine unless we have all its
    dependencies installed (i.e. a bunch of NI software we don't want on
    a dev machine), we'll cheat and use the C runtime library and
    atoi. atoi doesn't really return a NiFpga_Status, but we can pretend.
    """
    def setUp(self):
        self._c_runtime = StatusCheckedLibrary(
            "c",
            library_function_infos=[
                LibraryFunctionInfo(
                    pretty_name="c_atoi",
                    name_in_library="atoi",
                    named_argtypes=[
                        NamedArgtype("nptr", ctypes.c_char_p),
                    ])
            ])

    def test_success(self):
        self._c_runtime.c_atoi(b"0")
        self._c_runtime["c_atoi"](b"0")

    def test_get_unknown_error(self):
        with self.assertRaises(nifpga.UnknownError):
            self._c_runtime.c_atoi(b"-1")

    def test_get_unknown_warning(self):
        with warnings.catch_warnings(record=True) as w:
            self._c_runtime.c_atoi(b"1")

            assert len(w) == 1
            warning = w[0].message
            # Make sure all this propagates into the warning.
            self.assertEqual(1, warning.get_code())
            self.assertEqual(b"1", warning.get_args()["nptr"])

            # These make the warning message readable
            self.assertIn("atoi", str(warning))
            if python_version == 2:
                self.assertIn("nptr: '1'", str(warning))
            else:
                self.assertIn("nptr: b'1'", str(warning))


class StatusCheckedLibraryTestFunctionDoesntExist(unittest.TestCase):
    """
    New versions of NiFpga will have new functions.  We want the API to support
    old versions of NiFpga without erroring because it can't find certain symbols.
    So StatusCheckedLibrary will return VersionMismatchError for symbols it can't
    find.
    """
    def setUp(self):
        self._c_runtime = StatusCheckedLibrary(
            "c",
            library_function_infos=[
                LibraryFunctionInfo(
                    pretty_name="DoesntExist",
                    name_in_library="functionThatDoesntExist",
                    named_argtypes=[
                        NamedArgtype("nptr", ctypes.c_char_p),
                    ])
            ])

    def test_correct_error(self):
        with self.assertRaises(nifpga.VersionMismatchError):
            self._c_runtime.DoesntExist(b"0")
        with self.assertRaises(nifpga.VersionMismatchError):
            self._c_runtime["DoesntExist"](b"0")


class StatusCheckedLibraryTestMockedLibrary(unittest.TestCase):
    """
    Since we can't load NiFpga on a dev machine unless we have all its
    dependencies installed (i.e. a bunch of NI software we don't want on
    a dev machine), we'll monkey patch and use mocked libraries.
    """
    # so nose shows test names instead of docstrings
    def shortDescription(self):
        return None

    @mock.patch('nifpga.statuscheckedlibrary.ctypes.util.find_library')
    @mock.patch('nifpga.statuscheckedlibrary.ctypes.cdll')
    def setUp(self, mock_cdll, mock_find_library):
        """
        Setup up self._library so that self._library.AwesomeFunction(int, str)
        can be called, and the return value can be changed by setting
        self._mock_awesome_function.return_value.
        """
        mock_loaded_library = mock.Mock()
        mock_cdll.LoadLibrary.return_value = mock_loaded_library
        self._mock_awesome_function = mock.Mock()
        self._mock_awesome_function.__name__ = "Entrypoint_AwesomeFunction"
        mock_loaded_library.Entrypoint_AwesomeFunction = self._mock_awesome_function
        self._library = StatusCheckedLibrary(
            library_name="CoolLibrary",
            library_function_infos=[
                LibraryFunctionInfo(
                    pretty_name="AwesomeFunction",
                    name_in_library="Entrypoint_AwesomeFunction",
                    named_argtypes=[NamedArgtype("some_integer", ctypes.c_uint32),
                                    NamedArgtype("some_string", ctypes.c_char_p)])
            ])

    def test_good_error_message_from_memory_full_error(self):
        """ Tests a good error message from a library call that fails.
        1. Correctly converts -52000 to NiFpgaMemoryFullError
        2. An integer arg gets printed as hex (easier to debug than decimal)
        3. A string arg gets printed with quotes surrounding it (so it's obviously a string)
        """
        self._mock_awesome_function.return_value = -52000
        try:
            self._library.AwesomeFunction(ctypes.c_uint32(33), ctypes.c_char_p(b"2"))
            self.fail("AwesomeFunction should have raised MemoryFull")
        except nifpga.MemoryFullError as e:
            if python_version == 2:
                self.assertEqual(
                    "Error: MemoryFull (-52000) when calling 'Entrypoint_AwesomeFunction' with arguments:"
                    "\n\tsome_integer: 0x21L"
                    "\n\tsome_string: '2'", str(e))
            else:
                self.assertEqual(
                    "Error: MemoryFull (-52000) when calling 'Entrypoint_AwesomeFunction' with arguments:"
                    "\n\tsome_integer: 0x21"
                    "\n\tsome_string: b'2'", str(e))

    def test_success_when_library_function_is_success(self):
        """ Tests that a 0 status return value does not raise any errors. """
        self._mock_awesome_function.return_value = 0
        self._library.AwesomeFunction(ctypes.c_uint32(33), ctypes.c_char_p(b"2"))

    def test_good_error_message_if_wrong_number_of_arguments(self):
        """ Tests that calling a function with wrong number of arguments is error """
        try:
            self._library.AwesomeFunction(ctypes.c_uint32(33))
            self.fail("AwesomeFunction should have raised TypeError")
        except TypeError as e:
            self.assertEqual("Entrypoint_AwesomeFunction takes exactly 2 arguments (1 given)", str(e))


class NiFpgaTest(unittest.TestCase):
    def test_that_we_at_least_get_to_try_loading_library(self):
        # We can't do much without NiFpga and other NI software actually
        # being installed, but on a dev machine we can at least
        # catch a few more errors by trying to creating a NiFpga instance and
        # expect to fail when the library can't be found.
        try:
            nifpga.nifpga._NiFpga()
        except LibraryNotFoundError:
            pass