# ======================================================================================
#
#     Rapid Deep Neural Networks
#
#     Licensed under the MIT License
# ______________________________________________________________________________________
# ......................................................................................

# Copyright (c) 2024-2026 Pantelis I. Kaplanoglou

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

# .......................................................................................
import numpy as np
import json
import time
import hashlib
import zlib
import contextlib
import re

phi=(1.0+np.sqrt(5.0))/2.0

# ======================================================================================================================
class classproperty(property):
  def __get__(self, obj, owner):
    return self.fget(owner)
# ======================================================================================================================


# ----------------------------------------------------------------------------------------------------------------------
def to_json(obj, is_sorted_keys=False, is_utf8=False):
  if isinstance(obj, dict):
    sJSON = json.dumps(obj, sort_keys=is_sorted_keys, indent=4, ensure_ascii=is_utf8)
  else:
    sJSON = json.dumps(obj, default=lambda o: obj.__dict__, sort_keys=is_sorted_keys, indent=4, ensure_ascii=is_utf8)
  return sJSON
# ----------------------------------------------------------------------------------------------------------------------
'''
  Checks if the p_sSettingsName is inside the settings dictionary p_dConfig
  and returns its value, otherwise the p_oDefault value
'''
def default_value(dictionary, key, default_value=None):
  if key in dictionary:
    return dictionary[key]
  else:
    return default_value
# ----------------------------------------------------------------------------------------------------------------------
def camel_case(text: str):
  return "".join([sWord.capitalize() for sWord in text.split()])
# ----------------------------------------------------------------------------------------------------------------------
def snake_case(text):
  return "_".join(text.lower().split())
# ----------------------------------------------------------------------------------------------------------------------
def order_str(n: int) -> str:
  n = int(n)
  if 11 <= (n % 100) <= 13:
    suffix = "th"
  else:
    suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
  return f"{n}{suffix}"
# ----------------------------------------------------------------------------------------------------------------------
_CAMEL_TO_SNAKE_1 = re.compile(r"(.)([A-Z][a-z]+)")
_CAMEL_TO_SNAKE_2 = re.compile(r"([a-z0-9])([A-Z])")

def camel_case_to_snake_case(name: str) -> str:
  """
  Convert camelCase or PascalCase to snake_case.
  Examples:
    "camelCase"   -> "camel_case"
    "PascalCase"  -> "pascal_case"
    "JSONDataAPI" -> "json_data_api"
  """
  s = _CAMEL_TO_SNAKE_1.sub(r"\1_\2", name)
  s = _CAMEL_TO_SNAKE_2.sub(r"\1_\2", s)
  return s.replace("__", "_").lower()
# ----------------------------------------------------------------------------------------------------------------------
def digit_count(n: int) -> int:
  return len(str(abs(n)))
# ----------------------------------------------------------------------------------------------------------------------
def interactive_matplotlib():
  import matplotlib
  matplotlib.use('TkAgg')
# ----------------------------------------------------------------------------------------------------------------------
def print_method_execution_time(func, *args, **kwargs):
  start = time.perf_counter()  # High-resolution timer
  result = func(*args, **kwargs)
  end = time.perf_counter()
  print(f"{func.__name__} took {end - start:.6f} seconds")
  return result
# ----------------------------------------------------------------------------------------------------------------------
def data_hash(data: np.ndarray):
  nBytes = data.tobytes()
  return hashlib.sha256(nBytes).hexdigest()
# ----------------------------------------------------------------------------------------------------------------------
def data_crc32(data: np.ndarray):
  nBytes = data.tobytes()
  return zlib.crc32(nBytes)
# --------------------------------------------------------------------------------------
def set_float_format(decimal_digits):
  np.set_printoptions(decimal_digits, suppress=True)
  np.set_printoptions(edgeitems=10)
  np.core.arrayprint._line_width = 180

# ----------------------------------------------------------------------------------------------------------------------
def subdict(maindict: dict, prefix: str, is_level2_only=False):
  dResult = {}
  for k, v in maindict.items():
    bInclude = k.startswith(prefix + ".")
    if bInclude:
      if is_level2_only:
        bInclude = k.count(".") == 1
    
    if bInclude:
      child_key = k.split(".", 1)[1]
      dResult[child_key] = v
      
  return dResult
# ----------------------------------------------------------------------------------------------------------------------
def keys_to_snake_case(dictionary: dict):
  dResult = {}
  for k, v in dictionary.items():
    k = camel_case_to_snake_case(k)
    dResult[k] = v
  return dResult
# ----------------------------------------------------------------------------------------------------------------------
def subdict_kwargs(maindict: dict, prefix: str):
  dResult = {}
  for k, v in maindict.items():
    if k.startswith(prefix + ".") and k.count(".") == 1:
      k = camel_case_to_snake_case(k.split(".", 1)[1])
      dResult[k] = v
  return dResult
