"""
Inter-rater reliability metrics for sign language corpus analysis.

This module provides statistical measures for assessing agreement between
multiple annotators on categorical labels and temporal boundaries in sign
language corpora.

The metrics support:
- Categorical agreement: Cohen's Kappa (2 raters), Fleiss' Kappa (n raters)
- Ordinal/interval data: Krippendorff's Alpha (with missing data support)
- Temporal boundaries: Precision, Recall, F1 for segment boundaries
- Label agreement: Combined temporal and categorical agreement

Example:
    >>> from sltk.linguistics.reliability import cohens_kappa, boundary_agreement
    >>> from sltk.data.core import SegmentList, Segment
    >>>
    >>> # Categorical agreement between two annotators
    >>> rater1 = ["HELLO", "WORLD", "HELLO", "GOODBYE"]
    >>> rater2 = ["HELLO", "WORLD", "HI", "GOODBYE"]
    >>> result = cohens_kappa(rater1, rater2)
    >>> print(f"Kappa: {result.kappa:.3f} ({result.interpretation})")
    >>>
    >>> # Temporal boundary agreement
    >>> segs1 = SegmentList([Segment(0, 1, "A"), Segment(1.5, 2.5, "B")])
    >>> segs2 = SegmentList([Segment(0, 1.1, "A"), Segment(1.4, 2.6, "B")])
    >>> boundary_result = boundary_agreement(segs1, segs2, tolerance_sec=0.2)
    >>> print(f"Boundary F1: {boundary_result.f1:.3f}")
"""

from __future__ import annotations

import logging
from collections import Counter
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal

import numpy as np

from sltk.data.core import Segment, SegmentList

if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)


# =============================================================================
# Result Dataclasses
# =============================================================================


@dataclass
class KappaResult:
    """
    Result of a kappa coefficient calculation.

    Attributes:
        kappa: The kappa coefficient value (-1 to 1, where 1 is perfect agreement)
        observed_agreement: Proportion of items where raters agreed
        expected_agreement: Expected agreement by chance
        interpretation: Human-readable interpretation of the kappa value
        n_items: Number of items that were compared
        n_categories: Number of unique categories in the data

    The interpretation follows Landis and Koch (1977):
        - kappa < 0.00: Poor (less than chance)
        - 0.00-0.20: Slight
        - 0.21-0.40: Fair
        - 0.41-0.60: Moderate
        - 0.61-0.80: Substantial
        - 0.81-1.00: Almost perfect
    """

    kappa: float
    observed_agreement: float
    expected_agreement: float
    interpretation: str
    n_items: int
    n_categories: int = 0

    def to_dict(self) -> dict:
        """Convert to JSON-serializable dictionary."""
        return {
            "kappa": float(self.kappa),
            "observed_agreement": float(self.observed_agreement),
            "expected_agreement": float(self.expected_agreement),
            "interpretation": self.interpretation,
            "n_items": self.n_items,
            "n_categories": self.n_categories,
        }


@dataclass
class BoundaryAgreementResult:
    """
    Result of boundary agreement calculation.

    Measures how well two annotators agree on the placement of temporal
    boundaries in a segmented annotation.

    Attributes:
        precision: Proportion of rater1's boundaries that match rater2's
        recall: Proportion of rater2's boundaries that match rater1's
        f1: Harmonic mean of precision and recall
        n_boundaries_rater1: Number of boundaries from first rater
        n_boundaries_rater2: Number of boundaries from second rater
        n_matched: Number of boundary pairs that matched within tolerance
        tolerance_sec: The tolerance threshold used for matching

    Example:
        If rater1 marks boundaries at [0, 1.0, 2.5] and rater2 marks
        boundaries at [0.05, 1.1, 2.4, 3.0] with tolerance=0.2:
        - n_matched = 3 (all of rater1's boundaries match)
        - precision = 3/3 = 1.0
        - recall = 3/4 = 0.75
        - f1 = 2 * (1.0 * 0.75) / (1.0 + 0.75) = 0.857
    """

    precision: float
    recall: float
    f1: float
    n_boundaries_rater1: int
    n_boundaries_rater2: int
    n_matched: int
    tolerance_sec: float = 0.1

    def to_dict(self) -> dict:
        """Convert to JSON-serializable dictionary."""
        return {
            "precision": float(self.precision),
            "recall": float(self.recall),
            "f1": float(self.f1),
            "n_boundaries_rater1": self.n_boundaries_rater1,
            "n_boundaries_rater2": self.n_boundaries_rater2,
            "n_matched": self.n_matched,
            "tolerance_sec": float(self.tolerance_sec),
        }


