from collections import Counter
import math

import numpy as np
import os

def _faketqdm(*args, **kwargs):
    return args[0] if len(args) > 0 else None
try:
    if os.environ.get("DISABLE_TQDM", False):
        tqdm = _faketqdm
    else:
        from tqdm.auto import tqdm
except ImportError:
    tqdm = _faketqdm

try:
    from numba import njit
    HAS_NUMBA = True
except ImportError:
    HAS_NUMBA = False
    # Dummy decorator to allow definition if numba is missing (code won't be used)
    def njit(*args, **kwargs):
        def decorator(func):
            return func
        return decorator

def _calculate_doc_freqs(
    corpus_tokens, unique_tokens, show_progress=True, leave_progress=False
) -> dict:
    """
    Document Frequency, aka DF, is the number of documents that contain a specific token.
    This function return a dictionary with the document frequency of each token, which is
    why it is called `doc_frequencies`.
    """
    unique_tokens = set(unique_tokens)

    # Now that we have all the unique tokens, we can count the number of
    # documents that contain each token
    doc_frequencies = {token: 0 for token in unique_tokens}

    for doc_tokens in tqdm(
        corpus_tokens,
        leave=leave_progress,
        disable=not show_progress,
        desc="BM25S Count Tokens",
    ):

        # get intersection of unique tokens and the tokens in the document
        shared_tokens = unique_tokens.intersection(doc_tokens)

        # for each token in the document, we increment the count of documents
        # This is a simple way to count the number of documents that contain each token
        for token in shared_tokens:
            doc_frequencies[token] += 1

    return doc_frequencies


def _build_idf_array(
    doc_frequencies: dict,
    n_docs: int,
    compute_idf_fn: callable = None,
    dtype="float32",
) -> np.ndarray:
    n_vocab = len(doc_frequencies)
    idf_array = np.zeros(n_vocab, dtype=dtype)

    for token_id, df in doc_frequencies.items():
        if df != 0:
            idf_array[token_id] = compute_idf_fn(df, N=n_docs)

    return idf_array


def _build_nonoccurrence_array(
    doc_frequencies: dict,
    n_docs: int,
    compute_idf_fn: callable,
    calculate_tfc_fn: callable,
    l_d,
    l_avg,
    k1,
    b,
    delta,
    dtype="float32",
) -> np.ndarray:
    """
    The non-occurrence array is used to store the idf score for tokens that do not occur in the
    document. This is useful for BM25L and BM25+ variants, where we need to calculate the idf
    score for tokens that do not occur in the document, which will be used to calculate the
    final score.

    The nonoccurence array has length |V|, where V is the set of unique tokens in the corpus.

    The `compute_idf_fn` is the function to calculate the idf score for a token that does not occur
    in the document. The `calculate_tfc_fn` is the function to calculate the term frequency component
    of the BM25 score, which is used to calculate the final score for tokens that do not occur in the
    document.
    """
    n_vocab = len(doc_frequencies)
    nonoccurrence_array = np.zeros(n_vocab, dtype=dtype)

    for token_id, df in doc_frequencies.items():
        if df != 0:
            idf = compute_idf_fn(df, N=n_docs)
            tfc = calculate_tfc_fn(
                tf_array=0, l_d=l_d, l_avg=l_avg, k1=k1, b=b, delta=delta
            )
            nonoccurrence_array[token_id] = idf * tfc

    return nonoccurrence_array


def _score_tfc_robertson(tf_array, l_d, l_avg, k1, b, delta=None):
    """
    Computes the term frequency component of the BM25 score using Robertson+ (original) variant
    Implementation: https://cs.uwaterloo.ca/~jimmylin/publications/Kamphuis_etal_ECIR2020_preprint.pdf
    """
    # idf component is given by the idf_array
    # we calculate the term-frequency component (tfc)
    return tf_array / (k1 * ((1 - b) + b * l_d / l_avg) + tf_array)


def _score_tfc_lucene(tf_array, l_d, l_avg, k1, b, delta=None):
    """
    Computes the term frequency component of the BM25 score using Lucene variant (accurate)
    Implementation: https://cs.uwaterloo.ca/~jimmylin/publications/Kamphuis_etal_ECIR2020_preprint.pdf
    """
    return _score_tfc_robertson(tf_array, l_d, l_avg, k1, b)


def _score_tfc_atire(tf_array, l_d, l_avg, k1, b, delta=None):
    """
    Computes the term frequency component of the BM25 score using ATIRE variant
    Implementation: https://cs.uwaterloo.ca/~jimmylin/publications/Kamphuis_etal_ECIR2020_preprint.pdf
    """
    # idf component is given by the idf_array
    # we calculate the term-frequency component (tfc)
    return (tf_array * (k1 + 1)) / (tf_array + k1 * (1 - b + b * l_d / l_avg))


def _score_tfc_bm25l(tf_array, l_d, l_avg, k1, b, delta):
    """
    Computes the term frequency component of the BM25 score using BM25L variant
    Implementation: https://cs.uwaterloo.ca/~jimmylin/publications/Kamphuis_etal_ECIR2020_preprint.pdf
    """
    c_array = tf_array / (1 - b + b * l_d / l_avg)
    return ((k1 + 1) * (c_array + delta)) / (k1 + c_array + delta)


