import io
import warnings
from pathlib import Path
import random
from typing import List
import subprocess
import re
import biotite
import hydride
from sklearn.cluster import DBSCAN
from Bio import PDB
from biotite import structure
from Bio.Seq import Seq


from matplotlib import pyplot as plt
import matplotlib as mpl

mpl.rcParams["figure.max_open_warning"] = 100

from Bio import Align

from boltzgen.data.rmsd_computation import get_true_coordinates
from boltzgen.model.loss.diffusion import weighted_rigid_align
from boltzgen.task.predict.data_from_generated import collate

import torch
import numpy as np
import pandas as pd
from tqdm import tqdm

from boltzgen.data import const
from boltzgen.data.data import Structure, biotite_array_from_feat
from boltzgen.data.write.mmcif import to_mmcif
from boltzgen.model.loss.validation import factored_lddt_loss, compute_subset_rmsd

from biotite.structure.sasa import sasa
from biotite.structure.info import vdw_radius_single, vdw_radius_protor
import biotite.structure.io.pdbx as pdbx
import biotite.structure.io.pdb as pdbio

TARGET_ID_RE = re.compile(
    r"^(?:(?:sample\d+_|batch\d+_|rank\d+_)+)?([^_]+)(?:_[^_]+)*?(?:_(?:gen))*$"
)


def _load_stack(path):
    path = Path(path)
    suffix = path.suffix.lower()
    if suffix in {".cif", ".mmcif"}:
        cif_file = pdbx.CIFFile.read(str(path))
        stack = pdbx.get_structure(cif_file, use_author_fields=False)
    elif suffix in {".pdb", ".ent"}:
        pdb_file = pdbio.PDBFile.read(str(path))
        stack = pdbio.get_structure(pdb_file, model=None)
    else:
        raise ValueError(f"Unsupported structure file extension: {suffix}")

    return stack


def compute_rmsd(atom_coords: torch.Tensor, pred_atom_coords: torch.Tensor):
    rmsd, _ = compute_subset_rmsd(
        atom_coords,
        pred_atom_coords,
        atom_mask=torch.ones_like(atom_coords[..., 0]),
        align_weights=torch.ones_like(atom_coords[..., 0]),
        subset_mask=torch.ones_like(atom_coords[..., 0]),
        multiplicity=1,
    )
    return rmsd


def make_histogram(
    df,
    column_name: str,
):
    data = df[column_name].dropna()
    fig, ax = plt.subplots(figsize=(6, 4))

    ax.hist(data, bins=50, alpha=0.7, edgecolor="black", linewidth=0.5)
    ax.axvline(data.mean(), color="red", linestyle="dashed", linewidth=1)

    ax.set_title(
        f"{column_name.replace('_', ' ').capitalize()} Distribution", fontsize=12
    )
    ax.set_xlabel(column_name.replace("_", " "), fontsize=10)
    ax.set_ylabel("Count", fontsize=10)

    ax.grid(True, axis="y", linestyle="--", linewidth=0.5, alpha=0.6)
    ax.tick_params(axis="both", which="major", labelsize=8)

    plt.tight_layout()
    return fig


def get_best_folding_sample(folded):
    confidence = 0.8 * folded["design_to_target_iptm"] + 0.2 * folded["design_ptm"]
    best_idx = np.argmax(confidence)
    
    # TODO: remove the "if k in folded"
    best_sample = {
        k: folded[k][best_idx] for k in const.eval_keys_confidence if k in folded
    }
    best_sample["coords"] = folded["coords"][best_idx]
    return best_sample


def get_fold_metrics(
    feat,
    folded,
    compute_lddts=True,
    prefix="",
):
    batch = collate([feat])
    diffusion_samples = batch["coords"].shape[0]
    best_sample = get_best_folding_sample(folded)

    # Compute RMSDs
    rmsd_out = get_true_coordinates(
        batch=batch,
        out={"sample_atom_coords": torch.from_numpy(best_sample["coords"])},
        diffusion_samples=1,
        symmetry_correction=False,
        protein_lig_rmsd=True,
    )
    true_coords_resolved_mask = rmsd_out["true_coords_resolved_mask"]

    # Add to metrics dictionary
    metrics = {}
    metrics["rmsd"] = rmsd_out.get("rmsd").item()
    metrics["rmsd_design"] = rmsd_out.get("rmsd_design").item()
    metrics["rmsd_target"] = rmsd_out.get("rmsd_target").item()
    metrics["rmsd_design_target"] = rmsd_out.get("rmsd_design_target").item()
    metrics["target_aligned_rmsd_design"] = rmsd_out.get(
        "target_aligned_rmsd_design"
    ).item()
    metrics["rmsd<2.5"] = bool(metrics["rmsd"] <= 2.5)
    metrics["target_aligned<2.5"] = bool(metrics["target_aligned_rmsd_design"] <= 2.5)
    metrics["designability_rmsd_2"] = bool(metrics["rmsd_design"] <= 2.0)
    metrics["designability_rmsd_4"] = bool(metrics["rmsd_design"] <= 4.0)

    # Comput LDDTs
    if compute_lddts:
        all_lddt_dict, _ = factored_lddt_loss(
            feats=batch,
            atom_mask=true_coords_resolved_mask,
            true_atom_coords=batch["coords"],
            pred_atom_coords=torch.from_numpy(best_sample["coords"]),
            multiplicity=diffusion_samples,
            exclude_ions=False,
        )
        metrics.update({f"lddt_{k}": v.max().item() for k, v in all_lddt_dict.items()})
        metrics["designability_lddt_60"] = bool(metrics["lddt_intra_design"] >= 0.6)
        metrics["designability_lddt_65"] = bool(metrics["lddt_intra_design"] >= 0.65)
        metrics["designability_lddt_70"] = bool(metrics["lddt_intra_design"] >= 0.7)
        metrics["designability_lddt_75"] = bool(metrics["lddt_intra_design"] >= 0.75)
        metrics["designability_lddt_80"] = bool(metrics["lddt_intra_design"] >= 0.8)
        metrics["designability_lddt_85"] = bool(metrics["lddt_intra_design"] >= 0.85)
        metrics["designability_lddt_90"] = bool(metrics["lddt_intra_design"] >= 0.9)

    # metrics without prefix (backbone only is the same as all atom)
    # TODO: remove the "if k in best_sample"
    confs = {k: best_sample[k] for k in const.eval_keys_confidence if k in best_sample}
    confs["min_interaction_pae<1.5"] = bool(confs["min_interaction_pae"] <= 1.5)
    confs["min_interaction_pae<2"] = bool(confs["min_interaction_pae"] <= 2.0)
    confs["min_interaction_pae<2.5"] = bool(confs["min_interaction_pae"] <= 2.5)
    confs["min_interaction_pae<3"] = bool(confs["min_interaction_pae"] <= 3)
    confs["min_interaction_pae<4"] = bool(confs["min_interaction_pae"] <= 4)
    confs["min_interaction_pae<5"] = bool(confs["min_interaction_pae"] <= 5)
    confs["design_ptm>80"] = bool(confs["design_ptm"] >= 0.8)
    confs["design_ptm>75"] = bool(confs["design_ptm"] >= 0.75)
    confs["design_iptm>80"] = bool(confs["design_iptm"] >= 0.8)
    confs["design_iptm>70"] = bool(confs["design_iptm"] >= 0.7)
    confs["design_iptm>60"] = bool(confs["design_iptm"] >= 0.6)
    confs["design_iptm>50"] = bool(confs["design_iptm"] >= 0.5)

    prefixed_metrics = {f"{prefix}{k}": v for k, v in metrics.items()}
    prefixed_metrics.update(confs)
    return prefixed_metrics


