"""This module provides classes to perform analyses of the local
environments (e.g., finding near neighbors) of single sites in molecules
and structures based on bonding analysis with LOBSTER.

If you use this module, please cite:
J. George, G. Petretto, A. Naik, M. Esters, A. J. Jackson, R. Nelson, R. Dronskowski, G.-M. Rignanese, G. Hautier,
"Automated Bonding Analysis with Crystal Orbital Hamilton Populations",
ChemPlusChem 2022, e202200123,
DOI: 10.1002/cplu.202200123.
"""

from __future__ import annotations

import collections
import copy
import math
import tempfile
import warnings
from typing import TYPE_CHECKING, NamedTuple

import matplotlib as mpl
import numpy as np
from monty.dev import deprecated

from pymatgen.analysis.bond_valence import BVAnalyzer
from pymatgen.analysis.chemenv.coordination_environments.coordination_geometry_finder import LocalGeometryFinder
from pymatgen.analysis.chemenv.coordination_environments.structure_environments import LightStructureEnvironments
from pymatgen.analysis.local_env import NearNeighbors
from pymatgen.core import Structure
from pymatgen.electronic_structure.cohp import CompleteCohp
from pymatgen.electronic_structure.core import Spin
from pymatgen.electronic_structure.plotter import CohpPlotter
from pymatgen.io.lobster import Charge, Icohplist
from pymatgen.util.due import Doi, due

if TYPE_CHECKING:
    from typing import Any, Literal, Self

    import matplotlib as mpl
    from numpy.typing import NDArray

    from pymatgen.core import IStructure, PeriodicNeighbor, PeriodicSite
    from pymatgen.core.periodic_table import Element
    from pymatgen.electronic_structure.cohp import IcohpCollection, IcohpValue
    from pymatgen.util.typing import PathLike

__author__ = "Janine George"
__copyright__ = "Copyright 2021, The Materials Project"
__version__ = "1.0"
__maintainer__ = "J. George"
__email__ = "janinegeorge.ulfen@gmail.com"
__status__ = "Production"
__date__ = "February 2, 2021"

due.cite(
    Doi("10.1002/cplu.202200123"),
    description="Automated Bonding Analysis with Crystal Orbital Hamilton Populations",
)


