"""
Core entry point for the encrypted and optimized parallel queryset search engine.
"""

from functools import partial
from multiprocessing import cpu_count
from typing import Any
import psutil
import gc

from django.core.cache import cache
from django.utils.text import slugify

from .executors import run_processes
from .settings import DEFAULTS
from .processors import process_values
from .utils import (
    build_proc_qs,
    make_cache_key,
    estimate_avg_obj_size,
    compute_batch_size,
    normalize_text,
    compute_chunksize,
)


def search_queryset(
    search: str,
    queryset: Any,
    related_field: str | None = None,
    *,
    fields: tuple[str, ...] | None = None,
    cache_timeout: int | None = None,
) -> Any:
    """
    Executes a parallel search over a Django queryset by checking raw database values.

    Memory allocation, batch sizing, and core distribution are managed internally
    based on predefined Django settings.

    Args:
        search (str): The raw text string to search for.
        queryset (Any): The base Django QuerySet to be filtered.
        related_field (str | None, optional): The related model field name to perform joins. Defaults to None.
        fields (tuple[str] | None, optional): Specific fields to retrieve via values_list. Defaults to None.
        cache_timeout (int | None, optional): Custom cache TTL in seconds. Overrides global settings. Defaults to None.

    Returns:
        Any: A filtered Django QuerySet containing only the matched objects.
    """
    if not search:
        return queryset.none()

    search = normalize_text(search)

    # Generate a unique cryptographic signature for the queryset to ensure safe caching
    model = queryset.model
    model_label = f"{model._meta.app_label}.{model._meta.model_name}"
    qs_signature = str(queryset.query)
    cache_key = make_cache_key(
        model_label, qs_signature, slugify(search), fields or (), related_field
    )

    cached_ids = cache.get(cache_key)
    if cached_ids:
        return queryset.filter(pk__in=cached_ids)

    # Dynamically estimate available RAM based on the configured memory fraction
    if DEFAULTS.MEMORY_FRACTION > 0:
        try:
            mem = psutil.virtual_memory()
            available_bytes = int(mem.available * DEFAULTS.MEMORY_FRACTION)
        except Exception:
            available_bytes = DEFAULTS.AVG_OBJ_SIZE_FALLBACK * DEFAULTS.MIN_BATCH_SIZE
    else:
        available_bytes = DEFAULTS.AVG_OBJ_SIZE_FALLBACK * DEFAULTS.MIN_BATCH_SIZE

    # Build an optimized queryset that strictly yields tuples via .values_list()
    proc_qs = build_proc_qs(queryset, related_field, fields)

    # Sample a small fraction of the data to estimate average object size in bytes
    avg_size = estimate_avg_obj_size(proc_qs, DEFAULTS.AVG_OBJ_SIZE_FALLBACK)

    # Calculate the optimal batch size while enforcing internal safety limits
    batch_size = compute_batch_size(
        available_bytes, avg_size, max_batch_size=DEFAULTS.MAX_BATCH_SIZE
    )

    total: int = queryset.count()
    matched_ids: set[int] = set()

    # Automatically determine the maximum number of CPU cores available
    max_workers = cpu_count()

    # Dynamically calculate the ideal chunksize to balance IPC overhead and CPU load
    chunksize = compute_chunksize(batch_size, max_workers)

    func = partial(
        process_values,
        search=search,
    )

    # Process the queryset iteratively in safe memory batches
    for start in range(0, total, batch_size):
        # Evaluate the queryset slice ONCE and load it entirely into memory as a list
        batch_data = list(proc_qs[start : start + batch_size])

        for result in run_processes(
            batch_data, func, chunksize=chunksize, max_workers=max_workers
        ):
            if result:
                matched_ids.add(result)

        # Explicitly delete the batch pointer and force garbage collection
        # to prevent memory spikes between iterations
        del batch_data
        gc.collect()

    # Apply the specific timeout if provided, otherwise fallback to the global default
    timeout = cache_timeout if cache_timeout is not None else DEFAULTS.CACHE_TIMEOUT
    cache.set(cache_key, set(matched_ids), timeout=timeout)

    return queryset.filter(pk__in=matched_ids)