def count_noncovalents(feat):
    metrics = {}
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        biotite_array = biotite_array_from_feat(feat)
        biotite_array, _ = hydride.add_hydrogen(biotite_array)
        hbond = biotite.structure.hbond(biotite_array)
    donor_idxs, acceptor_idxs = hbond[:, 0], hbond[:, 2]
    cross_design_hbonds = (
        biotite_array.is_design[donor_idxs] != biotite_array.is_design[acceptor_idxs]
    )
    metrics["plip_hbonds"] = int(cross_design_hbonds.sum())

    # saltbridges
    pos_atoms = biotite_array[biotite_array.charge > 0]
    neg_atoms = biotite_array[biotite_array.charge < 0]
    if len(neg_atoms) > 0 and len(pos_atoms) > 0:
        pos_neg_distances = torch.cdist(
            torch.as_tensor(pos_atoms.coord), torch.as_tensor(neg_atoms.coord)
        )
        pos_idxs, neg_idxs = torch.where(
            (pos_neg_distances > 0.5) & (pos_neg_distances < 5.5)
        )
        # only keep the ones between design and non design
        cross_design_saltbridges = (
            pos_atoms.is_design[pos_idxs] != neg_atoms.is_design[neg_idxs]
        )
        metrics["plip_saltbridge"] = int(cross_design_saltbridges.sum())
    else:
        metrics["plip_saltbridge"] = 0
    return metrics


def tm_score(coords1, coords2):
    num_atoms1 = coords1.shape[0]
    num_atoms2 = coords2.shape[0]

    atom_array1 = structure.AtomArray(num_atoms1)
    atom_array1.coord = coords1.numpy()
    atom_array1.element = np.array(["C"] * num_atoms1)
    atom_array1.atom_name = np.array(["CA"] * num_atoms1)
    atom_array1.res_name = np.array(["ALA"] * num_atoms1)
    atom_array1.chain_id = np.array(["A"] * num_atoms1)
    atom_array1.res_id = np.arange(1, num_atoms1 + 1)

    atom_array2 = structure.AtomArray(num_atoms2)
    atom_array2.coord = coords2.numpy()
    atom_array2.element = np.array(["C"] * num_atoms2)
    atom_array2.atom_name = np.array(["CA"] * num_atoms2)
    atom_array2.res_name = np.array(["ALA"] * num_atoms2)
    atom_array2.chain_id = np.array(["A"] * num_atoms2)
    atom_array2.res_id = np.arange(1, num_atoms2 + 1)

    try:
        # This fails with a value error if the structures are too dissimilar. In that event, we return 0 as the TM-Score
        aligned, transform, fixed_indices, mobile_indices = (
            structure.superimpose_structural_homologs(
                atom_array1, atom_array2, max_iterations=25
            )
        )
        tm_align_fixed = structure.tm_score(
            atom_array1,
            aligned,
            fixed_indices,
            mobile_indices,
        )
    except:
        tm_align_fixed = 0

    tm_score_rmsd_aligned = 0
    if num_atoms1 == num_atoms2:
        coords1 = weighted_rigid_align(
            coords1.float()[None],
            coords2.float()[None],
            weights=torch.ones(len(coords1)).float()[None],
            mask=torch.ones(len(coords2))[None],
        ).squeeze()

        atom_array1 = structure.AtomArray(num_atoms1)
        atom_array1.coord = coords1.numpy()
        atom_array1.element = np.array(["C"] * num_atoms1)
        atom_array1.atom_name = np.array(["CA"] * num_atoms1)
        atom_array1.res_name = np.array(["ALA"] * num_atoms1)
        atom_array1.chain_id = np.array(["A"] * num_atoms1)
        atom_array1.res_id = np.arange(1, num_atoms1 + 1)

        atom_array2 = structure.AtomArray(num_atoms2)
        atom_array2.coord = coords2.numpy()
        atom_array2.element = np.array(["C"] * num_atoms2)
        atom_array2.atom_name = np.array(["CA"] * num_atoms2)
        atom_array2.res_name = np.array(["ALA"] * num_atoms2)
        atom_array2.chain_id = np.array(["A"] * num_atoms2)
        atom_array2.res_id = np.arange(1, num_atoms2 + 1)
        try:
            _, _, fixed_indices, mobile_indices = (
                structure.superimpose_structural_homologs(
                    atom_array1, atom_array2, max_iterations=25
                )
            )
            tm_score_rmsd_aligned = structure.tm_score(
                atom_array1,
                atom_array2,
                fixed_indices,
                mobile_indices,
            )
        except:
            pass

    return tm_score_rmsd_aligned, tm_align_fixed


