#!/usr/bin/env python3
"""
json_utils.py

This script is a utility module providing functions for handling JSON data. It includes functionalities like:
1. Converting JSON strings to dictionaries and vice versa.
2. Merging, adding to, updating, and removing keys from dictionaries.
3. Retrieving keys, values, specific items, and key-value pairs from dictionaries.
4. Recursively displaying values of nested JSON data structures with indentation.
5. Loading from and saving dictionaries to JSON files.
6. Validating and cleaning up JSON strings.
7. Searching and modifying nested JSON structures based on specific keys, values, or paths.
8. Inverting JSON data structures.
9. Creating and reading from JSON files.

Each function is documented with Python docstrings for detailed usage instructions.

This module is part of the `abstract_utilities` package.

Author: putkoff
Date: 05/31/2023
Version: 0.1.2
"""

import json
import re
import os
import logging
from .read_write_utils import check_read_write_params, read_from_file, write_to_file
from .compare_utils import get_closest_match_from_list
from .path_utils import makeAllDirs
from .list_utils import make_list
from typing import List, Union, Dict, Any
from .class_utils import alias

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def convert_and_normalize_values(values):
    for value in values:
        if isinstance(value, str):
            yield value.lower()
        elif isinstance(value, (int, float)):
            yield value
        else:
            yield str(value).lower()
def json_key_or_default(json_data,key,default_value):
    json_data = safe_json_loads(json_data)
    if not isinstance(json_data,dict) or (isinstance(json_data,dict) and key not in json_data):
        return default_value
    return json_data[key]

def create_and_read_json(file_path: str=None, json_data: dict = None, error=False, error_msg=True,*args, **kwargs) -> dict:
    """
    Create a JSON file if it does not exist, then read from it.
    
    Args:
        file_path (str): The path of the file to create and read from.
        json_data (dict): The content to write to the file if it does not exist.
        
    Returns:
        dict: The contents of the JSON file.
    """
    params = check_read_write_params(file_path=file_path,contents=json_data,*args,  **kwargs)
    if params:
        file_path = params[0]
        data = params[1]
        if not os.path.isfile(file_path):
            if error_msg == True:
                error_msg = f"{file_path} does not exist; creating file with default_data"
            safe_dump_to_file(file_path,data)
        return safe_read_from_json(file_path)

def is_valid_json(json_string: str) -> bool:
    """
    Checks whether a given string is a valid JSON string.

    Args:
        json_string (str): The string to check.

    Returns:
        bool: True if the string is valid JSON, False otherwise.
    """
    try:
        json_obj = json.loads(json_string)
        return True
    except json.JSONDecodeError:
        return False
def get_error_msg(error_msg, default_error_msg):
    return error_msg if error_msg else default_error_msg



def safe_write_to_json(file_path:str=None, data:(dict or list or str)=None,*args, ensure_ascii=False, indent=4, error=False, error_msg=None, **kwargs):
    params = check_read_write_params(file_path=file_path,contents=data,*args, **kwargs)
    if params:
        temp_file_path = f"{params[0]}.temp"
        var_data = {"file_path": temp_file_path, "instance": 'w'}
        error_msg = get_error_msg(error_msg, f"Error writing JSON to '{params[0]}'")
        
        open_it = all_try(var_data=var_data, function=open, error_msg=error_msg)
        
        if open_it:
            with open_it as file:
                safe_dump_to_file(params[1], file, ensure_ascii=ensure_ascii, indent=indent)
        
            os.replace(temp_file_path, params[0])


def safe_write_to_json(file_path:str=None, data:(dict or list or str)=None,*args, ensure_ascii=False, indent=4, error=False, error_msg=None, **kwargs):
    params = check_read_write_params(file_path=file_path,contents=data,*args, **kwargs)
    if params:
        temp_file_path = f"{params[0]}.temp"
        var_data = {"file_path": temp_file_path, "instance": 'w'}
        error_msg = get_error_msg(error_msg, f"Error writing JSON to '{params[0]}'")
        
        open_it = all_try(var_data=var_data, function=open, error_msg=error_msg)
        
        if open_it:
            with open_it as file:
                safe_dump_to_file(params[1], file, ensure_ascii=ensure_ascii, indent=indent)
        
            os.replace(temp_file_path, params[0])
