#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""Customizations to how Python code objects are pickled.

This module provides helper functions to improve pickling code objects,
especially lambdas, in a consistent way by using code object identifiers. These
helper functions are used to patch pickler implementations used by Beam
(e.g. Cloudpickle).

A code object identifier is a unique identifier for a code object that provides
a unique reference to the code object in the context where the code is defined
and is invariant to small changes in the surrounding code.

The code object identifiers consists of a sequence of the following parts
separated by periods:
- Module names - The name of the module the code object is in
- Class names - The name of a class containing the code object. There can be
  multiple of these in the same identifier in the case of nested
  classes.
- Function names - The name of the function containing the code object.
  There can be multiple of these in the case of nested functions.
- __code__ - Attribute indicating that we are entering the code object of a
  function/method.
- __co_consts__[<name>] - The name of the local variable containing the
  code object. In the case of lambdas, the name is created by using the
  signature of the lambda and hashing the bytecode, as shown below.

Examples:
- __main__.top_level_function.__code__
- __main__.ClassWithNestedFunction.process.__code__.co_consts[nested_function]
- __main__.ClassWithNestedLambda.process.__code__.co_consts[
    get_lambda_from_dictionary].co_consts[<lambda>, ('x',)]
- __main__.ClassWithNestedLambda.process.__code__.co_consts[
    <lambda>, ('x',), 1234567890]