class LobsterNeighbors(NearNeighbors):
    """
    This class combines capabilities from LocalEnv and ChemEnv to determine
    coordination environments based on bonding analysis.
    """

    def __init__(
        self,
        structure: Structure,
        icoxxlist_obj: Icohplist,
        are_coops: bool = False,
        are_cobis: bool = False,
        charge_obj: Charge | None = None,
        valences: list[float] | None = None,
        limits: tuple[float, float] | None = None,
        additional_condition: Literal[0, 1, 2, 3, 4, 5, 6] = 0,
        only_bonds_to: list[str] | None = None,
        perc_strength_icohp: float = 0.15,
        noise_cutoff: float = 0.1,
        valences_from_charges: bool = False,
        which_charge: Literal["Mulliken", "Loewdin"] = "Mulliken",
        adapt_extremum_to_add_cond: bool = False,
        add_additional_data_sg: bool = False,
        bonding_list_1: Icohplist | None = None,
        bonding_list_2: Icohplist | None = None,
        id_blist_sg1: Literal["icoop", "icobi"] = "icoop",
        id_blist_sg2: Literal["icoop", "icobi"] = "icobi",
        backward_compatibility: bool = False,
    ):
        """
        Args:
            structure (Structure): Typically constructed by Structure.from_file("POSCAR").
            icoxxlist_obj (Icohplist): Icohplist object.
            are_coops (bool): Whether the file is a ICOOPLIST.lobster (True) or a
                ICOHPLIST.lobster (False). Only tested for ICOHPLIST.lobster so far.
            are_cobis (bool): Whether the file is a ICOBILIST.lobster (True) or
                a ICOHPLIST.lobster (False).
            charge_obj (Charge): Charge object.
            valences (list[float]): Valence/charge for each element.
            limits (tuple[float, float]): Range to decide which ICOHPs (ICOOP
                or ICOBI) should be considered.
            additional_condition (int): Additional condition that decides
                which kind of bonds will be considered
                    0 - NO_ADDITIONAL_CONDITION
                    1 - ONLY_ANION_CATION_BONDS
                    2 - NO_ELEMENT_TO_SAME_ELEMENT_BONDS
                    3 - ONLY_ANION_CATION_BONDS_AND_NO_ELEMENT_TO_SAME_ELEMENT_BONDS
                    4 - ONLY_ELEMENT_TO_OXYGEN_BONDS
                    5 - DO_NOT_CONSIDER_ANION_CATION_BONDS
                    6 - ONLY_CATION_CATION_BONDS
            only_bonds_to (list[str]): Only consider bonds to certain elements (e.g. ["O"] for oxygen).
            perc_strength_icohp (float): If no "limits" are given, this will decide
                which ICOHPs will be considered (relative to the strongest ICOHP/ICOOP/ICOBI).
            noise_cutoff (float): The lower limit of ICOHPs considered.
            valences_from_charges (bool): If True and path to CHARGE.lobster is provided,
                will use LOBSTER charges (Mulliken) instead of valences.
            which_charge ("Mulliken" | "Loewdin"): Source of charge.
            adapt_extremum_to_add_cond (bool): Whether to adapt the limits to only
                focus on the bonds determined by the additional condition.
            add_additional_data_sg (bool): Add the information from bonding_list_1.
            bonding_list_1 (Icohplist): Additional ICOOP, ICOBI data for structure graphs.
            bonding_list_2 (Icohplist): Additional ICOOP, ICOBI data for structure graphs.
            id_blist_sg1 ("icoop" | "icobi"): Identity of data in bonding_list_1.
            id_blist_sg2 ("icoop" | "icobi"): Identity of data in bonding_list_2.
            backward_compatibility (bool): If True, will use the old behavior of the code.
        """
        self.structure = structure
        self.ICOHP = icoxxlist_obj
        self.Icohpcollection = icoxxlist_obj.icohpcollection
        self.charge_obj = charge_obj
        self.valences = valences
        self.limits = limits
        self.only_bonds_to = only_bonds_to
        self.adapt_extremum_to_add_cond = adapt_extremum_to_add_cond
        self.add_additional_data_sg = add_additional_data_sg
        self.bonding_list_1 = bonding_list_1  # type:ignore[assignment]
        self.bonding_list_2 = bonding_list_2  # type:ignore[assignment]
        self.id_blist_sg1 = id_blist_sg1.lower()
        self.id_blist_sg2 = id_blist_sg2.lower()
        self.noise_cutoff = noise_cutoff
        self.additional_condition = additional_condition
        self.are_coops = are_coops
        self.are_cobis = are_cobis

        self.backward_compatibility = backward_compatibility

        # validate
        if self.id_blist_sg1 not in {"icoop", "icobi"} or self.id_blist_sg2 not in {"icoop", "icobi"}:
            raise ValueError("Algorithm can only work with ICOOPs, ICOBIs")

        if additional_condition not in range(7):
            raise ValueError(f"Unexpected {additional_condition=}, must be one of {list(range(7))}")

        # Read in valences, will prefer manual setting of valences over charges
        # if not provided, will try to read in from CHARGE.lobster if valences_from_charges is True,
        # otherwise will try to assign them based on bond valence analysis.
        self.valences: list[float] | None

        if self.valences is None:
            if valences_from_charges:
                if which_charge == "Mulliken":
                    self.valences = charge_obj.mulliken
                elif which_charge == "Loewdin":
                    self.valences = charge_obj.loewdin
            else:
                bv_analyzer = BVAnalyzer()
                try:
                    self.valences = bv_analyzer.get_valences(structure=self.structure)  # type:ignore[arg-type]
                except ValueError as exc:
                    self.valences = None
                    if additional_condition in {1, 3, 5, 6}:
                        raise ValueError(
                            "Valences cannot be assigned, additional_conditions 1, 3, 5 and 6 will not work"
                        ) from exc

        if np.allclose(self.valences or [], np.zeros_like(self.valences)) and additional_condition in {1, 3, 5, 6}:
            raise ValueError("All valences are equal to 0, additional_conditions 1, 3, 5 and 6 will not work")

        if limits is None:
            self.lowerlimit = self.upperlimit = None
        else:
            self.lowerlimit, self.upperlimit = limits

        # evaluate coordination environments
        self._evaluate_ce(
            lowerlimit=limits[0] if limits else None,
            upperlimit=limits[1] if limits else None,
            only_bonds_to=only_bonds_to,
            additional_condition=additional_condition,
            perc_strength_icohp=perc_strength_icohp,
            adapt_extremum_to_add_cond=adapt_extremum_to_add_cond,
        )

    @classmethod
    def from_files(
        cls,
        structure_path: PathLike = "CONTCAR",
        icoxxlist_path: PathLike = "ICOHPLIST.lobster",
        are_coops: bool = False,
        are_cobis: bool = False,
        charge_path: PathLike | None = None,
        blist_sg1_path: PathLike | None = None,
        blist_sg2_path: PathLike | None = None,
        id_blist_sg1: Literal["icoop", "icobi"] = "icoop",
        id_blist_sg2: Literal["icoop", "icobi"] = "icobi",
        **kwargs,
    ):
        """
        Instanitate LobsterNeighbors using file paths.

        Args:
            structure_path (PathLike): Path to structure file, typically CONTCAR
            icoxxlist_path (PathLike): Path to ICOHPLIST.lobster or
                ICOOPLIST.lobster or ICOBILIST.lobster.
            are_coops (bool): Whether the file is a ICOOPLIST.lobster (True) or a
                ICOHPLIST.lobster (False). Only tested for ICOHPLIST.lobster so far.
            are_cobis (bool): Whether the file is a ICOBILIST.lobster (True) or
                a ICOHPLIST.lobster (False).
            charge_path (PathLike): Path to Charge.lobster.
            blist_sg1_path (PathLike): Path to additional ICOOP, ICOBI data for structure graphs.
            blist_sg2_path (PathLike): Path to additional ICOOP, ICOBI data for structure graphs.
            id_blist_sg1 ("icoop" | "icobi"): Population type in blist_sg1_path.
            id_blist_sg2 ("icoop" | "icobi"): Population type in in blist_sg2_path.
        """
        structure = Structure.from_file(structure_path)
        icoxxlist_obj = Icohplist(filename=icoxxlist_path, are_coops=are_coops, are_cobis=are_cobis)
        charge_obj = Charge(filename=charge_path) if charge_path else None
        bonding_list_1 = bonding_list_2 = None

        if kwargs.get("add_additional_data_sg", False):
            if id_blist_sg1 == "icoop":
                are_coops_id1 = True
                are_cobis_id1 = False
            else:
                are_coops_id1 = False
                are_cobis_id1 = True

            bonding_list_1 = Icohplist(
                filename=blist_sg1_path,
                are_coops=are_coops_id1,
                are_cobis=are_cobis_id1,
            )

            if id_blist_sg2 == "icoop":
                are_coops_id2 = True
                are_cobis_id2 = False
            else:
                are_coops_id2 = False
                are_cobis_id2 = True

            bonding_list_2 = Icohplist(
                filename=blist_sg2_path,
                are_coops=are_coops_id2,
                are_cobis=are_cobis_id2,
            )

        return cls(
            structure=structure,
            icoxxlist_obj=icoxxlist_obj,
            are_coops=are_coops,
            are_cobis=are_cobis,
            charge_obj=charge_obj,
            bonding_list_1=bonding_list_1,
            bonding_list_2=bonding_list_2,
            id_blist_sg1=id_blist_sg1,
            id_blist_sg2=id_blist_sg2,
            **kwargs,
        )

    @property
    def structures_allowed(self) -> Literal[True]:
        """Whether this LobsterNeighbors class can be used with Structure objects."""
        return True

    @property
    def molecules_allowed(self) -> Literal[False]:
        """Whether this LobsterNeighbors class can be used with Molecule objects."""
        return False

    @property
    def anion_types(self) -> set[Element]:
        """The set of anion types in crystal structure.

        Returns:
            set[Element]: Anions in the crystal structure.
        """
        if self.valences is None:
            raise ValueError("No cations and anions defined")

        anion_species = []
        for site, val in zip(self.structure, self.valences, strict=True):
            if val < 0.0:
                anion_species.append(site.specie)

        return set(anion_species)

    @deprecated(anion_types)
    def get_anion_types(self) -> set[Element]:
        return self.anion_types

    def get_nn_info(
        self,
        structure: Structure | IStructure,
        n: int,
        use_weights: bool = False,
    ) -> dict[str, Any]:
        """Get coordination number (CN) of site by index.

        Args:
            structure (Structure): Input structure.
            n (int): Index of site for which to determine CN.
            use_weights (bool): Whether to use weights for computing
                the CN (True), or each coordinated site has equal weight (False).
                The former is not implemented yet.

        Raises:
            ValueError: If use_weights is True, or if arg "structure" and structure
                used to initialize LobsterNeighbors have different lengths.

        Returns:
            dict[str, Any]: coordination number and a list of nearest neighbors.
        """
        if use_weights:
            raise ValueError("LobsterEnv cannot use weights")

        if len(structure) != len(self.structure):
            raise ValueError(
                f"Length of structure ({len(structure)}) and LobsterNeighbors ({len(self.structure)}) differ"
            )

        return self.sg_list[n]  # type: ignore[return-value]

    def get_light_structure_environment(
        self,
        only_cation_environments: bool = False,
        only_indices: list[int] | None = None,
        on_error: Literal["raise", "warn", "ignore"] = "raise",
    ) -> LobsterLightStructureEnvironments:
        """Get a LobsterLightStructureEnvironments object if the structure
        only contains coordination environments smaller 13.

        Args:
            only_cation_environments (bool): Only return data for cations.
            only_indices (list[int]): Only evaluate indexes in this list.
            on_error ("raise" | "warn" | "ignore"): Whether to raise an error, warn or ignore
                if the environment of a site cannot be determined.

        Returns:
            LobsterLightStructureEnvironments
        """
        lgf = LocalGeometryFinder()
        lgf.setup_structure(structure=self.structure)  # type:ignore[arg-type]
        list_ce_symbols = []  # type: list[str | None]
        list_csm = []  # type: list[float | None]
        list_permut = []  # type: list[list[int] | None]
        for idx, _neigh_coords in enumerate(self.list_coords):
            if (len(_neigh_coords)) > 13:
                if on_error == "raise":
                    raise ValueError(
                        f"Environment cannot be determined for site {idx}. "
                        f"Number of neighbors ({len(_neigh_coords)}) is larger than 13."
                    )
                if on_error == "warn":
                    warnings.warn(
                        f"Site {idx} has {len(_neigh_coords)} neighbors (>13). "
                        f"Using coordination number instead of geometry.",
                        stacklevel=2,
                    )
                    list_ce_symbols.append(str(len(_neigh_coords)))
                    list_csm.append(None)
                    list_permut.append(None)
                    continue
                # "ignore"
                list_ce_symbols.append(str(len(_neigh_coords)))
                list_csm.append(None)
                list_permut.append(None)
                continue

            # Avoid problems if _neigh_coords is empty
            if _neigh_coords != []:
                lgf.setup_local_geometry(isite=idx, coords=_neigh_coords, optimization=2)
                cncgsm = lgf.get_coordination_symmetry_measures(optimization=2)
                list_ce_symbols.append(min(cncgsm.items(), key=lambda t: t[1]["csm_wcs_ctwcc"])[0])
                list_csm.append(min(cncgsm.items(), key=lambda t: t[1]["csm_wcs_ctwcc"])[1]["csm_wcs_ctwcc"])
                list_permut.append(min(cncgsm.items(), key=lambda t: t[1]["csm_wcs_ctwcc"])[1]["indices"])
            else:
                list_ce_symbols.append(None)
                list_csm.append(None)
                list_permut.append(None)

        new_list_ce_symbols = []
        new_list_csm = []
        new_list_permut = []
        new_list_neighsite = []
        new_list_neighisite = []

        if only_indices is None:
            if not only_cation_environments:
                return LobsterLightStructureEnvironments.from_Lobster(
                    list_ce_symbol=list_ce_symbols,
                    list_csm=list_csm,
                    list_permutation=list_permut,
                    list_neighsite=self.list_neighsite,  # type:ignore[arg-type]
                    list_neighisite=self.list_neighisite,
                    structure=self.structure,
                    valences=self.valences,
                )

            if self.valences is None:
                raise ValueError(f"{self.valences=}")

            for idx, val in enumerate(self.valences):
                if val >= 0.0:
                    new_list_ce_symbols.append(list_ce_symbols[idx])
                    new_list_csm.append(list_csm[idx])
                    new_list_permut.append(list_permut[idx])
                    new_list_neighisite.append(self.list_neighisite[idx])
                    new_list_neighsite.append(self.list_neighsite[idx])
                else:
                    new_list_ce_symbols.append(None)
                    new_list_csm.append(None)
                    new_list_permut.append([])
                    new_list_neighisite.append([])
                    new_list_neighsite.append([])

        else:
            for site_idx, _site in enumerate(self.structure):
                if site_idx in only_indices:
                    new_list_ce_symbols.append(list_ce_symbols[site_idx])
                    new_list_csm.append(list_csm[site_idx])
                    new_list_permut.append(list_permut[site_idx])
                    new_list_neighisite.append(self.list_neighisite[site_idx])
                    new_list_neighsite.append(self.list_neighsite[site_idx])
                else:
                    new_list_ce_symbols.append(None)
                    new_list_csm.append(None)
                    new_list_permut.append([])
                    new_list_neighisite.append([])
                    new_list_neighsite.append([])

        return LobsterLightStructureEnvironments.from_Lobster(
            list_ce_symbol=new_list_ce_symbols,
            list_csm=new_list_csm,
            list_permutation=new_list_permut,
            list_neighsite=new_list_neighsite,  # type:ignore[arg-type]
            list_neighisite=new_list_neighisite,
            structure=self.structure,
            valences=self.valences,
        )

    def get_info_icohps_to_neighbors(
        self,
        isites: list[int] | None = None,
        onlycation_isites: bool = True,
    ) -> ICOHPNeighborsInfo:
        """Get information on the ICOHPs of neighbors for certain sites
        as identified by their site id.

        This is useful for plotting the COHPs (ICOOPLIST.lobster/
        ICOHPLIST.lobster/ICOBILIST.lobster) of a site in the structure.


        Args:
            isites (list[int]): Site IDs. If is None, all isites will be used
                to add the ICOHPs of the neighbors.
            onlycation_isites (bool): If True and if isite is None, will
                only analyse the cations sites.

        Returns:
            ICOHPNeighborsInfo
        """
        if self.valences is None and onlycation_isites:
            raise ValueError("No valences are provided")

        if isites is None:
            if onlycation_isites:
                if self.valences is None:
                    raise ValueError(f"{self.valences}=")

                isites = [idx for idx in range(len(self.structure)) if self.valences[idx] >= 0.0]
            else:
                isites = list(range(len(self.structure)))

        if self.Icohpcollection is None:
            raise ValueError(f"{self.Icohpcollection=}")

        summed_icohps: float = 0.0
        list_icohps: list[float] = []
        number_bonds: int = 0
        labels: list[str] = []
        atoms: list[list[str]] = []
        final_isites: list[int] = []
        for idx, _site in enumerate(self.structure):
            if idx in isites:
                for key, icohpsum in zip(self.list_keys[idx], self.list_icohps[idx], strict=True):
                    summed_icohps += icohpsum
                    list_icohps.append(icohpsum)  # type:ignore[arg-type]
                    labels.append(key)
                    atoms.append(
                        [
                            self.Icohpcollection._list_atom1[int(key) - 1],
                            self.Icohpcollection._list_atom2[int(key) - 1],
                        ]
                    )
                    number_bonds += 1
                    final_isites.append(idx)
        return ICOHPNeighborsInfo(summed_icohps, list_icohps, number_bonds, labels, atoms, final_isites)

    def plot_cohps_of_neighbors(
        self,
        path_to_cohpcar: PathLike | None = "COHPCAR.lobster",
        coxxcar_obj: CompleteCohp | None = None,
        isites: list[int] | None = None,
        onlycation_isites: bool = True,
        only_bonds_to: list[str] | None = None,
        per_bond: bool = False,
        summed_spin_channels: bool = False,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] = (-10, 6),
        integrated: bool = False,
    ) -> mpl.axes.Axes:
        """Plot summed COHPs or COBIs or COOPs.

        Please be careful in the spin polarized case (plots might overlap).

        Args:
            path_to_cohpcar (PathLike): Path to COHPCAR or COOPCAR or COBICAR.
            coxxcar_obj (CompleteCohp | None): CompleteCohp object
            isites (list[int]): Site IDs. If empty, all sites will be used to add the ICOHPs of the neighbors.
            onlycation_isites (bool): Only use cations, if isite is empty.
            only_bonds_to (list[str]): Only anions in this list will be considered.
            per_bond (bool): Whether to plot a normalization of the plotted COHP
                per number of bond (True), or the sum (False).
            xlim (tuple[float, float]): Limits of x values.
            ylim (tuple[float, float]): Limits of y values.
            integrated (bool): Whether to show integrated COHP instead of COHP.

        Returns:
            plt of the COHPs or COBIs or COOPs.
        """
        cp = CohpPlotter(are_cobis=self.are_cobis, are_coops=self.are_coops)

        plotlabel, summed_cohp = self.get_info_cohps_to_neighbors(
            path_to_cohpcar,
            coxxcar_obj,
            isites,
            only_bonds_to,
            onlycation_isites,
            per_bond,
            summed_spin_channels=summed_spin_channels,
        )

        cp.add_cohp(plotlabel, summed_cohp)
        ax = cp.get_plot(integrated=integrated)
        if xlim is not None:
            ax.set_xlim(xlim)

        if ylim is not None:
            ax.set_ylim(ylim)

        return ax

    def get_info_cohps_to_neighbors(
        self,
        path_to_cohpcar: PathLike | None = "COHPCAR.lobster",
        coxxcar_obj: CompleteCohp | None = None,
        isites: list[int] | None = None,
        only_bonds_to: list[str] | None = None,
        onlycation_isites: bool = True,
        per_bond: bool = True,
        summed_spin_channels: bool = False,
    ) -> tuple[str | None, CompleteCohp | None]:
        """Get the COHPs (COOPs or COBIs) as a summed Cohp object
        and a label from all sites mentioned in isites with neighbors.

        Args:
            path_to_cohpcar (PathLike): Path to COHPCAR/COOPCAR/COBICAR.
            coxxcar_obj (CompleteCohp | None): CompleteCohp object.
            isites (list[int]): The indexes of the sites.
            only_bonds_to (list[str]): Only show COHPs to selected element, e.g. ["O"].
            onlycation_isites (bool): If isites is None, only cation sites will be returned.
            per_bond (bool): Whether to normalize per bond.
            summed_spin_channels (bool): Whether to sum both spin channels.

        Returns:
            str: Label for COHP.
            CompleteCohp: Describe all COHPs/COOPs/COBIs of the sites
                as given by isites and the other arguments.
        """
        # TODO: add options for orbital-resolved COHPs
        _summed_icohps, _list_icohps, _number_bonds, labels, atoms, final_isites = self.get_info_icohps_to_neighbors(
            isites=isites, onlycation_isites=onlycation_isites
        )
        with tempfile.TemporaryDirectory() as tmp_dir:
            path = f"{tmp_dir}/POSCAR.vasp"

            self.structure.to(filename=path, fmt="poscar")

            if not hasattr(self, "completecohp"):
                if path_to_cohpcar is not None and coxxcar_obj is None:
                    self.completecohp = CompleteCohp.from_file(
                        fmt="LOBSTER",
                        filename=path_to_cohpcar,
                        structure_file=path,
                        are_coops=self.are_coops,
                        are_cobis=self.are_cobis,
                    )
                elif coxxcar_obj is not None:
                    self.completecohp = coxxcar_obj
                else:
                    raise ValueError("Please provide either path_to_cohpcar or coxxcar_obj")

        # Check that the number of bonds in ICOHPLIST and COHPCAR are identical
        # TODO: Further checks could be implemented
        if self.Icohpcollection is None:
            raise ValueError(f"{self.Icohpcollection=}")

        if len(self.Icohpcollection._list_atom1) != len(self.completecohp.bonds):
            raise ValueError("COHPCAR and ICOHPLIST do not fit together")

        is_spin_completecohp = Spin.down in self.completecohp.get_cohp_by_label("1").cohp
        if self.Icohpcollection.is_spin_polarized != is_spin_completecohp:
            raise ValueError("COHPCAR and ICOHPLIST do not fit together")

        if only_bonds_to is None:
            # Sort by anion type
            divisor = len(labels) if per_bond else 1

            plot_label = self._get_plot_label(atoms, per_bond)
            summed_cohp = self.completecohp.get_summed_cohp_by_label_list(
                label_list=labels,
                divisor=divisor,
                summed_spin_channels=summed_spin_channels,
            )

        else:
            # Labels of the COHPs that will be summed
            # Iterate through labels and atoms and check which bonds can be included
            new_labels = []
            new_atoms = []
            if final_isites is None:
                raise ValueError(f"{final_isites=}")

            for key, atompair, isite in zip(labels, atoms, final_isites, strict=True):
                present = False
                for atomtype in only_bonds_to:
                    # This is necessary to identify also bonds between the same elements correctly
                    if str(self.structure[isite].species.elements[0]) != atomtype:
                        if atomtype in {
                            self._split_string(atompair[0])[0],
                            self._split_string(atompair[1])[0],
                        }:
                            present = True
                    elif (
                        atomtype == self._split_string(atompair[0])[0]
                        and atomtype == self._split_string(atompair[1])[0]
                    ):
                        present = True

                if present:
                    new_labels.append(key)
                    new_atoms.append(atompair)
            if new_labels:
                divisor = len(new_labels) if per_bond else 1

                plot_label = self._get_plot_label(new_atoms, per_bond)
                summed_cohp = self.completecohp.get_summed_cohp_by_label_list(
                    label_list=new_labels,
                    divisor=divisor,
                    summed_spin_channels=summed_spin_channels,
                )
            else:
                plot_label = None

                summed_cohp = None

        return plot_label, summed_cohp  # type:ignore[return-value]

    def _get_plot_label(self, atoms: list[list[str]], per_bond: bool) -> str:
        """Count the types of bonds and append a label."""
        all_labels = []
        for atoms_names in atoms:
            new = [
                self._split_string(atoms_names[0])[0],
                self._split_string(atoms_names[1])[0],
            ]
            new.sort()
            string_here = f"{new[0]}-{new[1]}"
            all_labels.append(string_here)

        counter = collections.Counter(all_labels)
        plotlabels = [f"{item} x {key}" for key, item in counter.items()]
        label = ", ".join(plotlabels)
        if per_bond:
            label += " (per bond)"
        return label

    def get_info_icohps_between_neighbors(
        self,
        isites: list[int] | None = None,
        onlycation_isites: bool = True,
    ) -> ICOHPNeighborsInfo:
        """Get interactions between neighbors of certain sites.

        Args:
            isites (list[int]): Site IDs. If is None, all sites will be used.
            onlycation_isites (bool): Only use cations, if isite is None.

        Returns:
            ICOHPNeighborsInfo
        """
        lowerlimit = self.lowerlimit
        upperlimit = self.upperlimit

        if self.valences is None and onlycation_isites:
            raise ValueError("No valences are provided")

        if isites is None:
            if onlycation_isites:
                if self.valences is None:
                    raise ValueError(f"{self.valences=}")

                isites = [idx for idx in range(len(self.structure)) if self.valences[idx] >= 0.0]
            else:
                isites = list(range(len(self.structure)))

        summed_icohps: float = 0.0
        list_icohps: list[float] = []
        number_bonds: int = 0
        labels: list[str] = []
        atoms: list[list[str]] = []
        if self.Icohpcollection is None:
            raise ValueError(f"{self.Icohpcollection=}")

        for isite in isites:
            for site1_idx, n_site in enumerate(self.list_neighsite[isite]):
                for site2_idx, n_site2 in enumerate(self.list_neighsite[isite]):
                    if site1_idx < site2_idx:
                        unitcell1 = self._determine_unit_cell(n_site)
                        unitcell2 = self._determine_unit_cell(n_site2)

                        index_n_site = self._get_original_site(self.structure, n_site)  # type:ignore[arg-type]
                        index_n_site2 = self._get_original_site(self.structure, n_site2)  # type:ignore[arg-type]

                        if index_n_site < index_n_site2:
                            translation = list(np.array(unitcell1) - np.array(unitcell2))
                        elif index_n_site2 < index_n_site:
                            translation = list(np.array(unitcell2) - np.array(unitcell1))
                        else:
                            translation = list(np.array(unitcell1) - np.array(unitcell2))

                        icohps = self._get_icohps(
                            icohpcollection=self.Icohpcollection,
                            site_idx=index_n_site,
                            lowerlimit=lowerlimit,
                            upperlimit=upperlimit,
                            only_bonds_to=self.only_bonds_to,
                        )
                        # TODO: check if necessary!
                        done = False
                        for icohp in icohps.values():
                            atomnr1 = self._get_atomnumber(icohp._atom1)
                            atomnr2 = self._get_atomnumber(icohp._atom2)
                            label = icohp._label

                            if (index_n_site == atomnr1 and index_n_site2 == atomnr2) or (
                                index_n_site == atomnr2 and index_n_site2 == atomnr1
                            ):
                                if atomnr1 != atomnr2:
                                    if np.all(np.asarray(translation) == np.asarray(icohp._translation)):
                                        summed_icohps += icohp.summed_icohp
                                        list_icohps.append(icohp.summed_icohp)
                                        number_bonds += 1
                                        labels.append(label)
                                        atoms.append(
                                            [
                                                self.Icohpcollection._list_atom1[int(label) - 1],
                                                self.Icohpcollection._list_atom2[int(label) - 1],
                                            ]
                                        )

                                elif not done:
                                    icohp_trans = -np.asarray(
                                        [
                                            icohp._translation[0],
                                            icohp._translation[1],
                                            icohp._translation[2],
                                        ]
                                    )

                                    if (np.all(np.asarray(translation) == np.asarray(icohp._translation))) or (
                                        np.all(np.asarray(translation) == icohp_trans)
                                    ):
                                        summed_icohps += icohp.summed_icohp
                                        list_icohps.append(icohp.summed_icohp)
                                        number_bonds += 1
                                        labels.append(label)
                                        atoms.append(
                                            [
                                                self.Icohpcollection._list_atom1[int(label) - 1],
                                                self.Icohpcollection._list_atom2[int(label) - 1],
                                            ]
                                        )
                                        done = True

        return ICOHPNeighborsInfo(summed_icohps, list_icohps, number_bonds, labels, atoms, None)

    def _evaluate_ce(
        self,
        lowerlimit: float | None,
        upperlimit: float | None,
        only_bonds_to: list[str] | None = None,
        additional_condition: Literal[0, 1, 2, 3, 4, 5, 6] = 0,
        perc_strength_icohp: float = 0.15,
        adapt_extremum_to_add_cond: bool = False,
    ) -> None:
        """
        Args:
            lowerlimit (float): Lower limit which determines the ICOHPs
                that are considered for the determination of the neighbors.
            upperlimit (float): Upper limit which determines the ICOHPs
                that are considered for the determination of the neighbors.
            only_bonds_to (list[str]): Restrict the types of bonds that will be considered.
            additional_condition (int): Additional condition for the evaluation.
            perc_strength_icohp (float): Determine how strong the ICOHPs
                (percentage * strongest_ICOHP) will be that are still considered.
            adapt_extremum_to_add_cond (bool): Whether to recalculate the limit
                based on the bonding type and not on the overall extremum.
        """
        # Get extremum
        if lowerlimit is None and upperlimit is None:
            if self.Icohpcollection is None:
                raise ValueError(f"{self.Icohpcollection=}")

            limits = self._get_limit_from_extremum(
                self.Icohpcollection,
                percentage=perc_strength_icohp,
                adapt_extremum_to_add_cond=adapt_extremum_to_add_cond,
                additional_condition=additional_condition,
            )

            if limits is None:
                raise ValueError(f"{limits=}")
            lowerlimit, upperlimit = limits

        elif upperlimit is None or lowerlimit is None:
            raise ValueError("Please give two limits or leave them both at None")

        # Find environments based on ICOHP values
        (list_icohps, list_keys, list_lengths, list_neighisite, list_neighsite, list_coords) = self._find_environments(
            additional_condition, lowerlimit, upperlimit, only_bonds_to
        )

        self.list_icohps = list_icohps
        self.list_lengths = list_lengths
        self.list_keys = list_keys
        self.list_neighsite = list_neighsite
        self.list_neighisite = list_neighisite
        self.list_coords = list_coords

        # Make a structure graph
        # Make sure everything is relative to the given Structure and
        # not just the atoms in the unit cell
        if self.add_additional_data_sg:
            if self.bonding_list_1.icohpcollection is None:
                raise ValueError(f"{self.bonding_list_1.icohpcollection=}")
            if self.bonding_list_2.icohpcollection is None:
                raise ValueError(f"{self.bonding_list_2.icohpcollection=}")

            self.sg_list = [
                [
                    {
                        "site": neighbor,
                        "image": tuple(
                            round(idx)
                            for idx in (
                                neighbor.frac_coords
                                - self.structure[
                                    next(
                                        site_idx
                                        for site_idx, site in enumerate(self.structure)
                                        if neighbor.is_periodic_image(site)
                                    )
                                ].frac_coords
                            )
                        ),
                        "weight": 1,
                        # Here, the ICOBIs and ICOOPs are added based on the bond
                        # strength cutoff of the ICOHP
                        # More changes are necessary here if we use ICOBIs for cutoffs
                        "edge_properties": {
                            "ICOHP": self.list_icohps[neighbors_idx][nbr_idx],
                            "bond_length": self.list_lengths[neighbors_idx][nbr_idx],
                            "bond_label": self.list_keys[neighbors_idx][nbr_idx],
                            self.id_blist_sg1.upper(): self.bonding_list_1.icohpcollection.get_icohp_by_label(
                                self.list_keys[neighbors_idx][nbr_idx]
                            ),
                            self.id_blist_sg2.upper(): self.bonding_list_2.icohpcollection.get_icohp_by_label(
                                self.list_keys[neighbors_idx][nbr_idx]
                            ),
                        },
                        "site_index": next(
                            site_idx for site_idx, site in enumerate(self.structure) if neighbor.is_periodic_image(site)
                        ),
                    }
                    for nbr_idx, neighbor in enumerate(neighbors)
                ]
                for neighbors_idx, neighbors in enumerate(self.list_neighsite)
            ]
        else:
            self.sg_list = [
                [
                    {
                        "site": neighbor,
                        "image": tuple(
                            round(idx)
                            for idx in (
                                neighbor.frac_coords
                                - self.structure[
                                    next(
                                        site_idx
                                        for site_idx, site in enumerate(self.structure)
                                        if neighbor.is_periodic_image(site)
                                    )
                                ].frac_coords
                            )
                        ),
                        "weight": 1,
                        "edge_properties": {
                            "ICOHP": self.list_icohps[neighbors_idx][nbr_idx],
                            "bond_length": self.list_lengths[neighbors_idx][nbr_idx],
                            "bond_label": self.list_keys[neighbors_idx][nbr_idx],
                        },
                        "site_index": next(
                            site_idx for site_idx, site in enumerate(self.structure) if neighbor.is_periodic_image(site)
                        ),
                    }
                    for nbr_idx, neighbor in enumerate(neighbors)
                ]
                for neighbors_idx, neighbors in enumerate(self.list_neighsite)
            ]

    def _find_environments(
        self,
        additional_condition: Literal[0, 1, 2, 3, 4, 5, 6],
        lowerlimit: float,
        upperlimit: float,
        only_bonds_to: list[str] | None,
    ) -> tuple[
        list[list[IcohpValue]],
        list[list[str]],
        list[list[float]],
        list[list[int]],
        list[list[PeriodicNeighbor]],
        list[list[NDArray]],
    ]:
        """Find all relevant neighbors based on certain restrictions.

        Args:
            additional_condition (int): Additional condition.
            lowerlimit (float): Lower limit that ICOHPs are considered.
            upperlimit (float): Upper limit that ICOHPs are considered.
            only_bonds_to (list[str]): Only bonds to these elements will be considered.

        Returns:
            Tuple of ICOHPs, keys, lengths, neighisite, neighsite, coords.
        """
        list_icohps: list[list[float]] = []
        list_keys: list[list[str]] = []
        list_lengths: list[list[float]] = []
        list_neighisite: list[list[int]] = []
        list_neighsite: list[list[PeriodicNeighbor]] = []
        list_coords: list[list[NDArray]] = []

        # Run over structure
        if self.Icohpcollection is None:
            raise ValueError(f"{self.Icohpcollection=}")

        for idx, site in enumerate(self.structure):
            icohps = self._get_icohps(
                icohpcollection=self.Icohpcollection,
                site_idx=idx,
                lowerlimit=lowerlimit,
                upperlimit=upperlimit,
                only_bonds_to=only_bonds_to,
            )

            additional_conds = self._find_relevant_atoms_additional_condition(idx, icohps, additional_condition)
            (
                keys_from_ICOHPs,
                lengths_from_ICOHPs,
                neighbors_from_ICOHPs,
                selected_ICOHPs,
                translations_ICOHPs,
            ) = additional_conds

            check_ICOHPs(
                lengths_from_ICOHPs=lengths_from_ICOHPs,
                selected_ICOHPs=selected_ICOHPs,
                translation=translations_ICOHPs,
            )

            if len(neighbors_from_ICOHPs) > 0:
                centralsite = site
                neighbors_by_distance_start = self.structure.get_sites_in_sphere(
                    pt=centralsite.coords,
                    r=np.max(lengths_from_ICOHPs) + 0.5,
                    include_image=True,
                    include_index=True,
                )

                neighbors_by_distance = []
                list_distances = []
                index_here_list = []
                coords = []
                translations_by_distance = []
                for neigh_new in sorted(neighbors_by_distance_start, key=lambda x: x[1]):
                    site_here = neigh_new[0].to_unit_cell()
                    index_here = neigh_new[2]
                    index_here_list.append(index_here)
                    cell_here = neigh_new[3]

                    new_coords = [
                        site_here.frac_coords[0] + float(cell_here[0]),
                        site_here.frac_coords[1] + float(cell_here[1]),
                        site_here.frac_coords[2] + float(cell_here[2]),
                    ]
                    coords.append(site_here.lattice.get_cartesian_coords(new_coords))

                    neighbors_by_distance.append(neigh_new[0])
                    list_distances.append(neigh_new[1])
                    translations_by_distance.append(cell_here)
                _list_neighsite = []
                _list_neighisite = []
                copied_neighbors_from_ICOHPs = copy.copy(neighbors_from_ICOHPs)
                copied_distances_from_ICOHPs = copy.copy(lengths_from_ICOHPs)
                copied_translations_from_ICOHPs = copy.copy(translations_ICOHPs)
                copied_icohps_from_ICOHPs = copy.copy(selected_ICOHPs)
                copied_keys_from_ICOHPs = copy.copy(keys_from_ICOHPs)
                _neigh_coords = []
                _neigh_frac_coords = []
                _list_icohps = []
                _list_lengths = []
                _list_keys = []
                _list_translations = []

                for neigh_idx, neigh in enumerate(neighbors_by_distance):
                    index_here2 = index_here_list[neigh_idx]
                    for dist_idx, dist in enumerate(copied_distances_from_ICOHPs):
                        if not self.backward_compatibility:
                            comparison = (
                                np.isclose(dist, list_distances[neigh_idx], rtol=1e-4)
                                and copied_neighbors_from_ICOHPs[dist_idx] == index_here2
                                and (
                                    (
                                        copied_translations_from_ICOHPs[dist_idx][0]
                                        == -translations_by_distance[neigh_idx][0]
                                        and copied_translations_from_ICOHPs[dist_idx][1]
                                        == -translations_by_distance[neigh_idx][1]
                                        and copied_translations_from_ICOHPs[dist_idx][2]
                                        == -translations_by_distance[neigh_idx][2]
                                    )
                                    or (
                                        copied_translations_from_ICOHPs[dist_idx][0]
                                        == translations_by_distance[neigh_idx][0]
                                        and copied_translations_from_ICOHPs[dist_idx][1]
                                        == translations_by_distance[neigh_idx][1]
                                        and copied_translations_from_ICOHPs[dist_idx][2]
                                        == translations_by_distance[neigh_idx][2]
                                    )
                                )
                            )
                        else:
                            warnings.warn(
                                "You are using an older version for neighbor detection that might not consider rare "
                                "LOBSTER edge cases. Consider switching LobsterNeighbors().backward_compatibility "
                                "to False for more advanced neighbor detection.",
                                stacklevel=2,
                            )
                            comparison = (
                                np.isclose(dist, list_distances[neigh_idx], rtol=1e-4)
                                and copied_neighbors_from_ICOHPs[dist_idx] == index_here2
                            )

                        if comparison:
                            _list_neighsite.append(neigh)
                            _list_neighisite.append(index_here2)
                            _neigh_coords.append(coords[neigh_idx])
                            _neigh_frac_coords.append(neigh.frac_coords)
                            _list_icohps.append(copied_icohps_from_ICOHPs[dist_idx])
                            _list_lengths.append(dist)
                            _list_keys.append(copied_keys_from_ICOHPs[dist_idx])
                            _list_translations.append(copied_translations_from_ICOHPs[dist_idx])
                            del copied_distances_from_ICOHPs[dist_idx]
                            del copied_neighbors_from_ICOHPs[dist_idx]
                            del copied_translations_from_ICOHPs[dist_idx]
                            del copied_icohps_from_ICOHPs[dist_idx]
                            del copied_keys_from_ICOHPs[dist_idx]
                            break

                list_neighisite.append(_list_neighisite)
                list_neighsite.append(_list_neighsite)
                list_lengths.append(_list_lengths)
                list_keys.append(_list_keys)
                list_coords.append(_neigh_coords)
                list_icohps.append(_list_icohps)
            else:
                list_neighsite.append([])
                list_neighisite.append([])
                list_icohps.append([])
                list_lengths.append([])
                list_keys.append([])
                list_coords.append([])
        return (  # type:ignore[return-value]
            list_icohps,
            list_keys,
            list_lengths,
            list_neighisite,
            list_neighsite,
            list_coords,
        )

    def _find_relevant_atoms_additional_condition(
        self,
        site_idx: int,
        icohps: dict[str, IcohpValue],
        additional_condition: Literal[0, 1, 2, 3, 4, 5, 6],
    ) -> tuple[list[str], list[float], list[int], list[float], list[tuple[int, int, int]]]:
        """Find all relevant atoms that fulfill the additional condition.

        Args:
            site_idx (int): Site index in structure (start from 0).
            icohps (dict[str, IcohpValue]): ICOHP values.
            additional_condition (int): Additional condition.

        Returns:
            tuple: keys, lengths, neighbors from selected ICOHPs and selected ICOHPs.
        """
        keys_from_ICOHPs: list[str] = []
        lengths_from_ICOHPs: list[float] = []
        neighbors_from_ICOHPs: list[int] = []
        icohps_from_ICOHPs: list[float] = []
        translation_from_ICOHPs: list[tuple[int, int, int]] = []

        for key, icohp in icohps.items():
            atomnr1 = self._get_atomnumber(icohp._atom1)
            atomnr2 = self._get_atomnumber(icohp._atom2)

            # Check additional conditions
            val1 = val2 = None
            if self.valences is None:
                raise ValueError(f"{self.valences=}")
            if additional_condition in {1, 3, 5, 6}:
                val1 = self.valences[atomnr1]
                val2 = self.valences[atomnr2]

            # NO_ADDITIONAL_CONDITION
            if additional_condition == 0:
                if atomnr1 == site_idx:
                    neighbors_from_ICOHPs.append(atomnr2)
                    lengths_from_ICOHPs.append(icohp._length)
                    icohps_from_ICOHPs.append(icohp.summed_icohp)
                    keys_from_ICOHPs.append(key)
                    # add translation to icohp value
                    translation_from_ICOHPs.append(icohp.translation)
                elif atomnr2 == site_idx:
                    neighbors_from_ICOHPs.append(atomnr1)
                    lengths_from_ICOHPs.append(icohp._length)
                    icohps_from_ICOHPs.append(icohp.summed_icohp)
                    keys_from_ICOHPs.append(key)
                    translation_from_ICOHPs.append(icohp.translation)

            # ONLY_ANION_CATION_BONDS
            elif additional_condition == 1:
                if (val1 < 0.0 < val2) or (val2 < 0.0 < val1):  # type: ignore[operator]
                    if atomnr1 == site_idx:
                        neighbors_from_ICOHPs.append(atomnr2)
                        lengths_from_ICOHPs.append(icohp._length)
                        icohps_from_ICOHPs.append(icohp.summed_icohp)
                        keys_from_ICOHPs.append(key)
                        translation_from_ICOHPs.append(icohp.translation)

                    elif atomnr2 == site_idx:
                        neighbors_from_ICOHPs.append(atomnr1)
                        lengths_from_ICOHPs.append(icohp._length)
                        icohps_from_ICOHPs.append(icohp.summed_icohp)
                        keys_from_ICOHPs.append(key)
                        translation_from_ICOHPs.append(icohp.translation)

            # NO_ELEMENT_TO_SAME_ELEMENT_BONDS
            elif additional_condition == 2:
                if icohp._atom1.rstrip("0123456789") != icohp._atom2.rstrip("0123456789"):
                    if atomnr1 == site_idx:
                        neighbors_from_ICOHPs.append(atomnr2)
                        lengths_from_ICOHPs.append(icohp._length)
                        icohps_from_ICOHPs.append(icohp.summed_icohp)
                        keys_from_ICOHPs.append(key)
                        translation_from_ICOHPs.append(icohp.translation)

                    elif atomnr2 == site_idx:
                        neighbors_from_ICOHPs.append(atomnr1)
                        lengths_from_ICOHPs.append(icohp._length)
                        icohps_from_ICOHPs.append(icohp.summed_icohp)
                        keys_from_ICOHPs.append(key)
                        translation_from_ICOHPs.append(icohp.translation)

            # ONLY_ANION_CATION_BONDS_AND_NO_ELEMENT_TO_SAME_ELEMENT_BONDS
            elif additional_condition == 3:
                if ((val1 < 0.0 < val2) or (val2 < 0.0 < val1)) and icohp._atom1.rstrip(  # type: ignore[operator]
                    "0123456789"
                ) != icohp._atom2.rstrip("0123456789"):
                    if atomnr1 == site_idx:
                        neighbors_from_ICOHPs.append(atomnr2)
                        lengths_from_ICOHPs.append(icohp._length)
                        icohps_from_ICOHPs.append(icohp.summed_icohp)
                        keys_from_ICOHPs.append(key)
                        translation_from_ICOHPs.append(icohp.translation)

                    elif atomnr2 == site_idx:
                        neighbors_from_ICOHPs.append(atomnr1)
                        lengths_from_ICOHPs.append(icohp._length)
                        icohps_from_ICOHPs.append(icohp.summed_icohp)
                        keys_from_ICOHPs.append(key)
                        translation_from_ICOHPs.append(icohp.translation)

            # ONLY_ELEMENT_TO_OXYGEN_BONDS
            elif additional_condition == 4:
                if icohp._atom1.rstrip("0123456789") == "O" or icohp._atom2.rstrip("0123456789") == "O":
                    if atomnr1 == site_idx:
                        neighbors_from_ICOHPs.append(atomnr2)
                        lengths_from_ICOHPs.append(icohp._length)
                        icohps_from_ICOHPs.append(icohp.summed_icohp)
                        keys_from_ICOHPs.append(key)
                        translation_from_ICOHPs.append(icohp.translation)

                    elif atomnr2 == site_idx:
                        neighbors_from_ICOHPs.append(atomnr1)
                        lengths_from_ICOHPs.append(icohp._length)
                        icohps_from_ICOHPs.append(icohp.summed_icohp)
                        keys_from_ICOHPs.append(key)
                        translation_from_ICOHPs.append(icohp.translation)

            # DO_NOT_CONSIDER_ANION_CATION_BONDS
            elif additional_condition == 5:
                if (val1 > 0.0 and val2 > 0.0) or (val1 < 0.0 and val2 < 0.0):  # type: ignore[operator]
                    if atomnr1 == site_idx:
                        neighbors_from_ICOHPs.append(atomnr2)
                        lengths_from_ICOHPs.append(icohp._length)
                        icohps_from_ICOHPs.append(icohp.summed_icohp)
                        keys_from_ICOHPs.append(key)
                        translation_from_ICOHPs.append(icohp.translation)

                    elif atomnr2 == site_idx:
                        neighbors_from_ICOHPs.append(atomnr1)
                        lengths_from_ICOHPs.append(icohp._length)
                        icohps_from_ICOHPs.append(icohp.summed_icohp)
                        keys_from_ICOHPs.append(key)
                        translation_from_ICOHPs.append(icohp.translation)

            # ONLY_CATION_CATION_BONDS
            elif additional_condition == 6 and val1 > 0.0 and val2 > 0.0:  # type: ignore[operator]
                if atomnr1 == site_idx:
                    neighbors_from_ICOHPs.append(atomnr2)
                    lengths_from_ICOHPs.append(icohp._length)
                    icohps_from_ICOHPs.append(icohp.summed_icohp)
                    keys_from_ICOHPs.append(key)
                    translation_from_ICOHPs.append(icohp.translation)

                elif atomnr2 == site_idx:
                    neighbors_from_ICOHPs.append(atomnr1)
                    lengths_from_ICOHPs.append(icohp._length)
                    icohps_from_ICOHPs.append(icohp.summed_icohp)
                    keys_from_ICOHPs.append(key)
                    translation_from_ICOHPs.append(icohp.translation)

        return (
            keys_from_ICOHPs,
            lengths_from_ICOHPs,
            neighbors_from_ICOHPs,
            icohps_from_ICOHPs,
            translation_from_ICOHPs,
        )

    @staticmethod
    def _get_icohps(
        icohpcollection: IcohpCollection,
        site_idx: int,
        lowerlimit: float | None,
        upperlimit: float | None,
        only_bonds_to: list[str] | None,
    ) -> dict[str, IcohpValue]:
        """Get ICOHP dict for certain site.

        Args:
            icohpcollection (IcohpCollection): IcohpCollection object.
            site_idx (int): Site index.
            lowerlimit (float): Lower limit that ICOHPs are considered.
            upperlimit (float): Upper limit that ICOHPs are considered.
            only_bonds_to (list[str]): Only bonds to these elements will be considered, e.g. ["O"].

        Returns:
            dict of IcohpValues. The keys correspond to the initial list_labels.
        """
        return icohpcollection.get_icohp_dict_of_site(
            site=site_idx,
            maxbondlength=6.0,
            minsummedicohp=lowerlimit,
            maxsummedicohp=upperlimit,
            only_bonds_to=only_bonds_to,
        )

    @staticmethod
    def _get_atomnumber(atomstring: str) -> int:
        """Get the index of the atom within the POSCAR (e.g., Return 0 for "Na1").

        Args:
            atomstring (str): Atom as str, such as "Na1".

        Returns:
            int: Index of the atom in the POSCAR.
        """
        return int(LobsterNeighbors._split_string(atomstring)[1]) - 1

    @staticmethod
    def _split_string(s) -> tuple[str, str]:
        """Split strings such as "Na1" into ["Na", "1"] and return "1".

        Args:
            s (str): String to split.
        """
        head = s.rstrip("0123456789")
        tail = s[len(head) :]
        return head, tail

    @staticmethod
    def _determine_unit_cell(site: PeriodicSite) -> list[int]:
        """Determine the unit cell based on the site.

        Args:
            site (PeriodicSite): The site.
        """
        unitcell = []
        for coord in site.frac_coords:
            value = math.floor(round(coord, 4))
            unitcell.append(value)

        return unitcell

    def _adapt_extremum_to_add_cond(
        self,
        list_icohps: list[float],
        percentage: float,
    ) -> float:
        """Get the extremum from the given ICOHPs or ICOOPs or ICOBIs.

        Args:
            list_icohps (list): ICOHPs or ICOOPs or ICOBIs.
            percentage (float): The percentage to scale extremum.

        Returns:
            float: Min value of ICOHPs, or max value of ICOOPs/ICOBIs.
        """

        which_extr = min if not self.are_coops and not self.are_cobis else max
        return which_extr(list_icohps) * percentage

    def _get_limit_from_extremum(
        self,
        icohpcollection: IcohpCollection,
        percentage: float = 0.15,
        adapt_extremum_to_add_cond: bool = False,
        additional_condition: Literal[0, 1, 2, 3, 4, 5, 6] = 0,
    ) -> tuple[float, float] | None:
        """Get range for the ICOHP values from an IcohpCollection.

        Currently only work for ICOHPs.

        Args:
            icohpcollection (IcohpCollection): IcohpCollection object.
            percentage (float): Determine which ICOHPs/ICOOP/ICOBI will be considered.
            adapt_extremum_to_add_cond (bool): Whether the extrumum be adapted to
                the additional condition.
            additional_condition (int): Additional condition to determine which bonds to include.

        Returns:
            tuple[float, float]: [-inf, min(strongest_icohp*0.15, -noise_cutoff)]
                or [max(strongest_icohp*0.15, noise_cutoff), inf].
        """
        extremum_based = None

        if self.valences is None:
            raise ValueError(f"{self.valences=}")

        if not adapt_extremum_to_add_cond or additional_condition == 0:
            extremum_based = icohpcollection.extremum_icohpvalue(summed_spin_channels=True) * percentage

        elif additional_condition == 1:
            # ONLY_ANION_CATION_BONDS
            list_icohps = []
            for value in icohpcollection._icohplist.values():
                atomnr1 = type(self)._get_atomnumber(value._atom1)
                atomnr2 = type(self)._get_atomnumber(value._atom2)

                val1 = self.valences[atomnr1]
                val2 = self.valences[atomnr2]
                if (val1 < 0.0 < val2) or (val2 < 0.0 < val1):
                    list_icohps.append(value.summed_icohp)

            extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage)

        elif additional_condition == 2:
            # NO_ELEMENT_TO_SAME_ELEMENT_BONDS
            list_icohps = []
            for value in icohpcollection._icohplist.values():
                if value._atom1.rstrip("0123456789") != value._atom2.rstrip("0123456789"):
                    list_icohps.append(value.summed_icohp)

            extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage)

        elif additional_condition == 3:
            # ONLY_ANION_CATION_BONDS_AND_NO_ELEMENT_TO_SAME_ELEMENT_BONDS
            list_icohps = []
            for value in icohpcollection._icohplist.values():
                atomnr1 = type(self)._get_atomnumber(value._atom1)
                atomnr2 = type(self)._get_atomnumber(value._atom2)
                val1 = self.valences[atomnr1]
                val2 = self.valences[atomnr2]

                if ((val1 < 0.0 < val2) or (val2 < 0.0 < val1)) and value._atom1.rstrip(
                    "0123456789"
                ) != value._atom2.rstrip("0123456789"):
                    list_icohps.append(value.summed_icohp)

            extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage)

        elif additional_condition == 4:
            # ONLY_ELEMENT_TO_OXYGEN_BONDS
            list_icohps = []
            for value in icohpcollection._icohplist.values():
                if value._atom1.rstrip("0123456789") == "O" or value._atom2.rstrip("0123456789") == "O":
                    list_icohps.append(value.summed_icohp)

            extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage)

        elif additional_condition == 5:
            # DO_NOT_CONSIDER_ANION_CATION_BONDS
            list_icohps = []
            for value in icohpcollection._icohplist.values():
                atomnr1 = type(self)._get_atomnumber(value._atom1)
                atomnr2 = type(self)._get_atomnumber(value._atom2)
                val1 = self.valences[atomnr1]
                val2 = self.valences[atomnr2]

                if (val1 > 0.0 and val2 > 0.0) or (val1 < 0.0 and val2 < 0.0):
                    list_icohps.append(value.summed_icohp)

            extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage)

        elif additional_condition == 6:
            # ONLY_CATION_CATION_BONDS
            list_icohps = []
            for value in icohpcollection._icohplist.values():
                atomnr1 = type(self)._get_atomnumber(value._atom1)
                atomnr2 = type(self)._get_atomnumber(value._atom2)
                val1 = self.valences[atomnr1]
                val2 = self.valences[atomnr2]

                if val1 > 0.0 and val2 > 0.0:
                    list_icohps.append(value.summed_icohp)

            extremum_based = self._adapt_extremum_to_add_cond(list_icohps, percentage)

        if not self.are_coops and not self.are_cobis:
            max_here = min(extremum_based, -self.noise_cutoff) if self.noise_cutoff is not None else extremum_based
            return -float("inf"), max_here

        if self.are_coops or self.are_cobis:
            min_here = max(extremum_based, self.noise_cutoff) if self.noise_cutoff is not None else extremum_based
            return min_here, float("inf")

        return None