def safe_dump_to_file(data, file_path=None, ensure_ascii=False, indent=4, *args, **kwargs):
    if file_path is not None and data is not None:
        try:
            if isinstance(data, (dict, list, tuple)):
                with open(file_path, 'w', encoding='utf-8') as file:
                    json.dump(data, file, ensure_ascii=ensure_ascii, indent=indent)
            else:
                with open(file_path, 'w', encoding='utf-8') as file:
                    file.write(str(data))
        except Exception as e:
            logger.error(f"Error in safe_dump_to_file: {e}\nData: {file_path}")
    else:
        logger.error("file_path and data must be provided to safe_dump_to_file")

def safe_read_from_json(file_path):
    try:
        with open(file_path, 'r', encoding='utf-8') as file:
            return json.load(file)
    except Exception as e:
        logger.error(f"Error in safe_read_from_json: {e}\nFile path: {file_path}")
        return None
def read_from_json(file_path):
    return safe_read_from_json(file_path)
def safe_load_from_json(file_path):
    return safe_read_from_json(file_path)
def safe_load_from_file(file_path):
    return safe_read_from_json(file_path)
def safe_json_reads(file_path):
    return safe_read_from_json(file_path)


def safe_dump_to_json(*args, **kwargs):
    return safe_write_to_json(*args, **kwargs)
def safe_write_to_json(*args, **kwargs):
    return safe_write_to_json(*args, **kwargs)
def safe_write_to_file(*args, **kwargs):
    return safe_write_to_json(*args, **kwargs)



def find_keys(data, target_keys):
    def _find_keys_recursive(data, target_keys, values):
        if isinstance(data, dict):
            for key, value in data.items():
                if key in target_keys:
                    values.append(value)
                _find_keys_recursive(value, target_keys, values)
        elif isinstance(data, list):
            for item in data:
                _find_keys_recursive(item, target_keys, values)
    
    values = []
    _find_keys_recursive(data, target_keys, values)
    return values

def try_json_loads(data):
    try:
        return json.loads(data)
    except json.JSONDecodeError:
        return None
    
def try_json_load(file):
    try:
        return json.load(file)
    except json.JSONDecodeError:
        return None
    
def try_json_dump(file):
    try:
        return json.dump(file)
    except json.JSONDecodeError:
        return None
    
def try_json_dumps(data):
    try:
        return json.dumps(data)
    except json.JSONDecodeError:
        return None
    
def safe_json_loads(data):
    return try_json_loads(data) or data
    
def safe_json_load(file):
    return try_json_load(file) or file
    
def safe_json_dump(file):
    return try_json_dump(file) or file
    
def safe_json_dumps(data):
    return try_json_dumps(data) or data
def unified_json_loader(file_path, default_value=None, encoding='utf-8'):
    # Try to load from the file
    with open(file_path, 'r', encoding=encoding) as file:
        content = all_try(data=file, function=try_json_load, error_value=json.JSONDecodeError, error=False)
    
    if isinstance(content, dict):
        return content
    
    # Try to load from the file as a string
    with open(file_path, 'r', encoding=encoding) as file:
        content_str = file.read()
        content = all_try(data=content_str, function=try_json_loads, error_value=json.JSONDecodeError, error=False)
    
    if isinstance(content, dict):
        return content
    
    print(f"Error reading JSON from '{file_path}'.")
    return default_value


def get_key_values_from_path(json_data, path):
    try_path = get_value_from_path(json_data, path[:-1])
    if isinstance(try_path, dict):
        return list(try_path.keys())
    
    current_data = json_data
    for step in path:
        try:
            current_data = current_data[step]
            if isinstance(current_data, str):
                try:
                    current_data = json.loads(current_data)
                except json.JSONDecodeError:
                    pass
        except (TypeError, KeyError, IndexError):
            return None
    
    if isinstance(current_data, dict):
        return list(current_data.keys())
    else:
        return None
def convert_to_json(obj):
    if isinstance(obj, dict):
        return obj
    if isinstance(obj, str):
        return safe_json_loads(obj)
    return None
