#
# 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.
#

import logging
import re
import time
import uuid
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import field
from typing import Any
from typing import Callable
from typing import Optional

from apache_beam.ml.rag.types import Chunk
from apache_beam.ml.rag.types import Content
from apache_beam.ml.rag.types import Embedding

_LOGGER = logging.getLogger(__name__)

# Default batch size for writing data to Milvus, matching
# JdbcIO.DEFAULT_BATCH_SIZE.
DEFAULT_WRITE_BATCH_SIZE = 1000


@dataclass
class MilvusConnectionParameters:
  """Configurations for establishing connections to Milvus servers.

  Args:
    uri: URI endpoint for connecting to Milvus server in the format
      "http(s)://hostname:port".
    user: Username for authentication. Required if authentication is enabled and
      not using token authentication.
    password: Password for authentication. Required if authentication is enabled
      and not using token authentication.
    db_name: Database Name to connect to. Specifies which Milvus database to
      use. Defaults to 'default'.
    token: Authentication token as an alternative to username/password.
    timeout: Connection timeout in seconds. Uses client default if None.
    kwargs: Optional keyword arguments for additional connection parameters.
      Enables forward compatibility.
  """
  uri: str
  user: str = field(default_factory=str)
  password: str = field(default_factory=str)
  db_name: str = "default"
  token: str = field(default_factory=str)
  timeout: Optional[float] = None
  kwargs: dict[str, Any] = field(default_factory=dict)

  def __post_init__(self):
    if not self.uri:
      raise ValueError("URI must be provided for Milvus connection")

    # Generate unique alias if not provided. One-to-one mapping between alias
    # and connection - each alias represents exactly one Milvus connection.
    if "alias" not in self.kwargs:
      alias = f"milvus_conn_{uuid.uuid4().hex[:8]}"
      self.kwargs["alias"] = alias


class MilvusHelpers:
  """Utility class providing helper methods for Milvus vector db operations."""
  @staticmethod
  def sparse_embedding(
      sparse_vector: Optional[tuple[list[int], list[float]]]
  ) -> Optional[dict[int, float]]:
    if not sparse_vector:
      return None
    # Converts sparse embedding from (indices, values) tuple format to
    # Milvus-compatible values dict format {dimension_index: value, ...}.
    indices, values = sparse_vector
    return {int(idx): float(val) for idx, val in zip(indices, values)}


def parse_chunk_strings(chunk_str_list: list[str]) -> list[Chunk]:
  parsed_chunks = []

  # Define safe globals and disable built-in functions for safety.
  safe_globals = {
      'Chunk': Chunk,
      'Content': Content,
      'Embedding': Embedding,
      'defaultdict': defaultdict,
      'list': list,
      '__builtins__': {}
  }

  for raw_str in chunk_str_list:
    try:
      # replace "<class 'list'>" with actual list reference.
      cleaned_str = re.sub(
          r"defaultdict\(<class 'list'>", "defaultdict(list", raw_str)

      # Evaluate string in restricted environment.
      chunk = eval(cleaned_str, safe_globals)  # pylint: disable=eval-used
      if isinstance(chunk, Chunk):
        parsed_chunks.append(chunk)
      else:
        raise ValueError("Parsed object is not a Chunk instance")
    except Exception as e:
      raise ValueError(f"Error parsing string:\n{raw_str}\n{e}")

  return parsed_chunks


def unpack_dataclass_with_kwargs(dataclass_instance):
  """Unpacks dataclass fields into a flat dict, merging kwargs with precedence.

  Args:
    dataclass_instance: Dataclass instance to unpack.

  Returns:
    dict: Flattened dictionary with kwargs taking precedence over fields.
  """
  # Create a copy of the dataclass's __dict__.
  params_dict: dict = dataclass_instance.__dict__.copy()

  # Extract the nested kwargs dictionary.
  nested_kwargs = params_dict.pop('kwargs', {})

  # Merge the dictionaries, with nested_kwargs taking precedence
  # in case of duplicate keys.
  return {**params_dict, **nested_kwargs}


def retry_with_backoff(
    operation: Callable[[], Any],
    max_retries: int = 3,
    retry_delay: float = 1.0,
    retry_backoff_factor: float = 2.0,
    operation_name: str = "operation",
    exception_types: tuple[type[BaseException], ...] = (Exception, )
) -> Any:
  """Executes an operation with retry logic and exponential backoff.

  This is a generic retry utility that can be used for any operation that may
  fail transiently. It retries the operation with exponential backoff between
  attempts.

  Note:
    This utility is designed for one-time setup operations and complements
    Apache Beam's RequestResponseIO pattern. Use retry_with_backoff() for:

    * Establishing client connections in __enter__() methods (e.g., creating
      MilvusClient instances, database connections) before processing elements
    * One-time setup/teardown operations in DoFn lifecycle methods
    * Operations outside of per-element processing where retry is needed

    For per-element operations (e.g., API calls within Caller.__call__),
    use RequestResponseIO which already provides automatic retry with
    exponential backoff, failure handling, caching, and other features.
    See: https://beam.apache.org/documentation/io/built-in/webapis/

  Args:
    operation: Callable that performs the operation to retry. Should return
      the result of the operation.
    max_retries: Maximum number of retry attempts. Default is 3.
    retry_delay: Initial delay in seconds between retries. Default is 1.0.
    retry_backoff_factor: Multiplier for the delay after each retry. Default
      is 2.0 (exponential backoff).
    operation_name: Name of the operation for logging purposes. Default is
      "operation".
    exception_types: Tuple of exception types to catch and retry. Default is
      (Exception,) which catches all exceptions.

  Returns:
    The result of the operation if successful.

  Raises:
    The last exception encountered if all retry attempts fail.

  Example:
    >>> def connect_to_service():
    ...     return service.connect(host="localhost")
    >>> client = retry_with_backoff(
    ...     connect_to_service,
    ...     max_retries=5,
    ...     retry_delay=2.0,
    ...     operation_name="service connection")
  """
  last_exception = None
  for attempt in range(max_retries + 1):
    try:
      result = operation()
      _LOGGER.info(
          "Successfully completed %s on attempt %d",
          operation_name,
          attempt + 1)
      return result
    except exception_types as e:
      last_exception = e
      if attempt < max_retries:
        delay = retry_delay * (retry_backoff_factor**attempt)
        _LOGGER.warning(
            "%s attempt %d failed: %s. Retrying in %.2f seconds...",
            operation_name,
            attempt + 1,
            e,
            delay)
        time.sleep(delay)
      else:
        _LOGGER.error(
            "Failed %s after %d attempts", operation_name, max_retries + 1)
        raise last_exception