@dataclass
class LabelAgreementResult:
    """
    Result of label agreement calculation for temporally aligned segments.

    First aligns segments by time overlap, then compares their labels.

    Attributes:
        kappa: Cohen's Kappa for aligned segment labels
        agreement_rate: Simple proportion of aligned segments with matching labels
        n_aligned_pairs: Number of segment pairs that were temporally aligned
        n_label_matches: Number of aligned pairs with matching labels
        n_segments_rater1: Total segments from first rater
        n_segments_rater2: Total segments from second rater
        unaligned_rater1: Number of segments from rater1 with no temporal match
        unaligned_rater2: Number of segments from rater2 with no temporal match
        label_pairs: List of (label1, label2) pairs for aligned segments
    """

    kappa: float
    agreement_rate: float
    n_aligned_pairs: int
    n_label_matches: int
    n_segments_rater1: int
    n_segments_rater2: int
    unaligned_rater1: int
    unaligned_rater2: int
    label_pairs: list[tuple[str | None, str | None]] = field(default_factory=list)

    def to_dict(self) -> dict:
        """Convert to JSON-serializable dictionary."""
        return {
            "kappa": float(self.kappa),
            "agreement_rate": float(self.agreement_rate),
            "n_aligned_pairs": self.n_aligned_pairs,
            "n_label_matches": self.n_label_matches,
            "n_segments_rater1": self.n_segments_rater1,
            "n_segments_rater2": self.n_segments_rater2,
            "unaligned_rater1": self.unaligned_rater1,
            "unaligned_rater2": self.unaligned_rater2,
            "label_pairs": self.label_pairs,
        }


@dataclass
class ReliabilityResult:
    """
    Combined reliability metrics for a tier or annotation set.

    Attributes:
        kappa: Cohen's Kappa for label agreement
        boundary_f1: F1 score for boundary detection
        n_segments: Total number of segments analyzed
        tier: Tier name (if applicable)
        detailed_boundary: Full boundary agreement results
        detailed_label: Full label agreement results
    """

    kappa: float
    boundary_f1: float
    n_segments: int
    tier: str | None = None
    detailed_boundary: BoundaryAgreementResult | None = None
    detailed_label: LabelAgreementResult | None = None

    def to_dict(self) -> dict[str, Any]:
        """Convert to JSON-serializable dictionary."""
        result: dict[str, Any] = {
            "kappa": float(self.kappa),
            "boundary_f1": float(self.boundary_f1),
            "n_segments": self.n_segments,
            "tier": self.tier,
        }
        if self.detailed_boundary:
            result["detailed_boundary"] = self.detailed_boundary.to_dict()
        if self.detailed_label:
            result["detailed_label"] = self.detailed_label.to_dict()
        return result


@dataclass
class ConfusionMatrixResult:
    """
    Confusion matrix showing disagreement patterns between two raters.

    Attributes:
        matrix: 2D array where matrix[i][j] = count of (label_i, label_j) pairs
        labels: List of unique labels in the same order as matrix rows/columns
        n_items: Total number of items
        agreement_rate: Proportion of items on the diagonal (matching labels)
        most_confused_pairs: Top pairs of labels that are commonly confused
    """

    matrix: list[list[int]]
    labels: list[str]
    n_items: int
    agreement_rate: float
    most_confused_pairs: list[tuple[str, str, int]] = field(default_factory=list)

    def to_dict(self) -> dict:
        """Convert to JSON-serializable dictionary."""
        return {
            "matrix": self.matrix,
            "labels": self.labels,
            "n_items": self.n_items,
            "agreement_rate": float(self.agreement_rate),
            "most_confused_pairs": [
                {"label1": p[0], "label2": p[1], "count": p[2]} for p in self.most_confused_pairs
            ],
        }


# =============================================================================
# Helper Functions
# =============================================================================


def _interpret_kappa(kappa: float) -> str:
    """
    Interpret kappa value using Landis and Koch (1977) thresholds.

    Args:
        kappa: Kappa coefficient value

    Returns:
        Interpretation string
    """
    if kappa < 0.0:
        return "poor"
    elif kappa < 0.20:
        return "slight"
    elif kappa < 0.40:
        return "fair"
    elif kappa < 0.60:
        return "moderate"
    elif kappa < 0.80:
        return "substantial"
    else:
        return "almost_perfect"


def _get_boundaries(segments: SegmentList) -> list[float]:
    """
    Extract all unique boundary times from segments.

    Args:
        segments: SegmentList to extract boundaries from

    Returns:
        Sorted list of boundary times
    """
    boundaries = set()
    for seg in segments:
        boundaries.add(seg.start)
        boundaries.add(seg.end)
    return sorted(boundaries)