def vendi_from_sim(mat):
    mat = mat + mat.T
    np.fill_diagonal(mat, 1.0)
    eigvals, _ = np.linalg.eigh(mat / len(mat))
    eigvals = np.clip(eigvals, 0.0, None)
    return np.exp(np.nansum(-(eigvals * np.log(eigvals))))


def vendi_scores(
    all_ca_coords: List[np.ndarray],
    all_metrics: list = None,
    fold_metrics: bool = False,
    diversity_subset: int = None,
    compute_lddts: bool = True,
    compute_iptms: bool = True,
    compute_min_int_paes: bool = True,
    backbone_fold_metrics: bool = False,
    allatom_fold_metrics: bool = True,
) -> float:
    if fold_metrics or diversity_subset is not None:
        assert all_metrics is not None
    if all_metrics is not None:
        assert len(all_ca_coords) == len(all_metrics)
    if diversity_subset is not None and diversity_subset < len(all_ca_coords):
        indices = random.sample(range(len(all_ca_coords)), diversity_subset)
        all_metrics = [all_metrics[i] for i in indices]
        all_ca_coords = [all_ca_coords[i] for i in indices]
    N = len(all_ca_coords)
    tm = np.zeros((N, N), dtype=np.float32)
    tm_fixed = np.zeros((N, N), dtype=np.float32)

    for i in tqdm(range(N), desc="Computing structure diversity."):
        for j in range(i + 1, N):
            tm_score_rmsd_aligned, tm_fixeds = tm_score(
                all_ca_coords[i], all_ca_coords[j]
            )
            tm[i, j] = tm_score_rmsd_aligned
            tm_fixed[i, j] = tm_fixeds

    scores = {
        "vendi_tm_fixed": vendi_from_sim(tm_fixed),
        "vendi_tm_align": vendi_from_sim(tm),
    }
    prefixes = []
    if allatom_fold_metrics:
        prefixes.append("")
    if backbone_fold_metrics:
        prefixes.append("bb_")
    for prefix in prefixes:
        mask_2 = np.array([m[f"{prefix}designability_rmsd_2"] for m in all_metrics])
        mask_4 = np.array([m[f"{prefix}designability_rmsd_4"] for m in all_metrics])
        mask_25 = np.array([m[f"{prefix}rmsd<2.5"] for m in all_metrics])
        mask_target_25 = np.array(
            [m[f"{prefix}target_aligned<2.5"] for m in all_metrics]
        )
        scores.update(
            {
                f"vendi_tm_{prefix}rmsd<2.5": vendi_from_sim(
                    tm_fixed[mask_25][:, mask_25]
                )
                if np.sum(mask_25) > 0
                else 0.0,
                f"vendi_tm_{prefix}rmsd_2": vendi_from_sim(tm_fixed[mask_2][:, mask_2])
                if np.sum(mask_2) > 0
                else 0.0,
                f"vendi_tm_{prefix}rmsd_4": vendi_from_sim(tm_fixed[mask_4][:, mask_4])
                if np.sum(mask_4) > 0
                else 0.0,
                f"vendi_tm_{prefix}target_aligned_rmsd<2.5": vendi_from_sim(
                    tm_fixed[mask_target_25][:, mask_target_25]
                )
                if np.sum(mask_target_25) > 0
                else 0.0,
            }
        )

        if compute_lddts:
            mask_60 = np.array(
                [m[f"{prefix}designability_lddt_60"] for m in all_metrics]
            )
            mask_65 = np.array(
                [m[f"{prefix}designability_lddt_65"] for m in all_metrics]
            )
            mask_70 = np.array(
                [m[f"{prefix}designability_lddt_70"] for m in all_metrics]
            )
            mask_75 = np.array(
                [m[f"{prefix}designability_lddt_75"] for m in all_metrics]
            )
            mask_80 = np.array(
                [m[f"{prefix}designability_lddt_80"] for m in all_metrics]
            )
            mask_85 = np.array(
                [m[f"{prefix}designability_lddt_85"] for m in all_metrics]
            )
            mask_90 = np.array(
                [m[f"{prefix}designability_lddt_90"] for m in all_metrics]
            )
            scores.update(
                {
                    f"vendi_tm_{prefix}lddt_60": vendi_from_sim(
                        tm_fixed[mask_60][:, mask_60]
                    )
                    if np.sum(mask_60) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}lddt_65": vendi_from_sim(
                        tm_fixed[mask_65][:, mask_65]
                    )
                    if np.sum(mask_65) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}lddt_70": vendi_from_sim(
                        tm_fixed[mask_70][:, mask_70]
                    )
                    if np.sum(mask_70) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}lddt_75": vendi_from_sim(
                        tm_fixed[mask_75][:, mask_75]
                    )
                    if np.sum(mask_75) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}lddt_80": vendi_from_sim(
                        tm_fixed[mask_80][:, mask_80]
                    )
                    if np.sum(mask_80) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}lddt_85": vendi_from_sim(
                        tm_fixed[mask_85][:, mask_85]
                    )
                    if np.sum(mask_85) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}lddt_90": vendi_from_sim(
                        tm_fixed[mask_90][:, mask_90]
                    )
                    if np.sum(mask_90) > 0
                    else 0.0,
                }
            )
        if compute_iptms:
            mask_80 = np.array([m[f"design_iptm>80"] for m in all_metrics])
            mask_70 = np.array([m[f"design_iptm>70"] for m in all_metrics])
            mask_60 = np.array([m[f"design_iptm>60"] for m in all_metrics])
            mask_50 = np.array([m[f"design_iptm>50"] for m in all_metrics])
            scores.update(
                {
                    f"vendi_tm_{prefix}design_iptm_80": vendi_from_sim(
                        tm_fixed[mask_80][:, mask_80]
                    )
                    if np.sum(mask_80) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}design_iptm_70": vendi_from_sim(
                        tm_fixed[mask_70][:, mask_70]
                    )
                    if np.sum(mask_70) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}design_iptm_60": vendi_from_sim(
                        tm_fixed[mask_60][:, mask_60]
                    )
                    if np.sum(mask_60) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}design_iptm_50": vendi_from_sim(
                        tm_fixed[mask_50][:, mask_50]
                    )
                    if np.sum(mask_50) > 0
                    else 0.0,
                }
            )
        if compute_min_int_paes:
            mask_15 = np.array([m[f"min_interaction_pae<1.5"] for m in all_metrics])
            mask_2 = np.array([m[f"min_interaction_pae<2"] for m in all_metrics])
            mask_25 = np.array([m[f"min_interaction_pae<2.5"] for m in all_metrics])
            mask_3 = np.array([m[f"min_interaction_pae<3"] for m in all_metrics])
            mask_4 = np.array([m[f"min_interaction_pae<4"] for m in all_metrics])
            mask_5 = np.array([m[f"min_interaction_pae<5"] for m in all_metrics])
            scores.update(
                {
                    f"vendi_tm_{prefix}min_interaction_pae_1.5": vendi_from_sim(
                        tm_fixed[mask_15][:, mask_15]
                    )
                    if np.sum(mask_15) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}min_interaction_pae_2": vendi_from_sim(
                        tm_fixed[mask_2][:, mask_2]
                    )
                    if np.sum(mask_2) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}min_interaction_pae_2.5": vendi_from_sim(
                        tm_fixed[mask_25][:, mask_25]
                    )
                    if np.sum(mask_25) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}min_interaction_pae_3": vendi_from_sim(
                        tm_fixed[mask_3][:, mask_3]
                    )
                    if np.sum(mask_3) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}min_interaction_pae_4": vendi_from_sim(
                        tm_fixed[mask_4][:, mask_4]
                    )
                    if np.sum(mask_4) > 0
                    else 0.0,
                    f"vendi_tm_{prefix}min_interaction_pae_5": vendi_from_sim(
                        tm_fixed[mask_5][:, mask_5]
                    )
                    if np.sum(mask_5) > 0
                    else 0.0,
                }
            )

    return scores


