from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
import traceback
from typing import Dict, List, Optional

import numpy as np
import pandas as pd
import pytorch_lightning as pl
import torch
from rdkit.Chem import Mol
from torch import Tensor
from torch.utils.data import DataLoader

from boltzgen.data import const
from boltzgen.data.crop.cropper import Cropper
from boltzgen.data.select.selector import Selector
from boltzgen.data.data import (
    MSA,
    Input,
    Manifest,
    Record,
    Structure,
)
from boltzgen.data.feature.featurizer import Featurizer
from boltzgen.data.filter.dynamic.filter import DynamicFilter
from boltzgen.data.mol import load_canonicals, load_molecules
from boltzgen.data.pad import pad_to_max
from boltzgen.data.sample.sampler import Sample, Sampler
from boltzgen.data.template.features import load_dummy_templates
from boltzgen.data.tokenize.tokenizer import Tokenizer
from boltzgen.task.predict import data_ligands, data_protein_binder


@dataclass
class DatasetConfig:
    """Dataset configuration."""

    target_dir: str
    msa_dir: str
    prob: Optional[float]
    sampler: Sampler
    cropper: Cropper
    selector: Optional[Selector] = None
    manifest_path: Optional[str] = None
    filters: Optional[list[DynamicFilter]] = None
    split: Optional[str] = None
    symmetry_correction: bool = True
    val_group: Optional[str] = "RCSB"
    use_train_subset: Optional[float] = None
    moldir: Optional[str] = None
    override_bfactor: Optional[bool] = False
    override_method: Optional[str] = None


@dataclass
class DataConfig:
    """Data configuration."""

    datasets: List[DatasetConfig]
    featurizer: Featurizer
    tokenizer: Tokenizer
    selector: Selector
    max_atoms: int
    max_tokens: int
    max_seqs: int
    samples_per_epoch: int
    batch_size: int
    num_workers: int
    random_seed: int
    pin_memory: bool
    atoms_per_window_queries: int
    min_dist: float
    max_dist: float
    num_bins: int
    overfit: Optional[int] = None
    pad_to_max_tokens: bool = False
    pad_to_max_atoms: bool = False
    pad_to_max_seqs: bool = False
    return_train_symmetries: bool = False
    return_val_symmetries: bool = True
    val_batch_size: int = 1
    single_sequence_prop_training: float = 0.0
    msa_sampling_training: bool = False
    moldir: Optional[str] = None
    compute_frames: bool = True
    backbone_only: bool = False
    atom14: bool = False
    atom37: bool = False
    design: bool = False
    monomer_split: str = None
    monomer_target_dir: str = None
    monomer_seq_len: int = 100
    monomer_target_structure_condition: bool = True
    inverse_fold: bool = False
    ligand_split: str = None
    ligand_target_dir: str = None
    ligand_seq_len: int = 100
    use_msa: bool = True
    disulfide_prob: float = 1.0
    disulfide_on: bool = False


@dataclass
class Dataset:
    """Data holder."""

    samples: pd.DataFrame
    struct_dir: Path
    msa_dir: Path
    record_dir: Path
    prob: float
    cropper: Cropper
    tokenizer: Tokenizer
    featurizer: Featurizer
    val_group: str
    selector: Selector
    symmetry_correction: bool = True
    moldir: Optional[str] = None
    override_bfactor: Optional[bool] = False
    override_method: Optional[str] = None


def load_record(record_id: str, record_dir: Path) -> Record:
    """Load the given record.

    Parameters
    ----------
    record_id : str
        The record id to load.
    record_dir : Path
        The path to the record directory.

    Returns
    -------
    Record
        The loaded record.
    """
    return Record.load(record_dir / f"{record_id}.json")


def load_structure(record: Record, struct_dir: Path) -> Structure:
    """Load the given input data.

    Parameters
    ----------
    record : str
        The record to load.
    target_dir : Path
        The path to the data directory.

    Returns
    -------
    Input
        The loaded input.

    """
    if (struct_dir / f"{record.id}.npz").exists():
        structure_path = struct_dir / f"{record.id}.npz"
    else:
        structure_path = struct_dir / f"{record.id}" / f"{record.id}_model_0.npz"
    return Structure.load(structure_path)


def load_msas(chain_ids: set[int], record: Record, msa_dir: Path) -> Input:
    """Load the given input data.

    Parameters
    ----------
    chain_ids : set[int]
        The chain ids to load.
    record : Record
        The record to load.
    msa_dir : Path
        The path to the MSA directory.

    Returns
    -------
    Input
        The loaded input.

    """
    msas = {}
    for chain in record.chains:
        if chain.chain_id not in chain_ids:
            continue

        msa_id = chain.msa_id
        if msa_id != -1:
            msa_path = msa_dir / f"{msa_id}.npz"
            msa = MSA.load(msa_path)
            msas[chain.chain_id] = msa

    return msas