def _f1_score(precision: float, recall: float) -> float:
    """Compute F1 score from precision and recall."""
    if precision + recall == 0:
        return 0.0
    return 2 * precision * recall / (precision + recall)


# =============================================================================
# Cohen's Kappa (Two Raters)
# =============================================================================


def cohens_kappa(
    rater1_labels: list[str],
    rater2_labels: list[str],
) -> KappaResult:
    """
    Calculate Cohen's Kappa for two raters on categorical data.

    Cohen's Kappa measures inter-rater agreement for categorical items,
    correcting for agreement that would be expected by chance.

    Args:
        rater1_labels: Labels assigned by first rater
        rater2_labels: Labels assigned by second rater (same order as rater1)

    Returns:
        KappaResult with kappa value, observed/expected agreement,
        interpretation, and item count

    Raises:
        ValueError: If label lists have different lengths or are empty

    Example:
        >>> rater1 = ["A", "B", "A", "B", "A"]
        >>> rater2 = ["A", "B", "B", "B", "A"]
        >>> result = cohens_kappa(rater1, rater2)
        >>> print(f"Kappa: {result.kappa:.3f}")
        Kappa: 0.545
        >>> print(f"Interpretation: {result.interpretation}")
        Interpretation: moderate

    Notes:
        - If all items have the same label, kappa is undefined (returns 1.0
          if both raters agree completely, 0.0 otherwise)
        - Kappa can be negative if agreement is worse than chance
    """
    if len(rater1_labels) != len(rater2_labels):
        raise ValueError(
            f"Label lists must have same length: " f"{len(rater1_labels)} vs {len(rater2_labels)}"
        )

    if len(rater1_labels) == 0:
        raise ValueError("Label lists cannot be empty")

    n = len(rater1_labels)

    # Get all unique categories
    categories = sorted(set(rater1_labels) | set(rater2_labels))
    n_categories = len(categories)

    # Edge case: only one category
    if n_categories == 1:
        # Both raters used only one category
        return KappaResult(
            kappa=1.0,
            observed_agreement=1.0,
            expected_agreement=1.0,
            interpretation="almost_perfect",
            n_items=n,
            n_categories=n_categories,
        )

    # Count agreements
    n_agree = sum(1 for a, b in zip(rater1_labels, rater2_labels) if a == b)
    observed_agreement = n_agree / n

    # Calculate expected agreement by chance
    # P(chance agreement) = sum over categories of P(rater1 chooses c) * P(rater2 chooses c)
    rater1_counts = Counter(rater1_labels)
    rater2_counts = Counter(rater2_labels)

    expected_agreement = 0.0
    for cat in categories:
        p1 = rater1_counts.get(cat, 0) / n
        p2 = rater2_counts.get(cat, 0) / n
        expected_agreement += p1 * p2

    # Calculate kappa
    if expected_agreement == 1.0:
        # Perfect expected agreement (all items same category by both raters)
        kappa = 1.0 if observed_agreement == 1.0 else 0.0
    else:
        kappa = (observed_agreement - expected_agreement) / (1 - expected_agreement)

    return KappaResult(
        kappa=kappa,
        observed_agreement=observed_agreement,
        expected_agreement=expected_agreement,
        interpretation=_interpret_kappa(kappa),
        n_items=n,
        n_categories=n_categories,
    )


# =============================================================================
# Fleiss' Kappa (Multiple Raters)
# =============================================================================


