"""Módulo de dominio puro para calificar la evaluación RA3 CDT1 de Química.

No depende del framework web: puede probarse con unittest en cualquier entorno.
Contiene la clave oficial de respuestas (extraída de la Hoja de Respuestas docente),
la lógica de calificación por serie y el cálculo de penalizaciones anti-trampa.
"""

import math
import re
import unicodedata

# ---------------------------------------------------------------------------
# Clave oficial y ponderaciones (total académico: 100 puntos)
# ---------------------------------------------------------------------------

PUNTOS = {
    "serie1": 10,   # 5 ítems x 2
    "serie2": 10,   # 5 ítems x 2
    "serie3": 20,   # 5 preguntas x 4
    "serie4": 40,   # 7 ejercicios: 4,4,4,4,6,8,10
    "serie5": 20,   # masa=4, moles=4, moléculas=4, pasos=8
}

CLAVE_VF = ["V", "V", "V", "F", "V"]
CLAVE_MULTIPLE = ["B", "C", "C", "B", "A"]

# Serie IV ítems 1-3: (meta, tolerancia absoluta)
SERIE4_NUMERICAS = [
    (100.0, 0.5),   # masa molecular CaCO3
    (5.0, 0.05),    # moles en 90 g de H2O
    (132.0, 0.5),   # gramos en 3 mol de CO2
]
PESOS_SERIE4 = [4, 4, 4, 4, 6, 8, 10]

VALOR_MOLECULAS_2MOL = 1.2044e24     # Serie IV ejercicio 4
VALOR_MOLECULAS_72G = 2.4088e24      # Serie V resultado final

# Serie V resultados intermedios: (meta, tolerancia absoluta, peso)
SERIE5_NUMERICAS = [
    (18.0, 0.5, 4),
    (4.0, 0.05, 4),
]

# Orden correcto de los 8 pasos del diagrama de flujo (Serie V)
PASOS_CORRECTOS = [
    "inicio", "dato", "masa_molecular", "moles",
    "conversion", "calculo", "resultado", "fin",
]

# Serie III: grupos de palabras clave por pregunta; puntaje proporcional a grupos.
SERIE3_GRUPOS = [
    [  # 1. ¿Qué es un mol?
        ("cantidad de sustancia", "cantidad de materia"),
        ("re:6[.,]02", "avogadro"),
        ("particula", "atomo", "molecula", "ion"),
    ],
    [  # 2. ¿Qué representa el número de Avogadro?
        ("avogadro",),
        ("re:6[.,]02", "re:10\\s*\\^?\\s*23", "re:x\\s*10", "particula"),
        ("mol", "sustancia", "constituyente", "unidad elemental"),
    ],
    [  # 3. ¿Qué es una fórmula empírica?
        ("proporcion", "simple", "minima", "razon"),
        ("atomo", "elemento", "combinan"),
    ],
    [  # 4. Diferencia empírica vs molecular
        ("real", "exacto"),
        ("proporcion", "simple", "razon"),
        ("molecula", "molecular"),
        ("empirica", "empirico"),
    ],
    [  # 5. ¿Qué representa la composición porcentual?
        ("porcentaje", "%"),
        ("masa"),
        ("elemento", "compuesto"),
        ("100", "divide", "dividid", "entre"),
    ],
]

# Respuestas modelo (texto del solucionario) para detectar redacción copiada tipo IA.
MODELO_SERIE3 = [
    "un mol es la cantidad de sustancia que contiene 6.022 x 10^23 particulas "
    "(atomos, moleculas o iones). su masa en gramos equivale al valor de la "
    "masa atomica o molecular.",
    "el numero de avogadro (6.022 x 10^23) representa la cantidad de particulas "
    "que contiene un mol de cualquier sustancia.",
    "la formula empirica es la formula que indica la proporcion mas simple "
    "(minima) en que se combinan los atomos de los elementos de un compuesto.",
    "la formula empirica indica la proporcion mas simple de los atomos, mientras "
    "que la formula molecular indica el numero real y exacto de atomos de cada "
    "elemento en una molecula.",
    "la composicion porcentual indica el porcentaje en masa de cada elemento "
    "presente en un compuesto. se calcula con %x = (masa de x dividido la masa "
    "total del compuesto) multiplicado por 100.",
]

# Conectores formales atípicos en respuestas cortas de examen.
MARCADORES_FORMALES = (
    "asimismo", "cabe destacar", "cabe señalar", "en otras palabras",
    "en conclusion", "en resumen", "es importante destacar", "por lo tanto",
    "dicho de otro modo", "de igual manera",
)