class LobsterLightStructureEnvironments(LightStructureEnvironments):
    """Store LightStructureEnvironments based on LOBSTER outputs."""

    @classmethod
    def from_Lobster(
        cls,
        list_ce_symbol: list[str | None],
        list_csm: list[float | None],
        list_permutation: list,
        list_neighsite: list[PeriodicSite],
        list_neighisite: list[list[int]],
        structure: Structure | IStructure,
        valences: list[float] | None = None,
    ) -> Self:
        """Set up a LightStructureEnvironments from LOBSTER.

        Args:
            list_ce_symbol (list[str]): Coordination environments symbols.
            list_csm (list[float]): Continuous symmetry measures.
            list_permutation (list): Permutations.
            list_neighsite (list[PeriodicSite]): Neighboring sites.
            list_neighisite (list[list[int]]): Neighboring sites indexes.
            structure (Structure): Structure object.
            valences (list[float]): Valences.

        Returns:
            LobsterLightStructureEnvironments
        """
        strategy = None
        valences_origin = "user-defined"
        coordination_environments = []
        all_nbs_sites = []
        all_nbs_sites_indices = []
        neighbors_sets = []
        counter = 0

        for site_idx in range(len(structure)):
            # Coordination environment
            if list_ce_symbol is not None:
                ce_dict = {
                    "ce_symbol": list_ce_symbol[site_idx],
                    "ce_fraction": 1.0,
                    "csm": list_csm[site_idx],
                    "permutation": list_permutation[site_idx],
                }
            else:
                ce_dict = None

            if list_neighisite[site_idx] is not None:
                all_nbs_sites_indices_here = []
                for neigh_site_idx, neigh_site in enumerate(list_neighsite[site_idx]):
                    diff = neigh_site.frac_coords - structure[list_neighisite[site_idx][neigh_site_idx]].frac_coords
                    round_diff = np.round(diff)
                    if not np.allclose(diff, round_diff):
                        raise ValueError(
                            "Weird, differences between one site in a periodic image cell is not integer ..."
                        )
                    nb_image_cell = np.array(round_diff, int)

                    all_nbs_sites_indices_here.append(counter)

                    neighbor = {
                        "site": neigh_site,
                        "index": list_neighisite[site_idx][neigh_site_idx],
                        "image_cell": nb_image_cell,
                    }
                    all_nbs_sites.append(neighbor)
                    counter += 1

                all_nbs_sites_indices.append(all_nbs_sites_indices_here)

            else:
                all_nbs_sites.append({"site": None, "index": None, "image_cell": None})
                all_nbs_sites_indices.append([])

            if list_neighisite[site_idx] is not None:
                nb_set = cls.NeighborsSet(
                    structure=structure,  # type:ignore[arg-type]
                    isite=site_idx,
                    all_nbs_sites=all_nbs_sites,
                    all_nbs_sites_indices=all_nbs_sites_indices[site_idx],
                )

            else:
                nb_set = cls.NeighborsSet(
                    structure=structure,  # type:ignore[arg-type]
                    isite=site_idx,
                    all_nbs_sites=[],
                    all_nbs_sites_indices=[],
                )

            coordination_environments.append([ce_dict])
            neighbors_sets.append([nb_set])

        return cls(
            strategy=strategy,
            coordination_environments=coordination_environments,
            all_nbs_sites=all_nbs_sites,
            neighbors_sets=neighbors_sets,
            structure=structure,
            valences=valences,
            valences_origin=valences_origin,
        )

    @property
    def uniquely_determines_coordination_environments(self) -> Literal[True]:
        """Whether the coordination environments are uniquely determined."""
        return True

    def as_dict(self) -> dict[str, Any]:
        """Bson-serializable dict representation of the object.

        Returns:
            Bson-serializable dict representation.
        """
        return {
            "@module": type(self).__module__,
            "@class": type(self).__name__,
            "strategy": self.strategy,
            "structure": self.structure.as_dict(),
            "coordination_environments": self.coordination_environments,
            "all_nbs_sites": [
                {
                    "site": nb_site["site"].as_dict(),
                    "index": nb_site["index"],
                    "image_cell": [int(ii) for ii in nb_site["image_cell"]],
                }
                for nb_site in self._all_nbs_sites
            ],
            "neighbors_sets": [
                [nb_set.as_dict() for nb_set in site_nb_sets] or None for site_nb_sets in self.neighbors_sets
            ],
            "valences": self.valences,
        }


