from __future__ import annotations

import os
import copy
from pathlib import Path
from collections.abc import Iterable

from cachetools import cachedmethod, LRUCache
from cachetools.keys import hashkey
import networkx as nx

from molgeom.data.consts import ANGST2BOHR_GAU16, ATOMIC_NUMBERS
from molgeom.utils.fancy_indexing_list import FancyIndexingList
from molgeom.utils.vec3 import Vec3, mat_type, vec_type
from molgeom.utils.mat3 import Mat3, is_mat_type
from molgeom.utils.decorators import args_to_list, args_to_set
from molgeom.utils.lattice_utils import cart2frac, frac2cart, lat_vecs_to_lat_params
from molgeom.utils.symmetry_utils import symmop_from_xyz_str
from molgeom.utils.html_utils import view_mol
from molgeom.atom import Atom


default_tol = 0.15


class Molecule:
    """
    A class to represent a molecule.
    """

    def __init__(
        self,
        *atoms: Atom,
        lattice_vecs: mat_type | None = None,
        name: str | None = None,
    ):
        self.atoms: FancyIndexingList[Atom] = FancyIndexingList()
        if atoms:
            self.add_atoms_from(atoms)
        self.name: str | None = name
        self._lattice_vecs: list[Vec3] | None = None
        self.lattice_vecs = lattice_vecs
        self._cache = LRUCache(maxsize=10)

    @property
    def lattice_vecs(self) -> list[Vec3] | None:
        return self._lattice_vecs

    @lattice_vecs.setter
    def lattice_vecs(self, lattice_vecs: mat_type | None) -> None:
        if lattice_vecs is None:
            self._lattice_vecs = None
            return

        lat_vecs = Mat3(lattice_vecs)
        lat_det = Mat3.det(lat_vecs)
        if lat_det == 0:
            raise ValueError("lattice vectors must not be linearly dependent")
        if lat_det < 0:
            raise ValueError("lattice vectors must form a right-handed basis")

        self._lattice_vecs = lat_vecs

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

    def __eq__(self, other: Molecule) -> bool:
        return self.atoms == other.atoms

    def __ne__(self, other: Molecule) -> bool:
        return not self == other

    def __contains__(self, atom: Atom) -> bool:
        if not isinstance(atom, Atom):
            raise TypeError("atom must be an Atom object")
        return atom in self.atoms

    def __getitem__(self, index: int | slice | Iterable) -> Atom | Molecule:
        result = self.atoms[index]
        if isinstance(result, Atom):
            return result
        elif isinstance(result, Iterable):
            return Molecule(*result)
        else:
            raise TypeError("Index must be int, slice, list, or tuple")

    def __setitem__(self, index: int, atom: Atom) -> None:
        self.atoms[index] = atom

    def __delitem__(self, idx: int) -> None:
        del self.atoms[idx]

    def __iter__(self):
        for atom in self.atoms:
            if not isinstance(atom, Atom):
                raise TypeError(
                    "Invalid element type: atom must be Atom object\n"
                    + f"{type(atom)=}"
                )
            yield atom

    def __str__(self):
        formula = self.get_formula()
        return f"Molecule({formula})"

    def __repr__(self):
        formula = self.get_formula()
        return f"Molecule({formula})"

    @classmethod
    def sorted(cls, atoms: Molecule | Iterable[Atom], key: callable = None) -> Molecule:
        """
        Create a new molecule by sorting the atoms of the input molecule(s).
        """
        if isinstance(atoms, Molecule):
            atoms = atoms.atoms
        if not all(isinstance(atoms, Atom) for atoms in atoms):
            raise TypeError("All elements must be Molecule objects")
        atoms.sort(key=key)
        mol = cls()
        mol.add_atoms_from(atoms)

        return mol

    def sort(self, key=None) -> None:
        """
        Sort the atoms of the molecule.
        """
        if key is None:
            self.atoms.sort(
                key=lambda atom: (ATOMIC_NUMBERS[atom.symbol], atom.x, atom.y, atom.z)
            )
        else:
            self.atoms.sort(key=key)

    def pop(self, index: int) -> Atom:
        return self.atoms.pop(index)

    def copy(self) -> Molecule:
        return copy.deepcopy(self)

    def is_same_geom(
        self, other: Molecule, rel_tol: float = 1e-5, abs_tol: float = 0.0
    ) -> bool:
        sorted_self = Molecule.sorted(self)
        sorted_other = Molecule.sorted(other)
        return len(self) == len(other) and all(
            atom1.isclose(atom2, rel_tol=rel_tol, abs_tol=abs_tol)
            for atom1, atom2 in zip(
                sorted_self,
                sorted_other,
            )
        )

    @args_to_set
    def filtered_by_symbols(self, symbols: str | Iterable[str]) -> Molecule:
        return Molecule(*[atom for atom in self if atom.symbol in symbols])

    @classmethod
    @args_to_list
    def from_atoms(cls, atoms: Iterable[Atom]) -> Molecule:
        """
        Create a new molecule from Atom objects.
        """
        if not all(isinstance(atom, Atom) for atom in atoms):
            raise TypeError("All elements must be Atom objects")
        return cls(*atoms)

    @classmethod
    def from_file(cls, filepath: str | Path) -> Molecule:
        """
        Create a new molecule from a file.
        auto-detects file format (*.xyz, *.com, *.gjf, *.inp, *.cif, *POSCAR*)
        """
        from molgeom.parsers.parser_selector import read_file

        return read_file(filepath)

    def add_atom(self, atom: Atom) -> None:
        self.atoms.append(atom)

    def add_atoms_from(self, atoms: Iterable[Atom]) -> None:
        """
        Add atoms to the molecule.
        Accepts Iterable containing one or more Atom objects.
        """
        if not isinstance(atoms, Iterable):
            raise TypeError("atoms must be an Iterable of Atom objects")
        not_atoms = [atom for atom in atoms if not isinstance(atom, Atom)]
        if not_atoms:
            raise TypeError(
                "Invalid element type: atoms must be Atom objects " + f"{not_atoms=}"
            )
        self.atoms.extend(atoms)

    def _get_geom_hash(self):
        return hash(tuple((atom.symbol, atom.x, atom.y, atom.z) for atom in self))

    def get_symbols(self) -> list[str]:
        return tuple(atom.symbol for atom in self)

    def get_formula(self) -> str:
        symbol_count = dict()
        for atom in self:
            symbol_count[atom.symbol] = symbol_count.get(atom.symbol, 0) + 1

        formula = "-".join(
            f"{symbol}{count}" if count > 1 else symbol
            for symbol, count in sorted(
                symbol_count.items(), key=lambda x: ATOMIC_NUMBERS[x[0]]
            )
        )
        return formula

    def get_cart_coords(self) -> list[Vec3]:
        return [[atom.x, atom.y, atom.z] for atom in self]

    def set_cart_coords(self, coords: list[Vec3 | list[float | int]]) -> None:
        if len(coords) != len(self):
            raise ValueError(
                "Number of coordinates must match the number of atoms in the molecule"
            )

        for atom, coord in zip(self, coords):
            atom.x, atom.y, atom.z = coord

    def get_frac_coords(self, wrap=False) -> list[Vec3]:
        if self.lattice_vecs is None:
            raise ValueError("Lattice vectors must be set to bound the molecule.")

        return [
            cart2frac(atom.to_Vec3(), self.lattice_vecs, wrap=wrap) for atom in self
        ]

    @cachedmethod(
        lambda self: self._cache,
        key=lambda self, tol=0.15: hashkey(self._get_geom_hash(), tol),
    )
    def get_bonds(
        self,
        tol: float = default_tol,
    ) -> list[dict[tuple[int, int], float]]:

        bonds = list()
        num_atoms = len(self)
        for i in range(num_atoms):
            ai = self[i]
            for j in range(i + 1, num_atoms):
                aj = self[j]
                length = ai.get_bond_length(aj, tol)
                if length is not None:
                    bonds.append({"pair": (i, j), "length": length})

        return bonds

    def get_clusters(self, tol: float = default_tol) -> list[Molecule]:
        G = nx.Graph()
        G.add_nodes_from(range(len(self)))
        G.add_edges_from(bond["pair"] for bond in self.get_bonds(tol))
        copied_mol = self.copy()
        return [copied_mol[list(cluster)] for cluster in nx.connected_components(G)]

    def get_cycles(
        self, length_bound: int | None = None, tol: float = default_tol
    ) -> list[Molecule]:
        G = nx.Graph()
        G.add_edges_from(bond["pair"] for bond in self.get_bonds(tol))
        cycles = nx.simple_cycles(G, length_bound=length_bound)
        return [self[list(cycle)] for cycle in cycles]

    def get_connected_cluster(
        self, atom_idx: int, tol: float = default_tol
    ) -> Molecule:
        G = nx.Graph()
        G.add_edges_from(bond["pair"] for bond in self.get_bonds(tol))
        return self[list(nx.node_connected_component(G, atom_idx))]

    @classmethod
    @args_to_list
    def merged(cls, mols: Molecule | Iterable[Molecule]) -> Molecule:
        """
        Create a new molecule by merging multiple Molecule objects.
        lattice vectors, bonds, and cycles are initialized to None
        """
        if not all(isinstance(mol, Molecule) for mol in mols):
            raise TypeError("All elements must be Molecule objects")
        merged = cls()
        for mol in mols:
            merged.add_atoms_from(mol)

        return merged

    @args_to_list
    def merge(self, mols: Molecule | Iterable[Molecule]) -> Molecule:
        """
        Merge other Molecule objects into this Molecule object.
        resets the lattice vectors, bonds, and cycles
        """
        if not all(isinstance(mol, Molecule) for mol in mols):
            raise TypeError(
                "All elements must be Molecule objects\n"
                + f"{[(i, type(mol)) for i, mol in enumerate(mols) if not isinstance(mol, Molecule)]}"
            )

        for mol in mols:
            self.add_atoms_from(mol)

        self._lattice_vecs = None

    def translate(
        self, trans_vec: Vec3 | list[float | int], with_lattice_vecs: bool = False
    ) -> None:
        if not isinstance(trans_vec, (Vec3, list)):
            raise TypeError("trans_vec must be Vec3 object or list of 3 floats or ints")
        if isinstance(trans_vec, list):
            if len(trans_vec) != 3:
                raise ValueError("trans_vec must be of length 3")
            if not all(isinstance(i, (int, float)) for i in trans_vec):
                raise TypeError("elements of trans_vec must be int or float")
            trans_vec = Vec3(*trans_vec)

        for atom in self:
            atom.translate(trans_vec)

        if with_lattice_vecs and self.lattice_vecs is not None:
            for vec in self.lattice_vecs:
                vec.translate(trans_vec)

    def mirror(self, sx: int, sy: int, sz: int) -> None:
        for atom in self.atoms:
            atom.mirror(sx, sy, sz)

    def mirror_by_plane(self, p1: Vec3, p2: Vec3, p3: Vec3) -> None:
        for atom in self.atoms:
            atom.mirror_by_plane(p1, p2, p3)

    def matmul(self, mat: mat_type, with_lattice_vecs: bool = True) -> None:
        if not is_mat_type(mat):
            raise TypeError("mat must be a 3x3 matrix")

        for atom in self.atoms:
            atom.matmul(mat)

        if with_lattice_vecs and self.lattice_vecs is not None:
            self.lattice_vecs = self.lattice_vecs @ mat

    def rotate_by_axis(
        self,
        axis_point1: vec_type,
        axis_point2: vec_type,
        deg: float,
        with_lattice_vecs: bool = True,
    ) -> None:
        """
        :param axis_point1: One point on the rotation axis
        :param axis_point2: Another point on the rotation axis
        :param angle_degrees: Rotation angle (degrees)
        """

        for atom in self.atoms:
            atom.rotate_by_axis(axis_point1, axis_point2, deg)

        if with_lattice_vecs and self.lattice_vecs is not None:
            for vec in self.lattice_vecs:
                vec.rotate_by_axis(axis_point1, axis_point2, deg)

    def replicate(self, rep_a: list[int], rep_b: list[int], rep_c: list[int]) -> None:
        """
        Replicate the molecule in the a, b, and c directions.
        """
        if self.lattice_vecs is None:
            raise ValueError("Lattice vectors must be set to replicate the molecule.")

        if not all(isinstance(rep, list) for rep in (rep_a, rep_b, rep_c)):
            raise TypeError(
                "Replication vectors must be of length 2 list\n"
                + f"{(rep_a, rep_b, rep_c)=}"
            )

        if len(rep_a) != 2 or len(rep_b) != 2 or len(rep_c) != 2:
            raise ValueError(
                "Replication vectors must be of length 2 list\n"
                + f"{(rep_a, rep_b, rep_c)=}"
            )

        if rep_a[0] >= rep_a[1] or rep_b[0] >= rep_b[1] or rep_c[0] >= rep_c[1]:
            raise ValueError(
                "Replication vectors must be of the form [start, end], start < end\n"
                + f"{(rep_a, rep_b, rep_c)=}"
            )

        tmp_mol = self.copy()
        self.atoms = FancyIndexingList()
        for i in range(rep_a[0], rep_a[1]):
            for j in range(rep_b[0], rep_b[1]):
                for k in range(rep_c[0], rep_c[1]):

                    trans_vec = (
                        i * tmp_mol.lattice_vecs[0]
                        + j * tmp_mol.lattice_vecs[1]
                        + k * tmp_mol.lattice_vecs[2]
                    )
                    mol_copied = tmp_mol.copy()
                    mol_copied.translate(trans_vec)
                    self.merge(mol_copied)

        self.lattice_vecs = tmp_mol.lattice_vecs

    def is_inside_cell(self, atom: Atom) -> bool:
        """
        Check if the atom is inside the parallelepiped defined by the lattice vectors.

        let v = u1*a1 + u2*a2 + u3*a3, where a1, a2, a3 are lattice vectors
        mat = [a2×a3, a3×a1, a1×a2]
        to check if v is inside the cell, we need to check if 0 <= u1, u2, u3 < 1
        U = [u1, u2, u3] = 1/V * mat * v
        """
        if not isinstance(atom, Atom):
            raise TypeError(f"Expected Atom, got {type(atom).__name__}")

        U = atom.get_frac_coords(self.lattice_vecs)
        return all(0 <= u < 1 for u in U)

    def remove_duplicates(self, tol: float = default_tol) -> None:
        """
        Remove duplicate atoms by combining close atoms with the same symbol
        into their centroid.
        Args:
            tol (float): Tolerance for distance to consider atoms as duplicates.
        """
        unique_atoms = FancyIndexingList()

        symbols = set(a.symbol for a in self.atoms)
        for symbol in symbols:
            indices = [i for i, atom in enumerate(self) if atom.symbol == symbol]
            sub_atoms = self[indices]

            processed = set()
            for i, atom in enumerate(sub_atoms):
                if i in processed:
                    continue

                cluster = [i]
                for j, other_atom in enumerate(sub_atoms):
                    if j != i and atom.distance_to(other_atom) < tol:
                        cluster.append(j)

                processed.update(cluster)

                cluster_atoms = sub_atoms[cluster]
                centroid = cluster_atoms.center_of_mass()

                unique_atoms.append(Atom(symbol, *centroid))

        self.atoms = unique_atoms

    def wrap_to_cell(self) -> None:
        """
        Wrap the molecule to the cell defined by the lattice vectors.
        """
        if self.lattice_vecs is None:
            raise ValueError("Lattice vectors must be set to bound the molecule.")

        frac_coords = self.get_frac_coords(wrap=True)
        for atom, frac_coord in zip(self, frac_coords):
            cart_coords = frac2cart(frac_coord, self.lattice_vecs)
            atom.x, atom.y, atom.z = cart_coords

    def replicated_from_xyz_str(self, xyz_str: str, wrap: bool = True) -> Molecule:
        """
        Replicate the molecule from an xyz string in cif format.
        args:
            xyz_str (str): xyz string of symmetry operation. e.g. 'x, y, z', '-y, -x+3/4, z+1/2',
            wrap (bool): wrap the molecule to the cell after replication
        """

        if self.lattice_vecs is None:
            raise ValueError("Lattice vectors must be set to replicate the molecule.")

        rot_mat, trans_vec_frac = symmop_from_xyz_str(xyz_str)

        new_mol = self.copy()
        new_mol.matmul(rot_mat, with_lattice_vecs=False)
        new_mol.translate(frac2cart(trans_vec_frac, self.lattice_vecs))

        if wrap:
            new_mol.wrap_to_cell()

        return new_mol

    def total_mass(self) -> float:
        return sum(atom.mass for atom in self)

    def center_of_mass(self) -> Vec3:
        total_mass = self.total_mass()
        com_x = sum(atom.x * atom.mass for atom in self) / total_mass
        com_y = sum(atom.y * atom.mass for atom in self) / total_mass
        com_z = sum(atom.z * atom.mass for atom in self) / total_mass
        return Vec3(com_x, com_y, com_z)

    def nuclear_repulsion(self) -> float:
        """
        Calculate the nuclear repulsion energy of the molecule.
        uses gaussian16 constants
        """
        nuclrep = 0.0
        for i in range(len(self.atoms)):
            for j in range(i + 1, len(self.atoms)):
                dist_angst = self.atoms[i].distance_to(self.atoms[j])
                dist_bohr = dist_angst * ANGST2BOHR_GAU16
                nuclrep += (
                    self.atoms[i].atomic_number
                    * self.atoms[j].atomic_number
                    / dist_bohr
                )
        return nuclrep

    def nuclrep(self) -> float:
        """
        Alias for nuclear_repulsion method
        """
        return self.nuclear_repulsion()

    def electrostatic_energy(self) -> float:
        """
        Calculate the electrostatic energy of the molecule.
        """
        elec_energy = 0.0
        for i in range(len(self.atoms)):
            for j in range(i + 1, len(self.atoms)):
                dist_angst = self.atoms[i].distance_to(self.atoms[j])
                dist_bohr = dist_angst * ANGST2BOHR_GAU16
                if self.atoms[i].charge is None or self.atoms[j].charge is None:
                    raise ValueError("All atoms must have their charge set.")
                elec_energy += self.atoms[i].charge * self.atoms[j].charge / dist_bohr
        return elec_energy

    def to_xyz(self) -> str:
        return "\n".join([atom.to_xyz() for atom in self])

    def to_dict(self) -> dict:
        mol_dict = dict()
        for i, atom in enumerate(self):
            mol_dict[i] = atom.to_dict()
        return mol_dict

    def write_to_xyz(self, filepath: str) -> None:
        filepath = os.path.expanduser(filepath.strip())
        with open(filepath, "w") as f:
            f.write(f"{len(self)}\n")
            f.write(self.get_formula() + "\n")
            f.write(self.to_xyz())
        print(f"File written to {filepath}")

    @staticmethod
    def write_to_xyzs(
        mols: list[Molecule],
        filepath: str | Path,
    ) -> None:
        filepath = Path(str(filepath).strip()).expanduser()
        with open(filepath, "w") as f:
            for mol in mols:
                f.write(f"{len(mol)}\n")
                f.write(mol.get_formula() + "\n")
                f.write(mol.to_xyz() + "\n")
        print(f"File written to {filepath}")

    def write_to_gaussian_input(
        self, filepath: str | Path, head: str | None = None, tail: str | None = None
    ) -> None:
        filepath = Path(str(filepath).strip()).expanduser()
        with open(filepath, "w") as f:
            if head is not None:
                f.write(head)
                f.write("\n")
            else:
                f.write("#p B3LYP\n")
                f.write("\n")
                f.write(f"{self.get_formula()}\n")
                f.write("\n")
                f.write("0 1\n")
            f.write(self.to_xyz() + "\n")
            if self.lattice_vecs is not None:
                for vec in self.lattice_vecs:
                    f.write(
                        f"{'Tv':2s} {vec[0]:19.12f} {vec[1]:19.12f} {vec[2]:19.12f}\n"
                    )
            if tail is not None:
                f.write(tail)
                f.write("\n")
        print(f"File written to {filepath}")

    def write_to_gamess_input(
        self, filepath: str | Path, head: str | None = None, tail: str | None = None
    ) -> None:
        filepath = Path(str(filepath).strip()).expanduser()
        with open(filepath, "w") as f:
            if head is not None:
                f.write(head)
            else:
                f.write("$DATA\n")
                f.write(f"{self.get_formula()}\n")
                f.write("C1\n")
            for i in range(len(self)):
                f.write(
                    f" {self[i].symbol:2s}  {float(self[i].atomic_number):.1f}  {self[i].x:19.12f} {self[i].y:19.12f} {self[i].z:19.12f}\n"
                )
            if tail is not None:
                f.write(tail)
            else:
                f.write("$END\n")
        print(f"File written to {filepath}")

    def write_to_poscar(
        self, filepath: str | Path, frac: bool = True, wrap: bool = False
    ) -> None:
        filepath = Path(str(filepath).strip()).expanduser()
        with open(filepath, "w") as f:
            f.write(f"{self.get_formula()}\n")
            f.write("1.0\n")
            f.write(
                f"{self.lattice_vecs[0][0]:19.12f} {self.lattice_vecs[0][1]:19.12f} {self.lattice_vecs[0][2]:19.12f}\n"
            )
            f.write(
                f"{self.lattice_vecs[1][0]:19.12f} {self.lattice_vecs[1][1]:19.12f} {self.lattice_vecs[1][2]:19.12f}\n"
            )
            f.write(
                f"{self.lattice_vecs[2][0]:19.12f} {self.lattice_vecs[2][1]:19.12f} {self.lattice_vecs[2][2]:19.12f}\n"
            )
            # remove duplicates and keep the order
            unique_symbols = list(dict.fromkeys([atom.symbol for atom in self]))
            f.write(" ".join(unique_symbols) + "\n")
            symbol_count = dict()
            for symbol in unique_symbols:
                symbol_count[symbol] = sum(atom.symbol == symbol for atom in self)
            f.write(
                " ".join(str(symbol_count[symbol]) for symbol in unique_symbols) + "\n"
            )
            if frac:
                f.write("Direct\n")
                coords = self.get_frac_coords(wrap=wrap)
            else:
                f.write("Cartesian\n")
                coords = self.coords
            for coord in coords:
                f.write(f"{coord[0]:19.12f} {coord[1]:19.12f} {coord[2]:19.12f}\n")
        print(f"File written to {filepath}")

    def write_to_cif(self, filepath: str | Path, wrap: bool = False) -> None:
        filepath = Path(str(filepath).strip()).expanduser()
        with open(filepath, "w") as f:
            f.write("data_molecule\n")
            f.write("########################\n")
            f.write("# generated by molgeom #\n")
            f.write("########################\n")
            f.write("\n")
            f.write("loop_\n")
            f.write("_space_group_symop_operation_xyz\n")
            f.write(" 'x, y, z'\n")
            f.write("\n")
            a, b, c, alpha, beta, gamma = lat_vecs_to_lat_params(self.lattice_vecs)
            f.write(f"_cell_length_a {a:.9f}\n")
            f.write(f"_cell_length_b {b:.9f}\n")
            f.write(f"_cell_length_c {c:.9f}\n")
            f.write(f"_cell_angle_alpha {alpha:.9f}\n")
            f.write(f"_cell_angle_beta {beta:.9f}\n")
            f.write(f"_cell_angle_gamma {gamma:.9f}\n")
            f.write("\n")
            f.write("loop_\n")
            f.write("_atom_site_label\n")
            f.write("_atom_site_type_symbol\n")
            f.write("_atom_site_fract_x\n")
            f.write("_atom_site_fract_y\n")
            f.write("_atom_site_fract_z\n")
            frac_coords = self.get_frac_coords(wrap=wrap)
            for i in range(len(self)):
                f.write(
                    f"{i+1:3d} {self[i].symbol:2s} {frac_coords[i][0]:19.12f} {frac_coords[i][1]:19.12f} {frac_coords[i][2]:19.12f}\n"
                )
        print(f"File written to {filepath}")

    def write_to_mol(self, filepath: str | Path) -> None:
        """Write molecule to MOL V2000 format file.

        Creates a MOL file following V2000 format specification.
        Includes atomic coordinates, bonds, and charges.
        """
        bonds = self.get_bonds()

        filepath = Path(str(filepath).strip()).expanduser()
        with open(filepath, "w") as f:
            # Header block
            f.write(f"{self.get_formula()}\n")
            f.write("  Generated by molgeom\n")
            f.write("\n")  # Comment line

            # Counts line with actual bond count
            f.write(f"{len(self):3d}{len(bonds):3d}  0  0  0  0  0  0  0  0999 V2000\n")

            # Atom block
            for atom in self:
                charge = atom.charge if atom.charge is not None else 0
                f.write(
                    f"{atom.x:10.4f}{atom.y:10.4f}{atom.z:10.4f} {atom.symbol:<3s}"
                    f" 0  0  0  0  0  0  0  0  0{charge:3d}  0  0  0\n"
                )

            # Bond block - assuming single bonds for now
            for bond in bonds:
                i, j = bond["pair"]
                # MOL format uses 1-based indexing
                f.write(f"{i+1:3d}{j+1:3d}  1  0  0  0  0\n")

            # End marker
            f.write("M  END\n")

        print(f"File written to {filepath}")

    @staticmethod
    def write_to_sdf(
        mols: list[Molecule],
        filepath: str | Path,
        properties: dict[str, dict[str, str]] | None = None,
    ) -> None:
        """Write multiple Molecule objects to SDF format file.

        Args:
            mols: list of Molecule objects to write
            filepath: Output file path
            properties: Optional dictionary of molecular properties
                        Format: {mol_index: {property_name: property_value}}
        """

        filepath = Path(str(filepath).strip()).expanduser()
        with open(filepath, "w") as f:
            for i, mol in enumerate(mols):
                bonds = mol.get_bonds()

                # Write MOL format content
                f.write(f"{mol.get_formula()}\n")
                f.write("  Generated by molgeom\n")
                f.write("\n")
                f.write(
                    f"{len(mol):3d}{len(bonds):3d}  0  0  0  0  0  0  0  0999 V2000\n"
                )

                # Atom block
                for atom in mol:
                    charge = atom.charge if atom.charge is not None else 0
                    f.write(
                        f"{atom.x:10.4f}{atom.y:10.4f}{atom.z:10.4f} {atom.symbol:<3s}"
                        f" 0  0  0  0  0  0  0  0  0{charge:3d}  0  0  0\n"
                    )

                # Bond block
                for bond in bonds:
                    i, j = bond["pair"]
                    digits = len(str(len(mol)))
                    f.write(f"{i+1:>{digits+2}d}{j+1:>{digits+2}d}  1  0  0  0  0\n")

                f.write("M  END\n")

                # Write properties if provided
                if properties and i in properties:
                    for name, value in properties[i].items():
                        f.write(f"> <{name}>\n")
                        f.write(f"{value}\n")
                        f.write("\n")

                # Add molecule separator
                f.write("$$$$\n")

        print(f"File written to {filepath}")

    def view_mol(self, cleanup: bool = True, prefer_notebook: bool = True) -> None:
        """
        View molecular geometry using 3Dmol.js in a browser.
        args:
            cleanup: bool
                If True, removes temporary files after viewing (default: True)
            prefer_notebook: bool
                If True, opens in Jupyter notebook if available (default: True)
        """
        xyz_data = f"{len(self)}\n{self.name or str(self)}\n{self.to_xyz()}"
        view_mol(
            xyz_mol_data=xyz_data, cleanup=cleanup, prefer_notebook=prefer_notebook
        )

    @staticmethod
    def view_mols(
        mols: list[Molecule], cleanup: bool = True, prefer_notebook: bool = True
    ) -> None:
        """
        View multiple molecular geometries using 3Dmol.js in a browser.
        args:
            mols: list[Molecule]
                List of Molecule objects to view
            cleanup: bool
                If True, removes temporary files after viewing (default: True)
            prefer_notebook: bool
                If True, opens in Jupyter notebook if available (default: True)
        """
        xyz_data = "\n".join(
            [f"{len(mol)}\n{mol.name or str(mol)}\n{mol.to_xyz()}" for mol in mols]
        )
        view_mol(
            xyz_mol_data=xyz_data, cleanup=cleanup, prefer_notebook=prefer_notebook
        )
