"""
Utility module for queryset optimization, caching, memory estimation, and text processing.
"""

import sys
import pickle
import hashlib
import unicodedata
import re
from typing import Any

from .settings import DEFAULTS

# Pre-compile the regular expression globally to optimize performance during text normalization
_PUNCTUATION_RE = re.compile(r"[^\w\s]")


def normalize_text(text: Any) -> str:
    """
    Normalizes a given string by stripping accents, removing punctuation,
    and converting all characters to lowercase.

    Args:
        text (Any): The input string or object to be normalized.

    Returns:
        str: The cleaned, ASCII-compatible, lowercase string.
    """
    text = str(text)
    text = unicodedata.normalize("NFD", text)
    text = text.encode("ascii", "ignore").decode("utf-8")
    return _PUNCTUATION_RE.sub("", text).lower()


def build_proc_qs(
    qs: Any,
    related_field: str | None,
    fields: tuple[str, ...] | None,
) -> Any:
    """
    Optimizes a queryset for search operations by enforcing `.values_list()`.

    Args:
        qs (Any): The source Django QuerySet.
        related_field (str | None): A related model string to traverse (e.g., 'profile').
        fields (tuple[str] | None): Specific fields to fetch.

    Returns:
        Any: An optimized QuerySet configured to yield tuples starting with the primary key.
    """

    def prepend_related(names: tuple[str, ...]) -> tuple[str, ...]:
        return tuple(f"{related_field}__{f}" for f in names) if related_field else names

    target_fields = prepend_related(fields or ())

    # Django automatically handles SQL JOINs when using the double-underscore ('__')
    # syntax in .values_list(). Therefore, calling .select_related() is unnecessary
    # and would only add overhead.
    return qs.values_list("pk", *target_fields)


def make_cache_key(
    model_label: str,
    qs_signature: str,
    search: str,
    fields: tuple[str, ...],
    related_field: str | None,
) -> str:
    """
    Generates a deterministic and stable cache key based on the current search context.

    Args:
        model_label (str): The app label and model name.
        qs_signature (str): The stringified raw SQL query of the base queryset.
        search (str): The normalized search term.
        fields (tuple[str]): The specific fields being searched.
        related_field (str | None): Any related field traversal used.

    Returns:
        str: A hashed cache key string prefixed with 'dds:'.
    """
    base = f"{model_label}|{qs_signature}|{search}|{','.join(fields)}|{related_field or ''}"
    digest = hashlib.sha1(base.encode("utf-8")).hexdigest()
    return f"dds:{digest}"


def estimate_avg_obj_size(sample: Any, fallback: int) -> int:
    """
    Estimates the average memory footprint of a single object in bytes
    by analyzing a small sample set.

    Args:
        sample (Any): A queryset or list of objects to sample.
        fallback (int): The default byte size to return if estimation fails.

    Returns:
        int: The estimated average size in bytes, multiplied by a safety margin.
    """
    sample_size: int = 10
    safety_margin: float = 2.5

    try:
        objs = set(sample[:sample_size])
        total_size = sum(
            sys.getsizeof(pickle.dumps(o, protocol=pickle.HIGHEST_PROTOCOL))
            for o in objs
        )
        avg_size = total_size / len(objs)
        return int(avg_size * safety_margin)
    except Exception:
        return fallback


def compute_batch_size(
    available_bytes: int, avg_obj_size: int, max_batch_size: int
) -> int:
    """
    Calculates the maximum number of items that can be safely loaded into memory
    in a single batch.

    Args:
        available_bytes (int): Total target memory available for the process.
        avg_obj_size (int): Estimated size of a single data row.
        max_batch_size (int): The absolute upper limit for a batch.

    Returns:
        int: The optimized batch size, bounded by minimum and maximum constraints.
    """
    if avg_obj_size <= 0:
        return DEFAULTS.MIN_BATCH_SIZE

    batch = available_bytes // avg_obj_size
    batch = max(batch, DEFAULTS.MIN_BATCH_SIZE)
    batch = min(batch, max_batch_size)
    return batch


def compute_chunksize(batch_size: int, max_workers: int) -> int:
    """
    Calculates the optimal multiprocessing chunksize to balance inter-process
    communication (IPC) overhead against CPU utilization.

    Args:
        batch_size (int): The total number of items in the current processing batch.
        max_workers (int): The number of active CPU cores.

    Returns:
        int: The computed chunksize.
    """
    tasks_per_worker = 4
    max_workers = max(1, max_workers)

    ideal_chunksize = batch_size // (max_workers * tasks_per_worker)

    # If the batch is too small to distribute effectively, divide it evenly
    if batch_size < (DEFAULTS.MIN_CHUNKSIZE * max_workers):
        return max(1, batch_size // max_workers)

    return max(DEFAULTS.MIN_CHUNKSIZE, min(ideal_chunksize, DEFAULTS.MAX_CHUNKSIZE))