def collate(data: List[Dict[str, Tensor]]) -> Dict[str, Tensor]:
    """Collate the data.

    Parameters
    ----------
    data : List[Dict[str, Tensor]]
        The data to collate.

    Returns
    -------
    Dict[str, Tensor]
        The collated data.

    """
    # Get the keys
    keys = data[0].keys()

    # Collate the data
    collated = {}
    for key in keys:
        values = [d[key] for d in data]

        if key not in [
            "all_coords",
            "all_resolved_mask",
            "crop_to_all_atom_map",
            "chain_symmetries",
            "chain_swaps",
            "amino_acids_symmetries",
            "ligand_symmetries",
            "activity_name",
            "activity_qualifier",
            "sid",
            "cid",
            "normalized_protein_accession",
            "pair_id",
            "ligand_edge_index",
            "ligand_edge_lower_bounds",
            "ligand_edge_upper_bounds",
            "ligand_edge_bond_mask",
            "ligand_edge_angle_mask",
            "connections_edge_index",
            "ligand_chiral_atom_index",
            "ligand_chiral_check_mask",
            "ligand_chiral_atom_orientations",
            "ligand_stereo_bond_index",
            "ligand_stereo_check_mask",
            "ligand_stereo_bond_orientations",
            "ligand_aromatic_5_ring_index",
            "ligand_aromatic_6_ring_index",
            "ligand_planar_double_bond_index",
            "pdb_id",
            "id",
            "structure_bonds",
            "extra_mols",
        ]:
            if values[0] is not None:
                # Check if all have the same shape
                shape = values[0].shape
                if not all(v.shape == shape for v in values):
                    values = pad_to_max(values, 0)
                else:
                    values = torch.stack(values, dim=0)

        # Stack the values
        collated[key] = values

    return collated


