from __future__ import annotations

import bisect
import itertools
import sys
import time
from collections import Counter
from collections.abc import Generator, Sequence
from dataclasses import dataclass
from typing import Any, AnyStr, NamedTuple

from fakeredis._commands import AfterAny, BeforeAny
from fakeredis._helpers import SimpleError, current_time

from ._base_type import BaseModel


class StreamEntryKey(NamedTuple):
    ts: int
    seq: int

    def encode(self) -> bytes:
        return f"{self.ts}-{self.seq}".encode()

    @staticmethod
    def parse_str(entry_key: AnyStr) -> StreamEntryKey:
        entry_key_str: str = entry_key.decode() if isinstance(entry_key, bytes) else entry_key
        parts = entry_key_str.split("-")
        if not all(parts[i].isdigit() for i in range(len(parts))):
            raise SimpleError("Invalid stream ID specified as stream command argument")
        (timestamp, sequence) = (int(parts[0]), 0) if len(parts) == 1 else (int(parts[0]), int(parts[1]))
        return StreamEntryKey(timestamp, sequence)


# Delivery count assigned by XNACK FATAL to mark a message as permanently failed (LLONG_MAX in redis)
MAX_DELIVERY_COUNT = 2**63 - 1


class PelEntry(NamedTuple):
    """Pending Entry List entry: tracks consumer ownership and delivery count

    A `time_read` of 0 marks an entry released by XNACK: it is unowned (empty consumer name) and
    immediately claimable regardless of idle time.
    """

    consumer_name: bytes
    time_read: int
    times_delivered: int


class StreamRangeTest:
    """Argument converter for sorted set LEX endpoints."""

    def __init__(self, value: StreamEntryKey | BeforeAny | AfterAny, exclusive: bool):
        self.value = value
        self.exclusive = exclusive

    @staticmethod
    def valid_key(entry_key: AnyStr) -> bool:
        try:
            StreamEntryKey.parse_str(entry_key)
            return True
        except ValueError:
            return False

    @classmethod
    def decode(cls, value: bytes, exclusive: bool = False) -> StreamRangeTest:
        if value == b"-":
            return cls(BeforeAny(), True)
        elif value == b"+":
            return cls(AfterAny(), True)
        elif value[:1] == b"(":
            return cls(StreamEntryKey.parse_str(value[1:]), True)
        return cls(StreamEntryKey.parse_str(value), exclusive)


@dataclass
class StreamConsumerInfo:
    name: bytes
    pending: int
    last_attempt: int  # Impacted by XREADGROUP, XCLAIM, XAUTOCLAIM
    last_success: int  # Impacted by XREADGROUP, XCLAIM, XAUTOCLAIM

    def __init__(self, name: bytes) -> None:
        self.name = name
        self.pending = 0
        _time = current_time()
        self.last_attempt = _time
        self.last_success = _time

    def info(self, curr_time: int) -> dict[str, bytes | int]:
        return {
            "name": self.name,
            "pending": self.pending,
            "idle": curr_time - self.last_attempt,
            "inactive": curr_time - self.last_success,
        }