def fleiss_kappa(
    ratings: list[list[str]],
) -> KappaResult:
    """
    Calculate Fleiss' Kappa for multiple raters on categorical data.

    Unlike Cohen's Kappa (which is for exactly 2 raters), Fleiss' Kappa
    measures agreement among any number of raters assigning categorical
    ratings to the same set of items.

    Args:
        ratings: List of rating lists, where each inner list is one rater's
                 labels for all items. All lists must have the same length.

    Returns:
        KappaResult with kappa value, observed/expected agreement,
        interpretation, and item/category counts

    Raises:
        ValueError: If less than 2 raters, empty ratings, or mismatched lengths

    Example:
        >>> # Three raters rating 5 items
        >>> rater1 = ["A", "B", "A", "B", "A"]
        >>> rater2 = ["A", "B", "B", "B", "A"]
        >>> rater3 = ["A", "B", "A", "A", "A"]
        >>> result = fleiss_kappa([rater1, rater2, rater3])
        >>> print(f"Kappa: {result.kappa:.3f}")

    Notes:
        - Assumes all raters rate all items (use krippendorff_alpha for
          missing data)
        - The algorithm follows Fleiss (1971)
    """
    if len(ratings) < 2:
        raise ValueError("Fleiss' Kappa requires at least 2 raters")

    if not ratings[0]:
        raise ValueError("Rating lists cannot be empty")

    n_items = len(ratings[0])
    n_raters = len(ratings)

    # Validate all raters have same number of items
    for i, rater_labels in enumerate(ratings):
        if len(rater_labels) != n_items:
            raise ValueError(f"Rater {i} has {len(rater_labels)} items, expected {n_items}")

    # Get all unique categories
    categories = sorted(set(label for rater_labels in ratings for label in rater_labels))
    n_categories = len(categories)
    cat_to_idx = {cat: i for i, cat in enumerate(categories)}

    # Edge case: only one category
    if n_categories == 1:
        return KappaResult(
            kappa=1.0,
            observed_agreement=1.0,
            expected_agreement=1.0,
            interpretation="almost_perfect",
            n_items=n_items,
            n_categories=n_categories,
        )

    # Build count matrix: n_ij = number of raters who assigned category j to item i
    count_matrix = np.zeros((n_items, n_categories), dtype=int)
    for rater_labels in ratings:
        for i, label in enumerate(rater_labels):
            count_matrix[i, cat_to_idx[label]] += 1

    # Calculate P_i (agreement for item i)
    # P_i = (1 / (n*(n-1))) * sum_j(n_ij * (n_ij - 1))
    p_i = np.zeros(n_items)
    for i in range(n_items):
        row_sum = 0
        for j in range(n_categories):
            n_ij = count_matrix[i, j]
            row_sum += n_ij * (n_ij - 1)
        p_i[i] = row_sum / (n_raters * (n_raters - 1))

    # P_bar (mean observed agreement)
    observed_agreement = float(np.mean(p_i))

    # Calculate p_j (proportion of all ratings in category j)
    total_ratings = n_items * n_raters
    p_j = np.sum(count_matrix, axis=0) / total_ratings

    # P_e_bar (expected agreement by chance)
    expected_agreement = float(np.sum(p_j**2))

    # Calculate kappa
    if expected_agreement == 1.0:
        kappa = 1.0 if observed_agreement == 1.0 else 0.0
    else:
        kappa = (observed_agreement - expected_agreement) / (1 - expected_agreement)

    return KappaResult(
        kappa=kappa,
        observed_agreement=observed_agreement,
        expected_agreement=expected_agreement,
        interpretation=_interpret_kappa(kappa),
        n_items=n_items,
        n_categories=n_categories,
    )


# =============================================================================
# Krippendorff's Alpha (Handles Missing Data)
# =============================================================================