def vendi_sequences(all_seqs: List[np.ndarray], diversity_subset: int = None) -> float:
    if diversity_subset is not None and diversity_subset < len(all_seqs):
        all_seqs = random.sample(all_seqs, diversity_subset)

    N = len(all_seqs)
    sims = np.zeros((N, N), dtype=np.float32)
    aligner = Align.PairwiseAligner()
    for i in tqdm(range(N), desc="Computing sequence diversity."):
        for j in range(i + 1, N):
            seq1 = Seq(all_seqs[i])
            seq2 = Seq(all_seqs[j])
            alignments = aligner.align(seq1, seq2)

            similarity = alignments[0].score / max(len(seq1), len(seq2))
            sims[i, j] = similarity

    return {
        "vendi_seq_sim": vendi_from_sim(sims),
    }


def compute_novelty_foldseek(
    indir: Path,
    outdir: Path,
    reference_db: Path,
    files: List[str],
    foldseek_binary: str = "/data/rbg/users/hstark/foldseek/bin/foldseek",
) -> pd.DataFrame:
    if len(files) == 0:
        return np.nan

    aln_tsv = outdir / "aln.tsv"
    tmp_dir = outdir / "tmp"

    cmd = [
        foldseek_binary,
        "easy-search",
        str(indir),
        str(reference_db),
        str(aln_tsv),
        str(tmp_dir),
        "--format-output",
        "query,target,alntmscore,qtmscore,ttmscore",
        "--alignment-type",
        "1",
        "--exhaustive-search",
        "1",
    ]

    subprocess.run(cmd, check=True)

    df = pd.read_csv(
        aln_tsv,
        sep="\t",
        names=["query", "target", "alntmscore", "qtmscore", "ttmscore"],
    )
    df["tmscore"] = (df["qtmscore"] + df["ttmscore"]) / 2
    df = df.groupby("query").max().reset_index()
    queries = [Path(f).stem for f in files]
    df = df.set_index("query").reindex(queries, fill_value=0.0).reset_index()
    df_novelty = df[["query", "tmscore"]].rename(columns={"tmscore": "novelty"})
    return df_novelty


def _radius(res_name: str, atom_name: str, element: str) -> float:
    """
    ProtOr radius with element fallback.
    """
    try:
        r = vdw_radius_protor(res_name, atom_name)
        if r is not None:
            return r
    except KeyError:
        pass
    r = vdw_radius_single(element)
    return r if r is not None else 1.8