def get_any_key(data,key):
    path_to_key = find_paths_to_key(safe_json_loads(data),key)
    if path_to_key:
        value = safe_json_loads(data)
        for each in path_to_key[0]:
            value = safe_json_loads(value[each])
        return value
    return path_to_key

def all_try(function=None, data=None, var_data=None, error=False, error_msg=None, error_value=Exception, attach=None, attach_var_data=None):
    try:
        if not function:
            raise ValueError("Function is required")

        if var_data and not data:
            result = function(**var_data)
        elif data and not var_data:
            if attach and attach_var_data:
                result = function(data).attach(**attach_var_data)
            else:
                result = function(data).attach() if attach else function(data)
        elif data and var_data:
            raise ValueError("Both data and var_data cannot be provided simultaneously")
        else:
            result = function()

        return result
    except error_value as e:
        if error:
            raise e
        elif error_msg:
            print_error_msg(error_msg, f': {e}')
        return False
def all_try_json_loads(data, error=False, error_msg=None, error_value=(json.JSONDecodeError, TypeError)):
    return all_try(data=data, function=json.loads, error=error, error_msg=error_msg, error_value=error_value)

def safe_json_loads(data, default_value=None, error=False, error_msg=None): 
    """ Safely attempts to load a JSON string. Returns the original data or a default value if parsing fails.
    Args:
        data (str): The JSON string to parse.
        default_value (any, optional): The value to return if parsing fails. Defaults to None.
        error (bool, optional): Whether to raise an error if parsing fails. Defaults to False.
        error_msg (str, optional): The error message to display if parsing fails. Defaults to None.
    
    Returns:
        any: The parsed JSON object, or the original data/default value if parsing fails.
    """
    if isinstance(data,dict):
        return data
    try_json = all_try_json_loads(data=data, error=error, error_msg=error_msg)
    if try_json:
        return try_json
    if default_value:
        data = default_value
    return data
def clean_invalid_newlines(json_string: str,line_replacement_value='') -> str: 
    """ Removes invalid newlines from a JSON string that are not within double quotes.
    Args:
        json_string (str): The JSON string containing newlines.
    
    Returns:
        str: The JSON string with invalid newlines removed.
    """
    pattern = r'(?<!\\)\n(?!([^"]*"[^"]*")*[^"]*$)'
    return re.sub(pattern, line_replacement_value, json_string)
def get_value_from_path(json_data, path,line_replacement_value='*n*'): 
    """ Traverses a nested JSON object using a specified path and returns the value at the end of that path.
    Args:
        json_data (dict/list): The JSON object to traverse.
        path (list): The path to follow in the JSON object.
    
    Returns:
        any: The value at the end of the specified path.
    """
    current_data = safe_json_loads(json_data)
    for step in path:
        current_data = safe_json_loads(current_data[step])
        if isinstance(current_data, str):
            current_data = read_malformed_json(current_data,line_replacement_value=line_replacement_value)
    return current_data
def find_paths_to_key(json_data, key_to_find,line_replacement_value='*n*'): 
    """ Searches a nested JSON object for all paths that lead to a specified key.
    Args:
        json_data (dict/list): The JSON object to search.
        key_to_find (str): The key to search for in the JSON object.
    
    Returns:
        list: A list of paths (each path is a list of keys/indices) leading to the specified key.
    """
    def _search_path(data, current_path):
        if isinstance(data, dict):
            for key, value in data.items():
                new_path = current_path + [key]
                if key == key_to_find:
                    paths.append(new_path)
                if isinstance(value, str):
                    try:
                        json_data = read_malformed_json(value,line_replacement_value=line_replacement_value)
                        _search_path(json_data, new_path)
                    except json.JSONDecodeError:
                        pass
                _search_path(value, new_path)
        elif isinstance(data, list):
            for index, item in enumerate(data):
                new_path = current_path + [index]
                _search_path(item, new_path)
    
    paths = []
    _search_path(json_data, [])
    return paths