# ----------------------------------------------------------------------------------------------------------------------
@contextlib.contextmanager
def print_options(*args, **kwargs):
  original = np.get_printoptions()
  np.set_printoptions(*args, **kwargs)
  try:
    yield
  finally:
    np.set_printoptions(**original)
# ----------------------------------------------------------------------------------------------------------------------
@contextlib.contextmanager
def print_options_float(precision=6):
  original = np.get_printoptions()
  np.set_printoptions(precision=precision, suppress=True)
  try:
    yield
  finally:
      np.set_printoptions(**original)
# ----------------------------------------------------------------------------------------------------------------------
def print_tensor(tensor: np.ndarray, title=None, format="%+.3f", axes_descr=[""], is_simple=False):
  # ................................................
  def printElement(p_nElement, p_bIsScalar):
    if p_bIsScalar:
      print(format % p_nElement, end=" ")
    else:
      print(np.array2string(p_nElement, separator=",", formatter={'float': lambda x: format % x}), end=" ")
  # ................................................
  def strBoxLeft(p_nIndex, p_nCount):
    if (p_nIndex == 0):
      return "┌ "
    elif (p_nIndex == (p_nCount - 1)):
      return "└ "
    else:
      return "│ "

  # ................................................
  def strBoxRight(p_nIndex, p_nCount):
    if (p_nIndex == 0):
      return "┐ "
    elif (p_nIndex == (p_nCount - 1)):
      return "┘ "
    else:
      return "│ "
  
  # ................................................
  def print_header(shape_of_tensor):
    if not is_simple:
      print("-" * 70)
    if title is None:
      print(f"shape:{shape_of_tensor}")
    else:
      if is_simple:
        print(f"{title} ", end="")
      else:
        print(f"{title} shape:{shape_of_tensor}")
  # ................................................
  shape_of_tensor = tensor.shape
  rank_of_tensor = len(tensor.shape)
  tensor = tensor.squeeze()
  actual_rank_of_tensor = len(tensor.shape)
  
  if actual_rank_of_tensor == 3:
    tensor = tensor[np.newaxis, ...]
  elif actual_rank_of_tensor == 2:
    tensor = tensor[np.newaxis, ..., np.newaxis]
  elif actual_rank_of_tensor == 5:
    tensor = tensor[..., np.newaxis]
  elif actual_rank_of_tensor == 1:
    print_header(shape_of_tensor)
    print("[ ", end="")
    for nX in tensor:
      printElement(nX, True)
    print(" ]")
    return
  else:
    raise Exception("Supported tensors are rank 2 to rank 5")

  nSampleIndex = 0
  nCount, nGridRows, nGridCols = tensor.shape[0:3]
  nSliceCoordDigits = len(str(nGridRows))
  if len(str(nGridCols)) > nSliceCoordDigits:
    nSliceCoordDigits = len(str(nGridCols))

  bIsGridOfTensors = (len(tensor.shape) == 6)
  if bIsGridOfTensors:
    nWindowRows, nWindowCols = tensor.shape[3:5]

  bIsScalar = (tensor.shape[-1] == 1)

  nSpaces = nSliceCoordDigits * 2 + 5
  sSliceHeaders = ["X" + " " * (nSpaces - 3) + "= ",
                   " %" + str(nSliceCoordDigits) + "d" ",%" + str(nSliceCoordDigits) + "d   ",
                   " " * nSpaces]

  print_header(shape_of_tensor)
  while nSampleIndex < nCount:
    if axes_descr[0].strip() != "":
      print(f"  {axes_descr[0]}:#{nSampleIndex}")
    for nRow in range(0, nGridRows):
      if bIsGridOfTensors:
        for nY in range(nWindowRows):
          for nCol in range(0, nGridCols):
            if (nY == 0):
              print(sSliceHeaders[0] + strBoxLeft(nY, nWindowRows), end="")
            elif (nY == 1):
              sBaseStr = sSliceHeaders[1] % (nRow, nCol)
              print(sBaseStr + strBoxLeft(nY, nWindowRows), end="")
            else:
              print(sSliceHeaders[2] + strBoxLeft(nY, nWindowRows), end="")
            for nX in range(nWindowCols):
              printElement(tensor[nSampleIndex, nRow, nCol, nY, nX, ...], bIsScalar)

            print(strBoxRight(nY, nWindowRows), end="")
          print("")
        print("")
      else:
        print(strBoxLeft(nRow, nGridRows), end="")
        for nCol in range(0, nGridCols):
          printElement(tensor[nSampleIndex, nRow, nCol, ...], bIsScalar)
        print(strBoxRight(nRow, nGridRows))
    print("." * 60)
    nSampleIndex += 1
# ----------------------------------------------------------------------------------------------------------------------