def compute_sasa(structure_path):
    HYDROPHOBIC_RESIDUES = {"ALA", "VAL", "LEU", "ILE", "MET", "PHE", "PRO", "TRP"}
    atoms = _load_stack(structure_path)[0]
    res_names = [
        bytes(r).decode() if isinstance(r, bytes) else str(r) for r in atoms.res_name
    ]
    atom_names = [
        bytes(a).decode() if isinstance(a, bytes) else str(a) for a in atoms.atom_name
    ]
    elements = [
        bytes(e).decode() if isinstance(e, bytes) else str(e) for e in atoms.element
    ]

    radii = np.array(
        [
            _radius(rn.strip(), an.strip(), el)
            for rn, an, el in zip(res_names, atom_names, elements)
        ],
        dtype=float,
    )
    atom_sasa = sasa(atoms, probe_radius=1.4, point_number=960, vdw_radii=radii)
    mask = (
        np.array([rn.strip() in HYDROPHOBIC_RESIDUES for rn in res_names])
        & np.char.startswith(atom_names, "C")
        & (atom_sasa > 0)
    )

    return atoms.coord[mask], atom_sasa[mask]


def largest_hydrophobic_patch_area(cif_path, distance_cutoff=6.0):
    result = compute_sasa(cif_path)
    if result is None or result[0].size == 0:
        return np.nan

    coords, sasa_vals = result
    if len(coords) == 0:
        return 0.0
    clustering = DBSCAN(eps=distance_cutoff, min_samples=1).fit(coords)
    labels = clustering.labels_

    max_patch_area = 0.0
    for label in np.unique(labels):
        area = sasa_vals[labels == label].sum()
        max_patch_area = max(max_patch_area, area)

    return max_patch_area


def get_delta_sasa(path, atom_design_mask):
    stack = _load_stack(path)
    atoms = stack[0]
    res = [
        r.decode().strip() if isinstance(r, bytes) else str(r).strip()
        for r in atoms.res_name
    ]
    atm = [
        a.decode().strip() if isinstance(a, bytes) else str(a).strip()
        for a in atoms.atom_name
    ]
    elem = [e.decode() if isinstance(e, bytes) else str(e) for e in atoms.element]

    radii = np.array(
        [_radius(rn, an, el) for rn, an, el in zip(res, atm, elem)], dtype=float
    )
    area = sasa(atoms, probe_radius=1.4, point_number=960, vdw_radii=radii)
    design_bound = area[atom_design_mask].sum()

    ligand_atoms = atoms[atom_design_mask]
    lig_res = [r for r, m in zip(res, atom_design_mask) if m]
    lig_atm = [a for a, m in zip(atm, atom_design_mask) if m]
    lig_elem = [e for e, m in zip(elem, atom_design_mask) if m]

    radii_lig = np.array(
        [_radius(rn, an, el) for rn, an, el in zip(lig_res, lig_atm, lig_elem)],
        dtype=float,
    )
    ligand_area = sasa(
        ligand_atoms, probe_radius=1.4, point_number=960, vdw_radii=radii_lig
    )
    delta = ligand_area.sum() - design_bound
    return delta, ligand_area.sum(), design_bound


def compute_ss_metrics(dssp_pred, ss_conditioning_metricsed):
    ss_metrics = {}
    conditioned_mask = ss_conditioning_metricsed != 0
    if conditioned_mask.sum() == 0:
        return {
            "precision_loop": float("nan"),
            "recall_loop": float("nan"),
            "accuracy_loop": float("nan"),
            "precision_helix": float("nan"),
            "recall_helix": float("nan"),
            "accuracy_helix": float("nan"),
            "precision_sheet": float("nan"),
            "recall_sheet": float("nan"),
            "accuracy_sheet": float("nan"),
            "accuracy_overall": float("nan"),
        }
    types = {1: "loop", 2: "helix", 3: "sheet"}
    TP_total, total_conditioned = 0, conditioned_mask.sum().item()
    for i, name in types.items():
        TP = ((dssp_pred == i) & (ss_conditioning_metricsed == i)).sum().item()
        FP = (
            ((dssp_pred == i) & (ss_conditioning_metricsed != i) & conditioned_mask)
            .sum()
            .item()
        )
        FN = ((dssp_pred != i) & (ss_conditioning_metricsed == i)).sum().item()
        precision = TP / (TP + FP) if (TP + FP) > 0 else float("nan")
        recall = TP / (TP + FN) if (TP + FN) > 0 else float("nan")
        accuracy = TP / (TP + FP + FN) if (TP + FP + FN) > 0 else float("nan")
        ss_metrics[f"precision_{name}"] = precision
        ss_metrics[f"recall_{name}"] = recall
        ss_metrics[f"accuracy_{name}"] = accuracy
        TP_total += TP

    accuracy_overall = (
        TP_total / total_conditioned if total_conditioned > 0 else float("nan")
    )
    ss_metrics["accuracy_overall"] = accuracy_overall
    return ss_metrics


def filter_resolved_atoms(structure: Structure) -> Structure:
    resolved_atom_indices = np.where(structure.atoms["is_present"])[0]
    return Structure.extract_atoms(structure, resolved_atom_indices)


def save_design_only_structure_to_cif(atom_design_mask, structure, output_path: Path):
    design_atom_indices = torch.where(atom_design_mask)[0].cpu().numpy()
    design_only_str = Structure.extract_atoms(
        structure, design_atom_indices, res_reindex=True
    )
    cif_text = to_mmcif(design_only_str)
    output_path.write_text(cif_text)
    return cif_text


def save_design_only_structure_to_pdb(atom_design_mask, structure, output_path: Path):
    cif_path = output_path.with_suffix(".cif")
    cif_text = save_design_only_structure_to_cif(atom_design_mask, structure, cif_path)

    cif_io = io.StringIO(cif_text)
    mmcif_parser = PDB.MMCIFParser()
    pdb_writer = PDB.PDBIO()
    parsed_structure = mmcif_parser.get_structure("des_only", cif_io)
    pdb_writer.set_structure(parsed_structure)
    pdb_writer.save(str(output_path))


