# Copyright (C) 2023 - 2024  Jakub Więckowski

import numpy as np
from itertools import product
from ..validator import Validator
from ..utils import memory_guard

@memory_guard
def percentage_modification(matrix: np.ndarray, percentages: int | np.ndarray, direction: None | np.ndarray = None, indexes: None | np.ndarray = None, step: int | np.ndarray = 1) -> list[tuple[int, int | tuple, tuple, np.ndarray]]:
    """
    Modify a decision matrix based on specified percentage changes, directions, indexes, and steps of percentage modifications.

    Parameters:
    -------------
    matrix : ndarray
        2D array representing the initial decision matrix.

    percentages : int | ndarray
        Percentage changes to be applied to the values in decision matrix. 
        If int, the same percentage change is applied to all values.
        If ndarray, it specifies the percentage change for each column from matrix individually.

    direction : None | ndarray, optional, default=None
        Direction of the modification for each column in the matrix. If None, both increase and decrease directions are considered.
        If ndarray, it specifies the direction (1 for increase, -1 for decrease) for each criterion individually.

    indexes : None | ndarray, optional, default=None
        Indexes of the columns from matrix to be modified. If None, all columns are considered subsequently.
        If ndarray, it specifies the indexes or combinations of indexes for the columns to be modified.

    step : int | np.ndarray, optional, default=1
        Step size for the percentage change. If int, all changes for columns are made with the same step.
        If ndarray, the modification step is adjusted for each column separately.

    Returns:
    ----------
    List[Tuple[int, int | tuple, tuple, ndarray]]
        A list of tuples containing information about the modified alternative index, criteria index, percentage change,
        and the resulting decision matrix.

    Examples:
    -----------
    Example 1: Modify decision matrix with a single percentage change

    >>> matrix = np.array([
    ... [4, 1, 6],
    ... [2, 6, 3],
    ... [9, 5, 7],
    ... ])
    >>> percentages = 5
    >>> results = percentage_modification(matrix, percentages)
    >>> for r in results:
    ...     print(r)

    Example 2: Modify matrix with percentages list

    >>> matrix = np.array([
    ... [4, 1, 6],
    ... [2, 6, 3],
    ... [9, 5, 7],
    ... ])
    >>> percentages = np.array([3, 5, 8])
    >>> results = percentage_modification(matrix, percentages)
    >>> for r in results:
    ...     print(r)

    Example 3: Modify matrix with percentages list and specific direction for each column

    >>> matrix = np.array([
    ... [4, 1, 6],
    ... [2, 6, 3],
    ... [9, 5, 7],
    ... ])
    >>> percentages = np.array([2, 4, 6])
    >>> direction = np.array([-1, 1, -1])
    >>> results = percentage_modification(matrix, percentages, direction)
    >>> for r in results:
    ...     print(r)

    Example 4: Modify matrix with percentages list, and specific column indexes

    >>> matrix = np.array([
    ... [4, 1, 6],
    ... [2, 6, 3],
    ... [9, 5, 7],
    ... ])
    >>> percentages = np.array([1, 4, 2])
    >>> indexes = np.array([[0, 2], 1], dtype='object')
    >>> results = percentage_modification(matrix, percentages, indexes)
    >>> for r in results:
    ...     print(r)

    Example 5: Modify matrix with percentages list, and specific modification step
    
    >>> matrix = np.array([
    ... [4, 1, 6],
    ... [2, 6, 3],
    ... [9, 5, 7],
    ... ])
    >>> percentages = np.array([2, 4, 9])
    >>> step = np.array([2, 2, 3])
    >>> results = percentage_modification(matrix, percentages, step=step)
    >>> for r in results:
    ...     print(r)
    """

    def modify_matrix(matrix: np.ndarray, alt_idx: int, crit_idx: int, diff: float, direction_val: int) -> np.ndarray:
        new_matrix = matrix.copy().astype(float)

        new_matrix[alt_idx, crit_idx] = matrix[alt_idx, crit_idx] + diff * direction_val

        return new_matrix

    Validator.is_type_valid(matrix, np.ndarray, 'matrix')
    Validator.is_dimension_valid(matrix, 2, 'matrix')

    Validator.is_type_valid(percentages, (int, np.integer, np.ndarray), 'percentages')
    if isinstance(percentages, np.ndarray):
        # check if matrix and percentages have the same length
        Validator.is_shape_equal(matrix.shape[1], percentages.shape[0], custom_message="Number of columns in 'matrix' and length of 'percentages' are different")

    if direction is not None:
        Validator.is_type_valid(direction, np.ndarray, 'direction')
        # check if matrix and direction have the same length
        Validator.is_shape_equal(matrix.shape[1], direction.shape[0], custom_message="Number of columns in 'matrix' and length of 'direction' are different")

    Validator.is_type_valid(step, (int, np.integer, np.ndarray), 'step')
    if isinstance(step, np.ndarray):
        # check if matrix and step have the same length
        Validator.is_shape_equal(matrix.shape[1], step.shape[0], custom_message="Number of columns in 'matrix' and length of 'step' are different")

    if indexes is not None:
        Validator.is_type_valid(indexes, np.ndarray, 'indexes')
        Validator.are_indexes_valid(indexes, matrix.shape[1])
        
    results = []

    # size of changes of matrix values
    percentages_values = None
    if isinstance(percentages, (int, np.integer)):
        percentages_values = np.array([percentages] * matrix.shape[1])
    if isinstance(percentages, np.ndarray):
        percentages_values = percentages

    # vectors with subsequent changes for criteria in matrix
    if isinstance(step, (int, np.integer)):
        percentages_changes = np.array([np.arange(step, p+step, step) / 100 for p in percentages_values], dtype='object')
    else:
        percentages_changes = np.array([np.arange(step[idx], p+step[idx], step[idx]) / 100 for idx, p in enumerate(percentages_values)], dtype='object')

    # increasing or decreasing matrix values
    direction_values = None
    if direction is None:
        direction_values = np.array([[-1, 1]] * matrix.shape[1])
    else:
        direction_values = np.array([[val] for val in direction])

    # criteria indexes to modify matrix values
    indexes_values = None
    if indexes is None:
        indexes_values = np.arange(0, matrix.shape[1], dtype=int)
    else:
        indexes_values = indexes

    alt_indexes = np.arange(0, matrix.shape[0], dtype=int)

    for alt_idx in alt_indexes:
        for crit_idx in indexes_values:
            if isinstance(crit_idx, (int, np.integer)):
                changes = percentages_changes[crit_idx]
            else:
                changes = list(product(*percentages_changes[crit_idx]))
            
            for change in changes:
                diff = matrix[alt_idx, crit_idx] * change
                
                change_direction = direction_values[crit_idx]
                if not isinstance(crit_idx, (int, np.integer)):
                    change_direction = [direction_values[crit_idx][0]]
                
                for val in change_direction:
                    if isinstance(val, (int, np.integer)):
                        new_matrix = modify_matrix(matrix, alt_idx, crit_idx, diff, val)
                        results.append((alt_idx, crit_idx, change * val, new_matrix))
                    else:
                        for v in val:
                            change_val = tuple(c * v for c in change)
                            new_matrix = modify_matrix(matrix, alt_idx, crit_idx, diff, v)
                            results.append((alt_idx, tuple(crit_idx), change_val, new_matrix))
    
    return results
