import importlib
from functools import cached_property

class AtomPositionMaker:
    """
    Lazily builds and manipulates molecular structures (mono-, di-, and triatomic).

    Attributes are computed on first access to minimize initial import overhead.
    """

    def __init__(self, file_location: str = None, name: str = None, **kwargs):
        self.file_location = file_location
        self.name = name
        # other attributes (e.g. atomPositions, mass_list) will be set when building

    # --- periodic data & helpers ---
    @cached_property
    def _covalent_radius(self):
        # Å | minimal but broad enough; fallback = 0.77
        return {
            'H':0.31,'B':0.85,'C':0.76,'N':0.71,'O':0.66,'F':0.57,
            'Si':1.11,'P':1.07,'S':1.05,'Cl':1.02,'Br':1.20,'I':1.39,
            'Na':1.66,'K':2.03,'Li':1.28,'Cu':1.32,'Ni':1.24
        }

    @cached_property
    def _empirical_diatomic_lengths(self):
        # Gas-phase bond lengths in Å (approx); covers your presets + a few more.
        return {
            ('H','H'):0.74, ('O','O'):1.21, ('N','N'):1.10,
            ('F','F'):1.42, ('Cl','Cl'):1.99, ('Br','Br'):2.28, ('I','I'):2.66,
            ('H','F'):0.92, ('H','Cl'):1.27, ('H','Br'):1.41, ('H','I'):1.61,
            ('O','H'):0.97, ('S','H'):1.34,
            ('C','O'):1.13, ('N','O'):1.15, ('C','N'):1.17, ('B','O'):1.21,
            ('Si','O'):1.52, ('Cu','O'):1.82, ('C','S'):1.54
        }

    def _norm_sym(self, s: str) -> str:
        s = s.strip()
        if not s: return s
        return s[0].upper() + s[1:].lower()

    def _is_element(self, s: str) -> bool:
        import math, re
        return bool(re.fullmatch(r'[A-Z][a-z]?', self._norm_sym(s)))

    def _default_bond_length(self, A: str, B: str) -> float:
        """Empirical length if known, else scaled sum of covalent radii."""
        A, B = self._norm_sym(A), self._norm_sym(B)
        key = tuple(sorted((A, B)))
        if key in self._empirical_diatomic_lengths:
            return self._empirical_diatomic_lengths[key]
        ra = self._covalent_radius.get(A, 0.77)
        rb = self._covalent_radius.get(B, 0.77)
        # single-bond-ish scale; works fine as a neutral fallback
        return 1.20 * (ra + rb)

    def _parse_diatomic_formula(self, name: str):
        """
        Accepts 'CO', 'NaCl', 'Cl2', 'H2' etc. Returns (A, B) or None if total atoms != 2.
        """
        import math, re
        tokens = re.findall(r'([A-Z][a-z]?)(\d*)', name)
        if not tokens:
            return None
        expanded = []
        for sym, cnt in tokens:
            n = int(cnt) if cnt else 1
            expanded.extend([self._norm_sym(sym)] * n)
        if len(expanded) != 2:
            return None
        return expanded[0], expanded[1]

    # --- public builders (no external deps) ---
    def build_mono(self, symbol_or_Z: str, center: str = 'mass_center'):
        """Monoatomic: symbol ('Ni') or atomic number ('28')."""
        sym = None
        if isinstance(symbol_or_Z, str) and symbol_or_Z.isdigit():
            # If you keep a periodic table map elsewhere, plug it here; otherwise best-effort:
            Z2sym = {
                1:'H',6:'C',7:'N',8:'O',9:'F',14:'Si',15:'P',16:'S',17:'Cl',
                29:'Cu',28:'Ni',35:'Br',53:'I',11:'Na',19:'K',5:'B'
            }
            sym = Z2sym.get(int(symbol_or_Z))
        else:
            sym = self._norm_sym(str(symbol_or_Z))
        if not sym or not self._is_element(sym):
            raise ValueError(f"Unrecognized element: {symbol_or_Z}")
        self.build_molecule([sym], [[0.0, 0.0, 0.0]], center=center)

    def build_diatomic(self, A: str, B: str, center: str = 'mass_center'):
        """Diatomic: place along z with reasonable bond length."""
        A, B = self._norm_sym(A), self._norm_sym(B)
        if not (self._is_element(A) and self._is_element(B)):
            raise ValueError(f"Unrecognized diatomic: {A}-{B}")
        d = self._default_bond_length(A, B)
        self.build_molecule([A, B], [[0.0, 0.0, 0.0], [0.0, 0.0, d]], center=center)

    @cached_property
    def atomic_compounds(self):
        # Single-atom entries; uses self.atomic_numbers if defined
        nums = getattr(self, 'atomic_numbers', [])
        return {str(a): {'atomLabels': [str(a)], 'atomPositions': [[0, 0, 0]]} for a in nums}

    @cached_property
    def diatomic_compounds(self):
        return {
            'H2':  {'atomLabels': ['H', 'H'],   'atomPositions': [[0, 0, 0], [0, 0, 0.74]]},  
            'O2':  {'atomLabels': ['O', 'O'],   'atomPositions': [[0, 0, 0], [0, 0, 1.21]]},  
            'OH':  {'atomLabels': ['O', 'H'],   'atomPositions': [[0, 0, 0], [0, 0, 0.97]]},  
            'N2':  {'atomLabels': ['N', 'N'],   'atomPositions': [[0, 0, 0], [0, 0, 1.10]]},  
            'F2':  {'atomLabels': ['F', 'F'],   'atomPositions': [[0, 0, 0], [0, 0, 1.42]]},  
            'Cl2': {'atomLabels': ['Cl', 'Cl'], 'atomPositions': [[0, 0, 0], [0, 0, 1.99]]},  
            'Br2': {'atomLabels': ['Br', 'Br'], 'atomPositions': [[0, 0, 0], [0, 0, 2.28]]},  
            'I2':  {'atomLabels': ['I', 'I'],   'atomPositions': [[0, 0, 0], [0, 0, 2.66]]},  
            'HF':  {'atomLabels': ['H', 'F'],   'atomPositions': [[0, 0, 0], [0, 0, 0.92]]},  
            'CO':  {'atomLabels': ['C', 'O'],   'atomPositions': [[0, 0, 0], [0, 0, 1.13]]},  
            'NO':  {'atomLabels': ['N', 'O'],   'atomPositions': [[0, 0, 0], [0, 0, 1.15]]},  
            'CN':  {'atomLabels': ['C', 'N'],   'atomPositions': [[0, 0, 0], [0, 0, 1.17]]},  
            'BO':  {'atomLabels': ['B', 'O'],   'atomPositions': [[0, 0, 0], [0, 0, 1.21]]},  
            'SiO': {'atomLabels': ['Si', 'O'],  'atomPositions': [[0, 0, 0], [0, 0, 1.52]]},  
            'CuO': {'atomLabels': ['Cu', 'O'],  'atomPositions': [[0, 0, 0], [0, 0, 1.82]]},   
            'SH':  {'atomLabels': ['S', 'H'],   'atomPositions': [[0, 0, 0], [0, 0, 1.02]]},   
            'HCl': {'atomLabels': ['H','Cl'], 'atomPositions': [[0,0,0], [0,0,1.27]]},
            'HBr': {'atomLabels': ['H','Br'], 'atomPositions': [[0,0,0], [0,0,1.41]]},
            'HI':  {'atomLabels': ['H','I'],  'atomPositions': [[0,0,0], [0,0,1.61]]},
            'CS':  {'atomLabels': ['C','S'],  'atomPositions': [[0,0,0], [0,0,1.54]]},
        }

    @cached_property
    def triatomic_compounds(self):
        # lazy import numpy only when needed
        np = importlib.import_module('numpy')
        to_rad = np.radians
        return {
            'CO2': {'atomLabels': ['C', 'O', 'O'], 'atomPositions': [[0, 0, 0], [0, 0, 1.16], [0, 0, -1.16]]},
            'H2O': {'atomLabels': ['O', 'H', 'H'], 'atomPositions': [
                [0, 0, 0], 
                [0.96 * np.cos(np.radians(104.5 / 2)), 0, 0.96 * np.sin(np.radians(104.5 / 2))], 
                [0.96 * np.cos(np.radians(104.5 / 2)), 0, -0.96 * np.sin(np.radians(104.5 / 2))]]},
            'SO2': {'atomLabels': ['S', 'O', 'O'], 'atomPositions': [
                [0, 0, 0], 
                [1.43 * np.cos(np.radians(119.5)), 0, 1.43 * np.sin(np.radians(119.5))], 
                [-1.43 * np.cos(np.radians(119.5)), 0, -1.43 * np.sin(np.radians(119.5))]]},  
            'O3':  {'atomLabels': ['O', 'O', 'O'], 'atomPositions': [
                [0, 0, 0], 
                [1.28 * np.cos(np.radians(116.8)), 0, 1.28 * np.sin(np.radians(116.8))], 
                [-1.28 * np.cos(np.radians(116.8)), 0, -1.28 * np.sin(np.radians(116.8))]]},
            'HCN': {'atomLabels': ['H', 'C', 'N'], 'atomPositions': [[0, 0, 1.06], [0, 0, 0], [0, 0, -1.16]]},
            'H2S': {'atomLabels': ['S', 'H', 'H'], 'atomPositions': [
                [0, 0, 0], 
                [0.96 * np.cos(np.radians(92.1 / 2)), 0, 0.96 * np.sin(np.radians(92.1 / 2))], 
                [0.96 * np.cos(np.radians(92.1 / 2)), 0, -0.96 * np.sin(np.radians(92.1 / 2))]]},  
            'CS2': {'atomLabels': ['C', 'S', 'S'], 'atomPositions': [[0, 0, 0], [0, 0, 1.55], [0, 0, -1.55]]},  
            'NO2': {'atomLabels': ['N', 'O', 'O'], 'atomPositions': [
                [0, 0, 0], 
                [1.20 * np.cos(np.radians(134.1)), 0, 1.20 * np.sin(np.radians(134.1))], 
                [-1.20 * np.cos(np.radians(134.1)), 0, -1.20 * np.sin(np.radians(134.1))]]},  
            'HCO': {'atomLabels': ['H', 'C', 'O'], 'atomPositions': [[0, 0, 1.10], [0, 0, 0], [0, 0, -1.12]]}, 
            'HOF': {'atomLabels': ['H', 'O', 'F'], 'atomPositions': [[0, 0, 1.10], [0, 0, 0], [0, 0, -1.44]]}, 
            'C2H2': {'atomLabels': ['C', 'C', 'H', 'H'], 'atomPositions': [[0, 0, 0], [1.20, 0, 0], [0, 0, 1.08], [1.20, 0, -1.08]]},  
            'KOH':  {'atomLabels': ['K', 'O', 'H'], 'atomPositions': [
                [0, 0, 0], 
                [2.00 * np.cos(np.radians(116.8)), 0, 0], 
                [2.00 * np.cos(np.radians(116.8)), 0, 1]]},
        }

    def get_triatomic_compound(self, name: str):
        return self.triatomic_compounds.get(name)

    def build_molecule(self, atomLabels: list, atomPositions, center: str = 'mass_center', lattice=None, pbc=None):
        import numpy as np
        # add atoms, compute mass_list etc before centering
        for label, pos in zip(atomLabels, atomPositions):
            self.add_atom(label, pos, [1, 1, 1])

        if center in ('mass_center', 'gravity_center'):
            disp = (self.atomPositions.T * self.mass_list).sum(axis=1) / self.mass_list.sum()
        elif center in ('geometric_center', 'baricenter'):
            disp = self.atomPositions.mean(axis=1)
        else:
            disp = [0.0, 0.0, 0.0]

        self.set_atomPositions(self.atomPositions - disp)

        # Inject PBC metadata physically into SAGE
        if lattice is not None:
            try: self.set_latticeVectors(np.array(lattice, dtype=np.float64))
            except: self.latticeVectors = np.array(lattice, dtype=np.float64)
            
        if pbc is not None:
            try: self.set_pbc(list(pbc))
            except: self.pbc = list(pbc)

    def build_alkane(self, n: int, center: str = 'mass_center'):
        """Builds a zigzag generic linear organic chain (Alkane) procedurally."""
        import numpy as np
        labels = []
        pos = []
        d_CC = 1.54; d_CH = 1.09
        angle = np.radians(109.5)
        
        for i in range(n):
            labels.append('C')
            x = i * d_CC * np.sin(angle/2)
            y = (i % 2) * d_CC * np.cos(angle/2)
            pos.append([x, y, 0.0])
            
            # Simplified hydrogens for demonstration of custom chains
            if i == 0 or i == n - 1:
                labels.extend(['H', 'H', 'H'])
                sg = 1 if i == n-1 else -1
                pos.extend([[x + sg*d_CH, y, 0.0], [x-sg*d_CH*0.5, y+0.8, 0.8], [x-sg*d_CH*0.5, y-0.8, -0.8]])
            else:
                labels.extend(['H', 'H'])
                pos.extend([[x, y + (1 if i%2==0 else -1)*d_CH, 0.8], [x, y + (1 if i%2==0 else -1)*d_CH, -0.8]])
                
        self.build_molecule(labels, pos, center=center)

    def build_miller_surface(self, element: str, structure: str, hkl: tuple, w: float, h_dim: float, layers: int, center: str = 'mass_center'):
        """Builds an arbitrary surface slab (FCC, BCC, SC) defined by Miller indices (h,k,l)."""
        import numpy as np
        element = self._norm_sym(element)
        labels = []; pos = []
        
        # Approximate lattice constants from atomic radii
        R = self._covalent_radius.get(element, 1.25)
        if structure == 'fcc': a = R * 2 * np.sqrt(2)
        elif structure == 'bcc': a = R * 4 / np.sqrt(3)
        else: a = R * 2 # simple cubic factor
        
        # Unit cell basis atoms (fractional)
        if structure == 'fcc':
            base = np.array([[0,0,0], [0.5,0.5,0], [0.5,0,0.5], [0,0.5,0.5]])
        elif structure == 'bcc':
            base = np.array([[0,0,0], [0.5,0.5,0.5]])
        else:
            base = np.array([[0,0,0]])
            
        # Rotation matrix to align (h,k,l) with Z
        h, k, l = hkl
        n = np.array([h, k, l], dtype=np.float64)
        n /= (np.linalg.norm(n) + 1e-12)
        
        if abs(n[0]) > 0.1 or abs(n[1]) > 0.1: u = np.array([-n[1], n[0], 0], dtype=np.float64)
        else: u = np.array([0, -n[2], n[1]], dtype=np.float64)
        u /= np.linalg.norm(u)
        v = np.cross(n, u)
        R_mat = np.column_stack((u, v, n)) # Standard to new basis
        
        d_hkl = a / np.sqrt(h**2 + k**2 + l**2 + 1e-12)
        max_dist = np.linalg.norm([w, h_dim, layers * d_hkl]) + a * 2
        cells = int(np.ceil(max_dist / a)) + 1
        
        # Generate block & clip by rotated bounding box
        for cx in range(-cells, cells):
            for cy in range(-cells, cells):
                for cz in range(-cells, cells):
                    origin = np.array([cx, cy, cz]) * a
                    for b in base:
                        p = origin + b * a
                        p_rot = p @ R_mat
                        # Accept if inside spatial extents
                        if 0 <= p_rot[0] < w and 0 <= p_rot[1] < h_dim and -1e-5 <= p_rot[2] <= layers * d_hkl - 1e-5:
                            labels.append(element)
                            pos.append(list(p_rot))
                            
        # Pseudo-PBC for slab: exact in XY (approx via box), vacuum in Z
        lattice = np.diag([w, h_dim, layers * d_hkl + 15.0])
        pbc = [True, True, False]
        self.build_molecule(labels, pos, center=center, lattice=lattice, pbc=pbc)

    def build_from_smiles(self, smiles: str, center: str = 'mass_center'):
        """
        Pure-Python heuristic 3D SMILES builder.
        Maps topological branching and automatically fulfills valences with Hydrogens.
        """
        import numpy as np
        import re
        
        # 1. Tokenize SMILES (Supports basic aromatics as aliphatic for now, halides, branching)
        tokens = re.findall(r'Cl|Br|Si|[A-Za-z=#/\\[\]()]', smiles)
        
        atoms = [] # Keep track of nodes to add Hydrogens later
        stack = []
        labels = []
        pos = []
        
        current_idx = -1
        curr_pos = np.array([0.0, 0.0, 0.0])
        curr_dir = np.array([1.0, 0.0, 0.0])
        bond_order = 1
        
        def rot_axis(axis, theta):
            axis = np.asarray(axis) / np.linalg.norm(axis)
            a = np.cos(theta / 2.0); b, c, d = -axis * np.sin(theta / 2.0)
            return np.array([[a*a+b*b-c*c-d*d, 2*(b*c+a*d), 2*(b*d-a*c)],
                             [2*(b*c-a*d), a*a+c*c-b*b-d*d, 2*(c*d+a*b)],
                             [2*(b*d+a*c), 2*(c*d-a*b), a*a+d*d-b*b-c*c]])

        # Build backbone 3D graph
        for t in tokens:
            if t in ['[', ']']: continue
            if t in ['=', '#', '/', '\\']:
                if t == '=': bond_order = 2
                elif t == '#': bond_order = 3
                continue
            elif t == '(':
                stack.append((current_idx, curr_pos.copy(), curr_dir.copy()))
                continue
            elif t == ')':
                if stack: current_idx, curr_pos, curr_dir = stack.pop()
                continue
            
            # Identify element (ignore aromatic lowercases simply by capitalizing)
            sym = t.capitalize()
            # Simple ring closure numbers bypass
            if sym.isdigit(): continue 
            
            idx = len(atoms)
            if current_idx == -1:
                p = curr_pos.copy()
            else:
                # Zig-zag generation
                r_axis = np.array([0.0, 1.0, 0.0]) if idx % 2 == 0 else np.array([0.0, 0.0, 1.0])
                # sp3/sp2 heuristic angle
                angle = np.radians(109.5 if bond_order == 1 else 120.0)
                curr_dir = rot_axis(r_axis, angle) @ curr_dir
                
                bl = 1.54 if bond_order == 1 else (1.34 if bond_order == 2 else 1.20)
                if sym in ['O', 'N'] or atoms[current_idx]['sym'] in ['O', 'N']: bl -= 0.1
                
                p = curr_pos + curr_dir * bl
                atoms[current_idx]['bonds'] += bond_order
                
            atoms.append({'sym': sym, 'bonds': bond_order if current_idx != -1 else 0})
            labels.append(sym)
            pos.append(list(p))
            
            curr_pos = p
            current_idx = idx
            bond_order = 1
            
        # 2. Add explicit Hydrogens based on standard valences
        valences = {'C': 4, 'N': 3, 'O': 2, 'S': 2, 'F': 1, 'Cl': 1, 'Br': 1, 'I': 1, 'Si': 4, 'B': 3, 'P': 3}
        n_heavy = len(atoms)
        
        for i in range(n_heavy):
            sym = atoms[i]['sym']
            needed = valences.get(sym, 0) - atoms[i]['bonds']
            
            if needed > 0:
                base_p = np.array(pos[i])
                for j in range(needed):
                    # Distribute hydrogens reasonably around the atom
                    phi = j * 2.0 * np.pi / needed + i
                    theta = np.arccos(1 - 2 * ((j + 0.5) / needed)) if needed > 1 else np.pi/4
                    
                    h_dir = np.array([np.sin(theta) * np.cos(phi), np.sin(theta) * np.sin(phi), np.cos(theta)])
                    h_pos = base_p + h_dir * 1.09
                    
                    labels.append('H')
                    pos.append(list(h_pos))
                    
        self.build_molecule(labels, pos, center=center)

    def build_nanocluster(self, element: str, size: float, is_atoms: bool = False, center: str = 'mass_center'):
        """Builds a spherical bulk crystalline nanocluster by radius or by exact number of atoms."""
        import numpy as np
        element = self._norm_sym(element)
        labels = []; pos = []
        a = self._covalent_radius.get(element, 1.25) * 2 * np.sqrt(2)
        
        if is_atoms:
            # Estimate required radius from atomic volume (FCC has 4 atoms per a^3)
            N = int(size)
            R_est = a * (3 * N / (16 * np.pi))**(1/3) + a # slightly larger bound
            cells = int(np.ceil((2*R_est) / a)) + 1
        else:
            radius = size
            cells = int(np.ceil((2*radius) / a)) + 1
            
        all_points = []
        
        for cx in range(-cells, cells+1):
            for cy in range(-cells, cells+1):
                for cz in range(-cells, cells+1):
                    base = np.array([cx, cy, cz]) * a
                    for fc in [[0,0,0], [0.5,0.5,0], [0.5,0,0.5], [0,0.5,0.5]]:
                        p = base + np.array(fc) * a
                        d = np.linalg.norm(p)
                        if not is_atoms and d <= radius:
                            labels.append(element)
                            pos.append(list(p))
                        elif is_atoms:
                            all_points.append((d, list(p)))
                            
        if is_atoms:
            all_points.sort(key=lambda x: x[0])
            for _, p in all_points[:N]:
                labels.append(element)
                pos.append(p)
                
        self.build_molecule(labels, pos, center=center)

    def _generate_bulk_data(self, elements: list, structure: str, reps: tuple, a: float = None):
        """Core generator for 3D crystalline arrays returning coords & PBC."""
        import numpy as np
        if a is None:
            r0 = self._covalent_radius.get(self._norm_sym(elements[0]), 1.25)
            if structure in ['fcc', 'zincblende', 'nacl', 'rocksalt']: a = r0 * 2 * np.sqrt(2)
            elif structure in ['bcc', 'cscl']: a = r0 * 4 / np.sqrt(3)
            elif structure == 'sc': a = r0 * 2
            elif structure == 'diamond': a = r0 * 4 / np.sqrt(3)
            else: a = r0 * 2.5
            
        labels = []; pos = []
        st = structure.lower()
        
        if len(elements) == 1:
            el = self._norm_sym(elements[0])
            if st == 'sc': base = [(el, [0,0,0])]
            elif st == 'bcc': base = [(el, [0,0,0]), (el, [0.5, 0.5, 0.5])]
            elif st == 'fcc': base = [(el, [0,0,0]), (el, [0.5, 0.5, 0]), (el, [0.5, 0, 0.5]), (el, [0, 0.5, 0.5])]
            elif st == 'diamond':
                b1 = [[0,0,0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5]]
                base = [(el, p) for p in b1] + [(el, [p[0]+0.25, p[1]+0.25, p[2]+0.25]) for p in b1]
            else: raise ValueError(f"Unknown monoatomic lattice: {st}")
        else:
            el1, el2 = self._norm_sym(elements[0]), self._norm_sym(elements[1])
            if st == 'cscl' or st == 'bcc': base = [(el1, [0,0,0]), (el2, [0.5, 0.5, 0.5])]
            elif st == 'rocksalt' or st == 'nacl':
                b1 = [[0,0,0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5]]
                base = [(el1, p) for p in b1] + [(el2, [(p[0]+0.5)%1.0, p[1], p[2]]) for p in b1]
            elif st == 'zincblende' or st == 'zns':
                b1 = [[0,0,0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5]]
                base = [(el1, p) for p in b1] + [(el2, [p[0]+0.25, p[1]+0.25, p[2]+0.25]) for p in b1]
            else: raise ValueError(f"Unknown diatomic lattice: {st}")
                
        nx, ny, nz = reps
        for i in range(nx):
            for j in range(ny):
                for k in range(nz):
                    offset = np.array([i, j, k], dtype=np.float64)
                    for sym, p_frac in base:
                        labels.append(sym)
                        p = (offset + np.array(p_frac)) * a
                        pos.append(list(p))
        
        lattice = np.diag([nx * a, ny * a, nz * a]).astype(np.float64)
        pbc = [True, True, True]
        return labels, pos, lattice, pbc

    def build_bulk(self, elements: list, structure: str, reps: tuple, a: float = None, center: str = 'none'):
        """Builds generalized 3D bulk supercells of arbitrary mono/diatomic lattices with full PBC."""
        labels, pos, lat, pbc = self._generate_bulk_data(elements, structure, reps, a)
        self.build_molecule(labels, pos, center=center, lattice=lat, pbc=pbc)

    def build_alloy_bulk(self, elements: list, ratios: list, structure: str, reps: tuple, a: float = None, center: str = 'none'):
        """Builds a random solid solution (alloy) on a specified crystal lattice."""
        import random
        # Base monoatomic grid to get coordinates and correct PBC box cleanly
        labels, pos, lat, pbc = self._generate_bulk_data([elements[0]], structure, reps, a)
        
        n_atoms = len(labels)
        total_r = sum(ratios)
        counts = [int(r / total_r * n_atoms) for r in ratios]
        counts[-1] = n_atoms - sum(counts[:-1]) # ensure total exact match
        
        pool = []
        for el, c in zip(elements, counts):
            pool.extend([self._norm_sym(el)] * c)
        random.shuffle(pool)
        
        self.build_molecule(pool, pos, center=center, lattice=lat, pbc=pbc)

    def build_2d_hexagonal(self, mat_type: str, reps: tuple, center: str = 'mass_center'):
        """Builds 2D Honeycomb meshes like Graphene, hBN, or transition metal dichalcogenides (MoS2)."""
        import numpy as np
        labels = []; pos = []
        nx, ny = reps[0], reps[1]
        
        # Preset library
        mat = mat_type.lower()
        if mat == 'graphene' or mat == 'c':
            a = 2.46
            b1, b2 = [0, 0, 0], [0.5, 0.5/np.sqrt(3), 0]
            elements = ['C', 'C']
        elif mat == 'hbn' or mat == 'bn':
            a = 2.50
            b1, b2 = [0, 0, 0], [0.5, 0.5/np.sqrt(3), 0]
            elements = ['B', 'N']
        elif mat == 'mos2':
            a = 3.15
            b1 = [0, 0, 0]; b2 = [0.5, 0.5/np.sqrt(3), 1.58/a]; b3 = [0.5, 0.5/np.sqrt(3), -1.58/a]
            elements = ['Mo', 'S', 'S']
        else:
            raise ValueError(f"Unknown 2D material: {mat}")
            
        a1 = np.array([a, 0.0, 0.0])
        a2 = np.array([a/2.0, a*np.sqrt(3)/2.0, 0.0])
        
        for i in range(nx):
            for j in range(ny):
                base_pos = i * a1 + j * a2
                labels.append(elements[0]); pos.append(list(base_pos + np.array(b1) * a))
                labels.append(elements[1]); pos.append(list(base_pos + np.array(b2) * a))
                if len(elements) == 3:
                     labels.append(elements[2]); pos.append(list(base_pos + np.array(b3) * a))
                     
        # 3. PBC Hexagonal Grid
        lattice = np.array([nx * a1, ny * a2, [0.0, 0.0, 15.0]], dtype=np.float64)
        pbc = [True, True, False]
        self.build_molecule(labels, pos, center=center, lattice=lattice, pbc=pbc)

    def build_nanotube(self, element: str, n: int, m: int, length_cells: int, center: str = 'mass_center'):
        """Builds Carbon/BN Nanotubes algorithmically given chiral indices (n, m)."""
        import numpy as np
        import math
        labels = []; pos = []
        a_cc = self._covalent_radius.get(self._norm_sym(element), 0.76) * 1.86 # 1.42A for C
        a = np.sqrt(3) * a_cc
        a1 = np.array([a * np.sqrt(3)/2, a / 2])
        a2 = np.array([a * np.sqrt(3)/2, -a / 2])
        
        ch = n * a1 + m * a2
        ch_len = np.linalg.norm(ch)
        radius = ch_len / (2 * np.pi)
        
        dR = math.gcd(2*m+n, 2*n+m)
        t1 = (2*m + n) // dR; t2 = -(2*n + m) // dR
        T = t1 * a1 + t2 * a2
        T_len = np.linalg.norm(T)
        
        M = max(n, m, 5) * 4 # generate oversized flat patch
        b1 = (a1 + a2) / 3.0
        b2 = 2.0 * (a1 + a2) / 3.0
        
        for i in range(-M, M):
            for j in range(-M, M):
                for b in [b1, b2]:
                    p2d = i * a1 + j * a2 + b
                    # Orthogonal projection onto chiral vector and axis
                    u = np.dot(p2d, ch) / ch_len
                    v = np.dot(p2d, T) / T_len
                    
                    # Unrolled nanotube bounded area filter
                    if -1e-4 <= u < ch_len - 1e-4 and -1e-4 <= v < length_cells * T_len - 1e-4:
                        theta = u / radius
                        labels.append(self._norm_sym(element))
                        pos.append([radius * np.cos(theta), radius * np.sin(theta), v])
                        
        # Setup 1D PBC along Z-axis
        lattice = np.diag([radius*2 + 15.0, radius*2 + 15.0, length_cells * T_len])
        pbc = [False, False, True]
        self.build_molecule(labels, pos, center=center, lattice=lattice, pbc=pbc)

    def build_wulff_cluster(self, element: str, radius: float, center: str = 'mass_center'):
        """Builds a Cuboctahedron (Wulff polyhedra construction cut by 100 and 111 planes)."""
        import numpy as np
        element = self._norm_sym(element)
        labels = []; pos = []
        a = self._covalent_radius.get(element, 1.25) * 2 * np.sqrt(2)
        cells = int(np.ceil((2*radius) / a)) + 1
        
        for cx in range(-cells, cells+1):
            for cy in range(-cells, cells+1):
                for cz in range(-cells, cells+1):
                    base = np.array([cx, cy, cz]) * a
                    for fc in [[0,0,0], [0.5,0.5,0], [0.5,0,0.5], [0,0.5,0.5]]:
                        p = base + np.array(fc) * a
                        # Octahedral planes limit {111}
                        c1 = sum(abs(x) for x in p) <= radius * 1.5
                        # Cubic planes limit {100}
                        c2 = max(abs(x) for x in p) <= radius
                        
                        if c1 and c2:
                            labels.append(element)
                            pos.append(list(p))
                            
        self.build_molecule(labels, pos, center=center)

    def build(self, name: str, center: str = 'mass_center'):
        """Intelligent pure-python parser for formulas, organics, surfaces, clusters, and SMILES."""
        nl = name.lower()
        
        # -- 1) SMILES 3D Generator via RDKit --
        if nl.startswith("smiles-"):
            sm = name.split("-", 1)[1]
            return self.build_from_smiles(sm, center)
            
        # -- 2) Pure algorithmic parsers --
        if nl.startswith("nanotube-"):
            try:
                # Format: nanotube-C-5_5-10
                parts = name.split("-")
                el = parts[1]
                nm = [int(x) for x in parts[2].split("_")]
                leng = int(parts[3])
                return self.build_nanotube(el, nm[0], nm[1], leng, center)
            except: pass

        if nl.startswith("2d-"):
            try:
                # Format: 2d-graphene-10x10
                parts = name.split("-")
                dims = [int(x) for x in parts[2].lower().split("x")]
                return self.build_2d_hexagonal(parts[1], tuple(dims[:2]), center)
            except: pass
            
        if nl.startswith("alloy-"):
            try:
                # Format: alloy-fcc-Cu70_Au30-4x4x4
                import re
                parts = name.split("-")
                struc = parts[1]
                comps = parts[2].split("_")
                elements = []; ratios = []
                for comp in comps:
                    match = re.match(r"([A-Za-z]+)(\d*\.?\d*)", comp)
                    if match:
                        elements.append(match.group(1))
                        ratios.append(float(match.group(2) if match.group(2) else 1))
                dims = [int(x) for x in parts[3].lower().split("x")]
                return self.build_alloy_bulk(elements, ratios, struc, tuple(dims[:3]), None, center)
            except: pass
            
        if nl.startswith("wulff-") or nl.startswith("cuboctahedron-"):
            try:
                # Format: wulff-Au-15.0
                parts = name.split("-")
                return self.build_wulff_cluster(parts[1], float(parts[2]), center)
            except: pass

        if nl.startswith("bulk-"):
            try:
                # Format: bulk-[estructura]-[elementos]-[Nx]x[Ny]x[Nz]
                # Ejemplos: bulk-fcc-Cu-4x4x4  |  bulk-zincblende-Zn_S-2x2x2
                parts = name.split("-")
                struc = parts[1].lower()
                els = parts[2].replace(',', '_').split('_')
                dims = [int(x) for x in parts[3].lower().split("x")]
                if len(dims) == 1: dims = [dims[0], dims[0], dims[0]]
                return self.build_bulk(els, struc, tuple(dims[:3]), None, center)
            except: pass
        if nl.startswith("alkane-") or nl.startswith("chain-"):
            try:
                return self.build_alkane(int(name.split("-")[1]), center)
            except: pass
            
        if nl.startswith("fcc-") or nl.startswith("bcc-") or nl.startswith("sc-"):
            try:
                # Format: fcc-[Element]-[h][k][l]-[Width]x[Height]x[Layers]
                # EX: fcc-Cu-111-15x15x4
                parts = name.split("-")
                struc = parts[0].lower() # fcc/bcc/sc
                el = parts[1]
                hkl = tuple(int(digit) for digit in parts[2])
                dims = [float(x) for x in parts[3].lower().split("x")]
                
                return self.build_miller_surface(el, struc, hkl, dims[0], dims[1], int(dims[2]) if len(dims)>2 else 3, center)
            except: pass
            
        if nl.startswith("cluster-") or nl.startswith("nano-"):
            try:
                parts = name.split("-")
                val_str = parts[2].lower()
                is_atoms = False
                if 'n' in val_str or 'a' in val_str:
                    is_atoms = True
                    val_str = ''.join(c for c in val_str if c.isdigit() or c == '.')
                return self.build_nanocluster(parts[1], float(val_str), is_atoms, center)
            except: pass

        # 1) Cached dictionaries
        if name in self.atomic_compounds:
            return self.build_molecule(self.atomic_compounds[name]['atomLabels'], self.atomic_compounds[name]['atomPositions'], center)
        if name in self.diatomic_compounds:
            return self.build_molecule(self.diatomic_compounds[name]['atomLabels'], self.diatomic_compounds[name]['atomPositions'], center)
        if name in self.triatomic_compounds:
            return self.build_molecule(self.triatomic_compounds[name]['atomLabels'], self.triatomic_compounds[name]['atomPositions'], center)

        # 2) Monoatomic fallback
        if self._is_element(name) or name.isdigit():
            return self.build_mono(name, center=center)

        # 3) Diatomic/Formula fallback
        pair = self._parse_diatomic_formula(name)
        if pair is not None:
            return self.build_diatomic(pair[0], pair[1], center=center)

        # If nothing matched or parsing completely failed
        err_msg = f"""
❌ Error: Structure or Macro '{name}' is unknown or malformed.

SAGE Universal Structure Builder - Available Generator Macros:

1. ORGANIC CHAINS & MOLECULES 🧬
   - alkane-[N]                  : Linear alkane chain (e.g., 'alkane-5' for pentane)
   - smiles-[SMILES]             : Generate 3D geometry from SMILES (e.g., 'smiles-CC(=O)O')
   - [Formula] / [Element]       : Diatomics/Triatomics or Single Atoms (e.g., 'H2O', 'NaCl', 'Fe')

2. BULK CRYSTALS & ALLOYS 💎
   - bulk-[Struc]-[Els]-[NxMxL]  : 3D Crystal Supercell (e.g., 'bulk-fcc-Cu-4x4x4', 'bulk-rocksalt-Na_Cl-3x3x3')
         >> Supported Lattices: sc, bcc, fcc, diamond, rocksalt, zincblende, cscl
   - alloy-[Struc]-[Comps]-[NxMxL]: Random Solid Solution (e.g., 'alloy-fcc-Cu70_Au30-4x4x4')

3. SURFACES & 2D MATERIALS 🧫
   - [Struc]-[El]-[hkl]-[WxHxL]  : Miller Index Cleaved Slabs (e.g., 'fcc-Pt-111-10x10x4')
         >> Supported Lattices: fcc, bcc, sc
   - 2d-[Material]-[WxH]         : Honeycomb lattices (e.g., '2d-graphene-10x10', '2d-mos2-5x5', '2d-hbn-8x8')

4. NANOCLUSTERS & NANOTUBES 🦠
   - nanotube-[El]-[n]_[m]-[L]   : Chiral Rolled Nanotube (e.g., 'nanotube-C-5_5-10', 'nanotube-BN-8_2-15')
   - cluster-[El]-[Size]         : Spherical Nanocluster radius cut (e.g., 'cluster-Au-15.0')
   - cluster-[El]-[N]n           : Spherical Cluster by EXACT atoms (e.g., 'cluster-Au-55n', 'cluster-Pt-147atoms')
   - wulff-[El]-[Radius]         : Faceted Cuboctahedron (Wulff construction) (e.g., 'wulff-Pt-12.0')
"""
        raise ValueError(err_msg)