########################################################################################################
# Hydrophobycity computation functions. From Jeremie Alexander.
########################################################################################################


def calc_base_h(seq: str) -> float:
    s = seq.upper()
    n = len(s)
    if n == 0:
        return 0.0
    H = 0.0

    # position-specific coefficients
    for i, aa in enumerate(s):
        if aa not in const.hydrophobicity_info:
            raise ValueError(f"Unknown residue '{aa}' in '{seq}'")
        if i == 0:
            key = "Rc1"
        elif i == 1:
            key = "Rc2"
        elif i == n - 1:
            key = "Rn"
        elif i == n - 2:
            key = "Rn1"
        else:
            key = "Rc"
        H += const.hydrophobicity_info[aa][key]

    # nearest-neighbor penalties around H/R/K
    for i, aa in enumerate(s):
        if aa in ("H", "R", "K"):
            for j in (i - 1, i + 1):
                if 0 <= j < n and s[j] in const.nn_penalty:
                    H -= const.nn_penalty[s[j]]

    # proline run penalties
    i = 0
    while i < n:
        if s[i] == "P":
            j = i
            while j < n and s[j] == "P":
                j += 1
            run = j - i
            if run >= 4:
                H -= 5.0
            elif run == 3:
                H -= 3.5
            elif run == 2:
                H -= 1.2
            i = j
        else:
            i += 1
    return H


def apply_length_weight(H: float, n: int) -> float:
    if n < 8:
        KL = 1.0 - 0.055 * (8 - n)
    elif n > 20:
        KL = 1.0 / (1.0 + 0.027 * (n - 20))
    else:
        KL = 1.0
    return H * KL


def overall_penalty(H: float) -> float:
    if H <= 20:
        return H
    if H <= 30:
        return H - 0.27 * (H - 18.0)
    if H <= 40:
        return H - 0.33 * (H - 18.0)
    if H <= 50:
        return H - 0.38 * (H - 18.0)
    return H - 0.447 * (H - 18.0)


def calc_hydrophobicity(seq: str) -> float:
    s = (seq or "").strip().upper()
    if not s or "X" in s:
        return float("nan")
    base = calc_base_h(s)
    base = apply_length_weight(base, len(s))
    return round(overall_penalty(base), 4)


########################################################################################################
# Liability computation functions
########################################################################################################


def get_motif_set(modality="antibody", peptide_type="linear"):
    """
    Returns a dict of motif_name -> compiled regex for the given modality.
    modality: 'antibody' or 'peptide'
    peptide_type: 'linear' or 'cyclic' (only for peptide modality)


    """
    if modality == "antibody":
        return {
            "DeAmdH": re.compile(r"N[GS]"),  # High-risk deamidation
            "DeAmdM": re.compile(r"N[AHNT]"),  # Medium-risk deamidation
            "DeAmdL": re.compile(r"[STK]N"),  # Low-risk deamidation
            "Ngly": re.compile(r"N[^P][ST]"),  # N-glycosylation sites
            "Isom": re.compile(r"D[DG HST]".replace(" ", "")),  # Isomerization
            "Isomer": re.compile(r"DG|DS|DD"),  # Isomerization variants
            "FragH": re.compile(r"DP"),  # High fragmentation risk
            "FragM": re.compile(r"TS"),  # Medium fragmentation risk
            "TrpOx": re.compile(r"W"),  # Tryptophan oxidation
            "MetOx": re.compile(r"M"),  # Methionine oxidation
            "Hydro": re.compile(r"NP"),  # Hydrolysis prone
            "IntBind": re.compile(r"GPR|RGD|RYD|LDV|DGE|KGD|NGR"),  # Integrin binding
            "Polyreactive": re.compile(
                r"GGG|GG|RR|VG|VVV|WWW|YY|WxW"
            ),  # Polyreactivity
            "AggPatch": re.compile(r"FHW"),  # Aggregation patches
            "ViscPatch": re.compile(r"HYF|HWH"),  # Viscosity patches
            "DeAmdH": re.compile(r"N[GS]"),
            "DeAmdM": re.compile(r"N[AHNT]"),
            "DeAmdL": re.compile(r"[STK]N"),
            "Ngly": re.compile(r"N[^P][ST]"),
            "Isom": re.compile(r"D[DG HST]".replace(" ", "")),
            "Isomer": re.compile(r"DG|DS|DD"),
            "FragH": re.compile(r"DP"),
            "FragM": re.compile(r"TS"),
            "TrpOx": re.compile(r"W"),
            "MetOx": re.compile(r"M"),
            "Hydro": re.compile(r"NP"),
            "IntBind": re.compile(r"GPR|RGD|RYD|LDV|DGE|KGD|NGR"),
            "Polyreactive": re.compile(r"GGG|GG|RR|VG|VVV|WWW|YY|WxW"),
            "AggPatch": re.compile(r"FHW"),
            "ViscPatch": re.compile(r"HYF|HWH"),
        }
    elif modality == "peptide":
        motifs = {
            "AspBridge": re.compile(r"N[GSQA]"),  # Deamidation hotspots
            "AspCleave": re.compile(r"D[PGS]"),  # Acidic cleavage sites
            "NTCycl": re.compile(r"^[QN]"),  # N-terminal cyclization
            "ProtTryp": re.compile(r"[KR](?=.)"),  # Trypsin cleavage sites
            "DPP4": re.compile(r"^[PX]?[AP]"),  # DPP4 cleavage sites
            "MetOx": re.compile(r"M"),  # Methionine oxidation
            "TrpOx": re.compile(r"W"),  # Tryptophan oxidation
            "HydroPatch": re.compile(r"[FILVWY]{3,}"),  # Hydrophobic patches
        }
        if peptide_type == "cyclic":
            # remove N-term liabilities for cyclic peptides
            motifs.pop("NTCycl", None)
            motifs.pop("DPP4", None)
        elif peptide_type == "linear":
            # For linear peptides, we handle cysteine pairing separately
            # so we don't include CysOx in the motif set
            pass
        return motifs
    else:
        raise ValueError(f"Unknown modality: {modality}")