def krippendorff_alpha(
    ratings: list[list[str | None]],
    level: Literal["nominal", "ordinal", "interval", "ratio"] = "nominal",
) -> float:
    """
    Calculate Krippendorff's Alpha, which is robust to missing data.

    Krippendorff's Alpha is a versatile reliability coefficient that:
    - Handles any number of raters
    - Handles missing data (None values)
    - Supports different measurement levels (nominal, ordinal, interval, ratio)

    Args:
        ratings: List of rating lists, where each inner list is one rater's
                 labels. None indicates missing data (rater did not rate item).
        level: Measurement level determining the distance metric:
               - "nominal": Categories have no order (e.g., gloss labels)
               - "ordinal": Categories are ordered (e.g., fluency 1-5)
               - "interval": Equal intervals between values (e.g., temperature)
               - "ratio": Has true zero (e.g., duration in seconds)

    Returns:
        Alpha coefficient (-1 to 1, where 1 is perfect agreement)

    Raises:
        ValueError: If less than 2 raters or no valid data

    Example:
        >>> # Three raters with some missing data (None)
        >>> rater1 = ["A", "B", None, "B", "A"]
        >>> rater2 = ["A", "B", "B", "B", "A"]
        >>> rater3 = ["A", None, "A", "A", None]
        >>> alpha = krippendorff_alpha([rater1, rater2, rater3])
        >>> print(f"Alpha: {alpha:.3f}")

    Notes:
        - For purely nominal data (most sign language annotation), the
          distance function is binary: d(c,k) = 0 if c==k, 1 otherwise
        - For ordinal/interval/ratio data, categories should be numeric
          or ordered strings that can be converted to ranks
    """
    if len(ratings) < 2:
        raise ValueError("Krippendorff's Alpha requires at least 2 raters")

    if not ratings[0]:
        raise ValueError("Rating lists cannot be empty")

    n_items = len(ratings[0])
    n_raters = len(ratings)

    # Validate all raters have same number of items
    for i, rater_labels in enumerate(ratings):
        if len(rater_labels) != n_items:
            raise ValueError(f"Rater {i} has {len(rater_labels)} items, expected {n_items}")

    # Collect all non-None values to determine categories
    all_values = [label for rater_labels in ratings for label in rater_labels if label is not None]

    if len(all_values) == 0:
        raise ValueError("No valid (non-None) ratings found")

    # Get unique categories and create mapping
    if level == "nominal":
        categories = sorted(set(all_values))
        cat_to_val: dict[str, float] = {cat: float(i) for i, cat in enumerate(categories)}
    else:
        # For ordinal/interval/ratio, try to use numeric values
        # If strings, convert to ranks
        try:
            categories = sorted(set(all_values), key=float)
            cat_to_val = {cat: float(cat) for cat in categories}
        except (ValueError, TypeError):
            # Fall back to rank-based ordering
            categories = sorted(set(all_values))
            cat_to_val = {cat: float(i) for i, cat in enumerate(categories)}

    n_categories = len(categories)

    # Edge case: only one category
    if n_categories == 1:
        return 1.0

    # Define distance function based on level
    def distance(c: str | None, k: str | None) -> float:
        if c is None or k is None:
            return 0.0  # Missing data contributes nothing
        vc = cat_to_val[c]
        vk = cat_to_val[k]
        if level == "nominal":
            return 0.0 if c == k else 1.0
        elif level == "ordinal":
            # Sum of squared ranks between values
            return (vc - vk) ** 2
        elif level == "interval":
            return (vc - vk) ** 2
        elif level == "ratio":
            denom = vc + vk
            if denom == 0:
                return 0.0
            return ((vc - vk) / denom) ** 2
        else:
            return 0.0 if c == k else 1.0

    # Calculate observed disagreement (Do)
    # Sum of weighted distances for all pairs of ratings on same item
    observed_disagreement = 0.0
    total_pairs = 0

    for i in range(n_items):
        # Get all non-None ratings for this item
        item_ratings = [ratings[r][i] for r in range(n_raters) if ratings[r][i] is not None]
        m_i = len(item_ratings)

        if m_i < 2:
            continue  # Need at least 2 ratings to compare

        # Sum distances for all pairs
        for j in range(m_i):
            for k in range(j + 1, m_i):
                observed_disagreement += distance(item_ratings[j], item_ratings[k])
                total_pairs += 1

    if total_pairs == 0:
        raise ValueError("No items have ratings from at least 2 raters")

    observed_disagreement /= total_pairs

    # Calculate expected disagreement (De)
    # Based on marginal distribution of all ratings
    value_counts = Counter(all_values)
    len(all_values)

    expected_disagreement = 0.0
    total_expected_pairs = 0

    for cat_c in categories:
        for cat_k in categories:
            if cat_c != cat_k:  # Only count disagreements
                n_c = value_counts[cat_c]
                n_k = value_counts[cat_k]
                expected_disagreement += n_c * n_k * distance(cat_c, cat_k)
                total_expected_pairs += n_c * n_k

    if total_expected_pairs > 0:
        expected_disagreement /= total_expected_pairs
    else:
        expected_disagreement = 0.0

    # Calculate alpha
    if expected_disagreement == 0:
        return 1.0  # Perfect agreement or undefined

    alpha = 1 - (observed_disagreement / expected_disagreement)

    return alpha


# =============================================================================
# Boundary Agreement
# =============================================================================