class StreamGroup:
    def __init__(
        self,
        stream: XStream,
        name: bytes,
        start_key: StreamEntryKey,
        entries_read: int | None = None,
    ):
        self.stream = stream
        self.name = name
        self.start_key = start_key
        self.entries_read = entries_read
        # consumer_name -> #pending_messages
        self.consumers: dict[bytes, StreamConsumerInfo] = {}
        self.last_delivered_key = start_key
        self.last_ack_key = start_key
        # Pending entry List, see https://redis.io/commands/xreadgroup/
        # msg_id -> PelEntry(consumer_name, time_read, times_delivered)
        self.pel: dict[StreamEntryKey, PelEntry] = {}

    def set_id(self, last_delivered_str: bytes, entries_read: int | None) -> None:
        """Set last_delivered_id for the group"""
        self.start_key = self.stream.parse_ts_seq(last_delivered_str)
        (start_index, _) = self.stream.find_index(self.start_key)
        self.entries_read = entries_read or 0
        self.last_delivered_key = self.stream.get_index(min(start_index + (entries_read or 0), len(self.stream) - 1))

    def add_consumer(self, consumer_name: bytes) -> int:
        if consumer_name in self.consumers:
            return 0
        self.consumers[consumer_name] = StreamConsumerInfo(consumer_name)
        return 1

    def del_consumer(self, consumer_name: bytes) -> int:
        if consumer_name not in self.consumers:
            return 0
        res = self.consumers[consumer_name].pending
        del self.consumers[consumer_name]
        return res

    def consumers_info(self) -> list[dict[str, bytes | int]]:
        return [self.consumers[k].info(current_time()) for k in self.consumers]

    def group_info(self) -> dict[bytes, Any]:
        start_index, _ = self.stream.find_index(self.start_key)
        last_delivered_index, _ = self.stream.find_index(self.last_delivered_key)
        _last_ack_index, _ = self.stream.find_index(self.last_ack_key)
        if start_index + (self.entries_read or 0) > len(self.stream):
            lag = len(self.stream) - start_index - (self.entries_read or 0)
        else:
            lag = len(self.stream) - 1 - last_delivered_index
        res = {
            b"name": self.name,
            b"consumers": len(self.consumers),
            b"pending": len(self.pel),
            b"last-delivered-id": self.last_delivered_key.encode(),
            b"entries-read": self.entries_read,
            b"lag": lag,
        }
        return res

    def group_read(
        self, consumer_name: bytes, start_id: bytes, count: int | None, noack: bool
    ) -> list[list[bytes | list[bytes] | None]]:
        _time = current_time()
        if consumer_name not in self.consumers:
            self.consumers[consumer_name] = StreamConsumerInfo(consumer_name)

        self.consumers[consumer_name].last_attempt = _time
        if start_id != b">":
            threshold = StreamEntryKey.parse_str(start_id)
            pel_keys = sorted(k for k, v in self.pel.items() if v.consumer_name == consumer_name and k > threshold)
            if count is not None:
                pel_keys = pel_keys[:count]
            for k in pel_keys:
                entry = self.pel[k]
                self.pel[k] = PelEntry(entry.consumer_name, entry.time_read, entry.times_delivered + 1)
            self.consumers[consumer_name].last_success = _time
            return [self.stream.format_record(k) if k in self.stream else [k.encode(), None] for k in pel_keys]  # type: ignore[misc]
        start_key = self.last_delivered_key
        ids_read = self.stream.stream_read(start_key, count)
        if not noack:
            for k in ids_read:
                # Initialize with times_delivered=1 for new messages
                self.pel[k] = PelEntry(consumer_name, _time, 1)
        if len(ids_read) > 0:
            self.last_delivered_key = max(self.last_delivered_key, ids_read[-1])
            self.entries_read = (self.entries_read or 0) + len(ids_read)
        self.consumers[consumer_name].last_success = _time
        self.consumers[consumer_name].pending += len(ids_read)
        return [self.stream.format_record(x) for x in ids_read]  # type: ignore[misc]

    def _calc_consumer_last_time(self) -> None:
        # pel values are PelEntry namedtuples
        # Extract just consumer_name and time_read for grouping
        new_last_success_map = {
            k: min(v, key=lambda x: x.time_read).time_read
            for k, v in itertools.groupby(self.pel.values(), key=lambda x: x.consumer_name)
        }
        for consumer, last_success in new_last_success_map.items():
            if consumer not in self.consumers:
                self.consumers[consumer] = StreamConsumerInfo(consumer)
            self.consumers[consumer].last_attempt = last_success
            self.consumers[consumer].last_success = last_success

    def nack_entries(
        self,
        ids: list[bytes],
        mode: bytes,
        retry_count: int | None = None,
        force: bool = False,
    ) -> int:
        """Release PEL entries back to the group without acknowledging them.

        mode: b'SILENT' (decrement counter), b'FAIL' (keep counter), b'FATAL' (set to max)
        """
        res = 0
        for id_bytes in ids:
            try:
                key = StreamEntryKey.parse_str(id_bytes)
            except Exception:
                continue

            if key not in self.pel:
                if force and key in self.stream:
                    if retry_count is not None:
                        times = retry_count
                    elif mode == b"FATAL":
                        times = MAX_DELIVERY_COUNT
                    else:
                        times = 0
                    self.pel[key] = PelEntry(b"", 0, times)
                    res += 1
                continue

            entry = self.pel[key]
            old_consumer = entry.consumer_name

            if retry_count is not None:
                new_times = retry_count
            elif mode == b"SILENT":
                new_times = max(0, entry.times_delivered - 1)
            elif mode == b"FATAL":
                new_times = MAX_DELIVERY_COUNT
            else:  # FAIL
                new_times = entry.times_delivered

            if old_consumer and old_consumer in self.consumers:
                self.consumers[old_consumer].pending -= 1

            self.pel[key] = PelEntry(b"", 0, new_times)
            res += 1

        return res

    def ack(self, args: tuple[bytes]) -> int:
        res = 0
        for k in args:
            try:
                parsed = StreamEntryKey.parse_str(k)
            except Exception:
                continue
            if parsed in self.pel:
                consumer_name = self.pel[parsed].consumer_name
                self.consumers[consumer_name].pending -= 1
                del self.pel[parsed]
                res += 1
        self._calc_consumer_last_time()
        return res

    def pending(
        self,
        idle: int | None,
        start: StreamRangeTest | None,
        end: StreamRangeTest | None,
        count: int | None,
        consumer: bytes | None,
    ) -> list[list[bytes | int]]:
        _time = current_time()
        relevant_ids = list(self.pel.keys())
        if consumer is not None:
            relevant_ids = [k for k in relevant_ids if self.pel[k].consumer_name == consumer]
        if idle is not None:
            relevant_ids = [k for k in relevant_ids if self.pel[k].time_read + idle < _time]
        if start is not None and end is not None:
            relevant_ids = [
                k
                for k in relevant_ids
                if (
                    ((start.value < k) or (start.value == k and not start.exclusive))
                    and ((end.value > k) or (end.value == k and not end.exclusive))
                )
            ]
        if count is not None:
            relevant_ids = sorted(relevant_ids)[:count]

        # Return all 4 fields: message_id, consumer, time_since_delivered, times_delivered
        # XNACK-released entries (time_read == 0) report an idle time of -1, as in real redis.
        return [
            [
                k.encode(),
                self.pel[k].consumer_name,
                (_time - self.pel[k].time_read) if self.pel[k].time_read else -1,
                self.pel[k].times_delivered,
            ]
            for k in relevant_ids
        ]

    def pending_summary(self) -> list[Any]:
        # XNACK-released entries are unowned and are not counted under any consumer.
        counter = Counter([self.pel[k].consumer_name for k in self.pel if self.pel[k].consumer_name])
        data = [
            len(self.pel),
            min(self.pel).encode() if len(self.pel) > 0 else None,
            max(self.pel).encode() if len(self.pel) > 0 else None,
            [[i, counter[i]] for i in counter],
        ]
        return data

    def claim(
        self,
        min_idle_ms: int,
        msgs: Sequence[bytes] | Sequence[StreamEntryKey],
        consumer_name: bytes,
        _time: int | None,
        force: bool,
    ) -> tuple[list[StreamEntryKey], list[StreamEntryKey]]:
        curr_time = current_time()
        if _time is None:
            _time = curr_time
        self.consumers.get(consumer_name, StreamConsumerInfo(consumer_name)).last_attempt = curr_time
        claimed_msgs, deleted_msgs = [], []
        for msg in msgs:
            try:
                key = StreamEntryKey.parse_str(msg) if isinstance(msg, bytes) else msg
            except Exception:
                continue
            if key not in self.pel:
                if force:
                    # Force claim msg - initialize with times_delivered=1
                    self.pel[key] = PelEntry(consumer_name, _time, 1)
                    if key in self.stream:
                        claimed_msgs.append(key)
                    else:
                        deleted_msgs.append(key)
                        del self.pel[key]
                continue
            if curr_time - self.pel[key].time_read < min_idle_ms:
                continue  # Not idle enough time to be claimed
            # Increment times_delivered when claiming
            old_times_delivered = self.pel[key].times_delivered
            self.pel[key] = PelEntry(consumer_name, _time, old_times_delivered + 1)
            if key in self.stream:
                claimed_msgs.append(key)
            else:
                deleted_msgs.append(key)
                del self.pel[key]
        self._calc_consumer_last_time()
        return sorted(claimed_msgs), sorted(deleted_msgs)

    def claim_for_read(self, min_idle_ms: int, consumer_name: bytes, count: int | None) -> list[list[Any]]:
        """Claim idle pending entries for `XREADGROUP ... CLAIM min-idle-time` (Redis 8.4).

        Entries pending for at least min_idle_ms milliseconds are re-assigned to consumer_name,
        longest-idle first (XNACK-released entries have a delivery time of 0, so they come first).
        Each claimed entry is returned as [id, fields, idle-time, previous-delivery-count].
        """
        curr_time = current_time()
        if consumer_name not in self.consumers:
            self.consumers[consumer_name] = StreamConsumerInfo(consumer_name)
        candidates = sorted(
            (k for k, v in self.pel.items() if curr_time - v.time_read >= min_idle_ms),
            key=lambda k: (self.pel[k].time_read, k),
        )
        if count is not None:
            candidates = candidates[:count]
        res: list[list[Any]] = []
        for key in candidates:
            if key not in self.stream:
                continue  # Entries deleted from the stream are skipped but remain in the PEL
            entry = self.pel[key]
            if entry.consumer_name != consumer_name:
                if entry.consumer_name in self.consumers:
                    self.consumers[entry.consumer_name].pending -= 1
                self.consumers[consumer_name].pending += 1
            self.pel[key] = PelEntry(consumer_name, curr_time, entry.times_delivered + 1)
            record: list[Any] = list(self.stream.format_record(key))
            record.extend([curr_time - entry.time_read, entry.times_delivered])
            res.append(record)
        return res

    def read_pel_msgs(self, min_idle_ms: int, start: bytes, count: int) -> list[StreamEntryKey]:
        start_key = StreamEntryKey.parse_str(start)
        curr_time = current_time()
        msgs = sorted([k for k in self.pel if (curr_time - self.pel[k].time_read >= min_idle_ms) and k >= start_key])
        count = min(count, len(msgs))
        return msgs[:count]