class TrainingDataset(torch.utils.data.Dataset):
    """Base iterable dataset."""

    def __init__(
        self,
        datasets: List[Dataset],
        canonicals: dict[str, Mol],
        moldir: str,
        samples_per_epoch: int,
        max_atoms: int,
        max_tokens: int,
        max_seqs: int,
        pad_to_max_atoms: bool = False,
        pad_to_max_tokens: bool = False,
        pad_to_max_seqs: bool = False,
        atoms_per_window_queries: int = 32,
        min_dist: float = 2.0,
        max_dist: float = 22.0,
        num_bins: int = 64,
        overfit: Optional[int] = None,
        return_symmetries: Optional[bool] = False,
        single_sequence_prop: Optional[float] = 0.0,
        msa_sampling: bool = False,
        compute_frames: bool = True,
        backbone_only: bool = False,
        atom14: bool = False,
        atom37: bool = False,
        design: bool = False,
        disulfide_prob: float = 1.0,
        disulfide_on: bool = False,
        use_msa: bool = True,
        inverse_fold: bool = False,
    ) -> None:
        """Initialize the training dataset.

        Parameters
        ----------
        datasets : List[Dataset]
            The datasets to sample from.
        samplers : List[Sampler]
            The samplers to sample from each dataset.
        probs : List[float]
            The probabilities to sample from each dataset.
        samples_per_epoch : int
            The number of samples per epoch.
        max_tokens : int
            The maximum number of tokens.

        """
        super().__init__()
        self.datasets = datasets
        self.canonicals = canonicals
        self.moldir = moldir
        self.probs = [d.prob for d in datasets]
        self.samples_per_epoch = samples_per_epoch
        self.max_tokens = max_tokens
        self.max_seqs = max_seqs
        self.max_atoms = max_atoms
        self.pad_to_max_tokens = pad_to_max_tokens
        self.pad_to_max_atoms = pad_to_max_atoms
        self.pad_to_max_seqs = pad_to_max_seqs
        self.atoms_per_window_queries = atoms_per_window_queries
        self.min_dist = min_dist
        self.max_dist = max_dist
        self.num_bins = num_bins
        self.return_symmetries = return_symmetries
        self.backbone_only = backbone_only
        self.atom14 = atom14
        self.atom37 = atom37
        self.design = design
        self.disulfide_prob = disulfide_prob
        self.disulfide_on = disulfide_on
        self.single_sequence_prop = single_sequence_prop
        self.msa_sampling = msa_sampling
        self.use_msa = use_msa
        self.overfit = overfit
        self.compute_frames = compute_frames
        self.inverse_fold = inverse_fold

        self.samples: list[list[Dict]] = []
        self.samples_weight: list[list[float]] = []
        for d in self.datasets:
            if self.overfit:
                samples = d.samples[: self.overfit]
            else:
                samples = d.samples
            self.samples.append(
                [
                    samples.iloc[sample_idx].to_dict()
                    for sample_idx in range(len(samples))
                ]
            )
            self.samples_weight.append(samples["weight"].tolist())

    def __getitem__(self, idx: int) -> Dict[str, Tensor]:
        """Get an item from the dataset.

        Returns
        -------
        Dict[str, Tensor]
            The sampled data features.

        """
        # Set a random state
        random = np.random.default_rng()

        # Pick a random dataset
        dataset_idx = random.choice(len(self.datasets), p=self.probs)

        dataset = self.datasets[dataset_idx]

        # Get a sample from the dataset
        samples = self.samples[dataset_idx]
        sample_idx = random.choice(
            len(samples),
            p=(
                self.samples_weight[dataset_idx]
                / np.sum(self.samples_weight[dataset_idx])
                if self.overfit
                else self.samples_weight[dataset_idx]
            ),
        )

        sample = samples[sample_idx]
        sample: Sample = Sample(
            record_id=str(sample["record_id"]),
            chain_id=(
                int(sample["chain_id"]) if sample["chain_id"] is not None else None
            ),
            interface_id=(
                int(sample["interface_id"])
                if sample["interface_id"] is not None
                else None
            ),
            weight=float(sample["weight"]),
        )

        # Load record
        record = load_record(sample.record_id, dataset.record_dir)

        # Get the structure
        try:
            structure = load_structure(record, dataset.struct_dir)
        except Exception as e:  # noqa: BLE001
            print(f"Failed to load input for {record.id} with error {e}. Skipping.")
            return self.__getitem__(random.integers(0, len(self)))

        # Tokenize structure
        try:
            tokenized = dataset.tokenizer.tokenize(
                structure, inverse_fold=self.inverse_fold
            )
        except Exception as e:  # noqa: BLE001
            print(f"Tokenizer failed on {record.id} with error {e}. Skipping.")
            traceback.print_exc()  # noqa: T201
            return self.__getitem__(random.integers(0, len(self)))

        # Compute crop
        try:
            if self.max_tokens is not None and len(tokenized.tokens) > self.max_tokens:
                tokenized = dataset.cropper.crop(
                    tokenized,
                    max_atoms=self.max_atoms,
                    max_tokens=self.max_tokens,
                    chain_id=sample.chain_id,
                    interface_id=sample.interface_id,
                    random=random,
                    prefer_protein_queries=self.inverse_fold,
                )
                if len(tokenized.tokens) == 0:
                    msg = "No tokens in cropped structure."
                    raise ValueError(msg)  # noqa: TRY301
        except Exception as e:  # noqa: BLE001
            print(f"Cropper failed on {record.id} with error {e}. Skipping.")
            traceback.print_exc()  # noqa: T201
            return self.__getitem__(random.integers(0, len(self)))

        # Select which tokens to design
        try:
            tokenized, design_task = dataset.selector.select(
                tokenized,
                random=random,
            )
        except Exception as e:  # noqa: BLE001
            print(f"Selector failed on {record.id} with error {e}. Skipping.")  # noqa: T201
            traceback.print_exc()  # noqa: T201
            return self.__getitem__(random.integers(0, len(self)))
        structure = tokenized.structure

        # Get unique chain ids
        chain_ids = set(tokenized.tokens["asym_id"])

        # Load msas and templates
        try:
            if self.use_msa:
                msas = load_msas(
                    chain_ids=chain_ids,
                    record=record,
                    msa_dir=dataset.msa_dir,
                )
            else:
                msas = {}
        except Exception as e:  # noqa: BLE001
            print(f"MSA loading failed for {record.id} with error {e}. Skipping.")
            return self.__getitem__(random.integers(0, len(self)))

        # Load molecules
        try:
            # Try to find molecules in the dataset moldir if provided
            # Find missing ones in global moldir and check if all found
            molecules = {}
            molecules.update(self.canonicals)
            mol_names = set(tokenized.tokens["res_name"].tolist())
            mol_names = mol_names - set(self.canonicals.keys())
            if dataset.moldir is not None:
                molecules.update(load_molecules(dataset.moldir, mol_names))

            mol_names = mol_names - set(molecules.keys())
            molecules.update(load_molecules(self.moldir, mol_names))
        except Exception as e:  # noqa: BLE001
            print(f"Molecule loading failed for {record.id} with error {e}. Skipping.")
            return self.__getitem__(random.integers(0, len(self)))

        # Finalize input data
        input_data = Input(
            tokens=tokenized.tokens,
            bonds=tokenized.bonds,
            token_to_res=tokenized.token_to_res,
            structure=tokenized.structure,
            msa=msas,
            templates=None,
            record=record,
        )

        # Compute features
        try:
            features: dict = dataset.featurizer.process(
                input_data,
                molecules=molecules,
                random=random,
                training=True,
                max_atoms=self.max_atoms if self.pad_to_max_atoms else None,
                max_tokens=self.max_tokens if self.pad_to_max_tokens else None,
                max_seqs=self.max_seqs,
                pad_to_max_seqs=self.pad_to_max_seqs,
                atoms_per_window_queries=self.atoms_per_window_queries,
                min_dist=self.min_dist,
                max_dist=self.max_dist,
                num_bins=self.num_bins,
                compute_symmetries=self.return_symmetries,
                single_sequence_prop=self.single_sequence_prop,
                msa_sampling=self.msa_sampling,
                override_bfactor=dataset.override_bfactor,
                override_method=dataset.override_method,
                compute_frames=self.compute_frames,
                backbone_only=self.backbone_only,
                atom14=self.atom14,
                atom37=self.atom37,
                design=self.design,
                disulfide_prob=self.disulfide_prob,
                inverse_fold=self.inverse_fold,
            )
        except Exception as e:  # noqa: BLE001
            print(f"Featurizer failed on {record.id} with error {e}. Skipping.")
            traceback.print_exc()
            return self.__getitem__(random.integers(0, len(self)))

        # Check that there is enough stuff to design in the inverse folding case so we have no nan losses
        if self.inverse_fold and features["design_mask"].sum() < 3:
            print(f"Skipping {record.id}. Fewer than 3 design residues.")
            return self.__getitem__(random.integers(0, len(self)))

        # Set template features
        template_features = load_dummy_templates(
            tdim=1, num_tokens=len(features["res_type"])
        )
        features.update(template_features)

        features.update({"id": sample.record_id})
        features["pdb_id"] = record.id

        # Assert that all design tokens make sense
        bad_protein_mask = (
            (~features["is_standard"].bool())
            & features["design_mask"].bool()
            & (features["mol_type"] == const.chain_type_ids["PROTEIN"])
        )
        assert not bad_protein_mask.any()

        return features

    def __len__(self) -> int:
        """Get the length of the dataset.

        Returns
        -------
        int
            The length of the dataset.

        """
        return self.samples_per_epoch