def boundary_agreement(
    segments1: SegmentList,
    segments2: SegmentList,
    tolerance_sec: float = 0.1,
    tier: str | None = None,
) -> BoundaryAgreementResult:
    """
    Calculate agreement on segment boundaries between two annotators.

    Boundaries are matched using a greedy algorithm: each boundary from
    rater1 is matched to the nearest unmatched boundary from rater2 within
    the tolerance window.

    Args:
        segments1: Segments from first rater
        segments2: Segments from second rater
        tolerance_sec: Maximum time difference for a boundary match (default 0.1s)
        tier: Optional tier to filter segments by before comparison

    Returns:
        BoundaryAgreementResult with precision, recall, F1, and counts

    Example:
        >>> from sltk.data.core import SegmentList, Segment
        >>> segs1 = SegmentList([Segment(0, 1, "A"), Segment(1.5, 2.5, "B")])
        >>> segs2 = SegmentList([Segment(0.05, 1.1, "A"), Segment(1.4, 2.6, "B")])
        >>> result = boundary_agreement(segs1, segs2, tolerance_sec=0.2)
        >>> print(f"Precision: {result.precision:.3f}")
        >>> print(f"Recall: {result.recall:.3f}")
        >>> print(f"F1: {result.f1:.3f}")

    Notes:
        - Each boundary can only be matched once (greedy, no replacement)
        - Precision = (matched boundaries) / (rater1 boundaries)
        - Recall = (matched boundaries) / (rater2 boundaries)
    """
    # Filter by tier if specified
    if tier is not None:
        segments1 = segments1.filter_by_tier(tier)
        segments2 = segments2.filter_by_tier(tier)

    # Extract boundaries
    boundaries1 = _get_boundaries(segments1)
    boundaries2 = _get_boundaries(segments2)

    n_b1 = len(boundaries1)
    n_b2 = len(boundaries2)

    # Handle empty cases
    if n_b1 == 0 and n_b2 == 0:
        return BoundaryAgreementResult(
            precision=1.0,
            recall=1.0,
            f1=1.0,
            n_boundaries_rater1=0,
            n_boundaries_rater2=0,
            n_matched=0,
            tolerance_sec=tolerance_sec,
        )
    if n_b1 == 0:
        return BoundaryAgreementResult(
            precision=0.0,
            recall=0.0,
            f1=0.0,
            n_boundaries_rater1=0,
            n_boundaries_rater2=n_b2,
            n_matched=0,
            tolerance_sec=tolerance_sec,
        )
    if n_b2 == 0:
        return BoundaryAgreementResult(
            precision=0.0,
            recall=0.0,
            f1=0.0,
            n_boundaries_rater1=n_b1,
            n_boundaries_rater2=0,
            n_matched=0,
            tolerance_sec=tolerance_sec,
        )

    # Greedy matching: for each boundary in rater1, find closest in rater2
    matched_b2_indices: set[int] = set()
    n_matched = 0

    for b1 in boundaries1:
        best_idx = None
        best_dist = float("inf")

        for i, b2 in enumerate(boundaries2):
            if i in matched_b2_indices:
                continue
            dist = abs(b1 - b2)
            if dist <= tolerance_sec and dist < best_dist:
                best_idx = i
                best_dist = dist

        if best_idx is not None:
            matched_b2_indices.add(best_idx)
            n_matched += 1

    precision = n_matched / n_b1 if n_b1 > 0 else 0.0
    recall = n_matched / n_b2 if n_b2 > 0 else 0.0
    f1 = _f1_score(precision, recall)

    return BoundaryAgreementResult(
        precision=precision,
        recall=recall,
        f1=f1,
        n_boundaries_rater1=n_b1,
        n_boundaries_rater2=n_b2,
        n_matched=n_matched,
        tolerance_sec=tolerance_sec,
    )


# =============================================================================
# Temporal Label Agreement
# =============================================================================