def _score_tfc_bm25plus(tf_array, l_d, l_avg, k1, b, delta):
    """
    Computes the term frequency component of the BM25 score using BM25+ variant
    Implementation: https://cs.uwaterloo.ca/~jimmylin/publications/Kamphuis_etal_ECIR2020_preprint.pdf
    """
    num = (k1 + 1) * tf_array
    den = k1 * (1 - b + b * l_d / l_avg) + tf_array
    return (num / den) + delta


def _select_tfc_scorer(method) -> callable:
    if method == "robertson":
        return _score_tfc_robertson
    elif method == "lucene":
        return _score_tfc_lucene
    elif method == "atire":
        return _score_tfc_atire
    elif method == "bm25l":
        return _score_tfc_bm25l
    elif method == "bm25+":
        return _score_tfc_bm25plus
    else:
        error_msg = f"Invalid score_tfc value: {method}. Choose from 'robertson', 'lucene', 'atire'."
        raise ValueError(error_msg)


def _score_idf_robertson(df, N, allow_negative=False):
    """
    Computes the inverse document frequency component of the BM25 score using Robertson+ (original) variant
    Implementation: https://cs.uwaterloo.ca/~jimmylin/publications/Kamphuis_etal_ECIR2020_preprint.pdf
    """
    inner = (N - df + 0.5) / (df + 0.5)
    if not allow_negative and inner < 1:
        inner = 1

    return math.log(inner)


def _score_idf_lucene(df, N):
    """
    Computes the inverse document frequency component of the BM25 score using Lucene variant (accurate)
    Implementation: https://cs.uwaterloo.ca/~jimmylin/publications/Kamphuis_etal_ECIR2020_preprint.pdf
    """
    return math.log(1 + (N - df + 0.5) / (df + 0.5))


def _score_idf_atire(df, N):
    """
    Computes the inverse document frequency component of the BM25 score using ATIRE variant
    Implementation: https://cs.uwaterloo.ca/~jimmylin/publications/Kamphuis_etal_ECIR2020_preprint.pdf
    """
    return math.log(N / df)


def _score_idf_bm25l(df, N):
    """
    Computes the inverse document frequency component of the BM25 score using BM25L variant
    Implementation: https://cs.uwaterloo.ca/~jimmylin/publications/Kamphuis_etal_ECIR2020_preprint.pdf
    """
    return math.log((N + 1) / (df + 0.5))


def _score_idf_bm25plus(df, N):
    """
    Computes the inverse document frequency component of the BM25 score using BM25+ variant
    Implementation: https://cs.uwaterloo.ca/~jimmylin/publications/Kamphuis_etal_ECIR2020_preprint.pdf
    """
    return math.log((N + 1) / df)


def _select_idf_scorer(method) -> callable:
    if method == "robertson":
        return _score_idf_robertson
    elif method == "lucene":
        return _score_idf_lucene
    elif method == "atire":
        return _score_idf_atire
    elif method == "bm25l":
        return _score_idf_bm25l
    elif method == "bm25+":
        return _score_idf_bm25plus
    else:
        error_msg = f"Invalid score_idf_inner value: {method}. Choose from 'robertson', 'lucene', 'atire', 'bm25l', 'bm25+'."
        raise ValueError(error_msg)


def _get_counts_from_token_ids(token_ids, dtype, int_dtype):
    token_counter = Counter(token_ids)
    voc_ind = np.array(list(token_counter.keys()), dtype=int_dtype)
    tf_array = np.array(list(token_counter.values()), dtype=dtype)

    return voc_ind, tf_array


def _build_scores_and_indices_for_matrix(
    corpus_token_ids,
    idf_array,
    avg_doc_len,
    doc_frequencies,
    k1,
    b,
    delta,
    nonoccurrence_array,
    method="robertson",
    dtype="float32",
    int_dtype="int32",
    show_progress=True,
    leave_progress=False,
):
    array_size = sum(doc_frequencies.values())

    # We create 3 arrays to store the scores, document indices, and vocabulary indices
    # The length is at most n_tokens, remaining elements will be truncated at the end
    scores = np.empty(array_size, dtype=dtype)
    doc_indices = np.empty(array_size, dtype=int_dtype)
    voc_indices = np.empty(array_size, dtype=int_dtype)

    calculate_tfc = _select_tfc_scorer(method)

    i = 0
    for doc_idx, token_ids in enumerate(
        tqdm(
            corpus_token_ids,
            desc="BM25S Compute Scores",
            disable=not show_progress,
            leave=leave_progress,
        )
    ):
        doc_len = len(token_ids)

        # Get the term frequency array for the document
        # Note: tokens might contain duplicates, we use Counter to get the term freq
        voc_ind_doc, tf_array = _get_counts_from_token_ids(
            token_ids, dtype=dtype, int_dtype=int_dtype
        )

        # Calculate the BM25 score for each token in the document
        tfc = calculate_tfc(
            tf_array=tf_array, l_d=doc_len, l_avg=avg_doc_len, k1=k1, b=b, delta=delta
        )
        idf = idf_array[voc_ind_doc]
        scores_doc = idf * tfc

        # If the method is uses a non-occurrence score array, then we need to subtract
        # the non-occurrence score from the scores
        if method in ("bm25l", "bm25+"):
            scores_doc -= nonoccurrence_array[voc_ind_doc]

        # Update the arrays with the new scores, document indices, and vocabulary indices
        doc_len = len(scores_doc)
        start, end = i, i + doc_len
        i = end

        doc_indices[start:end] = doc_idx
        voc_indices[start:end] = voc_ind_doc
        scores[start:end] = scores_doc

    return scores, doc_indices, voc_indices