def severity_score(name):
    return const.liability_severity.get(name, const.default_severity)


def compute_liability_scores(sequences, modality="antibody", peptide_type="linear"):
    """
    Compute liability scores for given sequences.
    modality: 'antibody' or 'peptide'; peptide_type: 'linear' or 'cyclic'.
    For cyclic peptides, terminal CysOx flags are skipped.

    Returns:
        dict: sequence -> {'score': int, 'violations': list of dicts}
    """
    motifs = get_motif_set(modality, peptide_type)
    results = {}
    for seq in sequences:
        violations = []
        total_score = 0
        length = len(seq)
        # motif scanning
        for name, pat in motifs.items():
            for m in pat.finditer(seq):
                pos = m.start() + 1
                # skip terminal cysteines for cyclic peptides
                if (
                    modality == "peptide"
                    and peptide_type == "cyclic"
                    and name == "CysOx"
                    and pos in (1, length)
                ):
                    continue
                sev = severity_score(name)
                violations.append(
                    {"motif": name, "pos": pos, "len": len(m.group()), "severity": sev}
                )
                total_score += sev
        # antibody-specific extras
        if modality == "antibody":
            # unpaired cysteines
            cpos = [i for i, aa in enumerate(seq) if aa == "C"]
            paired = set()
            for i in range(len(cpos) - 1):
                if abs(cpos[i + 1] - cpos[i]) in (1, 2):
                    paired.update({cpos[i], cpos[i + 1]})
            for i in cpos:
                if i not in paired:
                    sev = severity_score("UnpairedCys")
                    violations.append(
                        {
                            "motif": "UnpairedCys",
                            "pos": i + 1,
                            "len": 1,
                            "severity": sev,
                        }
                    )
                    total_score += sev
            # net charge
            charge = seq.count("K") + seq.count("R") - seq.count("D") - seq.count("E")
            if charge > 1:
                sev = const.default_severity
                violations.append(
                    {"motif": "HighNetCharge", "pos": None, "len": 0, "severity": sev}
                )
                total_score += sev
        # peptide-specific extras
        elif modality == "peptide":
            # For linear peptides, only flag unpaired cysteines (odd number of cysteines)
            # For cyclic peptides, terminal cysteines are expected, so only flag internal unpaired cysteines
            cpos = [i for i, aa in enumerate(seq) if aa == "C"]
            if peptide_type == "linear":
                # For linear peptides, if there's an odd number of cysteines, flag all cysteines as potential liabilities
                # since we don't know which one is unpaired
                if len(cpos) % 2 == 1:
                    sev = severity_score("UnpairedCys")
                    for cys_pos in cpos:
                        violations.append(
                            {
                                "motif": "UnpairedCys",
                                "pos": cys_pos + 1,
                                "len": 1,
                                "severity": sev,
                            }
                        )
                        total_score += sev
            elif peptide_type == "cyclic":
                # For cyclic peptides, terminal cysteines are expected for cyclization
                # Only flag internal unpaired cysteines
                internal_cpos = [
                    i for i in cpos if i != 0 and i != len(seq) - 1
                ]  # exclude terminal positions
                if len(internal_cpos) % 2 == 1:
                    # Flag all internal cysteines as potential liabilities
                    sev = severity_score("UnpairedCys")
                    for cys_pos in internal_cpos:
                        violations.append(
                            {
                                "motif": "UnpairedCys",
                                "pos": cys_pos + 1,
                                "len": 1,
                                "severity": sev,
                            }
                        )
                        total_score += sev

                # Additional liability checks for cyclic peptides
                # 1. Check for low hydrophilic content (< 40%)
                hydrophilic_residues = (
                    seq.count("D")
                    + seq.count("E")
                    + seq.count("K")
                    + seq.count("R")
                    + seq.count("H")
                    + seq.count("N")
                    + seq.count("Q")
                    + seq.count("S")
                    + seq.count("T")
                )
                hydrophilic_percentage = (hydrophilic_residues / len(seq)) * 100
                if hydrophilic_percentage < 40:
                    sev = severity_score("LowHydrophilic")
                    violations.append(
                        {
                            "motif": "LowHydrophilic",
                            "pos": None,
                            "len": 0,
                            "severity": sev,
                            "details": f"{hydrophilic_percentage:.1f}% hydrophilic",
                        }
                    )
                    total_score += sev

                # 2. Check for consecutive identical residues
                max_consec_identical = 1
                current_consec = 1
                for i in range(1, len(seq)):
                    if seq[i] == seq[i - 1]:
                        current_consec += 1
                        max_consec_identical = max(max_consec_identical, current_consec)
                    else:
                        current_consec = 1

                if max_consec_identical > 1:
                    sev = severity_score("ConsecIdentical")
                    violations.append(
                        {
                            "motif": "ConsecIdentical",
                            "pos": None,
                            "len": 0,
                            "severity": sev,
                            "details": f"{max_consec_identical} consecutive identical",
                        }
                    )
                    total_score += sev

                # 3. Check for more than 4 consecutive hydrophobic residues
                max_consec_hydrophobic = 0
                current_consec = 0
                for aa in seq:
                    if aa in "FILVWY":
                        current_consec += 1
                        max_consec_hydrophobic = max(
                            max_consec_hydrophobic, current_consec
                        )
                    else:
                        current_consec = 0

                if max_consec_hydrophobic > 4:
                    sev = severity_score("LongHydrophobic")
                    violations.append(
                        {
                            "motif": "LongHydrophobic",
                            "pos": None,
                            "len": 0,
                            "severity": sev,
                            "details": f"{max_consec_hydrophobic} consecutive hydrophobic",
                        }
                    )
                    total_score += sev
        results[seq] = {"score": total_score, "violations": violations}
    return results


