"""Boundary detection for message format inference.

Provides entropy-based, variance-based, and voting-expert boundary detection
algorithms used to find field boundaries in protocol messages.

References:
    IPART: IP Packet Analysis using Random Forests. IEEE ISSRE 2014.
"""

from __future__ import annotations

from typing import Literal

import numpy as np
from numpy.typing import NDArray

from oscura.inference.alignment import align_local
from oscura.inference.message_format.entropy import calculate_byte_entropy


def calculate_entropy_boundaries(messages: list[NDArray[np.uint8]], msg_len: int) -> list[int]:
    """Detect boundaries using entropy transitions.

    Args:
        messages: List of message arrays
        msg_len: Length of messages

    Returns:
        List of boundary positions
    """
    boundaries = []
    entropies = []

    # Calculate entropy at each byte position
    for offset in range(msg_len):
        entropy = calculate_byte_entropy(messages, offset)
        entropies.append(entropy)

    # Find transitions (entropy changes > threshold)
    entropy_threshold = 1.5  # bits
    for i in range(1, len(entropies)):
        delta = abs(entropies[i] - entropies[i - 1])
        if delta > entropy_threshold:
            boundaries.append(i)

    return boundaries


def calculate_variance_boundaries(messages: list[NDArray[np.uint8]], msg_len: int) -> list[int]:
    """Detect boundaries using variance transitions.

    Args:
        messages: List of message arrays
        msg_len: Length of messages

    Returns:
        List of boundary positions
    """
    boundaries = []
    variances = []

    # Calculate variance at each byte position
    for offset in range(msg_len):
        values = [msg[offset] for msg in messages]
        variance = np.var(values)
        variances.append(variance)

    # Find variance transitions
    var_threshold = 1000.0
    for i in range(1, len(variances)):
        delta = abs(variances[i] - variances[i - 1])
        if delta > var_threshold:
            boundaries.append(i)

    return boundaries


def merge_close_boundaries(boundaries: list[int]) -> list[int]:
    """Merge boundaries that are too close together.

    Args:
        boundaries: Sorted list of boundaries

    Returns:
        Merged boundary list
    """
    if not boundaries:
        return []

    merged = [boundaries[0]]
    for b in boundaries[1:]:
        if b - merged[-1] >= 2:
            merged.append(b)

    return merged


def detect_field_boundaries(
    messages: list[NDArray[np.uint8]],
    method: Literal["entropy", "variance", "combined"] = "combined",
) -> list[int]:
    """Detect field boundaries using entropy transitions.

    : Boundary detection via statistical transitions.

    Args:
        messages: List of message arrays
        method: Detection method ('entropy', 'variance', or 'combined')

    Returns:
        List of byte offsets marking field starts (always includes 0)
    """
    if not messages:
        return [0]

    msg_len = len(messages[0])
    boundaries = [0]  # Always start at offset 0

    # Detect boundaries using selected method
    if method in ["entropy", "combined"]:
        entropy_boundaries = calculate_entropy_boundaries(messages, msg_len)
        boundaries.extend(entropy_boundaries)

    if method in ["variance", "combined"]:
        variance_boundaries = calculate_variance_boundaries(messages, msg_len)
        boundaries.extend(variance_boundaries)

    # Merge and sort boundaries
    boundaries = sorted(set(boundaries))
    return merge_close_boundaries(boundaries)


# =============================================================================
# Voting Expert Boundary Detection
# =============================================================================


def expert_entropy(messages: list[NDArray[np.uint8]]) -> set[int]:
    """Detect boundaries based on entropy changes.

    : Entropy-based boundary expert.

    Args:
        messages: List of message arrays

    Returns:
        Set of boundary positions
    """
    if not messages:
        return {0}

    msg_len = len(messages[0])
    boundaries: set[int] = {0}

    entropies = []
    for offset in range(msg_len):
        entropy = calculate_byte_entropy(messages, offset)
        entropies.append(entropy)

    entropy_threshold = 1.5  # bits
    for i in range(1, len(entropies)):
        delta = abs(entropies[i] - entropies[i - 1])
        if delta > entropy_threshold:
            boundaries.add(i)

    return boundaries


def expert_alignment(messages: list[bytes]) -> set[int]:
    """Detect boundaries using Smith-Waterman alignment.

    : Alignment-based boundary expert.

    Uses local alignment to find conserved vs. variable regions.
    Transitions between regions indicate likely boundaries.

    Args:
        messages: List of protocol messages

    Returns:
        Set of boundary positions
    """
    if len(messages) < 2:
        return {0}

    boundaries: set[int] = {0}

    num_comparisons = min(5, len(messages) - 1)
    for i in range(1, num_comparisons + 1):
        result = align_local(messages[0], messages[i])

        for start, _end in result.conserved_regions:
            if start > 0:
                boundaries.add(start)

        for start, _end in result.variable_regions:
            if start > 0:
                boundaries.add(start)

    return boundaries


