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 combination product set is a scale generated by the following means:
  
      1. A set S of n positive real numbers is the starting point.
      
      2. All the combinations of k elements of the set are obtained, and their 
          products taken.
          
      3. These are combined into a set, and then all of the elements of that set 
          are divided by one of them (which one is arbitrary; if a canonical choice 
          is required, the smallest element could be used).
          
      4. The resulting elements are octave-reduced and sorted in ascending order, 
          resulting in an octave period of a periodic scale (the usual sort of 
          scale, in other words) which we may call CPS(S, k).
  
  Named presets (hexany, dekany, etc.) are available as classmethods.
  
  see: https://en.xen.wiki/w/Combination_product_set
  '''

  @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()

  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.nodes[node]['product'] = product
        self._graph.nodes[node]['ratio']   = ratio
        self._graph.nodes[node]['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):
    return dict(self._positions)

  @property
  def master_set(self):
    return self._master_set
  
  @property
  def master_set_instance(self):
    return self._master_set_instance

  @property
  def aliases(self):
    return self.factor_to_alias
    
  @property
  def products(self):
    return tuple(sorted(attrs['product'] for _, attrs in self._graph.nodes(data=True)))
  
  @property
  def ratios(self):
    return tuple(sorted(attrs['ratio'] for _, attrs in self._graph.nodes(data=True)))
  
  def get_node_by(self, attr_name, value):
    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):
    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.

    see:  https://en.wikipedia.org/wiki/Hexany
          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.

    see: 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.

    see: 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.

    see: 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.

    see: 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):
    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.nodes[i]['combo'] = entry['combo']
      directed_graph.nodes[i]['alias'] = entry['alias']
      directed_graph.nodes[i]['product'] = entry['product']
      directed_graph.nodes[i]['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()
    return instance
