Numeric = int | float
Label = int
InputData = tuple[Numeric, ...]
Centroid = tuple[Numeric, ...]

class KMeansAlgorithm:
    def __init__(self, k:int=2, max_n:int=1000) -> None:
        """ """
        if not isinstance(k, int):
            raise TypeError(f"k must be int, was type {type(k)}")
        self.__k = k
        self.__n = 0
        self.__ul = max_n


    @property
    def k(self) -> int:
        """ Returns k for k-means """
        return self.__k


    @k.setter
    def k(self, k:int) -> None:
        """ Defines setter for k with type checking
        
        :param k: number of clusters
        """
        if not isinstance(k, int):
            raise TypeError(f"k must be int, was type {type(k)}")
        self.__k = k


    @property
    def exceeds_max_n(self) -> bool:
        """ Runtime limiter for k-means algorithm. Returns True if the internal counter n exceeds the upper limit ul, False otherwise. """
        return self.__n >= self.__ul


    def _increment(self) -> None:
        """ Increment internal counter for runtime limiting """
        self.__n += 1


    def fit(self,
            X:list[InputData]) -> tuple[dict[Label, Centroid],
                                        dict[InputData, Label]]:
        """ Fit the k-means algorithm to the input data X.
        
        Requires implementation of the initialise, assign, update, and converged methods.
        
        :param X: list of input data points
        :return: tuple of (set of centroids, dict mapping input data to assigned centroid)
        """
        centroids = self.initialise(X)
        labels = self.assign(X, centroids)
        while not self.converged:
            centroids = self.update(X, labels)
            labels = self.assign(X, centroids)
            self._increment()
            if self.exceeds_max_n: break
            
        return centroids, labels


    def initialise(self, X:list[InputData]) -> dict[Label, Centroid]:
        """ 
        Initialise centroids for k-means algorithm. Must return a set of k centroids.
        
        Requires implementation in subclass.
        
        :param X: list of input data points
        :return: dictionary of k centroids with labels as keys
        """
        raise NotImplemented(
            f"initialise must be overriden in {self.__class__.__name__}")


    def assign(self, 
               X:list[InputData], 
               centroids:dict[Label, Centroid]) -> dict[InputData, Label]:
        """
        Assign each input data point to the nearest centroid. Must return a dict mapping each input data point to its assigned centroid.
        
        Requires implementation in subclass.
        
        :param X: list of input data points
        :param centroids: dictionary of current centroids with labels as keys
        :return: dict mapping each input data point to its assigned centroid
        """
        raise NotImplemented(
            f"assign must be overriden in {self.__class__.__name__}")

   
    def update(self,
               X:list[InputData],
               labels: dict[InputData, Label]) -> dict[Label, Centroid]:
        """
        Update the centroids based on the current assignment of input data points. Must return a new set of centroids.
        
        Requires implementation in subclass.
        
        :param X: list of input data points
        :param labels: dict mapping each input data point to its assigned label
        :return: new dictionary of centroids with labels as keys
        """
        raise NotImplemented(
            f"update must be overriden in {self.__class__.__name__}")

    
    @property
    def converged(self) -> bool:
        """
        Check if the k-means algorithm has converged. Must return a boolean indicating convergence.
        
        :return: True if the algorithm has converged, False otherwise
        """
        return False # default implementation, override in subclass for actual convergence checking