LONGITUD_MIN_MODELO = 90  # longitud mínima para comparar contra texto modelo IA


def normalizar(texto):
    """Minúsculas, sin tildes, coma decimal -> punto, espacios simples."""
    if not isinstance(texto, str):
        return ""
    texto = unicodedata.normalize("NFKD", texto)
    texto = "".join(c for c in texto if not unicodedata.combining(c))
    texto = texto.lower().replace(",", ".")
    return re.sub(r"\s+", " ", texto).strip()


def _contiene(texto_norm, alternativas):
    """True si alguna alternativa (literal o 're:<regex>') aparece en el texto."""
    for alt in alternativas:
        if alt.startswith("re:"):
            if re.search(alt[3:], texto_norm):
                return True
        elif alt in texto_norm:
            return True
    return False


# ---------------------------------------------------------------------------
# Series objetivas (I y II)
# ---------------------------------------------------------------------------

def calificar_serie1(respuestas):
    """Compara V/F exacto; devuelve (ganados, detalle, total)."""
    ganados = 0
    detalle = []
    for idx, correcta in enumerate(CLAVE_VF):
        respuesta = respuestas.get(str(idx))
        ok = respuesta == correcta
        ganados += 2 if ok else 0
        detalle.append({"item": idx + 1, "respuesta": respuesta,
                        "correcta": correcta, "ok": ok})
    return min(ganados, PUNTOS["serie1"]), detalle, PUNTOS["serie1"]


def calificar_serie2(respuestas):
    """Compara letra A-D exacta; devuelve (ganados, detalle, total)."""
    validas = {"A", "B", "C", "D"}
    limpias = {}
    for idx in range(len(CLAVE_MULTIPLE)):
        valor = respuestas.get(str(idx))
        limpias[str(idx)] = valor if valor in validas else ""
    ganados = 0
    detalle = []
    for idx, correcta in enumerate(CLAVE_MULTIPLE):
        respuesta = limpias[str(idx)]
        ok = respuesta == correcta
        ganados += 2 if ok else 0
        detalle.append({"item": idx + 1, "respuesta": respuesta,
                        "correcta": correcta, "ok": ok})
    return min(ganados, PUNTOS["serie2"]), detalle, PUNTOS["serie2"]


# ---------------------------------------------------------------------------
# Serie III: preguntas abiertas por grupos de palabras clave
# ---------------------------------------------------------------------------

def calificar_serie3(respuestas):
    """Cada pregunta vale 4 pts repartidos entre sus grupos de palabras clave."""
    ganado_total = 0
    detalle = []
    for idx, grupos in enumerate(SERIE3_GRUPOS):
        texto = normalizar(respuestas.get(str(idx), ""))
        detectados = sum(1 for grupo in grupos if texto and _contiene(texto, grupo))
        puntos = int(round(4 * detectados / len(grupos)))
        ganado_total += puntos
        detalle.append({
            "pregunta": idx + 1,
            "puntos": puntos,
            "maximo": 4,
            "grupos_detectados": detectados,
            "grupos_totales": len(grupos),
        })
    return min(ganado_total, PUNTOS["serie3"]), detalle, PUNTOS["serie3"]


# ---------------------------------------------------------------------------
# Utilidades numéricas (tolerancia y notación científica flexible)
# ---------------------------------------------------------------------------

def _a_flotante_seguro(valor):
    """Convierte a float rechazando NaN/inf/basura. None si no es válido."""
    try:
        numero = float(str(valor).strip().replace(",", "."))
    except (TypeError, ValueError):
        return None
    return numero if math.isfinite(numero) else None


def _valor_cientifico(texto):
    """Interpreta formas tipo 1.2044e24, 12.044x10^23 o un número simple."""
    texto = normalizar(texto).replace("×", "x").replace("*", "x").replace(" ", "")
    m = re.search(r"([\d.]+)e([+-]?\d+)", texto)
    if not m:
        m = re.search(r"([\d.]+)x10\^?([+-]?\d+)", texto)
    if m:
        try:
            return float(m.group(1)) * (10 ** int(m.group(2)))
        except ValueError:
            return None
    if "x10" in texto:
        return None
    return _a_flotante_seguro(texto)


def _valor_dentro(texto, objetivo, tolerancia_relativa=0.01):
    """True si el valor interpretado de 'texto' cae dentro de la tolerancia relativa."""
    numero = _valor_cientifico(texto)
    if numero is None:
        return False
    return abs(numero - objetivo) <= abs(objetivo) * tolerancia_relativa


def normalizar_formula(texto):
    """SO2 / so2 / so_2 -> 'SO2': solo letras y dígitos en mayúsculas."""
    return re.sub(r"[^A-Za-z0-9]", "", str(texto)).upper()


