class FakeSDL:
    """libSDL2, written out in Python.

    Every function is a real ctypes callback carrying SDL's own signature, so the
    binding's declarations land on something that behaves like the library and its
    calls are checked the way ctypes checks them: a call with fewer arguments than
    the argtypes it was given is refused, and a Python buffer handed over where a
    pointer is wanted is refused too. That is the point: the SDL layer is where the
    app was dying, and four separate faults were found in it. None of them could be
    seen from outside, because all four happened after the window had opened.

    Only the standard library is used, and nothing here needs a screen or a device.
    """

    def __init__(self, accept=("",), format_name=b"RGBA8888", events=(),
                 wait_events=(), close_after_waits=0, joysticks=0,
                 game_controllers=0):
        # Which video drivers SDL_Init says yes to. "" is SDL's own choice, made
        # when the binding sets no hint at all.
        self.accept = set(accept)
        self.format_name = format_name
        # What SDL_PollEvent hands back and what SDL_WaitEventTimeout hands back.
        # They are separate here because they are separate in SDL: an event can
        # arrive while the loop is asleep, and that one has already been taken off
        # SDL's queue by the time the loop sees it.
        self.events = list(events)
        self.wait_events = list(wait_events)
        # Append a close event after this many waits, so a loop that should still
        # be running ends rather than the test hanging on it.
        self.close_after_waits = close_after_waits
        self.joysticks = joysticks
        self.game_controllers = game_controllers
        # What has been asked of it, for the tests to read.
        self.driver = None
        self.drivers_tried = []
        self.window = None
        self.textures = []
        self.uploads = []
        self.rendered = 0
        self.presented = 0
        self.waited = 0

        @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, ctypes.c_char_p)
        def set_hint(name, value):
            if name == b"SDL_VIDEODRIVER":
                self.driver = value.decode() if value else ""
            return 1

        @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_uint32)
        def init(flags):
            self.drivers_tried.append(self.driver or "")
            return 0 if (self.driver or "") in self.accept else -1

        @ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int,
                          ctypes.c_int, ctypes.c_int, ctypes.c_int,
                          ctypes.c_uint32)
        def create_window(title, x, y, width, height, flags):
            if (self.driver or "") not in self.accept:
                return None
            self.window = (title.decode(), x, y, width, height, flags)
            return 0x1000

        @ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int,
                          ctypes.c_uint32)
        def create_renderer(window, index, flags):
            return 0x2000 if window else None

        @ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32,
                          ctypes.c_int, ctypes.c_int, ctypes.c_int)
        def create_texture(renderer, fmt, access, width, height):
            self.textures.append((fmt, access, width, height))
            return 0x3000

        @ctypes.CFUNCTYPE(ctypes.c_char_p, ctypes.c_uint32)
        def pixel_format_name(fmt):
            # SDL answers SDL_PIXELFORMAT_UNKNOWN for a number it does not have,
            # which is what makes a wrong constant here visible instead of
            # silently producing a texture in the wrong byte order.
            if fmt == sdlmod.Sdl._format(None, b"ARGB8888"):
                return b"ARGB8888"
            if fmt == sdlmod.Sdl._format(None, b"RGBA8888"):
                return self.format_name
            return b"SDL_PIXELFORMAT_UNKNOWN"

        @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p,
                          ctypes.POINTER(ctypes.c_int), ctypes.c_void_p,
                          ctypes.c_int)
        def update_texture(texture, rect, pixels, pitch):
            self.uploads.append((texture,
                                 None if not rect
                                 else tuple(rect[i] for i in range(4)),
                                 pixels, pitch))
            return 0

        @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p,
                          ctypes.POINTER(ctypes.c_int),
                          ctypes.POINTER(ctypes.c_int))
        def copy(renderer, texture, source, destination):
            self.rendered += 1
            return 0

        @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)
        def clear(renderer):
            return 0

        @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)
        def present(renderer):
            self.presented += 1
            return 0

        @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(sdlmod._Event))
        def poll(target):
            if not target or not self.events:
                return 0
            event = self.events.pop(0)
            ctypes.memmove(target, ctypes.byref(event),
                           ctypes.sizeof(sdlmod._Event))
            return 1

        @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(sdlmod._Event),
                          ctypes.c_int)
        def wait_event(target, milliseconds):
            self.waited += 1
            if self.close_after_waits and self.waited == self.close_after_waits:
                self.events.append(FakeSDL.window_event(sdlmod.WINDOWEVENT_CLOSE))
                return 0
            if not target:
                # SDL has nowhere to put the event, so it says nothing happened.
                # Reading that as a plain sleep is what lost the press that ended
                # the wait: by this point it is already off SDL's own queue.
                return 0
            waiting = self.wait_events or self.events
            if not waiting:
                return 0
            event = waiting.pop(0)
            ctypes.memmove(target, ctypes.byref(event),
                           ctypes.sizeof(sdlmod._Event))
            return 1

        @ctypes.CFUNCTYPE(ctypes.c_int)
        def num_joysticks():
            return self.joysticks

        @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int)
        def is_game_controller(index):
            return int(index < self.game_controllers)

        @ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_int)
        def open_controller(index):
            return 0x4000 + index

        @ctypes.CFUNCTYPE(ctypes.c_char_p, ctypes.c_int)
        def controller_name(index):
            return b"Anbernic Gamepad"

        @ctypes.CFUNCTYPE(None, ctypes.c_void_p)
        def destroy(handle):
            return None

        @ctypes.CFUNCTYPE(None, ctypes.c_uint32)
        def quit_subsystem(flags):
            return None

        @ctypes.CFUNCTYPE(None)
        def quit_all():
            return None

        @ctypes.CFUNCTYPE(ctypes.c_char_p)
        def get_error():
            return b"no such video device"

        @ctypes.CFUNCTYPE(ctypes.c_uint32)
        def get_ticks():
            return 1234

        self.SDL_SetHint = set_hint
        self.SDL_Init = init
        self.SDL_CreateWindow = create_window
        self.SDL_CreateRenderer = create_renderer
        self.SDL_CreateTexture = create_texture
        self.SDL_GetPixelFormatName = pixel_format_name
        self.SDL_UpdateTexture = update_texture
        self.SDL_RenderCopy = copy
        self.SDL_RenderClear = clear
        self.SDL_RenderPresent = present
        self.SDL_PollEvent = poll
        self.SDL_WaitEventTimeout = wait_event
        self.SDL_NumJoysticks = num_joysticks
        self.SDL_IsGameController = is_game_controller
        self.SDL_GameControllerOpen = open_controller
        self.SDL_GameControllerNameForIndex = controller_name
        self.SDL_GameControllerClose = destroy
        self.SDL_DestroyTexture = destroy
        self.SDL_DestroyRenderer = destroy
        self.SDL_DestroyWindow = destroy
        self.SDL_QuitSubSystem = quit_subsystem
        self.SDL_Quit = quit_all
        self.SDL_GetError = get_error
        self.SDL_GetTicks = get_ticks

    @staticmethod
    def window_event(what):
        event = sdlmod._Event()
        event.type = sdlmod.WINDOWEVENT
        event.window.event = what
        return event

    @staticmethod
    def button_event(button):
        event = sdlmod._Event()
        event.type = sdlmod.CONTROLLERBUTTONDOWN
        event.controller_button.button = button
        event.controller_button.state = 1
        return event

    def pixel(self, upload, canvas):
        """The four bytes of the centre pixel of a frame, read back through the
        pointer SDL was handed."""
        offset = ((canvas.height // 2) * canvas.width + canvas.width // 2) * 4
        return bytes((ctypes.c_char * 4)
                     .from_address(self.uploads[upload][2] + offset))


class LaunchTest(unittest.TestCase):
    """Opening the window, with the real Sdl in front of a stand-in library.

    Everything the app does before it can draw anything at all: start SDL, choose
    a video driver, make a window, choose a pixel format, make a texture, and put
    the first frame into it. That is the whole distance between a menu entry that
    does nothing and a picture on the screen.
    """

    def setUp(self):
        self.original = sdlmod.Sdl._load

    def tearDown(self):
        sdlmod.Sdl._load = self.original

    def sdl(self, lib, width=32, height=16):
        sdlmod.Sdl._load = staticmethod(lambda quiet=False: lib)
        return sdlmod.Sdl("PokeForge", width, height)

    def app(self, lib):
        sdlmod.Sdl._load = staticmethod(lambda quiet=False: lib)
        return App(start=Counting(), canvas=Canvas(32, 16))

    def painted(self, colour=(7, 8, 9, 255), width=32, height=16):
        canvas = Canvas(width, height)
        canvas.rect(0, 0, width, height, colour)
        return canvas

    def test_it_opens_a_fullscreen_window_at_the_size_it_was_asked_for(self):
        lib = FakeSDL()
        self.sdl(lib)
        title, x, y, width, height, flags = lib.window
        self.assertEqual("PokeForge", title)
        self.assertEqual((0, 0, 32, 16), (x, y, width, height))
        self.assertTrue(flags & sdlmod.WINDOW_FULLSCREEN)
        self.assertTrue(flags & sdlmod.WINDOW_SHOWN)

    def test_the_first_frame_is_handed_over_and_drawn(self):
        lib = FakeSDL()
        backend = self.sdl(lib)
        canvas = self.painted()
        backend.present(canvas)
        self.assertEqual(1, len(lib.uploads))
        self.assertEqual(32 * 4, lib.uploads[0][3], "the pitch is the row width")
        self.assertEqual(1, lib.rendered)
        self.assertEqual(1, lib.presented)
        self.assertEqual(bytes([7, 8, 9, 255]), lib.pixel(0, canvas))

    def test_the_frame_is_offered_in_the_format_the_buffer_already_is(self):
        # Red, green, blue, alpha in memory is RGBA8888, so the frame goes up with
        # no swapping at all. The constant for it used to be wrong, which quietly
        # made this fall through to the other format and copy every frame.
        lib = FakeSDL()
        backend = self.sdl(lib)
        self.assertFalse(backend._swapped, "this frame should need no swapping")
        self.assertEqual(sdlmod.Sdl._format(None, b"RGBA8888"),
                         lib.textures[0][0])

    def test_a_library_without_rgba_falls_back_to_the_swapped_one(self):
        # SDL answers a name it does not recognise for a format it does not have,
        # so a refused format has to be noticed rather than trusted.
        lib = FakeSDL(format_name=b"SDL_PIXELFORMAT_UNKNOWN")
        backend = self.sdl(lib)
        self.assertTrue(backend._swapped, "the fallback is the swapped one")
        self.assertEqual(sdlmod.Sdl._format(None, b"ARGB8888"),
                         lib.textures[0][0])
        canvas = self.painted((10, 20, 30, 255))
        backend.present(canvas)
        self.assertEqual(bytes([30, 20, 10, 255]), lib.pixel(0, canvas),
                         "blue first is what that format wants")

    def test_it_moves_on_to_the_next_video_driver_when_one_will_not_open(self):
        # The first driver is tried, fails, and the next one has to be tried from
        # a clean start rather than carrying the failed attempt with it.
        lib = FakeSDL(accept=("fbcon",))
        self.sdl(lib)
        self.assertEqual(["", "KMSDRM", "drm", "fbcon"], lib.drivers_tried)

    def test_the_driver_the_environment_asked_for_is_tried_first(self):
        lib = FakeSDL(accept=("KMSDRM",))
        was = os.environ.get("SDL_VIDEODRIVER")
        os.environ["SDL_VIDEODRIVER"] = "KMSDRM"
        try:
            self.sdl(lib)
        finally:
            if was is None:
                del os.environ["SDL_VIDEODRIVER"]
            else:
                os.environ["SDL_VIDEODRIVER"] = was
        self.assertEqual(["KMSDRM"], lib.drivers_tried,
                         "a launcher that set the driver knows what it is doing")

    def test_no_driver_at_all_says_which_ones_it_tried(self):
        lib = FakeSDL(accept=("nonesuch",))
        with self.assertRaises(sdlmod.SdlError) as raised:
            self.sdl(lib)
        message = str(raised.exception)
        for driver in sdlmod.VIDEO_DRIVERS:
            self.assertIn(driver or "default", message,
                          "the reason it could not start has to name the attempt")
        self.assertIn("no such video device", message, "and SDL's own reason")

    def test_controllers_are_opened_and_named(self):
        lib = FakeSDL(joysticks=2, game_controllers=1)
        backend = self.sdl(lib)
        self.assertEqual(1, len(backend.controllers))
        self.assertEqual(["Anbernic Gamepad"], backend.controller_names())

    def test_the_app_runs_a_frame_and_leaves_when_it_is_told_to(self):
        # The whole way through on the real Sdl: window, first frame, an event, and
        # back out. This is the path that ended the app on the device.
        lib = FakeSDL(events=[FakeSDL.window_event(sdlmod.WINDOWEVENT_CLOSE)])
        app = self.app(lib)
        self.assertEqual(0, app.run())
        self.assertGreaterEqual(lib.presented, 1,
                                "the first frame has to reach the screen")

    def test_the_app_keeps_running_through_the_window_being_shown(self):
        # SHOWN and EXPOSED arrive before anything the reader does. Being told the
        # window is on the screen was being read as the window going away, so the
        # app drew one frame and stopped. With the fault the loop is gone after a
        # single wait; with it held, it waits twice before it is told to stop.
        lib = FakeSDL(events=[FakeSDL.window_event(sdlmod.WINDOWEVENT_SHOWN),
                              FakeSDL.window_event(sdlmod.WINDOWEVENT_EXPOSED)],
                      close_after_waits=2)
        app = self.app(lib)
        self.assertEqual(0, app.run())
        self.assertGreaterEqual(lib.presented, 1)
        self.assertEqual(2, lib.waited, "the app gave up while it was on screen")

    def test_the_press_that_ended_the_wait_still_reaches_the_screen(self):
        # SDL_WaitEventTimeout takes the event off SDL's queue, so the one that
        # ended the wait is the one most easily thrown away. It is asked for by a
        # wait rather than handed over by a poll here, which is the only way that
        # press exists at all.
        lib = FakeSDL(wait_events=[FakeSDL.button_event(sdlmod.BUTTON_B)],
                      close_after_waits=3)
        app = self.app(lib)
        self.assertEqual(0, app.run())
        self.assertEqual([], app.stack, "B on the bottom screen ends the app")
        self.assertEqual(1, lib.waited,
                         "the press that ended the wait was dropped")