def read_malformed_json(json_string,line_replacement_value="*n"): 
    """ Attempts to parse a malformed JSON string after cleaning it.
    Args:
        json_string (str): The malformed JSON string.
    
    Returns:
        any: The parsed JSON object.
    """
    if isinstance(json_string, str):
        json_string = clean_invalid_newlines(json_string,line_replacement_value=line_replacement_value)
    return safe_json_loads(json_string)
def get_any_value(json_obj, key,line_replacement_value="*n*"): 
    """ Fetches the value associated with a specified key from a JSON object or file. If the provided input is a file path, it reads the file first.
    Args:
        json_obj (dict/list/str): The JSON object or file path containing the JSON object.
        key (str): The key to search for in the JSON object.
    
    Returns:
        any: The value associated with the specified key.
    """
    if isinstance(json_obj,str):
        if os.path.isfile(json_obj):
            with open(json_obj, 'r', encoding='UTF-8') as f:
                json_obj=f.read()
    json_data = read_malformed_json(json_obj)
    paths_to_value = find_paths_to_key(json_data, key)
    if not isinstance(paths_to_value, list):
        paths_to_value = [paths_to_value]
    for i, path_to_value in enumerate(paths_to_value):
        paths_to_value[i] = get_value_from_path(json_data, path_to_value)
        if isinstance(paths_to_value[i],str):
            paths_to_value[i]=paths_to_value[i].replace(line_replacement_value,'\n')
    if isinstance(paths_to_value,list):
        if len(paths_to_value) == 0:
            paths_to_value=None
        elif len(paths_to_value)==1:
            paths_to_value = paths_to_value[0]
    return paths_to_value
def format_json_key_values(json_data, indent=0):
    formatted_string = ""

    # Check if the input is a string and try to parse it as JSON
    if isinstance(json_data, str):
        try:
            json_data = json.loads(json_data)
        except json.JSONDecodeError:
            return "Invalid JSON string"

    # Function to format individual items based on their type
    def format_item(item, indent):
        if isinstance(item, dict):
            return format_json_key_values(item, indent)
        elif isinstance(item, list):
            return format_list(item, indent)
        else:
            return '    ' * indent + str(item) + "\n"

    # Function to format lists
    def format_list(lst, indent):
        lst_str = ""
        for elem in lst:
            lst_str += format_item(elem, indent + 1)
        return lst_str

    # Iterate over each key-value pair
    for key, value in json_data.items():
        # Append the key with appropriate indentation
        formatted_string += '    ' * indent + f"{key}:\n"

        # Recursively format the value based on its type
        formatted_string += format_item(value, indent)

    return formatted_string

def find_matching_dicts(dict_objs:(dict or list)=None,keys:(str or list)=None,values:(str or list)=None):
    values = make_list(values) if values is not None else []
    dict_objs = make_list(dict_objs) if dict_objs is not None else [{}]
    keys = make_list(keys) if keys is not None else []
    bool_list_og = [False for i in range(len(keys))]
    found_dicts = []
    for dict_obj in dict_objs:
        bool_list = bool_list_og
        for i,key in enumerate(keys):
            if key in list(dict_obj.keys()):
                if dict_obj[key] == values[i]:
                    bool_list[i]=True
                    if False not in bool_list:
                        found_dicts.append(dict_obj)
    return found_dicts

def closest_dictionary(dict_objs:dict=None,values:(str or list)=None):
    values = make_list(values) if values is not None else []
    dict_objs = make_list(dict_objs) if dict_objs is not None else [{}]
    total_values = [value for dict_obj in dict_objs for value in dict_obj.values()]
    matched_objs = [get_closest_match_from_list(value, total_values) for value in values]
    bool_list_og = [False for i in range(len(matched_objs))]
    for dict_obj in dict_objs:
        bool_list = bool_list_og
        for key, key_value in dict_obj.items():
            for i,matched_obj in enumerate(matched_objs):
                if key_value.lower() == matched_obj.lower():
                    bool_list[i]=True
                    if False not in bool_list:
                        return dict_obj
    return None

def get_dict_from_string(string, file_path=None):
    bracket_count = 0
    start_index = None
    for i, char in enumerate(string):
        if char == '{':
            bracket_count += 1
            if start_index is None:
                start_index = i
        elif char == '}':
            bracket_count -= 1
            if bracket_count == 0 and start_index is not None:
                json_data = safe_json_loads(string[start_index:i+1])
                if file_path:
                    safe_dump_to_file(file_path=makeAllDirs(file_path), data=json_data)
                return json_data
    return None
                    
