"""

    AUTHOR:

        Khushal P Soonderji

    DATE:

        Saturday, 18th May, 2024.

    OBJECTIVE:

        To provide a quick set of functions to work with fuzzy logic.

    REFERENCES:

        N/A

    DOWNLOADS:

        N/A

"""


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                   IMPORT                                                      ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# To make sibling directories accessible for imports:
import sys
sys.path.append(".")
sys.path.append("..")

# To apply fuzzy logic:
from thefuzz import fuzz, process

# To work with datatypes:
from typing import List


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                           MACROS / ONE-TIME INIT                                              ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# --- Nothing Yet


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                VARIABLES                                                      ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# --- Nothing Yet


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                  FUNCTIONS                                                    ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


def get_best_match(
        target: str,
        choices: list[str],
        threshold: float = 0.70,
        partial: bool = False
) -> str | None:

    """
    Given a list of options, this finds the best match to the target string as long as it fits within the similarity
    threshold.
    :param target: The string whose closest match needs to be found.
    :param choices: The list of options to match the target against.
    :param threshold: The absolute lowest similarity value to consider a match. Ranges from 0 to 1.
    :param partial: If partial string matches are allowed.
    :return: The string from the list of choices that is the closes match if it fits within the threshold, or None.
    """

    # The input needs to be a string:
    if not isinstance(target, str): return None

    # Pick a scoring mechanism based on whether, or not, partial matches are allowed:
    if partial: scorer = fuzz.partial_token_sort_ratio
    else: scorer = fuzz.ratio

    # Find the best match:
    result = process.extractOne(
        target,
        choices,
        score_cutoff = threshold * 100,
        scorer = scorer
    )

    # Done here:
    return result[0] if result else None


# ---------------------------------------------------------------------------------------------------------------------


def get_match_score(
        target: str,
        choice: str,
        partial: bool = False
) -> float:

    """
    Given a target and a choice to be considered, what is the similarity score of the choice to the target.
    :param target: The string whose closest match needs to be found.
    :param choice: The string that you want to test the target against.
    :param partial: If partial string matches are allowed.
    :return: The similarity score between the target and the choice (option) in the range from 0 to 1.
    """

    # The input needs to be a string:
    if not isinstance(target, str): return 0.0

    # Pick a scoring mechanism based on whether, or not, partial matches are allowed:
    if partial: scorer = fuzz.partial_token_sort_ratio
    else: scorer = fuzz.ratio

    # Find the best match with not threshold cut-off:
    result = process.extractOne(
        target,
        choices = [choice],
        score_cutoff = 0.0,
        scorer = scorer
    )

    # Done here:
    return result[1] / 100.0 if result else 0.0


# ---------------------------------------------------------------------------------------------------------------------


def rank(
        target: str,
        choices: List[str],
        partial: bool = False
) -> List[dict] | None:

    """
    To rank the similarity of all the options against the target.
    :param target: The string against which all options need to be matched.
    :param choices: The list of strings that need to be ranked for similarity with the target.
    :param partial: If partial string matches are allowed.
    :return: A list of all the choices with their similarity scores sorted from most to least similar.
    """

    # The input needs to be a string:
    if not isinstance(target, str): return None

    # Pick a scoring mechanism based on whether, or not, partial matches are allowed:
    if partial: scorer = fuzz.partial_token_sort_ratio
    else: scorer = fuzz.ratio

    # Match the target against all the options:
    result = process.extract(
        target,
        choices,
        limit = len(choices),
        scorer = scorer
    )

    # Done here:
    result = [{"choice": r[0], "score": r[1] / 100.0} for r in result]
    return result


# ---------------------------------------------------------------------------------------------------------------------


def match(
        targets: List[str],
        choices: List[str],
        threshold: float = 0.7,
        partial: bool = False,
        allow_null: bool = False
) -> dict | None:

    """
    Find the best matches for each target from the list of choices such that each choice is used not more than once.
    USE CASE: When you have a set of columns in a spreadsheet (choices) and you need to match them against a set of
              expected values (targets). In such a case, if you absolutely need a match for every expected column
              (target), you must set 'allow_null' to False. This could be useful in cases like bank statements.
    :param targets: The list of string whose best matches must be found.
    :param choices: The set of option strings that must be paired to the targets.
    :param threshold: The absolute lowest similarity value to consider a match. Ranges from 0 to 1.
    :param partial: If partial string matches are allowed.
    :param allow_null: If set to False, each target must have a match and even one non-match will void the whole process
        and return None. If set to True targets need not have matches.
    :return: A dictionary of the best match for each target from the list of choices such that each choice is used just
        once.
    """

    # Start with basic variables:
    all_rankings = []
    all_matches = {target: None for target in targets}

    # Match every choice against every target:
    for target in targets:
        rankings_for_target = rank(target, choices, partial = partial) or []
        for r in rankings_for_target: r["target"] = target
        all_rankings += rankings_for_target

    # Sort everything based entirely on the final scores of similarity
    # from most to least similar (descending order):
    all_rankings = sorted(all_rankings, key = lambda x: x["score"], reverse = True)

    # Now pick the target of the absolute best match,
    # and keep eliminating the best match for each target:
    while len(all_rankings) > 0:
        top_ranking = all_rankings[0]
        if top_ranking["score"] > threshold:
            all_matches[top_ranking["target"]] = top_ranking["choice"]
        elif not allow_null:
            all_matches = None
            break
        all_rankings = [r for r in all_rankings if r["choice"] != top_ranking["choice"]]

    # Done here:
    return all_matches


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                               MAIN PROGRAM                                                    ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


if __name__ == "__main__":

    pass