def compute_liability_metrics(sequence, liability_modality, liability_peptide_type):
    metrics = {}
    # check if sequence is valid
    if not sequence or len(sequence) == 0:
        raise ValueError(f"Sequence is empty: '{sequence}'")

    liability_results = compute_liability_scores(
        [sequence],
        modality=liability_modality,
        peptide_type=liability_peptide_type,
    )
    liability_data = liability_results[sequence]

    # Store liability metrics
    metrics["liability_score"] = liability_data["score"]
    metrics["liability_num_violations"] = len(liability_data["violations"])

    # Count violations by severity
    high_severity_violations = [
        v for v in liability_data["violations"] if v["severity"] >= 10
    ]
    medium_severity_violations = [
        v for v in liability_data["violations"] if 5 <= v["severity"] < 10
    ]
    low_severity_violations = [
        v for v in liability_data["violations"] if v["severity"] < 5
    ]

    metrics["liability_high_severity_violations"] = len(high_severity_violations)
    metrics["liability_medium_severity_violations"] = len(medium_severity_violations)
    metrics["liability_low_severity_violations"] = len(low_severity_violations)

    # Count violations by type
    violation_counts = {}
    for v in liability_data["violations"]:
        motif = v["motif"]
        violation_counts[motif] = violation_counts.get(motif, 0) + 1

    # Store individual violation type counts as metrics
    for motif, count in violation_counts.items():
        metrics[f"liability_{motif}_count"] = count

    # Add detailed violation information
    # Group violations by type for intelligent reporting
    violations_by_type = {}
    for v in liability_data["violations"]:
        motif = v["motif"]
        if motif not in violations_by_type:
            violations_by_type[motif] = []
        violations_by_type[motif].append(v)

    # Initialize default values for all motifs to ensure consistent dataframe columns
    # Use the full motif set for the configured modality/peptide_type so columns are consistent
    all_motifs = set(
        get_motif_set(
            modality=liability_modality,
            peptide_type=liability_peptide_type,
        ).keys()
    )
    for motif in all_motifs:
        # Initialize all possible fields with default values
        metrics[f"liability_{motif}_count"] = 0
        metrics[
            f"liability_{motif}_position"
        ] = -1  # use -1 for no position (keeps int dtype)
        metrics[f"liability_{motif}_length"] = 0
        metrics[f"liability_{motif}_severity"] = 0
        metrics[f"liability_{motif}_details"] = ""
        metrics[f"liability_{motif}_positions"] = ""
        metrics[f"liability_{motif}_num_positions"] = 0
        metrics[f"liability_{motif}_global_details"] = ""
        metrics[f"liability_{motif}_avg_severity"] = 0.0

    # Store detailed violation information
    for motif, motif_violations in violations_by_type.items():
        if len(motif_violations) == 1:
            # Single violation - store all details
            v = motif_violations[0]
            # Ensure position is an integer; use -1 for non-positional violations
            metrics[f"liability_{motif}_position"] = (
                int(v["pos"]) if v["pos"] is not None else -1
            )
            metrics[f"liability_{motif}_length"] = v["len"]
            metrics[f"liability_{motif}_severity"] = v["severity"]
            if "details" in v:
                metrics[f"liability_{motif}_details"] = v["details"]
            else:
                metrics[f"liability_{motif}_details"] = ""
        else:
            # Multiple violations - store summary information
            positions = [v["pos"] for v in motif_violations if v["pos"] is not None]
            global_violations = [v for v in motif_violations if v["pos"] is None]

            if positions:
                # Store position range for positional violations
                metrics[f"liability_{motif}_positions"] = (
                    f"{min(positions)}-{max(positions)}"
                )
                metrics[f"liability_{motif}_num_positions"] = len(positions)
            else:
                metrics[f"liability_{motif}_positions"] = ""
                metrics[f"liability_{motif}_num_positions"] = 0

            if global_violations:
                # Store details for global violations
                details = [
                    v.get("details", "") for v in global_violations if v.get("details")
                ]
                if details:
                    metrics[f"liability_{motif}_global_details"] = "; ".join(details)
                else:
                    metrics[f"liability_{motif}_global_details"] = ""
            else:
                metrics[f"liability_{motif}_global_details"] = ""

            # Store average severity
            avg_severity = sum(v["severity"] for v in motif_violations) / len(
                motif_violations
            )
            metrics[f"liability_{motif}_avg_severity"] = round(avg_severity, 1)

    # Add a comprehensive violation summary for easy interpretation
    violation_summary = []
    for motif, motif_violations in violations_by_type.items():
        count = len(motif_violations)
        if count == 1:
            v = motif_violations[0]
            if v["pos"] is not None:
                violation_summary.append(f"{motif}(pos{v['pos']},sev{v['severity']})")
            else:
                details = v.get("details", "")
                violation_summary.append(
                    f"{motif}({details},sev{v['severity']})"
                    if details
                    else f"{motif}(sev{v['severity']})"
                )
        else:
            positions = [v["pos"] for v in motif_violations if v["pos"] is not None]
            if positions:
                violation_summary.append(
                    f"{motif}x{count}(pos{min(positions)}-{max(positions)},sev{motif_violations[0]['severity']})"
                )
            else:
                violation_summary.append(
                    f"{motif}x{count}(sev{motif_violations[0]['severity']})"
                )

    metrics["liability_violations_summary"] = (
        "; ".join(violation_summary) if violation_summary else ""
    )
    return metrics