class XStream(BaseModel):
    """Class representing stream.

    The stream contains entries with keys (timestamp, sequence) and field->value pairs.
    This implementation has them as a sorted list of tuples, the first value in the tuple
    is the key (timestamp, sequence).

    The structure of _values list is:
    [
       ((timestamp, sequence), [field1, value1, field2, value2, ...]),
       ((timestamp, sequence), [field1, value1, field2, value2, ...]),
    ]
    """

    _model_type = b"stream"

    def __init__(self) -> None:
        self._ids: list[StreamEntryKey] = []
        self._values_dict: dict[StreamEntryKey, list[bytes]] = {}
        self._groups: dict[bytes, StreamGroup] = {}
        self._max_deleted_id = StreamEntryKey(0, 0)
        self._entries_added = 0
        self._last_generated_id: bytes | None = None
        self._idmp_duration: int = 100
        self._idmp_max_size: int = 100
        self._idmp_map: dict[bytes, dict[bytes, StreamEntryKey]] = {}  # producer_id -> idempotent_id -> entry_key
        self._iids_added: int = 0
        self._iids_duplicates: int = 0

    def set_idmp_duration(self, duration: int) -> None:
        if duration is not None and 1 <= duration <= 86400:
            self._idmp_duration = duration

    def set_idmp_max_size(self, max_size: int) -> None:
        if max_size is not None and 1 <= max_size <= 10000:
            self._idmp_max_size = max_size

    def group_get(self, group_name: bytes) -> StreamGroup | None:
        return self._groups.get(group_name, None)

    def group_add(self, name: bytes, start_key_str: bytes, entries_read: int | None) -> None:
        """Add a group listening to stream

        :param name: Group name
        :param start_key_str: start_key in `timestamp-sequence` format, or $ listen from last.
        :param entries_read: Number of entries read.
        """
        if start_key_str == b"$":
            start_key = self._ids[-1] if len(self._ids) > 0 else StreamEntryKey(0, 0)
        else:
            start_key = StreamEntryKey.parse_str(start_key_str)
        self._groups[name] = StreamGroup(self, name, start_key, entries_read)

    def group_delete(self, group_name: bytes) -> int:
        if group_name in self._groups:
            del self._groups[group_name]
            return 1
        return 0

    def groups_info(self) -> list[dict[bytes, Any]]:
        res: list[dict[bytes, Any]] = []
        for group in self._groups.values():
            group_res = group.group_info()
            res.append(group_res)
        return res

    def stream_info(self, full: bool) -> list[Any]:
        iids_tracked = sum([len(v) for v in self._idmp_map.values()])

        res: dict[bytes, Any] = {
            b"length": len(self._ids),
            b"groups": len(self._groups),
            b"first-entry": self.format_record(self._ids[0]) if len(self._ids) > 0 else None,
            b"last-generated-id": self._last_generated_id if self._last_generated_id else None,
            b"radix-tree-keys": len(self._ids),
            b"radix-tree-nodes": len(self._ids),
            b"last-entry": self.format_record(self._ids[-1]) if len(self._ids) > 0 else None,
            b"max-deleted-entry-id": self._max_deleted_id.encode(),
            b"entries-added": self._entries_added,
            b"recorded-first-entry-id": self._ids[0].encode() if len(self._ids) > 0 else b"0-0",
            b"idmp-duration": self._idmp_duration,
            b"idmp-maxsize": self._idmp_max_size,
            b"pids-tracked": len(self._idmp_map),
            b"iids-tracked": iids_tracked,
            b"iids-added": self._iids_added,
            b"iids-duplicates": self._iids_duplicates,
        }
        if full:
            res[b"entries"] = [self.format_record(i) for i in self._ids]
            res[b"groups"] = [g.group_info() for g in self._groups.values()]
        return list(itertools.chain(*res.items()))

    def delete(self, lst: list[AnyStr]) -> int:
        """Delete items from stream

        :param lst: List of IDs to delete, in the form of `timestamp-sequence`.
        :returns: Number of items deleted
        """
        res = 0
        for item in lst:
            ind, found = self.find_index_key_as_str(item)
            if found:
                self._max_deleted_id = max(self._ids[ind], self._max_deleted_id)
                del self._values_dict[self._ids[ind]]
                del self._ids[ind]
                res += 1
        return res

    def delete_ex(self, ids: list[bytes], mode: bytes) -> list[int]:
        """Extended delete with consumer-group reference control.

        mode: b'KEEPREF' preserve PEL refs, b'DELREF' remove all PEL refs,
              b'ACKED' only delete if not in any group's PEL
        Returns per-ID: -1 not found, 1 deleted, 2 skipped (ACKED mode)
        """
        results = []
        for id_bytes in ids:
            ind, found = self.find_index_key_as_str(id_bytes)
            if not found:
                results.append(-1)
                continue

            entry_key = self._ids[ind]

            if mode == b"ACKED" and any(entry_key in g.pel for g in self._groups.values()):
                results.append(2)
                continue

            self._max_deleted_id = max(entry_key, self._max_deleted_id)
            del self._values_dict[entry_key]
            del self._ids[ind]

            if mode == b"DELREF":
                for g in self._groups.values():
                    if entry_key in g.pel:
                        cn = g.pel[entry_key].consumer_name
                        if cn and cn in g.consumers:
                            g.consumers[cn].pending -= 1
                        del g.pel[entry_key]

            results.append(1)

        return results

    def ackdel(self, group: StreamGroup, ids: list[bytes], mode: bytes) -> list[int]:
        """Atomically acknowledge in group and conditionally delete.

        Returns per-ID: -1 not found, 1 acked+deleted, 2 acked but not deleted (ACKED mode)
        """
        results = []
        for id_bytes in ids:
            ind, found = self.find_index_key_as_str(id_bytes)
            if not found:
                results.append(-1)
                continue

            entry_key = self._ids[ind]
            if entry_key not in group.pel:
                results.append(-1)
                continue
            group.ack((id_bytes,))

            if mode == b"ACKED" and any(entry_key in g.pel for g in self._groups.values()):
                results.append(2)
                continue

            self._max_deleted_id = max(entry_key, self._max_deleted_id)
            del self._values_dict[entry_key]
            del self._ids[ind]

            if mode == b"DELREF":
                for g in self._groups.values():
                    if entry_key in g.pel:
                        cn = g.pel[entry_key].consumer_name
                        if cn and cn in g.consumers:
                            g.consumers[cn].pending -= 1
                        del g.pel[entry_key]

            results.append(1)

        return results

    def record_idmp(self, pid: bytes, iid: bytes, stream_id: bytes) -> None:
        """Record pid/iid -> stream_id mapping for XIDMPRECORD.

        Raises SimpleError if the pid/iid pair already maps to a different stream ID,
        or if stream_id does not exist in the stream.
        """
        entry_key = StreamEntryKey.parse_str(stream_id)
        if entry_key not in self._values_dict:
            raise SimpleError("ERR The specified stream ID was deleted or doesn't exist")

        if pid in self._idmp_map and iid in self._idmp_map[pid]:
            existing = self._idmp_map[pid][iid]
            if existing != entry_key:
                raise SimpleError("ERR The specified IDMP producer-id/idempotent-id pair maps to a different stream ID")
            return  # idempotent – already recorded

        if pid not in self._idmp_map:
            self._idmp_map[pid] = {}
        self._idmp_map[pid][iid] = entry_key

    def add(
        self,
        fields: Sequence[bytes | int],
        entry_key: str = "*",
        producer_id: bytes | None = None,
        idempotent_id: bytes | None = None,
    ) -> None | bytes:
        """Add entry to a stream.

        If the entry_key cannot be added (because its timestamp is before the last entry, etc.),
        nothing is added.

        :param fields: List of fields to add, must [key1, value1, key2, value2, ... ]
        :param entry_key:
            Key for the entry, formatted as 'timestamp-sequence'
            If entry_key is '*', the timestamp will be calculated as current time and the sequence based
            on the last entry key of the stream.
            If entry_key is 'ts-*', and the timestamp is greater or equal than the last entry timestamp,
            then the sequence will be calculated accordingly.
        :param producer_id:
            Uses the specified idempotent-id for the given producer-id. If this producer-id/idempotent-id combination
            was already used, the command returns the ID of the existing entry instead of creating a duplicate.
        :param idempotent_id:
            Uses the specified idempotent-id for the given producer-id. If this producer-id/idempotent-id combination
            was already used, the command returns the ID of the existing entry instead of creating a duplicate.
        :returns:
            The key of the added entry.
            None if nothing was added.
        :raises AssertionError: If len(fields) is not even.
        """
        if len(fields) % 2 != 0:
            raise AssertionError("The number of fields is not even")
        if isinstance(entry_key, bytes):
            entry_key = entry_key.decode()

        if producer_id is not None:
            if idempotent_id is None:
                idempotent_id = hex(hash(fields)).encode()
            if producer_id in self._idmp_map and idempotent_id in self._idmp_map[producer_id]:
                self._iids_duplicates += 1
                return self._idmp_map[producer_id][idempotent_id].encode()
        if entry_key is None or entry_key == "*":
            ts, seq = int(1000 * time.time()), 0
            if len(self._ids) > 0 and self._ids[-1].ts >= ts and self._ids[-1].seq >= seq:
                ts = self._ids[-1].ts
                seq = self._ids[-1].seq + 1
            ts_seq = StreamEntryKey(ts, seq)
        elif entry_key[-1] == "*":  # entry_key has `timestamp-*` structure
            split = entry_key.split("-")
            if len(split) != 2:
                return None
            ts, seq = int(split[0]), split[1]  # type: ignore
            if len(self._ids) > 0 and ts == self._ids[-1].ts:
                seq = self._ids[-1].seq + 1
            else:
                seq = 0
            ts_seq = StreamEntryKey(ts, seq)
        else:
            ts_seq = StreamEntryKey.parse_str(entry_key)

        if len(self._ids) > 0 and self._ids[-1] > ts_seq:
            return None
        self._ids.append(ts_seq)
        self._values_dict[ts_seq] = list(fields)  # type: ignore
        self._entries_added += 1
        self._last_generated_id = ts_seq.encode()
        if producer_id is not None and idempotent_id is not None:
            if producer_id not in self._idmp_map:
                self._idmp_map[producer_id] = {}
            self._idmp_map[producer_id][idempotent_id] = ts_seq
            self._iids_added += 1
        return ts_seq.encode()

    def __bool__(self) -> bool:
        return True

    def __len__(self) -> int:
        return len(self._ids)

    def __iter__(self) -> Generator[list[bytes | list[bytes]], Any, None]:
        def gen() -> Generator[list[bytes | list[bytes]], Any, None]:
            for k in self._ids:
                yield self.format_record(k)

        return gen()

    def __getitem__(self, key: bytes) -> StreamEntryKey | list[bytes]:
        return self._values_dict[StreamEntryKey.parse_str(key)]

    def get_index(self, ind: int) -> StreamEntryKey:
        return self._ids[ind]

    def __contains__(self, key: StreamEntryKey) -> bool:
        return key in self._values_dict

    def find_index(self, entry_key: StreamEntryKey, from_left: bool = True) -> tuple[int, bool]:
        """Find the closest index to entry_key_str in the stream
        :param entry_key: Key for the entry.
        :param from_left: If not found exact match, return index of last smaller element
        :returns: A tuple
            (index of entry with the closest (from the left) key to entry_key_str,
             whether the entry key is equal)
        """
        if len(self._ids) == 0:
            return 0, False
        if from_left:
            ind = bisect.bisect_left(self._ids, entry_key)
            check_idx = ind
        else:
            ind = bisect.bisect_right(self._ids, entry_key)
            check_idx = ind - 1
        return ind, (check_idx < len(self._ids) and self._ids[check_idx] == entry_key)

    def find_index_key_as_str(self, entry_key_str: AnyStr) -> tuple[int, bool]:
        """Find the closest index to entry_key_str in the stream
        :param entry_key_str: key for the entry, formatted as 'timestamp-sequence.'
        :returns: A tuple
            (index of entry with the closest (from the left) key to entry_key_str,
             whether the entry key is equal)
        """
        if entry_key_str == b"$":
            return max(len(self._ids) - 1, 0), True
        ts_seq = StreamEntryKey.parse_str(entry_key_str)
        return self.find_index(ts_seq)

    @staticmethod
    def parse_ts_seq(ts_seq_str: AnyStr) -> StreamEntryKey:
        if ts_seq_str == b"$":
            return StreamEntryKey(0, 0)
        return StreamEntryKey.parse_str(ts_seq_str)

    def trim(
        self,
        max_length: int | None = None,
        start_entry_key: str | None = None,
        limit: int | None = None,
    ) -> int:
        """Trim a stream

        :param max_length: Max length of the resulting stream after trimming (number of last values to keep)
        :param start_entry_key: Min entry-key to keep, cannot be given together with max_length.
        :param limit: Number of entries to keep from minid.
        :returns: The resulting stream after trimming.
        :raises ValueError: When both max_length and start_entry_key are passed.
        """
        if max_length is not None and start_entry_key is not None:
            raise ValueError("Can not use both max_length and start_entry_key")
        start_ind: int | None = None
        if max_length is not None:
            start_ind = len(self._ids) - max_length
        elif start_entry_key is not None:
            ind, _exact = self.find_index_key_as_str(start_entry_key)
            start_ind = ind
        res: int = min(max(start_ind or 0, 0), limit or sys.maxsize)

        remove_keys, self._ids = self._ids[:res], self._ids[res:]
        for k in remove_keys:
            del self._values_dict[k]
        return res

    def irange(self, start: StreamRangeTest, stop: StreamRangeTest, reverse: bool = False) -> list[Any]:
        """Returns a range of the stream values from start to stop.

        :param start: Start key
        :param stop: Stop key
        :param reverse: Should the range be in reverse order?
        :returns: The range between start and stop
        """

        def _find_index(elem: StreamRangeTest, from_left: bool = True) -> int:
            if isinstance(elem.value, BeforeAny):
                return 0
            if isinstance(elem.value, AfterAny):
                return len(self._ids)
            ind, found = self.find_index(elem.value, from_left)
            if found and elem.exclusive:
                ind += 1 if from_left else -1
            return ind

        start_ind = _find_index(start)
        stop_ind = _find_index(stop, from_left=False)
        matches = [self.format_record(self._ids[x]) for x in range(start_ind, stop_ind)]
        if reverse:
            return list(reversed(matches))
        return matches

    def last_item_key(self) -> bytes:
        return self._ids[-1].encode() if len(self._ids) > 0 else b"0-0"

    def stream_read(self, start_key: StreamEntryKey, count: int | None) -> list[StreamEntryKey]:
        start_ind, found = self.find_index(start_key)
        if found:
            start_ind += 1
        if start_ind >= len(self):
            return []
        end_ind = len(self) if count is None or start_ind + count >= len(self) else start_ind + count
        return self._ids[start_ind:end_ind]

    def format_record(self, key: StreamEntryKey) -> list[bytes | list[bytes]]:
        # results: Dict[bytes, bytes] = dict(zip(*[iter(self._values_dict[key])] * 2))
        results = self._values_dict[key]
        return [key.encode(), results]
