import math
from typing import Tuple
from math import prod
from fractions import Fraction
import sympy as sp
from tabulate import tabulate

from klotho.topos.collections import CombinationSet as CS
from klotho.topos.graphs import Graph
from klotho.tonos.utils.interval_normalization import equave_reduce
from .master_set import MasterSet, MASTER_SETS

__all__ = [
    'CombinationProductSet',
]

class CombinationProductSet(CS):
  """
  A Combination Product Set (CPS) scale.

  A CPS is a scale generated by taking all *k*-element combinations of a
  set *S* of *n* positive integers, computing their products, equave-reducing,
  and sorting. The resulting pitches form a highly symmetrical scale used
  extensively in just-intonation microtonality.

  Named presets (hexany, dekany, etc.) are available as classmethods.

  Parameters
  ----------
  factors : tuple of int
      The generator factors.
  r : int
      Combination rank (number of factors multiplied per product).
  master_set : str or MasterSet
      Geometric layout template. Available presets can be found in
      ``MASTER_SETS``.
  normalized : bool, optional
      If True, normalize ratios by the largest factor. Default is False.

  References
  ----------
  .. [1] https://en.xen.wiki/w/Combination_product_set

  Examples
  --------
  >>> cps = CombinationProductSet.hexany((1, 3, 5, 7))
  >>> cps.ratios
  (Fraction(1, 1), Fraction(7, 6), ...)
  """

  @staticmethod
  def _resolve_master_set(master_set, factors):
    if master_set is None:
      raise ValueError(
        "master_set is required. Pass a name (str) or a MasterSet instance. "
        f"Available: {list(MASTER_SETS.keys())}")
    if isinstance(master_set, str):
      name = master_set.lower()
      if name not in MASTER_SETS:
        raise ValueError(
          f"Unknown master set '{name}'. "
          f"Available: {list(MASTER_SETS.keys())}")
        
      ms = MASTER_SETS[name]()
    elif isinstance(master_set, MasterSet):
      name = master_set.name
      ms = master_set
    else:
      raise TypeError(
        f"master_set must be a str, MasterSet, or None — got {type(master_set)}")
    if ms.n_factors != len(factors):
      raise ValueError(
        f"MasterSet '{name}' has {ms.n_factors} nodes, "
        f"but {len(factors)} factors were provided.")
    return name, ms.with_factors(factors)

  def __init__(self, factors: Tuple[int], r: int, master_set, normalized: bool = False):
    super().__init__(factors, r)
    self._normalized = normalized
    self._master_set, self._master_set_instance = self._resolve_master_set(
      master_set, self._factors)
    self._populate_graph()
    self._build_master_set_structure(self._master_set_instance.relationship_dict)
    self._compute_positions()
    self._graph._set_mutability_policy(topology_mutable=False, node_attr_mutable=False)

  def _populate_graph(self):
    directed_graph = self._graph.to_directed()
    directed_graph._graph.clear_edges()
    self._graph = directed_graph
    
    for node, attrs in self._graph.nodes(data=True):
      if 'combo' in attrs:
        combo = attrs['combo']
        product = prod(combo)
        ratio = equave_reduce(product)
        
        if self._normalized:
          ratio = equave_reduce(ratio / max(self._factors))
        
        symbolic_expr = sp.Integer(1)
        for factor in combo:
          symbolic_expr *= self.factor_to_alias[factor]
        
        self._graph.set_node_data(node, product=product, ratio=ratio, alias=symbolic_expr)
  
  def _build_master_set_structure(self, relationship_dict):
    if not relationship_dict:
      return
      
    combo_to_node = {}
    for node, attrs in self._graph.nodes(data=True):
      if 'combo' in attrs:
        combo_to_node[attrs['combo']] = node
    
    for c1 in self._combos:
      node1 = combo_to_node[c1]
      for c2 in self._combos:
        if c1 != c2:
          node2 = combo_to_node[c2]
          
          alias1 = self.get_attrs_by('combo', c1)['alias']
          alias2 = self.get_attrs_by('combo', c2)['alias']
          sym_ratio = sp.simplify(alias1 / alias2)
          
          if sym_ratio in relationship_dict:
            rel_data = relationship_dict[sym_ratio]
            self._graph.add_edge(node1, node2, 
                               relation=sym_ratio,
                               angle=rel_data['angle'],
                               distance=rel_data['distance'],
                               elevation=rel_data['elevation'],
                               displacement=rel_data.get('displacement'))

  def _compute_positions(self):
    G = self._graph
    n_dims = 2
    for u, v, data in G.edges(data=True):
      disp = data.get('displacement')
      if disp is not None and len(disp) > n_dims:
        n_dims = len(disp)
      elev = data.get('elevation', 0.0) or 0.0
      if abs(elev) > 1e-12 and n_dims < 3:
        n_dims = 3

    all_nodes = set(n for n, _ in G.nodes(data=True))
    node_positions = {}
    visited_global = set()

    while visited_global != all_nodes:
      start = min(all_nodes - visited_global)
      pos = {start: tuple(0.0 for _ in range(n_dims))}
      visited = {start}
      queue = [start]

      while queue:
        current = queue.pop(0)
        for nb in list(G.successors(current)) + list(G.predecessors(current)):
          if nb in visited:
            continue
          if G.has_edge(current, nb):
            edata = G.edges[current, nb]
          elif G.has_edge(nb, current):
            edata = G.edges[nb, current]
          else:
            continue

          disp = edata.get('displacement')
          cur_pos = pos[current]

          if G.has_edge(current, nb) and disp is not None and len(disp) >= n_dims:
            pos[nb] = tuple(cur_pos[i] + disp[i] for i in range(n_dims))
          elif G.has_edge(nb, current) and edata.get('displacement') is not None and len(edata['displacement']) >= n_dims:
            pos[nb] = tuple(cur_pos[i] - edata['displacement'][i] for i in range(n_dims))
          elif n_dims == 3:
            angle = edata.get('angle', 0.0)
            dist = edata.get('distance', 0.0)
            elev = edata.get('elevation', 0.0) or 0.0
            sign = 1.0 if G.has_edge(current, nb) else -1.0
            horiz_dist = dist * math.cos(elev)
            pos[nb] = (cur_pos[0] + sign * horiz_dist * math.cos(angle),
                       cur_pos[1] + sign * horiz_dist * math.sin(angle),
                       cur_pos[2] + sign * dist * math.sin(elev))
          else:
            angle = edata.get('angle', 0.0)
            dist = edata.get('distance', 0.0)
            sign = 1.0 if G.has_edge(current, nb) else -1.0
            pos[nb] = (cur_pos[0] + sign * dist * math.cos(angle),
                       cur_pos[1] + sign * dist * math.sin(angle))

          visited.add(nb)
          queue.append(nb)

      if pos:
        n_pos = len(pos)
        center = tuple(sum(p[i] for p in pos.values()) / n_pos for i in range(n_dims))
        for n in pos:
          pos[n] = tuple(pos[n][i] - center[i] for i in range(n_dims))

      node_positions.update(pos)
      visited_global |= visited

    if node_positions:
      max_abs = 0.0
      for p in node_positions.values():
        for c in p:
          if abs(c) > max_abs:
            max_abs = abs(c)
      if max_abs > 1e-12:
        node_positions = {
          n: tuple(c / max_abs for c in p)
          for n, p in node_positions.items()
        }

    self._positions = node_positions

  @property
  def positions(self):
    """dict : Mapping of node IDs to their computed geometric positions."""
    return dict(self._positions)

  @property
  def master_set(self):
    """str : The name of the master set layout template."""
    return self._master_set
  
  @property
  def master_set_instance(self):
    """MasterSet : The resolved master set instance with factor assignments."""
    return self._master_set_instance

  @property
  def aliases(self):
    """dict : Mapping from integer factors to symbolic aliases."""
    return self.factor_to_alias
    
  @property
  def products(self):
    """tuple : Sorted tuple of raw combination products (before equave reduction)."""
    return tuple(sorted(attrs['product'] for _, attrs in self._graph.nodes(data=True)))
  
  @property
  def ratios(self):
    """tuple : Sorted tuple of equave-reduced ratios."""
    return tuple(sorted(attrs['ratio'] for _, attrs in self._graph.nodes(data=True)))
  
  def get_node_by(self, attr_name, value):
    """
    Find the first node whose attribute matches the given value.

    Parameters
    ----------
    attr_name : str
        Node attribute name to search.
    value
        Value to match.

    Returns
    -------
    int or None
        The node ID, or None if not found.
    """
    for node, attrs in self._graph.nodes(data=True):
      if attrs.get(attr_name) == value:
        return node
    return None
  
  def get_attrs_by(self, attr_name, value):
    """
    Return the full attribute dict of the first node matching a given attribute.

    Parameters
    ----------
    attr_name : str
        Node attribute name to search.
    value
        Value to match.

    Returns
    -------
    dict or None
        The node attribute dictionary, or None if not found.
    """
    for node, attrs in self._graph.nodes(data=True):
      if attrs.get(attr_name) == value:
        return attrs
    return None  
  
  def __str__(self):
    table_data = []
    node_data = [(attrs['combo'], attrs['product'], attrs['ratio']) for _, attrs in self._graph.nodes(data=True)]
    sorted_data = sorted(node_data, key=lambda x: x[2])
    for combo, product, ratio in sorted_data:
      table_data.append([combo, product, str(ratio)])
    return tabulate(table_data, headers=['Combo', 'Product', 'Ratio'], tablefmt='simple', colalign=('center', 'center', 'center'))
  
  def __repr__(self):
    return self.__str__()

  # ------------------------------------------------------------------
  # Named presets (nkany)
  # ------------------------------------------------------------------

  @classmethod
  def hexany(cls, factors: Tuple[int] = (1, 3, 5, 7), master_set='tetrad', normalized: bool = False):
    """
    Hexany -- a six-note CPS from 4 factors taken 2 at a time.

    Parameters
    ----------
    factors : tuple of int, optional
        Exactly 4 generator factors. Default is ``(1, 3, 5, 7)``.
    master_set : str or MasterSet, optional
        Geometric layout template. Default is ``'tetrad'``.
    normalized : bool, optional
        Normalize ratios by the largest factor. Default is False.

    Returns
    -------
    CombinationProductSet

    References
    ----------
    .. [1] https://en.wikipedia.org/wiki/Hexany
    .. [2] https://en.xen.wiki/w/Hexany
    """
    if len(factors) != 4:
      raise ValueError('Hexany requires exactly 4 factors.')
    return cls(factors, r=2, master_set=master_set, normalized=normalized)

  @classmethod
  def dekany(cls, factors: Tuple[int] = (1, 3, 5, 7, 11), r: int = 2, master_set='arrow', normalized: bool = False):
    """
    Dekany -- a 10-note CPS from 5 factors taken 2 or 3 at a time.

    Parameters
    ----------
    factors : tuple of int, optional
        Exactly 5 generator factors.
    r : int, optional
        Combination rank (2 or 3). Default is 2.
    master_set : str or MasterSet, optional
        Geometric layout template. Default is ``'arrow'``.
    normalized : bool, optional
        Normalize ratios by the largest factor. Default is False.

    Returns
    -------
    CombinationProductSet

    References
    ----------
    .. [1] https://en.xen.wiki/w/Dekany
    """
    if len(factors) != 5:
      raise ValueError('Dekany requires exactly 5 factors.')
    if r not in (2, 3):
      raise ValueError('Dekany rank must be 2 or 3.')
    return cls(factors, r, master_set=master_set, normalized=normalized)

  @classmethod
  def pentadekany(cls, factors: Tuple[int] = (1, 3, 5, 7, 11, 13), r: int = 2, master_set='asterisk', normalized: bool = False):
    """
    Pentadekany -- a 15-note CPS from 6 factors taken 2 or 4 at a time.

    Parameters
    ----------
    factors : tuple of int, optional
        Exactly 6 generator factors.
    r : int, optional
        Combination rank (2 or 4). Default is 2.
    master_set : str or MasterSet, optional
        Geometric layout template. Default is ``'asterisk'``.
    normalized : bool, optional
        Normalize ratios by the largest factor. Default is False.

    Returns
    -------
    CombinationProductSet

    References
    ----------
    .. [1] https://en.xen.wiki/w/Pentadekany
    """
    if len(factors) != 6:
      raise ValueError('Pentadekany requires exactly 6 factors.')
    if r not in (2, 4):
      raise ValueError('Pentadekany rank must be 2 or 4.')
    return cls(factors, r, master_set=master_set, normalized=normalized)

  @classmethod
  def eikosany(cls, factors: Tuple[int] = (1, 3, 5, 7, 9, 11), master_set='asterisk', normalized: bool = False):
    """
    Eikosany -- a 20-note CPS from 6 factors taken 3 at a time.

    Parameters
    ----------
    factors : tuple of int, optional
        Exactly 6 generator factors.
    master_set : str or MasterSet, optional
        Geometric layout template. Default is ``'asterisk'``.
    normalized : bool, optional
        Normalize ratios by the largest factor. Default is False.

    Returns
    -------
    CombinationProductSet

    References
    ----------
    .. [1] https://en.xen.wiki/w/Eikosany
    """
    if len(factors) != 6:
      raise ValueError('Eikosany requires exactly 6 factors.')
    return cls(factors, r=3, master_set=master_set, normalized=normalized)

  @classmethod
  def hebdomekontany(cls, factors: Tuple[int] = (1, 3, 5, 7, 9, 11, 13, 17), master_set='ogdoad', normalized: bool = False):
    """
    Hebdomekontany -- a 70-note CPS from 8 factors taken 4 at a time.

    Parameters
    ----------
    factors : tuple of int, optional
        Exactly 8 generator factors.
    master_set : str or MasterSet, optional
        Geometric layout template. Default is ``'ogdoad'``.
    normalized : bool, optional
        Normalize ratios by the largest factor. Default is False.

    Returns
    -------
    CombinationProductSet

    References
    ----------
    .. [1] https://en.xen.wiki/w/Hebdomekontany
    """
    if len(factors) != 8:
      raise ValueError('Hebdomekontany requires exactly 8 factors.')
    return cls(factors, r=4, master_set=master_set, normalized=normalized)

  # ------------------------------------------------------------------
  # Alternate constructors
  # ------------------------------------------------------------------

  @classmethod
  def from_rules(cls, factors, rules, master_set, normalized=False):
    """
    Construct a CPS from declarative rules instead of a single rank.

    Rules describe which combinations (products, powers, or ratios) to
    include, allowing hybrid CPS structures.

    Parameters
    ----------
    factors : tuple of int
        Generator factors.
    rules : list of tuple
        Each rule is a tuple whose first element is the operation
        (``'product'``, ``'power'``, or ``'ratio'``) followed by
        operation-specific arguments.
    master_set : str or MasterSet
        Geometric layout template.
    normalized : bool, optional
        Normalize ratios by the largest factor. Default is False.

    Returns
    -------
    CombinationProductSet
    """
    aliases = {f: sp.Symbol(chr(65 + i)) for i, f in enumerate(sorted(factors))}
    factors_sorted = tuple(sorted(factors))
    expressions = []
    seen = set()

    for rule in rules:
      op = rule[0]
      if op == 'product':
        r = rule[1]
        from itertools import combinations as _combs
        for combo in _combs(factors_sorted, r):
          expr = sp.Integer(1)
          for f in combo:
            expr *= aliases[f]
          product = prod(combo)
          key = sp.simplify(expr)
          if key not in seen:
            seen.add(key)
            expressions.append({'combo': combo, 'alias': expr, 'product': product})

      elif op == 'power':
        k = rule[1]
        for f in factors_sorted:
          expr = aliases[f] ** k
          product = f ** k
          key = sp.simplify(expr)
          if key not in seen:
            seen.add(key)
            expressions.append({'combo': (f,) * k, 'alias': expr, 'product': product})

      elif op == 'ratio':
        r_num = rule[1]
        r_den = rule[2]
        from itertools import combinations as _combs
        for num_combo in _combs(factors_sorted, r_num):
          remaining = [f for f in factors_sorted if f not in num_combo]
          for den_combo in _combs(remaining, r_den):
            num_expr = sp.Integer(1)
            for f in num_combo:
              num_expr *= aliases[f]
            den_expr = sp.Integer(1)
            for f in den_combo:
              den_expr *= aliases[f]
            expr = num_expr / den_expr
            product = Fraction(prod(num_combo), prod(den_combo))
            key = sp.simplify(expr)
            if key not in seen:
              seen.add(key)
              expressions.append({'combo': (num_combo, den_combo), 'alias': expr, 'product': product})

    n_nodes = len(expressions)
    G = Graph.complete_graph(n_nodes)
    directed_graph = G.to_directed()
    directed_graph._graph.clear_edges()

    for i, entry in enumerate(expressions):
      ratio = equave_reduce(entry['product']) if not isinstance(entry['product'], Fraction) else equave_reduce(entry['product'])
      if normalized:
        ratio = equave_reduce(ratio / max(factors_sorted))
      directed_graph.set_node_data(
        i,
        combo=entry['combo'],
        alias=entry['alias'],
        product=entry['product'],
        ratio=ratio,
      )

    instance = object.__new__(cls)
    instance._factors = factors_sorted
    instance._r = None
    instance._combos = set(e['combo'] for e in expressions)
    instance._factor_aliases = aliases
    instance._graph = directed_graph
    instance._normalized = normalized
    instance._master_set, instance._master_set_instance = cls._resolve_master_set(
      master_set, factors_sorted)

    if instance._master_set_instance is not None:
      instance._build_master_set_structure(instance._master_set_instance.relationship_dict)

    instance._compute_positions()
    instance._graph._set_mutability_policy(topology_mutable=False, node_attr_mutable=False)
    return instance