def closest_dictionary(dict_objs=None, values=None):
    values = make_list(values) if values is not None else []
    dict_objs = make_list(dict_objs) if dict_objs is not None else [{}]
    total_values = [value for dict_obj in dict_objs for value in dict_obj.values()]
    matched_objs = [get_closest_match_from_list(value, total_values) for value in values]

    for dict_obj in dict_objs:
        # Using all() with a generator expression for efficiency
        if all(match in convert_and_normalize_values(dict_obj.values()) for match in matched_objs):
            return dict_obj
    return None                  

def get_all_keys(dict_data,keys=[]):
  if isinstance(dict_data,dict):
    for key,value in dict_data.items():
      keys.append(key)
      keys = get_all_keys(value,keys=keys)
  return keys

def update_dict_value(data, paths, new_value):
    """
    Traverses a dictionary to the specified key path and updates its value.
    
    Args:
        data (dict): The dictionary to traverse.
        paths (list): The list of keys leading to the target value.
        new_value (any): The new value to assign to the specified key.

    Returns:
        dict: The updated dictionary.
    """
    d = data
    for key in paths[:-1]:
        # Traverse the dictionary up to the second-to-last key
        d = d[key]
    # Update the value at the final key
    d[paths[-1]] = new_value
    return data
def get_all_key_values(keys=None,dict_obj=None):
    keys = keys or []
    dict_obj = dict_obj or {}
    new_dict_obj = {}
    for key in keys:
        values = dict_obj.get(key)
        if values:
            new_dict_obj[key]=values
    return new_dict_obj

def get_all_key_values(keys=None,dict_obj=None):
    keys = keys or []
    dict_obj = dict_obj or {}
    new_dict_obj = {}
    for key in keys:
        values = dict_obj.get(key)
        if values:
            new_dict_obj[key]=values
    return new_dict_obj

def safe_update_json_datas(json_data,
                           update_data,
                           valid_keys=None,
                           invalid_keys=None
                           ):
    valid_keys=make_list(valid_keys or [])
    invalid_keys=make_list(invalid_keys or [])
    for key,value in update_data.items():
        json_data_value = json_data.get(key)
        if keys:
            if key in valid_keys:
                json_data[key] = json_data.get(key) or value
            if key in invalid_keys and json_data_value != None:
                del json_data[key]
        else:
            json_data[key] = json_data_value or value
    
    return json_data

def get_json_file_path(file_path,data=None):
    data = data or {}
    if not os.path.isfile(file_path):
        safe_dump_to_file(data={},file_path=file_path)
    return file_path

def get_json_file_data(file_path):
    if os.path.isfile(file_path):
        return safe_load_from_json(file_path)

def get_create_json_data(file_path,data=None):
    get_json_file_path(file_path,data=data)
    return get_json_file_data(file_path)

def get_json_data(file_path):
    file_path = get_file_path(file_path)
    data = safe_read_from_json(file_path)
    return data

def save_updated_json_data(data,file_path):
    data = data or {}
    new_data = get_json_data(file_path)
    new_data.update(data)
    safe_dump_to_file(new_data,file_path)
    
def safe_updated_json_data(data,
                            file_path,
                            valid_keys=None,
                            invalid_keys=None
                            ):
    update_data = data or {}
    json_data = get_create_json_data(file_path,data={})
    new_data = safe_update_json_datas(json_data=json_data,
                           update_data=update_data,
                           valid_keys=valid_keys,
                           invalid_keys=valid_keys
                           )
    return new_data
def safe_save_updated_json_data(data,
                            file_path,
                            valid_keys=None,
                            invalid_keys=None
                            ):
    new_data = safe_updated_json_data(data=data,
                            file_path=file_path,
                            valid_keys=valid_keys,
                            invalid_keys=invalid_keys
                            )    
    safe_dump_to_file(new_data,file_path)
    return new_data

def get_result_from_data(key,func,**data):
    result_data = func(**data)
    result = result_data.get(key)
    return result


