import functools
from typing import Callable, List, Optional, Sequence, Union, Any

from fakeredis import _msgs as msgs
from fakeredis._command_args_parsing import extract_args
from fakeredis._commands import Key, command, Int, CommandItem, Timeout, fix_range
from fakeredis._helpers import OK, SimpleError, SimpleString, casematch
from fakeredis.commands_mixins._mixin_base import CommandsMixinBase


def _list_pop_count(get_slice: Callable[[int], slice], key: CommandItem, count: int) -> Optional[List[bytes]]:
    if not key:
        return None
    elif type(key.value) is not list:
        raise SimpleError(msgs.WRONGTYPE_MSG)
    slc = get_slice(count)
    ret = key.value[slc]
    del key.value[slc]
    key.updated()
    return ret


def _list_pop(get_slice: Callable[[int], slice], key: CommandItem, *args: bytes) -> Optional[Union[bytes, List[bytes]]]:
    """Implements lpop and rpop.

    `get_slice` must take a count and return a slice expression for the range to pop.
    """
    # This implementation is somewhat contorted to match the odd
    # behaviours described in https://github.com/redis/redis/issues/9680.
    count = 1
    if len(args) > 1:
        raise SimpleError(msgs.SYNTAX_ERROR_MSG)
    elif len(args) == 1:
        count = Int.decode(args[0], msgs.INDEX_NEGATIVE_ERROR_MSG)
        if count < 0:
            raise SimpleError(msgs.INDEX_NEGATIVE_ERROR_MSG)
    ret = _list_pop_count(get_slice, key, count)
    if ret and not args:
        return ret[0]
    return ret


