# dataset.py
from __future__ import annotations

from typing import Dict, Iterable, List, Literal, Optional, Tuple

import numpy as np
import pandas as pd
import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import Dataset

from targets import (
    DAYS_PER_YEAR,
    NO_EVENT_IDX,
    PAD_IDX,
    RESERVED_IDX,
    build_next_token_targets,
)

DISEASE_HISTORY_MODE_TIMED = "timed"
DISEASE_HISTORY_MODE_ORDERED = "ordered"
DISEASE_HISTORY_MODE_SET = "set"
DISEASE_HISTORY_MODES = (
    DISEASE_HISTORY_MODE_TIMED,
    DISEASE_HISTORY_MODE_ORDERED,
    DISEASE_HISTORY_MODE_SET,
)


def normalize_disease_history_mode(mode: str | None) -> str:
    value = DISEASE_HISTORY_MODE_TIMED if mode is None else str(mode).lower()
    if value not in DISEASE_HISTORY_MODES:
        raise ValueError(
            "disease_history_mode must be one of "
            f"{list(DISEASE_HISTORY_MODES)}, got {mode!r}"
        )
    return value


def transform_disease_history(
    event_seq: np.ndarray,
    actual_time_seq: np.ndarray,
    actual_t_query: float,
    disease_history_mode: str,
) -> Tuple[np.ndarray, np.ndarray, np.float32]:
    """
    Convert an already-truncated disease history into its model representation.

    ``timed`` keeps the real event/query times. ``ordered`` preserves the
    chronological event order but replaces calendar time with ordinal event-time
    groups. Diseases first recorded on the same day share one ordinal position.
    ``set`` removes both time and order by sorting the unique disease codes and
    assigning every disease and the query the same model time.
    """
    mode = normalize_disease_history_mode(disease_history_mode)
    events = np.asarray(event_seq, dtype=np.int64)
    times = np.asarray(actual_time_seq, dtype=np.float32)
    if events.ndim != 1 or times.ndim != 1 or events.shape != times.shape:
        raise ValueError(
            "event_seq and actual_time_seq must be aligned 1D arrays, got "
            f"{events.shape} and {times.shape}"
        )

    if mode == DISEASE_HISTORY_MODE_TIMED:
        return events, times, np.float32(actual_t_query)

    special = events <= NO_EVENT_IDX
    if np.any(special):
        raise ValueError(
            f"{mode} disease history must contain only disease events; "
            f"found special token ids {np.unique(events[special]).tolist()}"
        )

    if mode == DISEASE_HISTORY_MODE_ORDERED:
        _, ordinal_groups = np.unique(times, return_inverse=True)
        model_times = ordinal_groups.astype(np.float32, copy=False)
        n_groups = int(model_times.max()) + 1 if model_times.size else 0
        return events, model_times, np.float32(n_groups)

    set_events = np.unique(events)
    model_times = np.zeros(set_events.size, dtype=np.float32)
    return set_events, model_times, np.float32(0.0)