# ---------------------------------------------------------------------------
# Serie IV: ejercicios de aplicación
# ---------------------------------------------------------------------------

def calificar_serie4(respuestas):
    """Devuelve (ganados, detalle, total). Claves esperadas en respuestas:
    0..2 numéricos simples; 3 moléculas científicas; porc_h/porc_o;
    5 fórmula empírica SO2; 6 fórmula molecular C3H6."""
    ganados = 0
    detalle = []

    for idx, (meta, tol) in enumerate(SERIE4_NUMERICAS):
        numero = _a_flotante_seguro(respuestas.get(str(idx), ""))
        ok = numero is not None and abs(numero - meta) <= tol
        ganados += PESOS_SERIE4[idx] if ok else 0
        detalle.append({"ejercicio": idx + 1, "ok": ok,
                        "puntos": PESOS_SERIE4[idx] if ok else 0})

    ok_mol = _valor_dentro(respuestas.get("3", ""), VALOR_MOLECULAS_2MOL)
    ganados += 4 if ok_mol else 0
    detalle.append({"ejercicio": 4, "ok": ok_mol, "puntos": 4 if ok_mol else 0})

    h = _a_flotante_seguro(respuestas.get("porc_h"))
    o = _a_flotante_seguro(respuestas.get("porc_o"))
    ok_h = h is not None and abs(h - 11.11) <= 0.4
    ok_o = o is not None and abs(o - 88.89) <= 0.4
    puntos5 = (3 if ok_h else 0) + (3 if ok_o else 0)
    ganados += puntos5
    detalle.append({"ejercicio": 5, "ok_h": ok_h, "ok_o": ok_o, "puntos": puntos5})

    for clave, meta in (("5", "SO2"), ("6", "C3H6")):
        peso = 8 if clave == "5" else 10
        ok = normalizar_formula(respuestas.get(clave, "")) == meta
        ganados += peso if ok else 0
        detalle.append({"ejercicio": int(clave) + 1, "ok": ok,
                        "puntos": peso if ok else 0})

    return min(ganados, PUNTOS["serie4"]), detalle, PUNTOS["serie4"]


# ---------------------------------------------------------------------------
# Serie V: diagrama de flujo
# ---------------------------------------------------------------------------

def calificar_serie5(respuestas):
    """Respuestas: v1 (masa), v2 (moles), v3 (moléculas), pasos=[ids ordenados].
    Devuelve (ganados, detalle, total=20)."""
    ganados = 0
    detalle = []

    for idx, (meta, tol, peso) in enumerate(SERIE5_NUMERICAS):
        campo = f"v{idx + 1}"
        numero = _a_flotante_seguro(respuestas.get(campo, ""))
        ok = numero is not None and abs(numero - meta) <= tol
        ganados += peso if ok else 0
        detalle.append({"campo": campo, "ok": ok, "puntos": peso if ok else 0})

    ok_fin = _valor_dentro(respuestas.get("v3", ""), VALOR_MOLECULAS_72G)
    ganados += 4 if ok_fin else 0
    detalle.append({"campo": "v3", "ok": ok_fin, "puntos": 4 if ok_fin else 0})

    pasos = respuestas.get("pasos", [])
    correctos = sum(
        1 for pos, paso in enumerate(pasos[:len(PASOS_CORRECTOS)])
        if paso == PASOS_CORRECTOS[pos]
    )
    puntos_pasos = int(round(8 * correctos / len(PASOS_CORRECTOS)))
    ganados += puntos_pasos
    detalle.append({"campo": "pasos", "correctos": correctos,
                    "total": len(PASOS_CORRECTOS), "puntos": puntos_pasos})

    return min(ganados, PUNTOS["serie5"]), detalle, PUNTOS["serie5"]


# ---------------------------------------------------------------------------
# Detección de indicios de uso de IA (preguntas abiertas, Series III/IV)
# ---------------------------------------------------------------------------

def _similitud_ngramas(texto_a, texto_b, n=3):
    """Similitud Jaccard sobre n-gramas de caracteres (0..1)."""
    if len(texto_a) < n or len(texto_b) < n:
        return 0.0
    conj_a = {texto_a[i:i + n] for i in range(len(texto_a) - n + 1)}
    conj_b = {texto_b[i:i + n] for i in range(len(texto_b) - n + 1)}
    if not conj_a or not conj_b:
        return 0.0
    return len(conj_a & conj_b) / len(conj_a | conj_b)