"""

import collections
import hashlib
import inspect
import re
import sys
import types
from typing import Optional
from typing import Union


def get_normalized_path(path):
  """Returns a normalized path. This function is intended to be overridden."""
  return path


def get_code_object_identifier(callable: types.FunctionType):
  """Returns the code object identifier for a given callable.

  Args:
    callable: The callable object to search for.

  Returns:
    The code object identifier.
      Examples:
      - __main__.top_level_function.__code__
      - __main__.ClassWithNestedFunction.process.__code__.co_consts[
        nested_function]
      - __main__.ClassWithNestedLambda.process.__code__.co_consts[
        get_lambda_from_dictionary].co_consts[<lambda>, ('x',)]
      - __main__.ClassWithNestedLambda.process.__code__.co_consts[
        <lambda>, ('x',), 1234567890]
  """
  if (not hasattr(callable, '__module__') or
      not hasattr(callable, '__qualname__') or not callable.__module__ or
      callable.__module__ not in sys.modules):
    return None
  code_path: str = _extend_path(
      callable.__module__,
      _search(
          callable,
          sys.modules[callable.__module__],
          callable.__qualname__.split('.'),
      ),
  )
  return code_path


def _extend_path(prefix: str, current_path: Optional[str]):
  """Extends the path to the code object.

  Args:
    prefix: The prefix of the path.
    current_path: The rest of the path.

  Returns:
    The extended path.
  """
  if current_path is None:
    return None
  if not current_path:
    return prefix
  return prefix + '.' + current_path


def _search(
    callable: types.FunctionType,
    node: Union[types.ModuleType, types.FunctionType, types.CodeType],
    qual_name_parts: list[str]):
  """Searches an object to create a code object identifier.

  Recursively searches the tree of objects starting from node to find the
    callable's code object. It navigates through the attributes by using
    the first element of qual_name_parts to indicate what object it is
    currently at, then recursively passes through the rest of the list until
    the callable is found. Special components like '<locals>' and '<lambda>'
    direct the search within nested code objects.


  Example of qual_name_parts: ['MyClass', 'process', '<locals>', '<lambda>']

  Args:
    callable: The callable object to search for.
    node: The object to search within.
    qual_name_parts: A list of strings representing the qualified name of the
      callable object.

  Returns:
    The code object identifier, or None if not found.
  """
  if node is None:
    return None
  if not qual_name_parts:
    if (hasattr(node, '__code__') and hasattr(callable, '__code__') and
        node.__code__ == callable.__code__):
      return '__code__'
    else:
      return None
  if inspect.ismodule(node) or inspect.isclass(node):
    return _search_module_or_class(callable, node, qual_name_parts)
  elif inspect.isfunction(node):
    return _search_function(callable, node, qual_name_parts)
  elif inspect.iscode(node):
    return _search_code(callable, node, qual_name_parts)


def _search_module_or_class(
    callable: types.FunctionType,
    node: types.ModuleType,
    qual_name_parts: list[str]):
  """Searches a module or class to create a code object identifier.

  Args:
    callable: The callable object to search for.
    node: The module or class to search within.
    qual_name_parts: The list of qual name parts.

  Returns:
    The code object identifier, or None if not found.
  """
  # Functions/methods have a name that is unique within a given module or class
  # so the traversal can directly lookup function object identified by the name.
  # Lambdas don't have a name so we need to search all the attributes of the
  # node.
  first_part = qual_name_parts[0]
  rest = qual_name_parts[1:]
  if first_part == '<lambda>':
    for name in dir(node):
      value = getattr(node, name)
      if (hasattr(callable, '__code__') and
          isinstance(value, type(callable)) and
          value.__code__ == callable.__code__):
        return name + '.__code__'
      elif (isinstance(value, types.FunctionType) and
            value.__defaults__ is not None):
        # Python functions can have other functions as default parameters which
        # might contain the code object so we have to search them.
        for i, default_param_value in enumerate(value.__defaults__):
          path = _search(callable, default_param_value, rest)
          if path is not None:
            return _extend_path(name, _extend_path(f'__defaults__[{i}]', path))
  else:
    if not hasattr(node, first_part):
      return None
    return _extend_path(
        first_part, _search(callable, getattr(node, first_part), rest))


def _search_function(
    callable: types.FunctionType,
    node: types.FunctionType,
    qual_name_parts: list[str]):
  """Searches a function to create a code object identifier.

  Args:
    callable: The callable object to search for.
    node: The function to search within.
    qual_name_parts: The list of qual name parts.

  Returns:
    The code object identifier, or None if not found.
  """
  first_part = qual_name_parts[0]
  if (node.__code__ == callable.__code__):
    if len(qual_name_parts) > 1:
      raise ValueError('Qual name parts too long')
    return '__code__'
  # If first part is '<locals>' then the code object is in a local variable
  # so we should add __code__ to the path to indicate that we are entering
  # the code object of the function.
  if first_part == '<locals>':
    return _extend_path(
        '__code__', _search(callable, node.__code__, qual_name_parts))


def _search_code(
    callable: types.FunctionType,
    node: types.CodeType,
    qual_name_parts: list[str]):
  """Searches a code object to create a code object identifier.

  Args:
    callable: The callable to search for.
    node: The code object to search within.
    qual_name_parts: The list of qual name parts.

  Returns:
    The code object identifier, or None if not found.

  Raises:
    ValueError: If the qual name parts are too long.
  """
  first_part = qual_name_parts[0]
  rest = qual_name_parts[1:]
  if hasattr(callable, '__code__') and node == callable.__code__:
    if len(qual_name_parts) > 1:
      raise ValueError('Qual name parts too long')
    return ''
  elif first_part == '<locals>':
    code_objects_by_name = collections.defaultdict(list)
    for co_const in node.co_consts:
      if inspect.iscode(co_const):
        code_objects_by_name[co_const.co_name].append(co_const)
    num_lambdas = len(code_objects_by_name.get('<lambda>', []))
    # If there is only one lambda, we can use the default path
    # 'co_consts[<lambda>]'. This is the most common case and it is
    # faster than calculating the signature and the hash.
    if num_lambdas == 1:
      path = _search(callable, code_objects_by_name['<lambda>'][0], rest)
      if path is not None:
        return _extend_path('co_consts[<lambda>]', path)
    else:
      return _search_lambda(callable, code_objects_by_name, rest)
  elif node.co_name == first_part:
    return _search(callable, node, rest)


def _search_lambda(
    callable: types.FunctionType,
    code_objects_by_name: dict[str, list[types.CodeType]],
    qual_name_parts: list[str]):
  """Searches a lambda to create a code object identifier.

  Args:
    callable: The callable to search for.
    code_objects_by_name: The code objects to search within, keyed by name.
    qual_name_parts: The rest of the qual_name_parts.

  Returns:
    The code object identifier, or None if not found.
  """
  # There are multiple lambdas in the code object, so we need to calculate
  # the signature and the hash to identify the correct lambda.
  lambda_code_objects_by_name = collections.defaultdict(list)
  name = qual_name_parts[0]
  code_objects = code_objects_by_name[name]
  if not code_objects:
    return None
  if name == '<lambda>':
    for code_object in code_objects:
      lambda_name = f'<lambda>, {_signature(code_object)}'
      lambda_code_objects_by_name[lambda_name].append(code_object)
    # Check if there are any lambdas with the same signature.
    # If there are, we need to calculate the hash to identify the correct
    # lambda.
    for lambda_name, lambda_objects in lambda_code_objects_by_name.items():
      if len(lambda_objects) > 1:
        for lambda_object in lambda_objects:
          path = _search(callable, lambda_object, qual_name_parts)
          if path is not None:
            return _extend_path(
                f'co_consts[{lambda_name},'
                f' {_create_bytecode_hash(lambda_object)}]',
                path,
            )
      else:
        # If there is only one lambda with this signature, we can
        # use the signature to identify the correct lambda.
        path = _search(callable, code_objects[0], qual_name_parts)
        if path is not None:
          return _extend_path(f'co_consts[{lambda_name}]', path)
  else:
    # For non lambda objects, we can use the name to identify the object.
    path = _search(callable, code_objects[0], qual_name_parts)
    if path is not None:
      return _extend_path(f'co_consts[{name}]', path)


# Matches a path like: co_consts[my_function]
_SINGLE_NAME_PATTERN = re.compile(r'co_consts\[([a-zA-Z0-9\<\>_-]+)]')
# Matches a path like: co_consts[<lambda>, ('x',)]
_LAMBDA_WITH_ARGS_PATTERN = re.compile(
    r"co_consts\[(<.*?>),\s(\('[^']+'(?:,\s*'[^']+')*,?\))\]")
# Matches a path like: co_consts[<lambda>, ('x',), 1234567890]
_LAMBDA_WITH_HASH_PATTERN = re.compile(
    r"co_consts\[(<[^>]+>),\s*(\([^\)]*\)),?\s*(.*)\]")
# Matches a path like: __defaults__[0]
_DEFAULT_PATTERN = re.compile(r'(__defaults__)\[(\d+)\]')
# Matches an argument like: 'x'
_ARGUMENT_PATTERN = re.compile(r"'([^']*)'")


def _get_code_object_from_single_name_pattern(
    obj: types.ModuleType, name_result: re.Match[str], path: str):
  """Returns the code object from a name pattern.

  Args:
    obj: The object to search within.
    name_result: The result of the name pattern search.
    path: The path to the code object.

  Returns:
    The code object.

  Raises:
    ValueError: If the pattern is invalid.
    AttributeError: If the code object is not found.
  """
  if len(name_result.groups()) > 1:
    raise ValueError(f'Invalid pattern for single name: {name_result.group(0)}')
  # Groups are indexed starting at 1, group(0) is the entire match.
  name = name_result.group(1)
  if hasattr(obj, 'co_consts'):
    for co_const in obj.co_consts:
      if inspect.iscode(co_const) and co_const.co_name == name:
        return co_const
  raise AttributeError(f'Could not find code object with path: {path}')


def _get_code_object_from_lambda_with_args_pattern(
    obj: types.ModuleType, lambda_with_args_result: re.Match[str], path: str):
  """Returns the code object from a lambda with args pattern.

  Args:
    obj: The object to search within.
    lambda_with_args_result: The result of the lambda with args pattern search.
    path: The path to the code object.

  Returns:
    The code object.

  Raises:
    AttributeError: If the code object is not found.
  """
  name = lambda_with_args_result.group(1)
  code_objects = collections.defaultdict(list)
  if hasattr(obj, 'co_consts'):
    for co_const in obj.co_consts:
      if inspect.iscode(co_const) and co_const.co_name == name:
        code_objects[co_const.co_name].append(co_const)
    for name, objects in code_objects.items():
      for obj_ in objects:
        args = tuple(
            re.findall(_ARGUMENT_PATTERN, lambda_with_args_result.group(2)))
        if obj_.co_varnames[:_get_arg_count(obj_)] == args:
          return obj_
  raise AttributeError(f'Could not find code object with path: {path}')


def _get_code_object_from_lambda_with_hash_pattern(
    obj: types.ModuleType, lambda_with_hash_result: re.Match[str], path: str):
  """Returns the code object from a lambda with hash pattern.

  Args:
    obj: The object to search within.
    lambda_with_hash_result: The result of the lambda with hash pattern search.
    path: The path to the code object.

  Returns:
    The code object.

  Raises:
    AttributeError: If the code object is not found.
  """
  name = lambda_with_hash_result.group(1)
  code_objects = collections.defaultdict(list)
  if hasattr(obj, 'co_consts'):
    for co_const in obj.co_consts:
      if inspect.iscode(co_const) and co_const.co_name == name:
        code_objects[co_const.co_name].append(co_const)
    for name, objects in code_objects.items():
      for obj_ in objects:
        args = tuple(
            re.findall(_ARGUMENT_PATTERN, lambda_with_hash_result.group(2)))
        if obj_.co_varnames[:_get_arg_count(obj_)] == args:
          hash_value = lambda_with_hash_result.group(3)
          if hash_value == str(_create_bytecode_hash(obj_)):
            return obj_
  raise AttributeError(f'Could not find code object with path: {path}')


def get_code_from_identifier(code_object_identifier: str):
  """Returns the code object corresponding to the code object identifier.

  Args:
    code_object_identifier: A string representing the code object identifier.

  Returns:
    The code object.

  Raises:
    ValueError: If the path is empty or invalid.
    AttributeError: If the attribute is not found.
  """
  if not code_object_identifier:
    raise ValueError('Path must not be empty.')
  parts = code_object_identifier.split('.')
  if parts[0] not in sys.modules:
    raise AttributeError(f'Module {parts[0]} not found in sys.modules')
  obj = sys.modules[parts[0]]
  for part in parts[1:]:
    if name_result := _SINGLE_NAME_PATTERN.fullmatch(part):
      obj = _get_code_object_from_single_name_pattern(
          obj, name_result, code_object_identifier)
    elif lambda_with_args_result := _LAMBDA_WITH_ARGS_PATTERN.fullmatch(part):
      obj = _get_code_object_from_lambda_with_args_pattern(
          obj, lambda_with_args_result, code_object_identifier)
    elif lambda_with_hash_result := _LAMBDA_WITH_HASH_PATTERN.fullmatch(part):
      obj = _get_code_object_from_lambda_with_hash_pattern(
          obj, lambda_with_hash_result, code_object_identifier)
    elif default_result := _DEFAULT_PATTERN.fullmatch(part):
      index = int(default_result.group(2))
      if index >= len(obj.__defaults__):
        raise ValueError(
            f'Index {index} is out of bounds for obj.__defaults__'
            f' {len(obj.__defaults__)} in path {code_object_identifier}')
      obj = getattr(obj, '__defaults__')[index]
    else:
      obj = getattr(obj, part)
  if isinstance(obj, types.CodeType):
    return obj
  else:
    raise AttributeError(
        f'Could not find code object with path: {code_object_identifier}')


def _signature(obj: types.CodeType):
  """Returns the signature of a code object.

  The signature is the names of the arguments of the code object. This is used
  to unique identify lambdas.

  Args:
    obj: A code object, function, method, or cell.

  Returns:
    A tuple of the names of the arguments of the code object.
  """
  return obj.co_varnames[:_get_arg_count(obj)]


def _get_arg_count(obj: types.CodeType):
  """Returns the number of arguments of a code object.

  Args:
    obj: A code object, function, method, or cell.

  Returns:
    The number of arguments of the code object, or None if the object is not a
    code object.
  """
  return (
      obj.co_argcount + obj.co_kwonlyargcount +
      (obj.co_flags & 4 == 4)  # PyCF_VARARGS
      + (obj.co_flags & 8 == 8)  # PyCF_VARKEYWORDS
  )


def _create_bytecode_hash(code_object: types.CodeType):
  """Returns the hash of a code object.

  Args:
    code_object: A code object.

  Returns:
    The hash of the code object.
  """
  return hashlib.md5(code_object.co_code).hexdigest()