def transform_disease_history_batch_at_position(
    event_seq: torch.Tensor,
    actual_time_seq: torch.Tensor,
    padding_mask: torch.Tensor,
    query_position: int,
    disease_history_mode: str,
    vocab_size: int,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
    """
    Build a model-visible prefix for token-position all-future evaluation.

    Actual event times remain outside this return value for AUC bookkeeping.
    For ordered/set modes, events after ``query_position`` are explicitly
    masked so collapsing time cannot expose future diseases.
    """
    mode = normalize_disease_history_mode(disease_history_mode)
    if event_seq.ndim != 2 or actual_time_seq.shape != event_seq.shape:
        raise ValueError(
            "event_seq and actual_time_seq must be aligned 2D tensors, got "
            f"{tuple(event_seq.shape)} and {tuple(actual_time_seq.shape)}"
        )
    if padding_mask.shape != event_seq.shape:
        raise ValueError(
            "padding_mask must match event_seq, got "
            f"{tuple(padding_mask.shape)} and {tuple(event_seq.shape)}"
        )
    if query_position < 0 or query_position >= event_seq.size(1):
        raise ValueError(
            f"query_position={query_position} is outside sequence length "
            f"{event_seq.size(1)}"
        )

    padding_mask = padding_mask.to(device=event_seq.device, dtype=torch.bool)
    if not torch.all(padding_mask[:, query_position]):
        raise ValueError("query_position must be valid for every batch row")

    if mode == DISEASE_HISTORY_MODE_TIMED:
        return (
            event_seq,
            actual_time_seq,
            padding_mask,
            actual_time_seq[:, query_position],
        )

    positions = torch.arange(
        event_seq.size(1),
        device=event_seq.device,
    )[None, :]
    history_mask = padding_mask & (positions <= query_position)
    visible_events = event_seq.masked_select(history_mask)
    if torch.any(visible_events <= NO_EVENT_IDX):
        special_ids = torch.unique(
            visible_events[visible_events <= NO_EVENT_IDX]
        ).detach().cpu().tolist()
        raise ValueError(
            f"{mode} disease history must contain only disease events; "
            f"found special token ids {special_ids}"
        )

    if mode == DISEASE_HISTORY_MODE_ORDERED:
        model_times = torch.zeros_like(actual_time_seq)
        model_t_query = torch.zeros(
            event_seq.size(0),
            device=actual_time_seq.device,
            dtype=actual_time_seq.dtype,
        )
        for row_idx in range(event_seq.size(0)):
            row_mask = history_mask[row_idx]
            _, ordinal_groups = torch.unique(
                actual_time_seq[row_idx, row_mask],
                sorted=True,
                return_inverse=True,
            )
            model_times[row_idx, row_mask] = ordinal_groups.to(
                dtype=actual_time_seq.dtype
            )
            model_t_query[row_idx] = float(
                int(ordinal_groups.max().item()) + 1
                if ordinal_groups.numel()
                else 0
            )
        return event_seq, model_times, history_mask, model_t_query

    sentinel = torch.full_like(event_seq, int(vocab_size))
    sortable = torch.where(history_mask, event_seq, sentinel)
    set_events = torch.sort(sortable, dim=1).values
    set_mask = set_events != int(vocab_size)
    set_events = set_events.masked_fill(~set_mask, PAD_IDX)
    model_times = torch.zeros_like(actual_time_seq)
    model_t_query = torch.zeros(
        event_seq.size(0),
        device=actual_time_seq.device,
        dtype=actual_time_seq.dtype,
    )
    return set_events, model_times, set_mask, model_t_query


def load_label_vocab(
    labels_file: str,
    include_no_event: bool = True,
) -> Tuple[Dict[str, int], Dict[int, str]]:
    label_id_to_code: Dict[int, str] = {
        PAD_IDX: "<PAD>",
        RESERVED_IDX: "<RESERVED>",
    }
    if include_no_event:
        label_id_to_code[NO_EVENT_IDX] = "<NO_EVENT>"

    offset = NO_EVENT_IDX + 1 if include_no_event else RESERVED_IDX + 1
    label_code_to_id: Dict[str, int] = {}
    with open(labels_file, encoding="utf-8") as f:
        for i, line in enumerate(f):
            parts = line.strip().split()
            if not parts:
                continue
            idx = offset + i
            code = parts[0]
            label_code_to_id[code] = idx
            label_id_to_code[idx] = code
    return label_code_to_id, label_id_to_code


def _insert_gap_no_event_tokens(
    times_days: np.ndarray,
    labels: np.ndarray,
    interval_years: float = 5.0,
) -> Tuple[np.ndarray, np.ndarray]:
    if len(times_days) < 2:
        return times_days, labels

    step_days = interval_years * DAYS_PER_YEAR
    unique_times = np.unique(times_days.astype(np.float64))
    extra_times: List[float] = []

    for i in range(len(unique_times) - 1):
        t_left = float(unique_times[i])
        t_right = float(unique_times[i + 1])
        if t_right - t_left <= step_days:
            continue
        first = np.ceil((t_left + 1e-6) / step_days) * step_days
        t = first
        while t < t_right - 1e-6:
            extra_times.append(t)
            t += step_days

    if not extra_times:
        return times_days, labels

    extra_arr = np.array(extra_times, dtype=np.float32)
    no_event_labels = np.full(len(extra_arr), NO_EVENT_IDX, dtype=np.int64)
    all_times = np.concatenate([times_days.astype(np.float32), extra_arr])
    all_labels = np.concatenate([labels.astype(np.int64), no_event_labels])
    order = np.lexsort((all_labels, all_times))
    return all_times[order], all_labels[order]


class _ExpoBaseDataset(Dataset):
    def __init__(
        self,
        data_prefix: str = "ukb",
        labels_file: str = "labels.csv",
        no_event_interval_years: float = 5.0,
        extra_info_types: Iterable[int] | None = None,
    ) -> None:
        self.data_prefix = data_prefix
        self.labels_file = labels_file
        self.no_event_interval_years = float(no_event_interval_years)
        self.requested_extra_info_types = (
            None
            if extra_info_types is None
            else list(dict.fromkeys(int(t) for t in extra_info_types))
        )

        self.label_code_to_id, self.label_id_to_code = load_label_vocab(
            labels_file,
            include_no_event=True,
        )

        event_data = np.load(f"{data_prefix}_event_data.npy")
        if event_data.ndim != 2 or event_data.shape[1] < 3:
            raise ValueError(f"event_data must have shape (N, 3+), got {event_data.shape}")
        event_data = event_data[:, :3].copy()
        order = np.lexsort((event_data[:, 2], event_data[:, 1], event_data[:, 0]))
        self.event_data = event_data[order]

        basic_table = pd.read_csv(f"{data_prefix}_basic_info.csv", index_col=0)
        other_info = np.load(f"{data_prefix}_other_info.npy")
        if other_info.ndim != 2 or other_info.shape[1] != 5:
            raise ValueError(
                f"other_info must have shape (N, 5), got {other_info.shape}"
            )
        cate_types = pd.read_csv("cate_types.csv")
        required_cate_cols = {"type", "name", "n_categories"}
        missing_cate_cols = required_cate_cols - set(cate_types.columns)
        if missing_cate_cols:
            raise ValueError(
                f"cate_types.csv is missing columns: {sorted(missing_cate_cols)}"
            )

        basic_table.index = basic_table.index.astype(np.int64)

        unique_eids = np.unique(self.event_data[:, 0].astype(np.int64))
        basic_table = basic_table.loc[unique_eids]

        self._prepare_sex(basic_table, unique_eids)
        self._prepare_other_info(other_info, cate_types, unique_eids)

        max_id_in_vocab = max(self.label_id_to_code.keys())
        max_id_in_data = int(self.event_data[:, 2].max()) if len(self.event_data) > 0 else 0
        max_id_in_data += 1
        self.vocab_size = max(max_id_in_vocab, max_id_in_data) + 1

    def _prepare_sex(self, basic_table: pd.DataFrame, unique_eids: np.ndarray) -> None:
        sex_values = pd.to_numeric(basic_table["sex"], errors="coerce").to_numpy()
        if np.isnan(sex_values).any():
            raise ValueError("sex column contains missing or non-numeric values")

        sex_values = sex_values.astype(np.int64)
        sex_unique = np.unique(sex_values)
        if np.all(np.isin(sex_unique, [0, 1])):
            sex01 = sex_values
        elif np.all(np.isin(sex_unique, [1, 2])):
            sex01 = sex_values - 1
        else:
            raise ValueError(
                f"Unexpected sex values: {sex_unique.tolist()}. Expected {{0,1}} or {{1,2}}."
            )
        self.sex_mapping = {int(eid): int(s) for eid, s in zip(unique_eids, sex01)}

    def _prepare_other_info(
        self,
        other_info: np.ndarray,
        cate_types: pd.DataFrame,
        unique_eids: np.ndarray,
    ) -> None:
        other_info = other_info.copy()
        other_info[:, 0] = other_info[:, 0].astype(np.int64)
        other_info[:, 1] = other_info[:, 1].astype(np.int64)
        other_info[:, 3] = other_info[:, 3].astype(np.int64)

        available_types = sorted(
            int(t) for t in np.unique(other_info[:, 1]) if int(t) > 0
        )
        if self.requested_extra_info_types is None:
            selected_types = available_types
        else:
            selected_types = self.requested_extra_info_types
            missing = sorted(set(selected_types) - set(available_types))
            if missing:
                raise ValueError(f"Requested extra_info_types not found: {missing}")

        keep = np.isin(other_info[:, 0].astype(np.int64), unique_eids)
        keep &= np.isin(other_info[:, 1].astype(np.int64), selected_types)
        other_info = other_info[keep]

        cate_counts = {
            int(row["type"]): int(row["n_categories"])
            for _, row in cate_types.iterrows()
        }
        cate_offsets: Dict[int, int] = {}
        next_offset = 0
        for type_id in selected_types:
            if type_id in cate_counts:
                cate_offsets[type_id] = next_offset
                next_offset += cate_counts[type_id]

        kinds = other_info[:, 3].astype(np.int64)
        types = other_info[:, 1].astype(np.int64)
        cate_rows = kinds == 2
        for type_id in np.unique(types[cate_rows]):
            type_id = int(type_id)
            if type_id not in cate_offsets:
                raise ValueError(
                    f"type {type_id} appears categorical but is missing from cate_types.csv"
                )
            row_mask = cate_rows & (types == type_id)
            local_value = other_info[row_mask, 2].astype(np.int64)
            other_info[row_mask, 2] = local_value + cate_offsets[type_id]

        cont_type_ids = [
            int(t)
            for t in selected_types
            if np.any((types == int(t)) & (kinds == 1))
        ]

        self.extra_info_types = selected_types
        self.cate_type_offsets = cate_offsets
        self.n_types = (max(selected_types) + 1) if selected_types else 1
        self.cont_type_ids = cont_type_ids
        self.n_cont_types = len(cont_type_ids)
        self.n_categories = next_offset + 1

        order = np.lexsort((other_info[:, 4], other_info[:, 1], other_info[:, 0]))
        other_info = other_info[order]
        self.other_info_by_eid: Dict[int, Dict[str, np.ndarray]] = {}

        for eid in unique_eids.astype(np.int64):
            self.other_info_by_eid[int(eid)] = {
                "other_type": np.zeros(0, dtype=np.int64),
                "other_value": np.zeros(0, dtype=np.float32),
                "other_value_kind": np.zeros(0, dtype=np.int64),
                "other_time": np.zeros(0, dtype=np.float32),
            }

        if len(other_info) == 0:
            return

        eids, starts = np.unique(other_info[:, 0].astype(np.int64), return_index=True)
        ends = np.concatenate([starts[1:], [len(other_info)]])
        for eid_raw, start, end in zip(eids, starts, ends):
            rows = other_info[start:end]
            self.other_info_by_eid[int(eid_raw)] = {
                "other_type": rows[:, 1].astype(np.int64),
                "other_value": rows[:, 2].astype(np.float32),
                "other_value_kind": rows[:, 3].astype(np.int64),
                "other_time": (rows[:, 4].astype(np.float32) / DAYS_PER_YEAR),
            }

    def _iter_patient_events(
        self,
        *,
        impute_no_event_gaps: bool,
    ) -> Iterable[tuple[int, np.ndarray, np.ndarray]]:
        unique_eids, starts = np.unique(self.event_data[:, 0], return_index=True)
        ends = np.concatenate([starts[1:], [len(self.event_data)]])
        for eid_raw, start, end in zip(unique_eids, starts, ends):
            eid = int(eid_raw)
            rows = self.event_data[start:end]
            times_days_raw = rows[:, 1].astype(np.float32)
            labels_raw = rows[:, 2].astype(np.int64)

            # Label 1 was emitted as a CHECKUP event by older prepared files.
            # It is now an unused reserved slot and must never enter either the
            # next-token or all-future disease sequence.
            keep = labels_raw != RESERVED_IDX
            times_days_raw = times_days_raw[keep]
            labels_raw = labels_raw[keep]

            if len(labels_raw) == 0:
                yield eid, times_days_raw, labels_raw
                continue

            labels_raw = np.where(labels_raw >= NO_EVENT_IDX, labels_raw + 1, labels_raw)
            if not impute_no_event_gaps:
                yield eid, times_days_raw, labels_raw
                continue

            times_days, labels = _insert_gap_no_event_tokens(
                times_days_raw,
                labels_raw,
                interval_years=self.no_event_interval_years,
            )
            yield eid, times_days, labels

    def _split_features(self, eid: int) -> Optional[Dict]:
        other_info = self.other_info_by_eid.get(eid)
        if other_info is None:
            return None
        return {
            "sex": self.sex_mapping[eid],
            **other_info,
        }

class NextStepHealthDataset(_ExpoBaseDataset):
    """
    Delphi2M next-token dataset with unified other-info tokens.
    """

    CACHE_VERSION = 3

    def __init__(
        self,
        data_prefix: str = "ukb",
        labels_file: str = "labels.csv",
        no_event_interval_years: float = 5.0,
        extra_info_types: Iterable[int] | None = None,
    ) -> None:
        super().__init__(
            data_prefix=data_prefix,
            labels_file=labels_file,
            no_event_interval_years=no_event_interval_years,
            extra_info_types=extra_info_types,
        )

        self.samples: List[Dict] = []
        for eid, times_days, labels in self._iter_patient_events(
            impute_no_event_gaps=True,
        ):
            if len(labels) < 2:
                continue

            features = self._split_features(eid)
            if features is None:
                continue

            targets = build_next_token_targets(
                labels=labels,
                times_days=times_days,
                require_sorted=True,
            )

            self.samples.append({
                "eid": eid,
                "event_seq": targets.input_events,
                "time_seq": targets.input_times_years,
                "target_event_seq": targets.target_events,
                "target_time_seq": targets.target_times_years,
                **features,
            })

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

    def __getitem__(self, idx: int) -> Dict:
        s = self.samples[idx]
        return {
            "event_seq": torch.from_numpy(s["event_seq"]).long(),
            "time_seq": torch.from_numpy(s["time_seq"]).float(),
            "sex": torch.tensor(s["sex"], dtype=torch.long),
            "other_type": torch.from_numpy(s["other_type"]).long(),
            "other_value": torch.from_numpy(s["other_value"]).float(),
            "other_value_kind": torch.from_numpy(s["other_value_kind"]).long(),
            "other_time": torch.from_numpy(s["other_time"]).float(),
            "target_event_seq": torch.from_numpy(s["target_event_seq"]).long(),
            "target_time_seq": torch.from_numpy(s["target_time_seq"]).float(),
        }


class AllFutureHealthDataset(_ExpoBaseDataset):
    """
    Dataset with unified other-info tokens and DeepHealthV2-style all-future
    targets.

    Every split uses the same patient-equal query distribution: choose one
    eligible inter-event interval uniformly, then choose a time uniformly in
    that interval. Train resamples on every ``__getitem__`` call; valid/test
    keep one deterministic draw per patient.
    """

    CACHE_VERSION = 5

    def __init__(
        self,
        data_prefix: str = "ukb",
        labels_file: str = "labels.csv",
        split: Literal["train", "valid", "test"] = "train",
        no_event_interval_years: float = 5.0,
        min_history_events: int = 1,
        min_future_events: int = 1,
        validation_query_seed: int = 42,
        extra_info_types: Iterable[int] | None = None,
        disease_history_mode: str = DISEASE_HISTORY_MODE_TIMED,
    ) -> None:
        if split not in {"train", "valid", "test"}:
            raise ValueError(f"split must be train/valid/test, got {split!r}")

        super().__init__(
            data_prefix=data_prefix,
            labels_file=labels_file,
            no_event_interval_years=no_event_interval_years,
            extra_info_types=extra_info_types,
        )

        self.disease_history_mode = normalize_disease_history_mode(
            disease_history_mode
        )
        if (
            self.disease_history_mode != DISEASE_HISTORY_MODE_TIMED
            and self.extra_info_types
        ):
            raise ValueError(
                f"disease_history_mode={self.disease_history_mode!r} is only "
                "supported with an explicitly empty extra-info selection"
            )

        self.split = split
        self.min_history_events = int(min_history_events)
        self.min_future_events = int(min_future_events)
        self.validation_query_seed = int(validation_query_seed)
        self.patients: List[Dict] = []
        self.valid_queries: List[Tuple[int, float]] = []
        validation_rng = None
        if split in {"valid", "test"}:
            split_offset = 0 if split == "valid" else 1_000_003
            validation_rng = np.random.RandomState(self.validation_query_seed + split_offset)

        for eid, times_days, labels in self._iter_patient_events(
            impute_no_event_gaps=False,
        ):
            times_years = (times_days / DAYS_PER_YEAR).astype(np.float32)
            unique_times = np.unique(times_years)
            if len(labels) < 2 or len(unique_times) < 2:
                continue

            features = self._split_features(eid)
            if features is None:
                continue

            patient = {
                "eid": eid,
                "times": times_years,
                "labels": labels.astype(np.int64),
                "t_obs": float(times_years.max()),
                **features,
            }

            query_intervals = self._eligible_query_intervals(patient)
            if not query_intervals:
                continue
            patient["query_intervals"] = query_intervals

            pidx = len(self.patients)
            self.patients.append(patient)

            if split in {"valid", "test"}:
                if validation_rng is None:
                    raise RuntimeError("validation_rng was not initialized")
                self.valid_queries.extend(
                    (pidx, t_query)
                    for t_query in self._sample_fixed_validation_queries(
                        patient,
                        validation_rng,
                    )
                )

        if split in {"valid", "test"} and not self.valid_queries:
            raise ValueError("No random-but-fixed validation query points were built.")

    def _is_valid_query(self, patient: Dict, t_query: float) -> bool:
        times = patient["times"]
        labels = patient["labels"]
        real_event_mask = ~np.isin(
            labels,
            np.array([PAD_IDX, RESERVED_IDX, NO_EVENT_IDX], dtype=np.int64),
        )
        n_hist = int(((times <= t_query) & real_event_mask).sum())
        n_future = int(((times > t_query) & real_event_mask).sum())
        return (
            n_hist >= self.min_history_events
            and n_future >= self.min_future_events
            and patient["t_obs"] > t_query
        )

    def _eligible_query_intervals(self, patient: Dict) -> List[Tuple[float, float]]:
        times = np.asarray(patient["times"], dtype=np.float32)
        labels = np.asarray(patient["labels"], dtype=np.int64)
        real_event_mask = ~np.isin(
            labels,
            np.array([PAD_IDX, RESERVED_IDX, NO_EVENT_IDX], dtype=np.int64),
        )
        unique_times = np.unique(times[real_event_mask])
        intervals: List[Tuple[float, float]] = []
        for j in range(1, len(unique_times)):
            left = float(unique_times[j - 1])
            right = float(unique_times[j])
            probe = float(
                np.nextafter(np.float32(right), np.float32(-np.inf))
            )
            if np.isfinite(left) and np.isfinite(probe) and probe >= left:
                if self._is_valid_query(patient, probe):
                    intervals.append((left, right))
        return intervals

    def sample_query(
        self,
        patient: Dict,
        rng,
    ) -> float:
        intervals = patient.get("query_intervals")
        if intervals is None:
            intervals = self._eligible_query_intervals(patient)
        if not intervals:
            raise RuntimeError("Patient has no eligible all-future query interval.")

        interval_idx = int(rng.randint(0, len(intervals)))
        left, right_event_time = intervals[interval_idx]
        right = float(
            np.nextafter(np.float32(right_event_time), np.float32(-np.inf))
        )
        if right <= left:
            t_query = float(left)
        else:
            t_query = float(rng.uniform(left, right))
        if not self._is_valid_query(patient, t_query):
            raise RuntimeError("Sampled an invalid all-future query time.")
        return t_query

    def _sample_fixed_validation_queries(
        self,
        patient: Dict,
        rng: np.random.RandomState,
    ) -> List[float]:
        return [self.sample_query(patient, rng)]

    def _sample_train_query(self, patient: Dict) -> float:
        return self.sample_query(patient, np.random)

    def _build_item(self, patient: Dict, t_query: float) -> Dict:
        times = patient["times"]
        labels = patient["labels"]
        hist = times <= t_query
        fut = times > t_query
        event_seq, model_time_seq, model_t_query = transform_disease_history(
            event_seq=labels[hist],
            actual_time_seq=times[hist],
            actual_t_query=t_query,
            disease_history_mode=self.disease_history_mode,
        )

        return {
            "event_seq": torch.from_numpy(event_seq).long(),
            "time_seq": torch.from_numpy(model_time_seq).float(),
            "t_query": torch.tensor(model_t_query, dtype=torch.float32),
            "future_targets": torch.from_numpy(labels[fut]).long(),
            "future_dt": torch.from_numpy(times[fut] - np.float32(t_query)).float(),
            "exposure": torch.tensor(np.float32(patient["t_obs"] - t_query), dtype=torch.float32),
            "sex": torch.tensor(patient["sex"], dtype=torch.long),
            "other_type": torch.from_numpy(patient["other_type"]).long(),
            "other_value": torch.from_numpy(patient["other_value"]).float(),
            "other_value_kind": torch.from_numpy(patient["other_value_kind"]).long(),
            "other_time": torch.from_numpy(patient["other_time"]).float(),
        }

    def __len__(self) -> int:
        if self.split == "train":
            return len(self.patients)
        return len(self.valid_queries)

    def __getitem__(self, idx: int) -> Dict:
        if self.split == "train":
            patient = self.patients[idx]
            t_query = self._sample_train_query(patient)
        else:
            pidx, t_query = self.valid_queries[idx]
            patient = self.patients[pidx]
        return self._build_item(patient, t_query)


def _collate_common_static(batch: List[Dict]) -> Dict:
    return {
        "sex": torch.stack([s["sex"] for s in batch]),
        "other_type": pad_sequence(
            [s["other_type"] for s in batch],
            batch_first=True,
            padding_value=0,
        ),
        "other_value": pad_sequence(
            [s["other_value"] for s in batch],
            batch_first=True,
            padding_value=0.0,
        ),
        "other_value_kind": pad_sequence(
            [s["other_value_kind"] for s in batch],
            batch_first=True,
            padding_value=0,
        ),
        "other_time": pad_sequence(
            [s["other_time"] for s in batch],
            batch_first=True,
            padding_value=0.0,
        ),
    }


def next_step_collate_fn(batch: List[Dict]) -> Dict:
    event_seq = pad_sequence(
        [s["event_seq"] for s in batch],
        batch_first=True,
        padding_value=PAD_IDX,
    )
    time_seq = pad_sequence(
        [s["time_seq"] for s in batch],
        batch_first=True,
        padding_value=0.0,
    )
    target_event_seq = pad_sequence(
        [s["target_event_seq"] for s in batch],
        batch_first=True,
        padding_value=PAD_IDX,
    )
    target_time_seq = pad_sequence(
        [s["target_time_seq"] for s in batch],
        batch_first=True,
        padding_value=0.0,
    )
    out = {
        "event_seq": event_seq,
        "time_seq": time_seq,
        "padding_mask": event_seq > PAD_IDX,
        "target_event_seq": target_event_seq,
        "target_time_seq": target_time_seq,
    }
    out.update(_collate_common_static(batch))
    return out


def all_future_collate_fn(batch: List[Dict]) -> Dict:
    event_seq = pad_sequence(
        [s["event_seq"] for s in batch],
        batch_first=True,
        padding_value=PAD_IDX,
    )
    time_seq = pad_sequence(
        [s["time_seq"] for s in batch],
        batch_first=True,
        padding_value=0.0,
    )
    future_targets = pad_sequence(
        [s["future_targets"] for s in batch],
        batch_first=True,
        padding_value=PAD_IDX,
    )
    future_dt = pad_sequence(
        [s["future_dt"] for s in batch],
        batch_first=True,
        padding_value=0.0,
    )

    out = {
        "event_seq": event_seq,
        "time_seq": time_seq,
        "padding_mask": event_seq > PAD_IDX,
        "t_query": torch.stack([s["t_query"] for s in batch]),
        "future_targets": future_targets,
        "future_dt": future_dt,
        "exposure": torch.stack([s["exposure"] for s in batch]),
    }
    out.update(_collate_common_static(batch))
    return out


HealthDataset = NextStepHealthDataset
collate_fn = next_step_collate_fn
