import math
import unicodedata
from dataclasses import dataclass
from typing import List, Sequence, Tuple

from .audio_metrics import AudioMetrics


SCORING_VERSION = "asr-fluency-v1"


@dataclass(frozen=True)
class TextAlignment:
    reference_tokens: List[str]
    recognized_tokens: List[str]
    matches: int
    missing_tokens: List[str]
    extra_tokens: List[str]
    substitutions: List[Tuple[str, str]]
    content_score: float
    completeness_score: float


@dataclass(frozen=True)
class ScoreBreakdown:
    overall_score: float
    content_score: float
    completeness_score: float
    fluency_score: float
    duration_score: float
    pause_score: float
    speech_rate_score: float
    duration_ratio: float
    missing_tokens: List[str]
    extra_tokens: List[str]
    substitutions: List[Tuple[str, str]]
    feedback: str


def tokenize(text: str) -> List[str]:
    normalized = unicodedata.normalize("NFKC", text).lower().strip()
    tokens: List[str] = []
    word: List[str] = []

    def flush_word() -> None:
        if word:
            token = "".join(word).strip("'")
            if token:
                tokens.append(token)
            word.clear()

    for char in normalized:
        if _is_cjk(char):
            flush_word()
            tokens.append(char)
        elif char.isalnum() or (char == "'" and word):
            word.append(char)
        else:
            flush_word()
    flush_word()
    return tokens


def align_text(reference_text: str, recognized_text: str) -> TextAlignment:
    reference = tokenize(reference_text)
    recognized = tokenize(recognized_text)
    if not reference:
        raise ValueError("Reference text does not contain any scoreable tokens.")

    costs = [[0] * (len(recognized) + 1) for _ in range(len(reference) + 1)]
    for row in range(1, len(reference) + 1):
        costs[row][0] = row
    for column in range(1, len(recognized) + 1):
        costs[0][column] = column
    for row in range(1, len(reference) + 1):
        for column in range(1, len(recognized) + 1):
            substitution_cost = 0 if reference[row - 1] == recognized[column - 1] else 1
            costs[row][column] = min(
                costs[row - 1][column] + 1,
                costs[row][column - 1] + 1,
                costs[row - 1][column - 1] + substitution_cost,
            )

    row = len(reference)
    column = len(recognized)
    matches = 0
    missing: List[str] = []
    extra: List[str] = []
    substitutions: List[Tuple[str, str]] = []
    while row > 0 or column > 0:
        if row > 0 and column > 0:
            same = reference[row - 1] == recognized[column - 1]
            diagonal_cost = costs[row - 1][column - 1] + (0 if same else 1)
            if costs[row][column] == diagonal_cost:
                if same:
                    matches += 1
                else:
                    substitutions.append((reference[row - 1], recognized[column - 1]))
                row -= 1
                column -= 1
                continue
        if row > 0 and costs[row][column] == costs[row - 1][column] + 1:
            missing.append(reference[row - 1])
            row -= 1
        else:
            extra.append(recognized[column - 1])
            column -= 1

    missing.reverse()
    extra.reverse()
    substitutions.reverse()
    distance = len(missing) + len(extra) + len(substitutions)
    accuracy = max(0.0, 1.0 - distance / len(reference)) * 100
    attempted_reference_tokens = matches + len(substitutions)
    completeness = attempted_reference_tokens / len(reference) * 100
    content = accuracy * 0.7 + completeness * 0.3
    return TextAlignment(
        reference_tokens=reference,
        recognized_tokens=recognized,
        matches=matches,
        missing_tokens=missing,
        extra_tokens=extra,
        substitutions=substitutions,
        content_score=_round_score(content),
        completeness_score=_round_score(completeness),
    )