def temporal_label_agreement(
    segments1: SegmentList,
    segments2: SegmentList,
    tolerance_sec: float = 0.1,
    tier: str | None = None,
) -> LabelAgreementResult:
    """
    Calculate label agreement for temporally aligned segments.

    First aligns segments from the two raters based on temporal overlap
    (using midpoint proximity), then computes agreement on the labels
    of aligned segment pairs.

    Args:
        segments1: Segments from first rater
        segments2: Segments from second rater
        tolerance_sec: Maximum midpoint difference for segment alignment
        tier: Optional tier to filter segments by before comparison

    Returns:
        LabelAgreementResult with kappa, agreement rate, and alignment details

    Example:
        >>> segs1 = SegmentList([Segment(0, 1, "HELLO"), Segment(1.5, 2.5, "WORLD")])
        >>> segs2 = SegmentList([Segment(0.1, 1.1, "HELLO"), Segment(1.4, 2.6, "EARTH")])
        >>> result = temporal_label_agreement(segs1, segs2, tolerance_sec=0.3)
        >>> print(f"Kappa: {result.kappa:.3f}")
        >>> print(f"Agreement rate: {result.agreement_rate:.3f}")

    Notes:
        - Segments are aligned based on midpoint proximity within tolerance
        - Each segment can only be matched once (greedy matching)
        - Unaligned segments are counted but don't affect kappa
    """
    # Filter by tier if specified
    if tier is not None:
        segments1 = segments1.filter_by_tier(tier)
        segments2 = segments2.filter_by_tier(tier)

    segs1 = list(segments1)
    segs2 = list(segments2)

    n_s1 = len(segs1)
    n_s2 = len(segs2)

    # Handle empty cases
    if n_s1 == 0 and n_s2 == 0:
        return LabelAgreementResult(
            kappa=1.0,
            agreement_rate=1.0,
            n_aligned_pairs=0,
            n_label_matches=0,
            n_segments_rater1=0,
            n_segments_rater2=0,
            unaligned_rater1=0,
            unaligned_rater2=0,
            label_pairs=[],
        )

    if n_s1 == 0 or n_s2 == 0:
        return LabelAgreementResult(
            kappa=0.0,
            agreement_rate=0.0,
            n_aligned_pairs=0,
            n_label_matches=0,
            n_segments_rater1=n_s1,
            n_segments_rater2=n_s2,
            unaligned_rater1=n_s1,
            unaligned_rater2=n_s2,
            label_pairs=[],
        )

    # Greedy alignment by midpoint proximity
    matched_s2_indices: set[int] = set()
    aligned_pairs: list[tuple[Segment, Segment]] = []
    matched_s1_indices: set[int] = set()

    for i, s1 in enumerate(segs1):
        mid1 = s1.midpoint
        best_idx = None
        best_dist = float("inf")

        for j, s2 in enumerate(segs2):
            if j in matched_s2_indices:
                continue
            mid2 = s2.midpoint
            dist = abs(mid1 - mid2)

            # Check if segments overlap or are within tolerance
            if dist <= tolerance_sec or s1.overlaps(s2):
                if dist < best_dist:
                    best_idx = j
                    best_dist = dist

        if best_idx is not None:
            matched_s2_indices.add(best_idx)
            matched_s1_indices.add(i)
            aligned_pairs.append((s1, segs2[best_idx]))

    # Extract labels from aligned pairs
    labels1 = []
    labels2 = []
    label_pairs = []
    n_label_matches = 0

    for s1, s2 in aligned_pairs:
        l1 = s1.label if s1.label is not None else "__NONE__"
        l2 = s2.label if s2.label is not None else "__NONE__"
        labels1.append(l1)
        labels2.append(l2)
        label_pairs.append((s1.label, s2.label))
        if l1 == l2:
            n_label_matches += 1

    # Calculate kappa on aligned pairs
    if len(labels1) >= 1:
        try:
            kappa_result = cohens_kappa(labels1, labels2)
            kappa = kappa_result.kappa
        except ValueError:
            # Edge case: all same label or other issue
            kappa = 1.0 if n_label_matches == len(aligned_pairs) else 0.0
    else:
        kappa = 0.0

    agreement_rate = n_label_matches / len(aligned_pairs) if aligned_pairs else 0.0

    return LabelAgreementResult(
        kappa=kappa,
        agreement_rate=agreement_rate,
        n_aligned_pairs=len(aligned_pairs),
        n_label_matches=n_label_matches,
        n_segments_rater1=n_s1,
        n_segments_rater2=n_s2,
        unaligned_rater1=n_s1 - len(matched_s1_indices),
        unaligned_rater2=n_s2 - len(matched_s2_indices),
        label_pairs=label_pairs,
    )


# =============================================================================
# Per-Tier Reliability Analysis
# =============================================================================