class ValidationDataset(torch.utils.data.Dataset):
    """Base iterable dataset."""

    def __init__(
        self,
        datasets: List[Dataset],
        canonicals: dict[str, Mol],
        moldir: str,
        seed: int,
        max_atoms: Optional[int] = None,
        max_tokens: Optional[int] = None,
        max_seqs: Optional[int] = None,
        pad_to_max_atoms: bool = False,
        pad_to_max_tokens: bool = False,
        pad_to_max_seqs: bool = False,
        atoms_per_window_queries: int = 32,
        min_dist: float = 2.0,
        max_dist: float = 22.0,
        num_bins: int = 64,
        overfit: Optional[int] = None,
        return_symmetries: Optional[bool] = False,
        compute_frames: bool = True,
        backbone_only: bool = False,
        atom14: bool = False,
        atom37: bool = False,
        design: bool = False,
        inverse_fold: bool = False,
        disulfide_prob: float = 1.0,
        disulfide_on: bool = False,
    ) -> None:
        """Initialize the training dataset.

        Parameters
        ----------
        datasets : List[Dataset]
            The datasets to sample from.
        seed : int
            The random seed.
        max_tokens : int
            The maximum number of tokens.
        overfit : bool
            Whether to overfit the dataset

        """
        super().__init__()
        self.datasets = datasets
        self.canonicals = canonicals
        self.moldir = moldir
        self.max_atoms = max_atoms
        self.max_tokens = max_tokens
        self.max_seqs = max_seqs
        self.seed = seed
        self.pad_to_max_tokens = pad_to_max_tokens
        self.pad_to_max_atoms = pad_to_max_atoms
        self.pad_to_max_seqs = pad_to_max_seqs
        self.overfit = overfit
        self.atoms_per_window_queries = atoms_per_window_queries
        self.min_dist = min_dist
        self.max_dist = max_dist
        self.num_bins = num_bins
        self.return_symmetries = return_symmetries
        self.compute_frames = compute_frames
        self.backbone_only = backbone_only
        self.atom14 = atom14
        self.atom37 = atom37
        self.design = design
        self.inverse_fold = inverse_fold
        self.disulfide_prob = disulfide_prob
        self.disulfide_on = disulfide_on

    def __getitem__(self, idx: int) -> Structure:
        """Get an item from the dataset.

        Returns
        -------
        Dict[str, Tensor]
            The sampled data features.

        """
        # Set random state
        seed = self.seed if self.overfit is None else None
        random = np.random.default_rng(seed)

        # Pick dataset based on idx
        for idx_dataset, dataset in enumerate(self.datasets):  # noqa: B007
            size = len(dataset.samples)
            if self.overfit is not None:
                size = min(size, self.overfit)
            if idx < size:
                break
            idx -= size

        # Get a sample from the dataset
        sample = Sample(**dataset.samples.iloc[idx].to_dict())
        record = load_record(sample.record_id, dataset.record_dir)

        # Get the structure
        try:
            structure = load_structure(record, dataset.struct_dir)
        except Exception as e:  # noqa: BLE001
            print(f"Failed to load input for {record.id} with error {e}. Skipping.")
            return self.__getitem__(0)

        # Tokenize structure
        try:
            tokenized = dataset.tokenizer.tokenize(structure)
        except Exception as e:  # noqa: BLE001
            print(f"Tokenizer failed on {record.id} with error {e}. Skipping.")  # noqa: T201
            return self.__getitem__(0)

        # Compute crop
        try:
            if self.max_tokens is not None:
                tokenized = dataset.cropper.crop(
                    tokenized,
                    max_atoms=self.max_atoms,
                    max_tokens=self.max_tokens,
                    chain_id=sample.chain_id,
                    interface_id=sample.interface_id,
                    random=random,
                    prefer_protein_queries=self.inverse_fold,
                )
                if len(tokenized.tokens) == 0:
                    msg = "No tokens in cropped structure."
                    raise ValueError(msg)  # noqa: TRY301
        except Exception as e:  # noqa: BLE001
            print(f"Cropper failed on {record.id} with error {e}. Skipping.")
            return self.__getitem__(0)

        # Get unique chains
        chain_ids = set(np.unique(tokenized.tokens["asym_id"]).tolist())

        # Load msas and templates
        try:
            msas = load_msas(chain_ids, record, dataset.msa_dir)
        except Exception as e:  # noqa: BLE001
            print(f"MSA loading failed for {record.id} with error {e}. Skipping.")
            return self.__getitem__(0)

        # Select which tokens to design
        try:
            tokenized, design_task = dataset.selector.select(
                tokenized,
                random=random,
            )
        except Exception as e:  # noqa: BLE001
            print(f"Selector failed on {sample.record_id} with error {e}. Skipping.")  # noqa: T201
            traceback.print_exc()  # noqa: T201
            return self.__getitem__(0)
        structure = tokenized.structure

        try:
            # Try to find molecules in the dataset moldir if provided
            # Find missing ones in global moldir and check if all found
            molecules = {}
            molecules.update(self.canonicals)
            mol_names = set(tokenized.tokens["res_name"].tolist())
            mol_names = mol_names - set(self.canonicals.keys())
            if dataset.moldir is not None:
                molecules.update(load_molecules(dataset.moldir, mol_names))

            mol_names = mol_names - set(molecules.keys())
            molecules.update(load_molecules(self.moldir, mol_names))
        except Exception as e:  # noqa: BLE001
            print(f"Molecule loading failed for {record.id} with error {e}. Skipping.")
            return self.__getitem__(0)

        # Finalize input data
        input_data = Input(
            tokens=tokenized.tokens,
            bonds=tokenized.bonds,
            token_to_res=tokenized.token_to_res,
            structure=tokenized.structure,
            msa=msas,
            templates=None,
            record=record,
        )

        # Compute features
        try:
            features: dict = dataset.featurizer.process(
                input_data,
                molecules=molecules,
                random=random,
                training=False,
                max_atoms=None,
                max_tokens=None,
                max_seqs=self.max_seqs,
                pad_to_max_seqs=self.pad_to_max_seqs,
                atoms_per_window_queries=self.atoms_per_window_queries,
                min_dist=self.min_dist,
                max_dist=self.max_dist,
                num_bins=self.num_bins,
                compute_symmetries=self.return_symmetries,
                single_sequence_prop=0.0,
                override_method=dataset.override_method,
                compute_frames=self.compute_frames,
                backbone_only=self.backbone_only,
                atom14=self.atom14,
                atom37=self.atom37,
                design=self.design,
                inverse_fold=self.inverse_fold,
                disulfide_prob=self.disulfide_prob,
                disulfide_on=self.disulfide_on,
            )
        except Exception as e:  # noqa: BLE001
            print(f"Featurizer failed on {record.id} with error {e}. Skipping.")
            return self.__getitem__(0)

        # Check that there is enough stuff to design in the inverse folding case so we have no nan losses
        if self.inverse_fold and features["design_mask"].sum() < 3:
            print(f"Skipping {record.id}. Fewer than 3 design residues.")
            return self.__getitem__(0)

        # Set template features
        template_features = load_dummy_templates(
            tdim=1, num_tokens=len(features["res_type"])
        )
        features.update(template_features)

        # Add dataset idx
        idx_dataset = torch.tensor([idx_dataset], dtype=torch.long)
        features.update({"idx_dataset": idx_dataset})
        features.update({"id": record.id})
        bad_protein_mask = (
            (~features["is_standard"].bool())
            & features["design_mask"].bool()
            & (features["mol_type"] == const.chain_type_ids["PROTEIN"])
        )
        assert not bad_protein_mask.any()
        return features

    def __len__(self) -> int:
        """Get the length of the dataset.

        Returns
        -------
        int
            The length of the dataaset.

        """
        if self.overfit is not None:
            length = sum(len(d.samples[: self.overfit]) for d in self.datasets)
        else:
            length = sum(len(d.samples) for d in self.datasets)

        return length