# Tiempo mínimo plausible (segundos) por tipo de pregunta abierta.
TIEMPO_MIN_S = {"serie3": 8.0, "serie4": 15.0}


def evaluar_indicadores_ia(textos_serie3, metas):
    """Evalúa señales de redacción 'demasiado perfecta' o pegada.

    textos_serie3: dict idx->texto. metas: dict "s3_<idx>" -> dict con
    tiempo_s, teclas, correcciones (meta del cliente; ausente = indicador).
    Devuelve lista de cadenas identificando cada indicador detectado.
    """
    indicadores = []
    for idx in range(len(SERIE3_GRUPOS)):
        clave_meta = f"s3_{idx}"
        meta = metas.get(clave_meta)
        if not isinstance(meta, dict):
            indicadores.append(f"{clave_meta}:sin_meta")
            continue

        texto_norm = normalizar(textos_serie3.get(str(idx), ""))
        tiempo = _a_flotante_seguro(meta.get("tiempo_s")) or 0.0
        minimo = TIEMPO_MIN_S["serie3"]
        if tiempo < minimo and len(texto_norm) > 20:
            indicadores.append(f"{clave_meta}:tiempo_imposible({tiempo:.1f}s)")

        teclas = _a_flotante_seguro(meta.get("teclas"))
        correcciones = _a_flotante_seguro(meta.get("correcciones")) or 0
        if teclas is not None and len(texto_norm) > 40 and teclas < len(texto_norm) * 0.7:
            indicadores.append(f"{clave_meta}:teclas_insuficientes")

        marcadores = sum(1 for m in MARCADORES_FORMALES if m in texto_norm)
        if marcadores >= 2:
            indicadores.append(f"{clave_meta}:estilo_formal({marcadores})")

        modelo = normalizar(MODELO_SERIE3[idx])
        if len(texto_norm) >= LONGITUD_MIN_MODELO:
            similitud = _similitud_ngramas(texto_norm, modelo)
            parecido_con_IA = (
                similitud >= 0.75
                or (len(texto_norm) >= LONGITUD_MIN_MODELO
                    and correcciones == 0 and tiempo > 0 and marcadores >= 1)
            )
            if parecido_con_IA:
                indicadores.append(f"{clave_meta}:redaccion_ia(sim={similitud:.2f})")

    return indicadores


def alertas_ia(n_indicadores):
    """Umbral acordado: -5 pts por cada bloque de 4+ indicadores acumulados."""
    return n_indicadores // 4


# ---------------------------------------------------------------------------
# Calificación total: académica + penalizaciones anti-trampa e IA
# ---------------------------------------------------------------------------

DESCUENTO_POR_INCIDENTE = 5


def calificar_examen(payload_respuestas, metas, n_incidentes_trampa):
    """Orquesta todas las series y aplica penalizaciones.

    payload_respuestas: dict con claves serie1..serie5 (dicts).
    metas: dict de metadatos por pregunta abierta.
    n_incidentes_trampa: incidentes de trampa ya registrados en el servidor.

    Devuelve dict completo con desglose, indicadores IA y nota final.
    """
    r = payload_respuestas
    p1, d1, t1 = calificar_serie1(r["serie1"])
    p2, d2, t2 = calificar_serie2(r["serie2"])
    p3, d3, t3 = calificar_serie3(r["serie3"])
    p4, d4, t4 = calificar_serie4(r["serie4"])
    p5, d5, t5 = calificar_serie5(r["serie5"])

    indicadores_ia = evaluar_indicadores_ia(r["serie3"], metas)
    descuento_ia = DESCUENTO_POR_INCIDENTE * alertas_ia(len(indicadores_ia))
    descuento_trampa = DESCUENTO_POR_INCIDENTE * max(0, int(n_incidentes_trampa))

    academico = p1 + p2 + p3 + p4 + p5
    descuentos = descuento_ia + descuento_trampa
    return {
        "academico": min(academico, 100),
        "series": {
            "serie1": {"puntos": p1, "total": t1, "detalle": d1},
            "serie2": {"puntos": p2, "total": t2, "detalle": d2},
            "serie3": {"puntos": p3, "total": t3, "detalle": d3},
            "serie4": {"puntos": p4, "total": t4, "detalle": d4},
            "serie5": {"puntos": p5, "total": t5, "detalle": d5},
        },
        "indicadores_ia": indicadores_ia,
        "alertas_ia": alertas_ia(len(indicadores_ia)),
        "incidentes_trampa": max(0, int(n_incidentes_trampa)),
        "descuento_ia": descuento_ia,
        "descuento_trampa": descuento_trampa,
        "nota_final": max(0, min(academico, 100) - descuentos),
    }