class ICOHPNeighborsInfo(NamedTuple):
    """Tuple to record information on relevant bonds.

    Args:
        total_icohp (float): Sum of ICOHP values of neighbors to the selected
            sites (given by the index in structure).
        list_icohps (list): Summed ICOHP values for all identified interactions with neighbors.
        n_bonds (int): Number of identified bonds to the selected sites.
        labels (list[str]): Labels (from ICOHPLIST) for all identified bonds.
        atoms (list[list[str]]): Lists describing the species present (from ICOHPLIST)
            in the identified interactions , e.g. ["Ag3", "O5"].
        central_isites (list[int]): The central site indexes for each identified interaction.
    """

    total_icohp: float
    list_icohps: list[float]
    n_bonds: int
    labels: list[str]
    atoms: list[list[str]]
    central_isites: list[int] | None


def check_ICOHPs(lengths_from_ICOHPs, selected_ICOHPs, translation, length_threshold=0.01, energy_threshold=0.1):
    for i in range(len(lengths_from_ICOHPs)):
        for j in range(i + 1, len(lengths_from_ICOHPs)):
            if (
                abs(lengths_from_ICOHPs[i] - lengths_from_ICOHPs[j]) < length_threshold
                and abs(selected_ICOHPs[i] - selected_ICOHPs[j]) > energy_threshold
                and translation[i] != (0, 0, 0)
                and (
                    translation[i][0] == -translation[j][0]
                    and translation[i][1] == -translation[j][1]
                    and translation[i][2] == -translation[j][2]
                )
            ):
                warnings.warn(
                    f"Lengths {lengths_from_ICOHPs[i]} and {lengths_from_ICOHPs[j]} are very close "
                    f"and translation exactly opposite, but corresponding ICOHPs {selected_ICOHPs[i]} "
                    f"and {selected_ICOHPs[j]} are not. Our neighbor detection might fail.",
                    stacklevel=2,
                )