def _compute_relevance_from_scores_legacy(
    data, indptr, indices, num_docs, query_tokens_ids, dtype
):
    """
    The legacy implementation of the `_compute_relevance_from_scores` function. This may
    be faster than the new implementation for some cases, but it cannot benefit from
    numba acceleration, as it uses python lists. This function is kept for reference
    and comparison purposes.
    """
    # First, we use the query_token_ids to select the relevant columns from the score_matrix
    query_tokens_ids = np.array(query_tokens_ids, dtype=int)
    indptr_starts = indptr[query_tokens_ids]
    indptr_ends = indptr[query_tokens_ids + 1]

    scores_lists = []
    indices_lists = []

    for i, (start, end) in enumerate(zip(indptr_starts, indptr_ends)):
        scores_lists.append(data[start:end])
        indices_lists.append(indices[start:end])

    # combine the lists into a single array

    scores = np.zeros(num_docs, dtype=dtype)
    if len(scores_lists) == 0:
        return scores

    scores_flat = np.concatenate(scores_lists)
    indices_flat = np.concatenate(indices_lists)
    np.add.at(scores, indices_flat, scores_flat)

    return scores

def _compute_relevance_from_scores_jit_ready(
    data: np.ndarray,
    indptr: np.ndarray,
    indices: np.ndarray,
    num_docs: int,
    query_tokens_ids: np.ndarray,
    dtype: np.dtype,
) -> np.ndarray:
    """
    This internal static function calculates the relevance scores for a given query,
    by using the BM25 scores that have been precomputed in the BM25 eager index.
    This version is ready for JIT compilation with numba, but is slow if not compiled.
    """
    indptr_starts = indptr[query_tokens_ids]
    indptr_ends = indptr[query_tokens_ids + 1]

    scores = np.zeros(num_docs, dtype=dtype)
    for i in range(len(query_tokens_ids)):
        start, end = indptr_starts[i], indptr_ends[i]
        # The following code is slower with numpy, but faster after JIT compilation
        for j in range(start, end):
            scores[indices[j]] += data[j]

    return scores


def _np_csc_jit_ready(data, rows, cols, shape):
    """
    Numba-compilable core implementation of CSC construction.
    Uses Counting Sort (linear time) instead of Argsort (linear-logarithmic time).
    
    This implementation assumes `rows` (doc_ids) are sorted in the input, which is 
    standard for BM25 construction. It performs a stable sort on `cols` to strictly 
    guarantee the final (col, row) sorted order required by CSC matrices.
    """
    n_cols = shape[1]
    n_items = len(data)

    # 1. Compute column counts (histogram)
    col_counts = np.bincount(cols, minlength=n_cols)
    
    # 2. Compute indptr and initialize write pointers (heads)
    # indptr: start/end of each column
    # heads: current write position for each column (mutable)
    indptr = np.zeros(n_cols + 1, dtype=np.int64)
    heads = np.zeros(n_cols, dtype=np.int64)
    
    acc = 0
    for i in range(n_cols):
        heads[i] = acc
        acc += col_counts[i]
        indptr[i+1] = acc

    # 3. Perform Counting Sort to fill data and indices
    # Since 'heads' tracks the write position, we can fill the output arrays in one pass.
    sorted_data = np.zeros_like(data)
    sorted_indices = np.zeros_like(rows)

    for i in range(n_items):
        col = cols[i]
        pos = heads[col]
        sorted_data[pos] = data[i]
        sorted_indices[pos] = rows[i]
        heads[col] += 1
    
    return sorted_data, sorted_indices, indptr

def _np_csc_python(data, rows, cols, shape):
    """
    Pure NumPy implementation of CSC construction.
    """
    n_cols = shape[1]

    # 1. Compute indptr
    col_counts = np.bincount(cols, minlength=n_cols)

    indptr = np.zeros(n_cols + 1, dtype=np.int64)
    np.cumsum(col_counts, out=indptr[1:])

    # 2. Sort the data
    packed_indices = (cols.astype(np.int64) << 32) | rows.astype(np.int64)
    sorter = np.argsort(packed_indices)

    # 3. Reorder arrays based on the sort
    sorted_data = data[sorter]
    sorted_indices = rows[sorter]
    
    return sorted_data, sorted_indices, indptr
