# -*- coding: utf-8 -*-

from fractions import Fraction
import logging
import pprint
import re

import numpy as np
import spglib

logger = logging.getLogger(__name__)
logger.setLevel("INFO")


class _Symmetry(object):
    """A class to handle point and space group symmetry."""

    spgno_to_hall = None
    spgname_to_hall = None
    spgname_to_system = None
    hall_to_spgname = None
    hall_to_hall_symbol = None
    hall_to_IT_symbol = None

    def __init__(self, configuration):
        """Initialize from the database.

        Parameters
        ----------
        system_db : SystemDB
            The SystemDB instance that we are working with.
        _id : int
            The id of this particular symmetry.
        """
        self._configuration = configuration
        self._system = self._configuration.system
        self._system_db = self._system.system_db
        self._id = configuration.symmetry_id
        self._operators = None  # 4x4 symmetry operation matrices
        self._symop_products = None  # Square matrix of symops that are products of 2
        self._atom_generators = None  # Sym operations that create the symmetric atoms
        self._symop_to_atom = None  # The symmetry atom resulting from symmetry ops
        self._atom_to_asymmetric_atom = None  # The asymmetric atom for each atom
        self._bond_to_asymmetric_bond = None  # The asymmetric bond for each bond
        self._bond_atoms = None  # The pair of symmetric atoms in each bond
        self._bond_offsets = None  # The triplet of cell offsets for each bond

        super().__init__()

    @property
    def configuration(self):
        """Return the configuration."""
        return self._configuration

    @property
    def cursor(self):
        return self.system_db.cursor

    @property
    def db(self):
        return self.system_db.db

    @property
    def atom_generators(self):
        """The symmetry operations that create the symmetric atoms."""
        if self._atom_generators is None:
            self._expand()
        return self._atom_generators

    @property
    def atom_to_asymmetric_atom(self):
        """The asymmetric atom related to each atom."""
        if self._atom_to_asymmetric_atom is None:
            self._expand()
        return self._atom_to_asymmetric_atom

    @property
    def bonds_for_asymmetric_bonds(self):
        """List of bonds for each asymmetric bond."""
        if self.n_symops == 1:
            result = [[i] for i in range(self.configuration.bonds.n_asymmetric_bonds)]
        else:
            result = [[] for i in range(self.configuration.bonds.n_asymmetric_bonds)]
            to_asym = self.bond_to_asymmetric_bond
            for i, asym_bond in enumerate(to_asym):
                result[asym_bond].append(i)
        return result

    @property
    def bond_to_asymmetric_bond(self):
        """The asymmetric bond corresponding to each bond."""
        if self._bond_to_asymmetric_bond is None:
            self._expand_bonds()
        return self._bond_to_asymmetric_bond

    @property
    def bond_atoms(self):
        """The pair of symmetric atoms in each bond."""
        if self._bond_atoms is None:
            self._expand_bonds()
        return self._bond_atoms

    @property
    def bond_offsets(self):
        """The triplet of cell offsets of second atom in each bond."""
        if self._bond_offsets is None:
            self._expand_bonds()
        return self._bond_offsets

    @property
    def group(self):
        """The point or space group of the system"""
        self.cursor.execute('SELECT "group" FROM symmetry WHERE id = ?', (self.id,))
        return self.cursor.fetchone()[0]

    @group.setter
    def group(self, value):
        if value == "":
            self.Ws = None
            symops = None
        else:
            if self.configuration.periodicity == 0:
                if value != "C1":
                    raise NotImplementedError("Point groups not implemented yet!")
                symops = ["x,y,z"]
                W4 = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]

                W4s = []
                W4s.append(W4)
                self._operators = np.array(W4s, dtype=float)
            else:
                # Get the 4x4 augmented matrices
                hall = self.to_hall(value)
                data = spglib.get_symmetry_from_database(hall)
                W4s = []
                for W, w in zip(
                    data["rotations"].tolist(), data["translations"].tolist()
                ):
                    W4 = []
                    for Wrow, w_i in zip(W, w):
                        W4.append([*Wrow, w_i])
                    W4.append([0, 0, 0, 1])
                    W4s.append(W4)
                self._operators = np.array(W4s, dtype=float)

                # The shorthand strings for the symmetry operations
                symops = []
                for W4 in W4s:
                    symop = []
                    for row in W4[0:3]:
                        line = ""
                        for r, xyz in zip(row[0:3], ("x", "y", "z")):
                            if r == 0:
                                pass
                            elif r == 1:
                                if line != "":
                                    line += "+"
                                line += xyz
                            elif r == -1:
                                line += "-" + xyz
                            else:
                                raise RuntimeError(f"bad rotation: '{row}'")
                        if row[3] == 0:
                            pass
                        else:
                            f = Fraction(row[3]).limit_denominator(10)
                            line += f"{f.numerator:+d}/{f.denominator}"
                        symop.append(line)
                    symops.append(",".join(symop))

        self.symops = symops
        self.db.execute(
            'UPDATE symmetry SET "group" = ? WHERE id = ?', (value, self.id)
        )
        self.db.commit()

    @property
    def hall_number(self):
        if self.configuration.periodicity == 0:
            return ""
        else:
            return self.to_hall(self.group)

    @property
    def hall_symbol(self):
        if self.configuration.periodicity == 0:
            return ""
        else:
            if _Symmetry.hall_to_hall_symbol is None:
                self.spacegroup_names_to_hall
            return _Symmetry.hall_to_hall_symbol[self.to_hall(self.group)]

    @property
    def id(self):
        """The id of this cell."""
        return self._id

    @property
    def inverse_operations(self):
        """The list of inverse symmetry operations for each symop."""
        return [x.index(0) for x in self.symop_products]

    @property
    def IT_number(self):
        if self.configuration.periodicity == 0:
            return ""
        else:
            if _Symmetry.hall_to_hall_symbol is None:
                self.spacegroup_names_to_hall
            return _Symmetry.hall_to_IT_number[self.to_hall(self.group)]

    @property
    def loglevel(self):
        """The logging level for this module."""
        result = logger.getEffectiveLevel()
        tmp = logging.getLevelName(result)
        if "Level" not in tmp:
            result = tmp
        return result

    @loglevel.setter
    def loglevel(self, value):
        logger.setLevel(value)

    @property
    def n_symops(self):
        """The number of symmetry operations."""
        return len(self.symops)

    @property
    def symmetry_matrices(self):
        """The symmetry operations as Numpy matrices (4 x 4 x n_ops)"""
        if self._operators is None:
            # Create the 4x4 matrices
            regexp = re.compile(
                r"([-+]?[0-9]/[0-9])?([-+]?[xyz][-+]?[xyz]?)([-+]?[0-9]/[0-9])?"
            )
            if len(self.symops) == 0:
                raise RuntimeError("The group is not defined!")
            W4s = []
            for line in self.symops:
                W4 = [
                    [0.0, 0.0, 0.0, 0.0],
                    [0.0, 0.0, 0.0, 0.0],
                    [0.0, 0.0, 0.0, 0.0],
                    [0.0, 0.0, 0.0, 1.0],
                ]
                for row, op in enumerate(line.split(",")):
                    match = regexp.fullmatch(op)
                    if match is None:
                        raise ValueError(
                            f"{op} is not a valid symmetry operation ({line})"
                        )
                    m1, m2, m3 = match.groups()
                    for column, xyz in enumerate(("x", "y", "z")):
                        if xyz in m2:
                            if "-" + xyz in m2:
                                W4[row][column] = -1
                            else:
                                W4[row][column] = 1
                    if m1 is None:
                        if m3 is None:
                            pass
                        else:
                            W4[row][3] = float(Fraction(m3))
                    else:
                        W4[row][3] = float(Fraction(m1))
                W4s.append(W4)
            self._operators = np.array(W4s, dtype=float)

        return self._operators

    @property
    def symop_products(self):
        """Matrix of symop ids that are the products of two other symops"""
        if self._symop_products is None:
            self._create_symop_products()
        return self._symop_products

    @property
    def symops(self):
        """The list of shorthand symmetry operations."""
        self.cursor.execute("SELECT symops FROM symmetry WHERE id = ?", (self.id,))
        text = self.cursor.fetchone()[0]

        result = []
        if text != "":
            result = text.split(" | ")
        return result

    @symops.setter
    def symops(self, ops):
        if ops is None:
            text = ""
        else:
            text = " | ".join(ops)

        self.db.execute("UPDATE symmetry SET symops = ? WHERE id = ?", (text, self.id))
        # Unset the group
        self.db.execute('UPDATE symmetry SET "group" = "" WHERE id = ?', (self.id,))
        self.db.commit()

        self._operators = None
        self._symop_products = None
        self._atom_generators = None
        self._atom_to_asymmetric_atom = None
        self._symop_to_atom = None
        self._bond_to_asymmetric_bond = None
        self._bond_atoms = None
        self._bond_offsets = None

    @property
    def symop_to_atom(self):
        """The list of sym ops for each asymmetric atom to create symmetric atoms."""
        if self._symop_to_atom is None:
            self._expand()
        return self._symop_to_atom

    @property
    def system(self):
        """Return the System object that contains this cell."""
        return self._system

    @property
    def system_db(self):
        """Return the SystemDB object that contains this cell."""
        return self._system_db

    @property
    def spacegroup_numbers_to_hall(self):
        """List of the Hall spacegroup names for the IT number."""
        if _Symmetry.spgno_to_hall is None:
            # Initialize the symmetry data
            _Symmetry.spgno_to_hall = {}
            if _Symmetry.spgname_to_system is None:
                _Symmetry.spgname_to_system = {}
            for hall in range(1, 530):
                data = spglib.get_spacegroup_type(hall)
                spgno = int(data["number"])
                if spgno not in _Symmetry.spgno_to_hall:
                    _Symmetry.spgno_to_hall[spgno] = hall
                    _Symmetry.spgname_to_system[spgno] = "Int_Tables_number"
                    _Symmetry.spgname_to_system[str(spgno)] = "Int_Tables_number"

        return _Symmetry.spgno_to_hall

    @property
    def spacegroup_names_to_hall(self):
        """Dictionary of Hall number for spacegroup names."""
        if _Symmetry.spgname_to_hall is None:
            system_name = {
                "international_full": "name_H-M_full",
                "international": "name_H-M_alt",
                "international_short": "name_H-M_short",
                "hall_symbol": "name_Hall",
            }
            # Initialize the symmetry data
            _Symmetry.spgname_to_hall = {}
            _Symmetry.hall_to_spgname = {}
            _Symmetry.hall_to_hall_symbol = {}
            _Symmetry.hall_to_IT_number = {}
            if _Symmetry.spgname_to_system is None:
                _Symmetry.spgname_to_system = {}
            for hall in range(1, 530):
                data = spglib.get_spacegroup_type(hall)
                choice = data["choice"]
                _Symmetry.hall_to_hall_symbol[hall] = data["hall_symbol"]
                _Symmetry.hall_to_IT_number[hall] = data["number"]
                for key in (
                    "international_full",
                    "international",
                    "international_short",
                    "hall_symbol",
                ):
                    name = data[key]
                    if "international" in key and choice in ("2",):
                        if (
                            name in _Symmetry.spgname_to_hall
                            and hall != _Symmetry.spgname_to_hall[name]
                        ):
                            raise RuntimeError(
                                f"{hall=} {key} --> {name} exists: "
                                f"{_Symmetry.spgname_to_hall[name]}"
                            )
                        if key == "international":
                            _Symmetry.hall_to_spgname[hall] = name
                        _Symmetry.spgname_to_hall[name] = hall
                        _Symmetry.spgname_to_system[name] = system_name[key]
                        for txt in ("_", " "):
                            tmp = name.replace(txt, "")
                            _Symmetry.spgname_to_hall[tmp] = hall
                            _Symmetry.spgname_to_system[tmp] = system_name[key]
                    if choice != "":
                        name += f" :{choice}"
                    if (
                        name in _Symmetry.spgname_to_hall
                        and hall != _Symmetry.spgname_to_hall[name]
                    ):
                        raise RuntimeError(
                            f"{hall=} {key} --> {name} exists: "
                            f"{_Symmetry.spgname_to_hall[name]}"
                        )
                    if key == "international" and hall not in _Symmetry.spgname_to_hall:
                        _Symmetry.hall_to_spgname[hall] = name
                    _Symmetry.spgname_to_hall[name] = hall
                    _Symmetry.spgname_to_system[name] = system_name[key]
                    for txt in ("_", " "):
                        tmp = name.replace(txt, "")
                        _Symmetry.spgname_to_hall[tmp] = hall
                        _Symmetry.spgname_to_system[tmp] = system_name[key]
        return _Symmetry.spgname_to_hall

    @property
    def spacegroup_names_to_system(self):
        """Dictionary of system (name_Hall, name_H-M_full,...) for spacegroup names."""
        if _Symmetry.spgname_to_hall is None:
            self.spacegroup_names_to_hall
        if _Symmetry.spgno_to_hall is None:
            self.spacegroup_numbers_to_hall

        return _Symmetry.spgname_to_system

    def find_spacegroup_from_operators(self):
        """Find the spacegroup from the symmetry operators."""
        if self.configuration.periodicity > 0:
            rotations = self.symmetry_matrices[:, 0:3, 0:3]
            translations = self.symmetry_matrices[:, 0:3, 3]
            data = spglib.get_spacegroup_type_from_symmetry(
                rotations.tolist(),
                translations.tolist(),
                self.configuration.cell.vectors(),
            )
            hall_number = data["hall_number"]
            international_number = data["number"]
            while True:
                data = self.configuration.get_symmetry_data(hall_number)
                if data is None:
                    raise RuntimeError("Error finding spacegroup from operators.")
                if data["number"] != international_number:
                    raise RuntimeError(
                        "Error finding setting for spacegroup number "
                        f"{international_number}."
                    )
                if all(abs(data["origin_shift"]) < 0.001):
                    break
                hall_number += 1
            return self.hall_to_spacegroup_name(hall_number)

    def hall_to_spacegroup_name(self, hall):
        """Return the International name including setting for a hall number."""
        if _Symmetry.hall_to_spgname is None:
            _Symmetry.spgname_to_hall = None
            self.spacegroup_names_to_hall
        return _Symmetry.hall_to_spgname[hall]

    def reset_atoms(self):
        """The atoms have changed, so need to recalculate the symmetric atoms."""
        self._operators = None
        self._atom_generators = None
        self._atom_to_asymmetric_atom = None
        self._symop_to_atom = None

    def reset_bonds(self):
        """The bonds have changed, so need to recalculate the symmetric bonds."""
        self._bond_to_asymmetric_bond = None
        self._bond_atoms = None
        self._bond_offsets = None

    def symmetrize_atomic_scalar(self, v_sym):
        """Return the symmetrized scalars and deltas.

        Parameters
        ----------
        v_sym : [n_atoms * float] or numpy.ndarray
            The full set of scalar values for the symmetric atoms.

        Returns
        -------
        ([n_asymmetric_atoms * float] or numpy.ndarray, ditto)
            The asymmetric (symmetry unique) values in the same form as given,
            and the delta of the values from the average (or None for C1/P1)
        """
        if self.configuration.periodicity == 0:
            if self.n_symops == 1:
                return v_sym, None
            else:
                raise NotImplementedError("symmetrize_atomic_scalar for molecules!")

        if self.n_symops == 1:
            return v_sym, None

        v_in = np.array(v_sym)
        print(f"{v_in=}")
        generators = self.atom_generators
        v = np.ndarray((len(generators),), dtype=float)
        delta = np.zeros_like(v_in)
        start = 0
        for asym_atom, ops in enumerate(generators):
            print(f"{asym_atom=} {ops}")
            n = len(ops)
            print(f"{n=}")
            v[asym_atom] = np.average(v_in[start : start + n], axis=0)
            delta[start : start + n] = v_in[start : start + n] - v[asym_atom]
            start += n

        if isinstance(v_sym, np.ndarray):
            return v, delta
        else:
            return v.tolist(), delta.tolist()

    def symmetrize_bond_scalar(self, v_sym):
        """Return the symmetrized scalar value and deltas for a bond property

        Parameters
        ----------
        v_sym : [n_bonds * float] or numpy.ndarray
            The full set of scalar values for the symmetric bonds.

        Returns
        -------
        ([n_asymmetric_bonds * float] or numpy.ndarray, ditto)
            The asymmetric (symmetry unique) values in the same form as given,
            and the delta of the values from the average (or None for C1/P1)
        """
        if self.configuration.periodicity == 0:
            if self.n_symops == 1:
                return v_sym, None
            else:
                raise NotImplementedError("symmetrize_bond_scalar for molecules!")

        if self.n_symops == 1:
            return v_sym, None

        v_in = np.array(v_sym)
        generators = self.bonds_for_asymmetric_bonds
        v = np.ndarray((len(generators),), dtype=float)
        delta = np.zeros_like(v_in)
        start = 0
        for asym_bond, ops in enumerate(generators):
            n = len(ops)
            v[asym_bond] = np.average(v_in[start : start + n], axis=0)
            delta[start : start + n] = v_in[start : start + n] - v[asym_bond]
            start += n

        if isinstance(v_sym, np.ndarray):
            return v, delta
        else:
            return v.tolist(), delta.tolist()

    def symmetrize_coordinates(self, xyz_sym, fractionals=True):
        """Return the symmetrized asymmetric coordinates and rms error.

        Parameters
        ----------
        xyz_sym : [n_atoms * [float]] or numpy.ndarray
            The full set of coordinates for the symmetric atoms.
        fractionals : bool = True
            Whether fractional (True) or Cartesian (False) coordinates are given.
            Only important for periodic systems, since molecules use Cartesians.

        Returns
        -------
        ([n_asymmetric_atoms * [float]] or numpy.ndarray, ditto)
            The asymmetric (symmetry unique) coordinates in the same form as given,
            and the delta of the atom positions from the average (or None for C1/P1)
        """
        if self.configuration.periodicity == 0:
            if self.n_symops == 1:
                return xyz_sym, None
            else:
                raise NotImplementedError("symmetrize_coordinates for molecules!")

        if self.n_symops == 1:
            return xyz_sym, None

        # We need fractional coordinates
        if fractionals:
            uvw_sym = np.array(xyz_sym)
        else:
            uvw_sym = self.configuration.cell.to_fractionals(xyz_sym, as_array=True)

        # Translate into cell
        uvw_sym -= np.floor(uvw_sym)

        generators = self.atom_generators
        inverse_ops = self.inverse_operations
        uvw = np.ndarray((len(generators), 3), dtype=float)
        delta = np.zeros_like(uvw_sym)
        atom = 0
        for asym_atom, ops in enumerate(generators):
            tmp = np.ndarray((len(ops), 3), dtype=float)
            start = atom
            for i, op in enumerate(ops):
                tmp[i] = self.vector_x_symop(uvw_sym[atom], inverse_ops[op])
                atom += 1
            tmp -= np.floor(tmp)
            uvw[asym_atom] = np.average(tmp, axis=0)
            delta[start:atom] = tmp - uvw[asym_atom]

        if isinstance(xyz_sym, np.ndarray):
            if fractionals:
                return uvw.round(8), delta.round(8)
            else:
                tmp = self.configuration.cell.to_cartesians(uvw, as_array=True)
                return tmp.round(8), delta.round(8)
        else:
            if fractionals:
                return uvw.round(8).tolist(), delta.round(8).tolist()
            else:
                tmp = self.configuration.cell.to_cartesians(uvw, as_array=True)
                return tmp.round(8).tolist(), delta.round(8).tolist()

    def to_hall(self, name):
        """Hall number given full spacegroup name or number."""
        if isinstance(name, int):
            return self.spacegroup_numbers_to_hall[name]
        return self.spacegroup_names_to_hall[name]

    def update_group(self, value):
        if self.configuration.periodicity == 0:
            if value != "C1":
                raise NotImplementedError("Point groups not implemented yet!")
        self.db.execute(
            'UPDATE symmetry SET "group" = ? WHERE id = ?', (value, self.id)
        )
        self.db.commit()

    def vector_x_symop(self, vector, symop, translation=True):
        """Multiply a vector by a symmetry matrix."""
        sym_mat = self.symmetry_matrices[symop]
        if translation:
            v = np.array([0.0, 0.0, 0.0, 1.0])
        else:
            v = np.array([0.0, 0.0, 0.0, 0.0])
        v[0:3] = vector
        xformed = np.einsum("ij,j", sym_mat, v)
        return xformed[0:3]

    def _expand(self):
        """Setup the information for going from asymmetric cell to full cell."""
        operators = self.symmetry_matrices

        n_atoms = self.configuration.n_asymmetric_atoms
        if n_atoms == 0:
            self._atom_generators = None
            self._symop_to_atom = None
        self._atom_generators = []  # Symmetry ops that create the symmetric atoms
        self._symop_to_atom = []  # The symmetry atom resulting from symmetry ops
        if self.configuration.periodicity == 3:
            if len(operators) == 1:
                # P1 is a special case. Nothing to do!
                self._atom_generators = [[0] for i in range(n_atoms)]
                self._symop_to_atom = [[i] for i in range(n_atoms)]
                self._atom_to_asymmetric_atom = [*range(n_atoms)]
            else:
                uvw0 = self.configuration.atoms.get_coordinates(
                    as_array=True, asymmetric=True
                )
                if uvw0.shape[0] != n_atoms:
                    raise RuntimeError(
                        f"Mismatch of number of atoms in symmetry: {uvw0.shape[0]} != "
                        f"{n_atoms}"
                    )
                logger.info("Original coordinates")
                logger.info(uvw0)
                logger.info(f"{uvw0.shape=}")

                logger.debug(f"{n_atoms=}")
                uvw = np.ndarray((n_atoms, 4))
                logger.debug(f"{uvw.shape=}")
                uvw[:, 0:3] = uvw0[:, :]
                logger.debug("Expanded coordinates")
                logger.debug(uvw)
                uvw[:, 3] = 1
                # logger.debug(self.symops)
                logger.debug(f"{operators.shape=}")
                # logger.debug(operators)
                logger.debug(f"{uvw.shape=}")
                logger.debug("Expanded coordinates")
                logger.debug(uvw)
                xformed = np.einsum("ijk,lk", operators, uvw)
                logger.debug(f"{xformed.shape=}")
                # logger.debug(xformed)

                # For comparison, bring all atoms into the cell [0..1)
                tmp = xformed - np.floor(xformed)

                logger.debug("tmp")
                logger.debug(tmp)

                n_sym_atoms = 0
                print(f"{n_atoms=}")
                for i in range(n_atoms):
                    values, I1, I2 = np.unique(
                        np.round(tmp[:, :3, i], 4),
                        axis=0,
                        return_index=True,
                        return_inverse=True,
                    )
                    logger.info(f"{i=}")
                    pprint.pprint(np.round(tmp[:, :3, i], 4).tolist())
                    logger.info("values")
                    pprint.pprint(values.tolist())
                    logger.debug(values)
                    logger.info("I1")
                    logger.info(I1)
                    logger.info("I2")
                    logger.info(I2)
                    I2 += n_sym_atoms
                    self._atom_generators.append(I1.tolist())
                    self._symop_to_atom.append(I2.tolist())
                    n_sym_atoms += I1.shape[0]
                tmp = []
                for i, generators in enumerate(self._atom_generators):
                    tmp.extend([i] * len(generators))
                self._atom_to_asymmetric_atom = tmp
        else:
            if len(operators) == 1:
                # P1 is a special case. Nothing to do!
                self._atom_generators = [[0] for i in range(n_atoms)]
                self._symop_to_atom = [[i] for i in range(n_atoms)]
                self._atom_to_asymmetric_atom = [*range(n_atoms)]

    def _expand_bonds(self):
        """Expand the list of asymmetric bonds to the full list.

        Keep track of the following:

            1. bond_to_asymmetric_bond: the asymmetric bond index for each bond
            2. bond_atoms: The pairs of symmetric atoms that form the bond
            3. bond_offsets: Triplets of offsets for bonds
        """
        logger.debug("In expand_bonds")

        if self._bond_to_asymmetric_bond is not None:
            return

        self._bond_to_asymmetric_bond = []
        self._bond_atoms = []
        self._bond_offsets = []

        bonds = self.configuration.bonds
        if bonds.n_asymmetric_bonds == 0:
            return

        indx = {j: i for i, j in enumerate(self.configuration.atoms.ids)}

        Is = [indx[i] for i in bonds.get_column_data("i")]
        Js = [indx[j] for j in bonds.get_column_data("j")]
        symop1s = bonds.get_column_data("symop1")
        symop2s = bonds.get_column_data("symop2")

        symop_to_atom = self.symop_to_atom
        product = self.symop_products
        n_symops = self.n_symops

        # Need to coordinates to sort out offsets introduced by symmetry
        uvw = self.configuration.atoms.get_coordinates(as_array=True)
        uvw_sym = self.configuration.atoms.get_coordinates(
            as_array=True, asymmetric=True
        )

        logger.debug(f"Asymmetric Coordinates:\n{str(uvw_sym)}\n\n")
        logger.debug(f"Coordinates:\n{str(uvw)}\n\n")

        asym_bonds = {}
        found = []
        # First work out offsets and look for bonds sharing atoms but different offsets
        for i, j, symop1, symop2 in zip(Is, Js, symop1s, symop2s):
            if symop1 == ".":
                op1 = 0
                offset1 = [0, 0, 0]
            else:
                if "_" in symop1:
                    op1, tmp = symop1.split("_")
                    op1 = int(op1) - 1
                    offset1 = [int(q) - 5 for q in tmp]
                else:
                    op1 = int(symop1) - 1
                    offset1 = [0, 0, 0]
            if symop2 == ".":
                op2 = 0
                offset2 = [0, 0, 0]
            else:
                if "_" in symop2:
                    op2, tmp = symop2.split("_")
                    op2 = int(op2) - 1
                    offset2 = [int(q) - 5 for q in tmp]
                else:
                    op2 = int(symop2) - 1
                    offset2 = [0, 0, 0]

            # Check if we've seen this pair, but with different offset.
            if i < j:
                key = (i, op1, j, op2)
                offkey = (offset1, offset2)
            else:
                key = (j, op2, i, op1)
                offkey = (offset2, offset1)
            logger.debug(f"{key=} {offkey}")
            if key in asym_bonds:
                if offkey in asym_bonds[key]:
                    raise RuntimeError(
                        f"Duplicate bonds specified for {i} ({symop1}) --  "
                        f"{j} ({symop2})"
                    )
                else:
                    asym_bonds[key].append(offkey)
            else:
                asym_bonds[key] = [offkey]

        bond_no = -1
        for ij, offsets in asym_bonds.items():
            i, op1, j, op2 = ij
            use_offsets = len(offsets) > 1 or (i == j and op1 == op2)
            for tmp in offsets:
                bond_no += 1
                offset1, offset2 = tmp
                if use_offsets:
                    logger.debug(
                        f"bond {bond_no}: {i}({op1}) - {j}({op2}) {offset1} {offset2}"
                    )
                else:
                    logger.debug(
                        f"bond {bond_no}: {i}({op1}) - {j}({op2}) ignore offsets"
                    )

                for op in range(n_symops):
                    prod_op1 = product[op1][op]
                    prod_op2 = product[op2][op]
                    iatom = symop_to_atom[i][prod_op1]
                    jatom = symop_to_atom[j][prod_op2]
                    ioff = self.vector_x_symop(offset1, prod_op1, translation=False)
                    joff = self.vector_x_symop(offset2, prod_op2, translation=False)

                    if iatom == jatom:
                        logger.debug(
                            f"iatom == jatom ({iatom}, {jatom}) {use_offsets=}"
                        )
                        if use_offsets:
                            if np.all(offset1 == offset2):
                                logger.debug(f"{offset1=} {offset2=}")
                                continue
                        else:
                            continue

                    if iatom > jatom:
                        ii, jj = j, i
                        iatom, jatom = jatom, iatom
                        offset1, offset2 = offset2, offset1
                        prod_op1, prod_op2 = prod_op2, prod_op1
                    else:
                        ii, jj = i, j

                    uvw1 = self.vector_x_symop(uvw_sym[ii], prod_op1)
                    uvw2 = self.vector_x_symop(uvw_sym[jj], prod_op2)

                    delta = uvw2 - uvw1
                    delta = delta.round(4)
                    # Not sure is <= or < :-)
                    tmpoff = np.select([delta < -0.5, delta > 0.5], [1, -1], 0)
                    if use_offsets:
                        ioff = np.array(offset1)
                        joff = np.array(tmpoff) + offset2
                    else:
                        ioff = np.array((0, 0, 0))
                        joff = np.array(tmpoff)

                    delta += joff - ioff

                    if np.any(abs(delta) > 1.0):
                        logger.debug(f"Throwing out delta = {str(delta)}")
                        continue

                    if (iatom, jatom, delta.tolist()) in found:
                        continue

                    found.append((iatom, jatom, delta.tolist()))

                    self._bond_to_asymmetric_bond.append(bond_no)
                    self._bond_atoms.append((iatom, jatom))
                    self._bond_offsets.append((ioff.tolist(), joff.tolist()))

                    if logger.isEnabledFor(logging.DEBUG):
                        uvw1 = self.vector_x_symop(uvw_sym[ii], prod_op1)
                        uvw2 = self.vector_x_symop(uvw_sym[jj], prod_op2)

                        delta = uvw2 - uvw1
                        tmpoff = np.select([delta <= -0.5, delta > 0.5], [1, -1], 0)
                        delta += joff - ioff
                        delta_xyz = self.configuration.cell.to_cartesians(
                            delta, as_array=True
                        )
                        r = np.round(np.linalg.norm(delta_xyz), 4)
                        xi, yi, zi = uvw1.tolist()
                        xj, yj, zj = uvw2.tolist()
                        xd, yd, zd = delta.tolist()
                        off1x, off1y, off1z = ioff.tolist()
                        off2x, off2y, off2z = joff.tolist()
                        logger.debug(
                            f"\t{iatom:3} {jatom:3} | {ii} {product[op1][op]:3} "
                            f"{jj} {product[op2][op]:3} | "
                            f"{xi:5.2f} {yi:5.2f} {zi:5.2f}"
                            f"({off1x:4} {off1y:4} {off1z:4}) "
                            f"{xj:5.2f} {yj:5.2f} {zj:5.2f}"
                            f"({off2x:4} {off2y:4} {off2z:4}) "
                            f"| {xd:5.2f} {yd:5.2f} {zd:5.2f}"
                            f" |  {r=:7.4f}"
                        )

    def _create_symop_products(self):
        """Create the matrix of the product of symmetry operators."""
        products = []
        ops = self.symmetry_matrices
        n = self.n_symops
        # Take the i'th symop and multiply by all the rest
        for i in range(n):
            prod = np.einsum("jk,ikl", ops[i, :, :], ops)
            row = []
            # For the i-j product, find it in the original list
            for j in range(n):
                # logger.debug(f"{j}:")

                # Subtract the product from the original symops
                tmp = ops - prod[j, :, :]

                # Shift any translation into the cell, i.e. [0,1)
                tmp[:, :, 3] = tmp[:, :, 3] - np.floor(tmp[:, :, 3])

                # We are looking for zeros, so sum the abs values
                tmp = tmp.reshape(n, 16)
                tmp2 = np.sum(np.abs(tmp), axis=1)

                # And find any matrices that are all zero
                hits = np.where(tmp2 == 0.0)[0]

                if len(hits) != 1:
                    symops = self.symops
                    logger.warning("Symmetry matrices")
                    for k in range(n):
                        logger.warning(f"{k}: {symops[k]}\n{str(ops[k])}")
                    logger.warning("")
                    logger.warning(f"Symop {i}:\n{str(ops[i])}")
                    logger.warning("")
                    logger.warning("Products")
                    for k in range(n):
                        logger.warning(f"\n{str(tmp[k])}")
                    raise RuntimeError(f"Problem with products of symops: {len(hits)=}")
                k = hits[0]
                row.append(k)
                # logger.debug(prod[j])
                # logger.debug(f"symop {k} is")
                # logger.debug(ops[k])
            if len(row) != n:
                raise RuntimeError(f"Not enough products of the symop {i}")
            # logger.debug(f"{i}: ({len(row)}) {row}")
            products.append(row)
        self._symop_products = products