def analyze_reliability_by_tier(
    annotation_sets: list[SegmentList],
    tiers: list[str] | None = None,
) -> dict[str, ReliabilityResult]:
    """
    Calculate reliability metrics broken down by annotation tier.

    Computes both boundary agreement and label agreement for each tier,
    comparing all pairs of annotators.

    Args:
        annotation_sets: List of SegmentList objects, one per annotator
        tiers: Optional list of tiers to analyze. If None, analyzes all
               tiers found in the data.

    Returns:
        Dictionary mapping tier names to ReliabilityResult objects

    Raises:
        ValueError: If fewer than 2 annotation sets provided

    Example:
        >>> # Two annotators' annotations
        >>> ann1 = SegmentList([
        ...     Segment(0, 1, "A", tier="gloss"),
        ...     Segment(1.5, 2.5, "B", tier="gloss"),
        ...     Segment(0, 2.5, "declarative", tier="sentence_type"),
        ... ])
        >>> ann2 = SegmentList([
        ...     Segment(0.1, 1.1, "A", tier="gloss"),
        ...     Segment(1.4, 2.6, "B", tier="gloss"),
        ...     Segment(0, 2.6, "declarative", tier="sentence_type"),
        ... ])
        >>> results = analyze_reliability_by_tier([ann1, ann2])
        >>> for tier, result in results.items():
        ...     print(f"{tier}: kappa={result.kappa:.3f}, boundary_f1={result.boundary_f1:.3f}")

    Notes:
        - For more than 2 annotators, computes pairwise metrics and averages
        - Returns empty dict if no segments found for any tier
    """
    if len(annotation_sets) < 2:
        raise ValueError("Need at least 2 annotation sets for reliability analysis")

    # Determine tiers to analyze
    if tiers is None:
        all_tiers: set[str] = set()
        for ann_set in annotation_sets:
            all_tiers.update(ann_set.tiers)
        tiers = sorted(all_tiers)

    results: dict[str, ReliabilityResult] = {}

    for tier in tiers:
        # Filter each annotation set to this tier
        tier_sets = [ann_set.filter_by_tier(tier) for ann_set in annotation_sets]

        # Count total segments
        total_segments = sum(len(ts) for ts in tier_sets)
        if total_segments == 0:
            logger.debug(f"No segments found for tier '{tier}', skipping")
            continue

        # Compute pairwise metrics
        boundary_f1s = []
        kappas = []

        n_sets = len(tier_sets)
        for i in range(n_sets):
            for j in range(i + 1, n_sets):
                # Boundary agreement
                b_result = boundary_agreement(tier_sets[i], tier_sets[j])
                boundary_f1s.append(b_result.f1)

                # Label agreement
                l_result = temporal_label_agreement(tier_sets[i], tier_sets[j])
                kappas.append(l_result.kappa)

        # Average across pairs
        avg_boundary_f1 = float(np.mean(boundary_f1s)) if boundary_f1s else 0.0
        avg_kappa = float(np.mean(kappas)) if kappas else 0.0

        results[tier] = ReliabilityResult(
            kappa=avg_kappa,
            boundary_f1=avg_boundary_f1,
            n_segments=total_segments,
            tier=tier,
        )

    return results


# =============================================================================
# Confusion Matrix
# =============================================================================


def annotation_confusion_matrix(
    rater1_labels: list[str],
    rater2_labels: list[str],
) -> ConfusionMatrixResult:
    """
    Generate confusion matrix showing disagreement patterns between raters.

    The confusion matrix reveals which label pairs are commonly confused,
    helping identify systematic annotation errors or ambiguous categories.

    Args:
        rater1_labels: Labels from first rater
        rater2_labels: Labels from second rater (same order as rater1)

    Returns:
        ConfusionMatrixResult with matrix, labels, and common confusion pairs

    Raises:
        ValueError: If label lists have different lengths or are empty

    Example:
        >>> rater1 = ["HELLO", "WORLD", "HELLO", "HELLO", "GOODBYE"]
        >>> rater2 = ["HELLO", "EARTH", "HI", "HELLO", "GOODBYE"]
        >>> result = annotation_confusion_matrix(rater1, rater2)
        >>> print(f"Labels: {result.labels}")
        >>> print(f"Agreement rate: {result.agreement_rate:.2%}")
        >>> for pair in result.most_confused_pairs[:3]:
        ...     print(f"  {pair[0]} <-> {pair[1]}: {pair[2]} times")

    Notes:
        - Matrix[i][j] = count of items where rater1 said labels[i] and
          rater2 said labels[j]
        - Diagonal elements are agreements
        - most_confused_pairs lists off-diagonal entries sorted by frequency
    """
    if len(rater1_labels) != len(rater2_labels):
        raise ValueError(
            f"Label lists must have same length: " f"{len(rater1_labels)} vs {len(rater2_labels)}"
        )

    if len(rater1_labels) == 0:
        raise ValueError("Label lists cannot be empty")

    n = len(rater1_labels)

    # Get all unique labels
    labels = sorted(set(rater1_labels) | set(rater2_labels))
    label_to_idx = {label: i for i, label in enumerate(labels)}
    n_labels = len(labels)

    # Build confusion matrix
    matrix = [[0] * n_labels for _ in range(n_labels)]
    for l1, l2 in zip(rater1_labels, rater2_labels):
        i = label_to_idx[l1]
        j = label_to_idx[l2]
        matrix[i][j] += 1

    # Calculate agreement rate (sum of diagonal / total)
    diagonal_sum = sum(matrix[i][i] for i in range(n_labels))
    agreement_rate = diagonal_sum / n

    # Find most confused pairs (off-diagonal entries)
    confusion_pairs = []
    for i in range(n_labels):
        for j in range(n_labels):
            if i != j and matrix[i][j] > 0:
                confusion_pairs.append((labels[i], labels[j], matrix[i][j]))

    # Sort by frequency descending
    confusion_pairs.sort(key=lambda x: x[2], reverse=True)

    return ConfusionMatrixResult(
        matrix=matrix,
        labels=labels,
        n_items=n,
        agreement_rate=agreement_rate,
        most_confused_pairs=confusion_pairs,
    )