def expert_variance(messages: list[NDArray[np.uint8]]) -> set[int]:
    """Detect boundaries based on statistical variance.

    : Variance-based boundary expert.

    Args:
        messages: List of message arrays

    Returns:
        Set of boundary positions
    """
    if not messages:
        return {0}

    msg_len = len(messages[0])
    boundaries: set[int] = {0}

    variances = []
    for offset in range(msg_len):
        values = [msg[offset] for msg in messages]
        variance = np.var(values)
        variances.append(variance)

    var_threshold = 1000.0
    for i in range(1, len(variances)):
        delta = abs(variances[i] - variances[i - 1])
        if delta > var_threshold:
            boundaries.add(i)

    return boundaries


def expert_distribution(messages: list[NDArray[np.uint8]]) -> set[int]:
    """Detect boundaries from byte value distribution changes.

    : Distribution-based boundary expert.

    Args:
        messages: List of message arrays

    Returns:
        Set of boundary positions
    """
    if not messages:
        return {0}

    msg_len = len(messages[0])
    boundaries: set[int] = {0}

    distributions = []
    for offset in range(msg_len):
        values = [msg[offset] for msg in messages]
        unique_count = len(set(values))
        distributions.append(unique_count)

    for i in range(1, len(distributions)):
        if distributions[i - 1] > 0:
            ratio = distributions[i] / distributions[i - 1]
            if ratio > 2.0 or ratio < 0.5:
                boundaries.add(i)

    return boundaries


def expert_ngrams(messages: list[NDArray[np.uint8]], n: int = 2) -> set[int]:
    """Detect boundaries using n-gram frequency analysis.

    : N-gram based boundary expert.

    Args:
        messages: List of message arrays
        n: N-gram size (default: 2)

    Returns:
        Set of boundary positions
    """
    if not messages or len(messages[0]) < n:
        return {0}

    msg_len = len(messages[0])
    boundaries: set[int] = {0}

    ngram_sets = []
    for offset in range(msg_len - n + 1):
        ngrams: set[tuple[int, ...]] = set()
        for msg in messages:
            if offset + n <= len(msg):
                ngram = tuple(msg[offset : offset + n])
                ngrams.add(ngram)
        ngram_sets.append(ngrams)

    for i in range(1, len(ngram_sets)):
        set1 = ngram_sets[i - 1]
        set2 = ngram_sets[i]

        if len(set1) == 0 or len(set2) == 0:
            continue

        intersection = len(set1 & set2)
        union = len(set1 | set2)

        if union > 0:
            similarity = intersection / union
            if similarity < 0.3:
                boundaries.add(i)

    return boundaries


def detect_boundaries_voting(
    messages: list[bytes],
    min_confidence: float = 0.6,
) -> list[int]:
    """Detect field boundaries using voting expert algorithm.

    : IPART-style voting expert for boundary detection.

    Combines multiple detection strategies:
    1. Entropy-based detection
    2. Alignment-based detection (Smith-Waterman)
    3. Statistical variance detection
    4. Byte value distribution analysis
    5. N-gram frequency analysis

    Args:
        messages: List of protocol messages (bytes)
        min_confidence: Minimum vote fraction to accept boundary (0.0-1.0)

    Returns:
        List of byte positions that are likely field boundaries

    References:
        IPART: IP Packet Analysis using Random Forests.
        IEEE ISSRE 2014.
    """
    if not messages:
        return [0]

    msg_arrays = []
    for msg in messages:
        msg_arrays.append(np.frombuffer(msg, dtype=np.uint8))

    experts = [
        expert_entropy(msg_arrays),
        expert_alignment(messages),
        expert_variance(msg_arrays),
        expert_distribution(msg_arrays),
        expert_ngrams(msg_arrays, n=2),
    ]

    num_experts = len(experts)

    all_boundaries: set[int] = set()
    for expert_boundaries in experts:
        all_boundaries.update(expert_boundaries)

    boundary_votes: dict[int, int] = {}
    for boundary in all_boundaries:
        votes = sum(1 for expert in experts if boundary in expert)
        boundary_votes[boundary] = votes

    min_votes = int(num_experts * min_confidence)
    accepted_boundaries = [pos for pos, votes in boundary_votes.items() if votes >= min_votes]

    if 0 not in accepted_boundaries:
        accepted_boundaries.append(0)

    accepted_boundaries = sorted(accepted_boundaries)

    merged = [accepted_boundaries[0]]
    for b in accepted_boundaries[1:]:
        if b - merged[-1] >= 2:
            merged.append(b)

    return merged