class ListCommandsMixin(CommandsMixinBase):
    _blocking: Callable[[Optional[Union[float, int]], Callable[[bool], Any]], Any]

    def _bpop_pass(
        self, keys: List[bytes], op: Callable[[List[bytes]], bytes], first_pass: bool
    ) -> Optional[List[bytes]]:
        for key in keys:
            item = CommandItem(key, self._db, item=self._db.get(key), default=[])
            if not isinstance(item.value, list):
                if first_pass:
                    raise SimpleError(msgs.WRONGTYPE_MSG)
                else:
                    continue
            if item.value:
                ret = op(item.value)
                item.updated()
                item.writeback()
                return [key, ret]
        return None

    def _bpop(self, args: Any, op: Callable[[List[bytes]], bytes]) -> Any:
        keys = args[:-1]
        timeout = Timeout.decode(args[-1])
        return self._blocking(timeout, functools.partial(self._bpop_pass, keys, op))

    @command((bytes, bytes), (bytes,), flags=msgs.FLAG_NO_SCRIPT)
    def blpop(self, *args: bytes) -> Any:
        return self._bpop(args, lambda lst: lst.pop(0))

    @command((bytes, bytes), (bytes,), flags=msgs.FLAG_NO_SCRIPT)
    def brpop(self, *args: bytes) -> Any:
        return self._bpop(args, lambda lst: lst.pop())

    def _brpoplpush_pass(self, source: bytes, destination: bytes, first_pass: bool) -> Any:
        src = CommandItem(source, self._db, item=self._db.get(source), default=[])
        if not isinstance(src.value, list):
            if first_pass:
                raise SimpleError(msgs.WRONGTYPE_MSG)
            else:
                return None
        if not src.value:
            return None  # Empty list
        dst = CommandItem(destination, self._db, item=self._db.get(destination), default=[])
        if not isinstance(dst.value, list):
            raise SimpleError(msgs.WRONGTYPE_MSG)
        el = src.value.pop()
        dst.value.insert(0, el)
        src.updated()
        src.writeback()
        if destination != source:
            # Ensure writeback only happens once
            dst.updated()
            dst.writeback()
        return el

    @command(name="BRPOPLPUSH", fixed=(bytes, bytes, Timeout), flags=msgs.FLAG_NO_SCRIPT)
    def brpoplpush(self, source: bytes, destination: bytes, timeout: float) -> Any:
        return self._blocking(timeout, functools.partial(self._brpoplpush_pass, source, destination))

    @command((Key(list, None), Int))
    def lindex(self, key: CommandItem, index: int) -> Any:
        try:
            return key.value[index]
        except IndexError:
            return None

    @command((Key(list), bytes, bytes, bytes))
    def linsert(self, key: CommandItem, where: bytes, pivot: bytes, value: bytes) -> int:
        if not casematch(where, b"before") and not casematch(where, b"after"):
            raise SimpleError(msgs.SYNTAX_ERROR_MSG)
        if not key:
            return 0
        else:
            try:
                index = key.value.index(pivot)
            except ValueError:
                return -1
            if casematch(where, b"after"):
                index += 1
            key.value.insert(index, value)
            key.updated()
            return len(key.value)

    @command((Key(list),))
    def llen(self, key: CommandItem) -> int:
        return len(key.value)

    def _lmove(
        self,
        first_list: CommandItem,
        second_list: CommandItem,
        src: bytes,
        dst: bytes,
        first_pass: bool,
    ) -> Any:
        if (not casematch(src, b"left") and not casematch(src, b"right")) or (
            not casematch(dst, b"left") and not casematch(dst, b"right")
        ):
            raise SimpleError(msgs.SYNTAX_ERROR_MSG)

        el = self.rpop(first_list) if casematch(src, b"RIGHT") else self.lpop(first_list)
        self.lpush(second_list, el) if casematch(dst, b"LEFT") else self.rpush(second_list, el)
        return el

    @command((Key(list, None), Key(list), SimpleString, SimpleString))
    def lmove(self, first_list: CommandItem, second_list: CommandItem, src: bytes, dst: bytes) -> Any:
        return self._lmove(first_list, second_list, src, dst, False)

    @command((Key(list, None), Key(list), SimpleString, SimpleString, Timeout))
    def blmove(
        self,
        first_list: CommandItem,
        second_list: CommandItem,
        src: bytes,
        dst: bytes,
        timeout: float,
    ) -> Any:
        return self._blocking(timeout, functools.partial(self._lmove, first_list, second_list, src, dst))

    @command(fixed=(Key(),), repeat=(bytes,))
    def lpop(self, key: CommandItem, *args: bytes) -> Optional[Union[bytes, List[bytes]]]:
        return _list_pop(lambda count: slice(None, count), key, *args)

    def _lmpop(self, keys: Sequence[bytes], count: int, direction_left: bool, first_pass: bool) -> Optional[List[Any]]:
        if direction_left:
            op = lambda count: slice(None, count)  # noqa:E731
        else:
            op = lambda count: slice(None, -count - 1, -1)  # noqa:E731

        for key in keys:
            item = CommandItem(key, self._db, item=self._db.get(key), default=[])
            res = _list_pop_count(op, item, count)
            if res:
                return [key, res]
        return None

    @command(fixed=(Int,), repeat=(bytes,))
    def lmpop(self, numkeys: int, *args: bytes) -> Optional[List[Any]]:
        if numkeys <= 0:
            raise SimpleError(msgs.NUMKEYS_GREATER_THAN_ZERO_MSG)
        if casematch(args[-2], b"count"):
            count = Int.decode(args[-1])
            args = args[:-2]
        else:
            count = 1
        if len(args) != numkeys + 1 or (not casematch(args[-1], b"left") and not casematch(args[-1], b"right")):
            raise SimpleError(msgs.SYNTAX_ERROR_MSG)

        return self._lmpop(args[:-1], count, casematch(args[-1], b"left"), False)

    @command(fixed=(Timeout, Int), repeat=(bytes,))
    def blmpop(self, timeout: float, numkeys: int, *args: bytes) -> Any:
        if numkeys <= 0:
            raise SimpleError(msgs.NUMKEYS_GREATER_THAN_ZERO_MSG)
        if casematch(args[-2], b"count"):
            count = Int.decode(args[-1])
            args = args[:-2]
        else:
            count = 1
        if len(args) != numkeys + 1 or (not casematch(args[-1], b"left") and not casematch(args[-1], b"right")):
            raise SimpleError(msgs.SYNTAX_ERROR_MSG)

        return self._blocking(
            timeout,
            functools.partial(self._lmpop, args[:-1], count, casematch(args[-1], b"left")),
        )

    @command((Key(list), bytes), (bytes,))
    def lpush(self, key: CommandItem, *values: bytes) -> int:
        for value in values:
            key.value.insert(0, value)
        key.updated()
        return len(key.value)

    @command((Key(list), bytes), (bytes,))
    def lpushx(self, key: CommandItem, *values: bytes) -> Any:
        if not key:
            return 0
        return self.lpush(key, *values)

    @command((Key(list), Int, Int))
    def lrange(self, key: CommandItem, start: int, stop: int) -> Any:
        start, stop = fix_range(start, stop, len(key.value))
        return key.value[start:stop]

    @command((Key(list), Int, bytes))
    def lrem(self, key: CommandItem, count: int, value: bytes) -> int:
        a_list = key.value
        found = []
        for i, el in enumerate(a_list):
            if el == value:
                found.append(i)
        if count > 0:
            indices_to_remove = found[:count]
        elif count < 0:
            indices_to_remove = found[count:]
        else:
            indices_to_remove = found
        # Iterating in reverse order to ensure the indices
        # remain valid during deletion.
        for index in reversed(indices_to_remove):
            del a_list[index]
        if indices_to_remove:
            key.updated()
        return len(indices_to_remove)

    @command((Key(list), bytes, bytes))
    def lset(self, key: CommandItem, index: bytes, value: bytes) -> SimpleString:
        if not key:
            raise SimpleError(msgs.NO_KEY_MSG)
        idx = Int.decode(index)
        try:
            key.value[idx] = value
            key.updated()
        except IndexError:
            raise SimpleError(msgs.INDEX_ERROR_MSG)
        return OK

    @command((Key(list), Int, Int))
    def ltrim(self, key: CommandItem, start: int, stop: int) -> SimpleString:
        if key:
            end: Optional[int] = None if stop == -1 else stop + 1
            new_value = key.value[start:end]
            # TODO: check if this should actually be conditional
            if len(new_value) != len(key.value):
                key.update(new_value)
        return OK

    @command(fixed=(Key(),), repeat=(bytes,))
    def rpop(self, key: CommandItem, *args: bytes) -> Optional[Union[bytes, List[bytes]]]:
        return _list_pop(lambda count: slice(None, -count - 1, -1), key, *args)

    @command((Key(list, None), Key(list)))
    def rpoplpush(self, src: CommandItem, dst: CommandItem) -> Any:
        el = self.rpop(src)
        self.lpush(dst, el)
        return el

    @command((Key(list), bytes), (bytes,))
    def rpush(self, key: CommandItem, *values: bytes) -> int:
        for value in values:
            key.value.append(value)
        key.updated()
        return len(key.value)

    @command((Key(list), bytes), (bytes,))
    def rpushx(self, key: CommandItem, *values: bytes) -> Any:
        if not key:
            return 0
        return self.rpush(key, *values)

    @command(fixed=(Key(list), bytes), repeat=(bytes,))
    def lpos(self, key: CommandItem, elem: bytes, *args: bytes) -> Union[None, int, List[int]]:
        (rank, count, maxlen), _ = extract_args(
            args,
            (
                "+rank",
                "+count",
                "+maxlen",
            ),
        )
        if rank == 0:
            raise SimpleError(msgs.LPOS_RANK_CAN_NOT_BE_ZERO)
        rank = rank or 1
        ind, direction = (0, 1) if rank > 0 else (len(key.value) - 1, -1)
        rank = abs(rank)
        parse_count = len(key.value) if count == 0 else (count or 1)
        maxlen = maxlen or len(key.value)
        res: List[int] = []
        comparisons = 0
        while 0 <= ind <= len(key.value) - 1 and len(res) < parse_count and comparisons < maxlen:
            comparisons += 1
            if key.value[ind] == elem:
                if rank > 1:
                    rank -= 1
                else:
                    res.append(ind)
            ind += direction
        if len(res) == 0 and count is None:
            return None
        if len(res) == 1 and count is None:
            return res[0]
        return res