class TrainingDataModule(pl.LightningDataModule):
    """DataModule for BoltzGen training."""

    def __init__(
        self,
        cfg: DataConfig,
    ) -> None:
        """Initialize the DataModule.

        Parameters
        ----------
        config : DataConfig
            The data configuration.

        """
        super().__init__()
        self.cfg = cfg
        self.inverse_fold = cfg.inverse_fold

        assert self.cfg.val_batch_size == 1, "Validation only works with batch size=1."

        # Load datasets
        train: List[Dataset] = []
        val: List[Dataset] = []

        for data_config in cfg.datasets:
            # Get relevant directories
            if data_config.manifest_path is not None:
                manifest_path = Path(data_config.manifest_path)
            else:
                manifest_path = Path(data_config.target_dir) / "manifest.json"
            struct_dir = Path(data_config.target_dir) / "structures"
            record_dir = Path(data_config.target_dir) / "records"
            msa_dir = Path(data_config.msa_dir)

            # Get moldir, if any
            moldir = data_config.moldir
            moldir = Path(moldir) if moldir is not None else None

            # Load all records
            manifest: Manifest = Manifest.load(manifest_path)

            # Split records if givens
            if data_config.split is not None:
                with Path(data_config.split).open("r") as f:
                    split = {x.lower() for x in f.read().splitlines()}

                train_records = []
                val_records = []
                for record in manifest.records:
                    if record.id.lower() in split:
                        val_records.append(record)
                    else:
                        train_records.append(record)
            else:
                train_records = manifest.records
                if cfg.overfit is None:
                    val_records = []
                else:
                    print("Warning: modified overfit val behavior.")
                    val_records = manifest.records[: cfg.overfit]

            print("train_records before filter", len(train_records))

            # Apply dataset-specific filters
            if data_config.filters is not None:
                train_records = [
                    record
                    for record in train_records
                    if all(f.filter(record) for f in data_config.filters)
                ]

            # Train with subset of data
            if data_config.use_train_subset is not None:
                # Shuffle train_records list
                assert 0 < data_config.use_train_subset < 1.0
                rng = np.random.default_rng(cfg.random_seed)
                rng.shuffle(train_records)
                train_records = train_records[
                    0 : int(len(train_records) * data_config.use_train_subset)
                ]
            print("train_records after filter", len(train_records))
            print("val_records after filter", len(val_records))

            # Get samples
            train_samples: list[Sample] = data_config.sampler.sample(train_records)
            val_samples: list[Sample] = [Sample(r.id) for r in val_records]

            # Convert samples to pandas dataframe to avoid copy-on-write behavior
            train_samples = pd.DataFrame(
                [
                    (
                        r.record_id,
                        r.chain_id,
                        r.interface_id,
                        r.weight,
                    )
                    for r in train_samples
                ],
                columns=["record_id", "chain_id", "interface_id", "weight"],
            )
            val_samples = pd.DataFrame(
                [s.record_id for s in val_samples], columns=["record_id"]
            )

            # Use appropriate string type
            train_samples = train_samples.replace({np.nan: None})
            val_samples = val_samples.replace({np.nan: None})
            train_samples["record_id"] = train_samples["record_id"].astype("string")
            val_samples["record_id"] = val_samples["record_id"].astype("string")

            del manifest, train_records, val_records
            # Create train dataset
            if data_config.prob > 0:
                train.append(
                    Dataset(
                        samples=train_samples,
                        record_dir=record_dir,
                        struct_dir=struct_dir,
                        msa_dir=msa_dir,
                        moldir=moldir,
                        prob=data_config.prob,
                        cropper=data_config.cropper,
                        tokenizer=cfg.tokenizer,
                        featurizer=cfg.featurizer,
                        val_group=data_config.val_group,
                        symmetry_correction=data_config.symmetry_correction,
                        override_bfactor=data_config.override_bfactor,
                        override_method=data_config.override_method,
                        selector=cfg.selector,
                    )
                )

            # Create validation dataset
            if len(val_samples) > 0:
                val.append(
                    Dataset(
                        samples=val_samples,
                        record_dir=record_dir,
                        struct_dir=struct_dir,
                        msa_dir=msa_dir,
                        moldir=moldir,
                        prob=data_config.prob,
                        cropper=data_config.cropper,
                        tokenizer=cfg.tokenizer,
                        featurizer=cfg.featurizer,
                        val_group=data_config.val_group,
                        symmetry_correction=data_config.symmetry_correction,
                        selector=cfg.selector,
                    )
                )

        # Print dataset sizes
        for dataset in train:
            dataset: Dataset
            print(f"Training dataset size: {len(dataset.samples)}")

        self.val_group_mapper = defaultdict(dict)

        for i, dataset in enumerate(train if cfg.overfit is not None else val):
            dataset: Dataset
            print(f"Validation dataset size: {len(dataset.samples)}")
            self.val_group_mapper[i]["label"] = dataset.val_group
            self.val_group_mapper[i]["symmetry_correction"] = (
                # If overfit, use symmetry_correction from val dataset instead of training dataset
                dataset.symmetry_correction
                if cfg.overfit is None
                else data_config.symmetry_correction
            )

        # Load canonical molecules
        canonicals = load_canonicals(cfg.moldir)

        # Create wrapper datasets
        self._train_set = TrainingDataset(
            datasets=train,
            canonicals=canonicals,
            moldir=cfg.moldir,
            samples_per_epoch=cfg.samples_per_epoch,
            max_atoms=cfg.max_atoms,
            max_tokens=cfg.max_tokens,
            max_seqs=cfg.max_seqs,
            pad_to_max_atoms=cfg.pad_to_max_atoms,
            pad_to_max_tokens=cfg.pad_to_max_tokens,
            pad_to_max_seqs=cfg.pad_to_max_seqs,
            atoms_per_window_queries=cfg.atoms_per_window_queries,
            min_dist=cfg.min_dist,
            max_dist=cfg.max_dist,
            num_bins=cfg.num_bins,
            overfit=cfg.overfit,
            return_symmetries=cfg.return_train_symmetries,
            single_sequence_prop=cfg.single_sequence_prop_training,
            msa_sampling=cfg.msa_sampling_training,
            use_msa=cfg.use_msa,
            compute_frames=cfg.compute_frames,
            backbone_only=cfg.backbone_only,
            atom14=cfg.atom14,
            atom37=cfg.atom37,
            design=cfg.design,
            inverse_fold=cfg.inverse_fold,
            disulfide_prob=cfg.disulfide_prob,
            disulfide_on=cfg.disulfide_on,
        )
        self._val_set = ValidationDataset(
            datasets=train if cfg.overfit is not None else val,
            canonicals=canonicals,
            moldir=cfg.moldir,
            seed=cfg.random_seed,
            max_atoms=cfg.max_atoms,
            max_tokens=cfg.max_tokens,
            max_seqs=cfg.max_seqs,
            pad_to_max_atoms=cfg.pad_to_max_atoms,
            pad_to_max_tokens=cfg.pad_to_max_tokens,
            pad_to_max_seqs=cfg.pad_to_max_seqs,
            atoms_per_window_queries=cfg.atoms_per_window_queries,
            min_dist=cfg.min_dist,
            max_dist=cfg.max_dist,
            num_bins=cfg.num_bins,
            overfit=cfg.overfit,
            return_symmetries=cfg.return_val_symmetries,
            compute_frames=cfg.compute_frames,
            backbone_only=cfg.backbone_only,
            atom14=cfg.atom14,
            atom37=cfg.atom37,
            design=cfg.design,
            inverse_fold=cfg.inverse_fold,
            disulfide_prob=cfg.disulfide_prob,
            disulfide_on=cfg.disulfide_on,
        )

        self.monomer_split = cfg.monomer_split
        print("monomer_split", self.monomer_split)
        if self.monomer_split is not None:
            with Path(self.monomer_split).open("r") as f:
                monomer_ids = [x.lower() for x in f.read().splitlines()]
                print("monomer_split", monomer_ids)

            dataset = data_protein_binder.Dataset(
                struct_dir=Path(cfg.monomer_target_dir) / "structures",
                record_dir=Path(cfg.monomer_target_dir) / "records",
                target_ids=monomer_ids,
                seq_len=cfg.monomer_seq_len,
                tokenizer=cfg.tokenizer,
                featurizer=cfg.featurizer,
            )

            # Load canonical molecules
            canonicals = load_canonicals(cfg.moldir)

            self.monomer_val_set = data_protein_binder.PredictionDataset(
                dataset=dataset,
                canonicals=canonicals,
                moldir=Path(cfg.moldir),
                backbone_only=cfg.backbone_only,
                atom14=cfg.atom14,
                atom37=cfg.atom37,
                design=cfg.design,
                target_structure_condition=cfg.monomer_target_structure_condition,
                inverse_fold=cfg.inverse_fold,
                disulfide_prob=cfg.disulfide_prob,
                disulfide_on=cfg.disulfide_on,
            )

        self.ligand_split = cfg.ligand_split
        print("ligand_split", self.ligand_split)
        if self.ligand_split is not None:
            with Path(self.ligand_split).open("r") as f:
                ligand_ids = [x.lower() for x in f.read().splitlines()]
                print("ligand_split", ligand_ids)

            dataset = data_ligands.Dataset(
                struct_dir=Path(cfg.ligand_target_dir) / "structures",
                record_dir=Path(cfg.ligand_target_dir) / "records",
                target_ids=ligand_ids,
                min_len=cfg.ligand_seq_len,
                max_len=cfg.ligand_seq_len,
                tokenizer=cfg.tokenizer,
                featurizer=cfg.featurizer,
            )

            # Load canonical molecules
            canonicals = load_canonicals(cfg.moldir)

            self.ligand_val_set = data_ligands.PredictionDataset(
                dataset=dataset,
                canonicals=canonicals,
                moldir=Path(cfg.moldir),
                backbone_only=cfg.backbone_only,
                atom14=cfg.atom14,
                atom37=cfg.atom37,
                design=cfg.design,
                disulfide_prob=cfg.disulfide_prob,
                disulfide_on=cfg.disulfide_on,
            )

    def setup(self, stage: Optional[str] = None) -> None:  # noqa: ARG002 (unused)
        """Run the setup for the DataModule.

        Parameters
        ----------
        stage : str, optional
            The stage, one of 'fit', 'validate', 'test'.

        """
        return

    def train_dataloader(self) -> DataLoader:
        """Get the training dataloader.

        Returns
        -------
        DataLoader
            The training dataloader.

        """
        return DataLoader(
            self._train_set,
            batch_size=self.cfg.batch_size,
            num_workers=self.cfg.num_workers,
            pin_memory=self.cfg.pin_memory,
            shuffle=False,
            collate_fn=collate,
        )

    def val_dataloader(self) -> DataLoader:
        """Get the validation dataloader.

        Returns
        -------
        DataLoader
            The validation dataloader.s

        """
        val_loaders = []
        val_loaders.append(
            DataLoader(
                self._val_set,
                batch_size=self.cfg.val_batch_size,
                num_workers=self.cfg.num_workers if not self.inverse_fold else 1,
                pin_memory=self.cfg.num_workers if not self.inverse_fold else False,
                shuffle=False,
                collate_fn=collate,
            )
        )
        if self.monomer_split is not None:
            val_loaders.append(
                DataLoader(
                    self.monomer_val_set,
                    batch_size=self.cfg.val_batch_size,
                    num_workers=self.cfg.num_workers if not self.inverse_fold else 1,
                    pin_memory=self.cfg.pin_memory if not self.inverse_fold else False,
                    shuffle=False,
                    collate_fn=data_protein_binder.collate,
                )
            )
        if self.ligand_split is not None:
            val_loaders.append(
                DataLoader(
                    self.ligand_val_set,
                    batch_size=self.cfg.val_batch_size,
                    num_workers=self.cfg.num_workers if not self.inverse_fold else 1,
                    pin_memory=self.cfg.pin_memory if not self.inverse_fold else False,
                    shuffle=False,
                    collate_fn=data_ligands.collate,
                )
            )
        return val_loaders

    def predict_dataloader(self) -> DataLoader:
        return DataLoader(
            self._val_set,
            batch_size=self.cfg.val_batch_size,
            num_workers=self.cfg.num_workers,
            pin_memory=self.cfg.pin_memory,
            shuffle=False,
            collate_fn=collate,
        )

    def transfer_batch_to_device(
        self,
        batch: Dict,
        device: torch.device,
        dataloader_idx: int,  # noqa: ARG002
    ) -> Dict:
        """Transfer a batch to the given device.

        Parameters
        ----------
        batch : Dict
            The batch to transfer.
        device : torch.device
            The device to transfer to.
        dataloader_idx : int
            The dataloader index.

        Returns
        -------
        np.Any
            The transferred batch.

        """
        for key in batch:
            if key not in [
                "all_coords",
                "all_resolved_mask",
                "crop_to_all_atom_map",
                "chain_symmetries",
                "chain_swaps",
                "amino_acids_symmetries",
                "ligand_symmetries",
                "activity_name",
                "activity_qualifier",
                "sid",
                "cid",
                "normalized_protein_accession",
                "pair_id",
                "ligand_edge_index",
                "ligand_edge_lower_bounds",
                "ligand_edge_upper_bounds",
                "ligand_edge_bond_mask",
                "ligand_edge_angle_mask",
                "connections_edge_index",
                "ligand_chiral_atom_index",
                "ligand_chiral_check_mask",
                "ligand_chiral_atom_orientations",
                "ligand_stereo_bond_index",
                "ligand_stereo_check_mask",
                "ligand_stereo_bond_orientations",
                "ligand_aromatic_5_ring_index",
                "ligand_aromatic_6_ring_index",
                "ligand_planar_double_bond_index",
                "pdb_id",
                "id",
                "tokenized",
                "structure",
                "structure_bonds",
                "extra_mols",
            ]:
                if hasattr(batch[key], "to"):
                    batch[key] = batch[key].to(device)
        return batch