def score_attempt(
    *,
    reference_text: str,
    recognized_text: str,
    reference_speech_duration_ms: int,
    student_metrics: AudioMetrics,
) -> ScoreBreakdown:
    if reference_speech_duration_ms <= 0:
        raise ValueError("reference_speech_duration_ms must be positive")
    alignment = align_text(reference_text, recognized_text)
    ratio = student_metrics.speech_duration_ms / reference_speech_duration_ms
    duration = duration_similarity_score(ratio)
    pause = pause_score(student_metrics.internal_pause_ratio)

    reference_rate = len(alignment.reference_tokens) / (reference_speech_duration_ms / 1000)
    recognized_units = max(1, len(alignment.recognized_tokens))
    student_rate = recognized_units / (student_metrics.speech_duration_ms / 1000)
    rate_ratio = student_rate / reference_rate if reference_rate > 0 else 1.0
    rate = symmetric_rate_score(rate_ratio)

    fluency = duration * 0.35 + pause * 0.40 + rate * 0.25
    overall = alignment.content_score * 0.80 + fluency * 0.20
    feedback = build_feedback(alignment, ratio, student_metrics.internal_pause_ratio)
    return ScoreBreakdown(
        overall_score=_round_score(overall),
        content_score=alignment.content_score,
        completeness_score=alignment.completeness_score,
        fluency_score=_round_score(fluency),
        duration_score=_round_score(duration),
        pause_score=_round_score(pause),
        speech_rate_score=_round_score(rate),
        duration_ratio=round(ratio, 4),
        missing_tokens=alignment.missing_tokens,
        extra_tokens=alignment.extra_tokens,
        substitutions=alignment.substitutions,
        feedback=feedback,
    )


def duration_similarity_score(ratio: float) -> float:
    if ratio <= 0:
        return 0.0
    if 0.80 <= ratio <= 1.30:
        return 100.0
    if 0.65 <= ratio < 0.80:
        return _interpolate(ratio, 0.65, 0.80, 60, 100)
    if 1.30 < ratio <= 1.50:
        return _interpolate(ratio, 1.30, 1.50, 100, 60)
    if 0.50 <= ratio < 0.65:
        return _interpolate(ratio, 0.50, 0.65, 20, 60)
    if 1.50 < ratio <= 1.80:
        return _interpolate(ratio, 1.50, 1.80, 60, 20)
    if ratio < 0.50:
        return max(0.0, ratio / 0.50 * 20)
    return max(0.0, 20 - (ratio - 1.80) / 0.40 * 20)


def pause_score(internal_pause_ratio: float) -> float:
    if internal_pause_ratio <= 0.15:
        return 100.0
    if internal_pause_ratio <= 0.30:
        return _interpolate(internal_pause_ratio, 0.15, 0.30, 100, 70)
    if internal_pause_ratio <= 0.50:
        return _interpolate(internal_pause_ratio, 0.30, 0.50, 70, 20)
    return max(0.0, 20 - (internal_pause_ratio - 0.50) / 0.30 * 20)


def symmetric_rate_score(ratio: float) -> float:
    if ratio <= 0:
        return 0.0
    deviation = abs(math.log(ratio))
    free_tolerance = math.log(1.20)
    if deviation <= free_tolerance:
        return 100.0
    return max(0.0, 100 - (deviation - free_tolerance) / math.log(2.5) * 100)


def build_feedback(
    alignment: TextAlignment,
    duration_ratio: float,
    internal_pause_ratio: float,
) -> str:
    messages = []
    if alignment.missing_tokens:
        messages.append("存在漏读")
    if alignment.substitutions:
        messages.append("存在错读")
    if alignment.extra_tokens:
        messages.append("存在多读")
    if duration_ratio < 0.65:
        messages.append("朗读明显偏快或内容不完整")
    elif duration_ratio > 1.50:
        messages.append("朗读速度偏慢")
    if internal_pause_ratio > 0.30:
        messages.append("句内停顿偏多")
    return "；".join(messages) if messages else "内容和朗读节奏匹配良好"


def _is_cjk(char: str) -> bool:
    codepoint = ord(char)
    return (
        0x3400 <= codepoint <= 0x4DBF
        or 0x4E00 <= codepoint <= 0x9FFF
        or 0xF900 <= codepoint <= 0xFAFF
    )


def _interpolate(value: float, start: float, end: float, start_score: float, end_score: float) -> float:
    return start_score + (value - start) / (end - start) * (end_score - start_score)


def _round_score(value: float) -> float:
    return round(min(100.0, max(0.0, value)), 1)
