#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AUDITORIA DO BANCO DE QUESTOES — ROTA 300
=========================================

Este script procura vieses que tornam uma questao acertavel SEM conhecimento
do assunto. Ele nao olha se a questao esta certa: olha se ela esta VAZANDO
a resposta por alguma pista que nao tem nada a ver com Power BI.

Os tres testes sao de LOTE, nao de questao. Uma questao isolada nunca parece
enviesada — o vies so existe no agregado. E por isso que revisao questao a
questao nunca pega: o revisor le uma de cada vez.

COMO RODAR
    python3 auditar.py ../banco/00_banco_completo.json
    python3 auditar.py ../banco/en/00_full_bank.json

Sem dependencia nenhuma. Python 3.8+, biblioteca padrao apenas.

LICENCA
    Dominio publico. Copie, rode no SEU banco de questoes, publique o
    resultado. Se der ruim, melhor voce descobrir do que o seu aluno.
"""
import json, sys, re, statistics, random
from collections import Counter

LETRAS = "ABCDEFGH"

# ---------------------------------------------------------------- utilitarios

def carregar(caminho):
    """Aceita dois formatos: o banco completo (com texto) e o arquivo de
    metrica publicado em /auditoria/ (so comprimentos e gabarito).

    Os tres testes so olham comprimento e posicao, entao o resultado e'
    identico nos dois. E' por isso que da para publicar a auditoria sem
    publicar as questoes."""
    d = json.load(open(caminho, encoding="utf-8"))
    qs = (d.get("questoes") or d.get("questions")) if isinstance(d, dict) else d
    out = []
    for q in qs:
        if "n" in q and "c" in q:                       # formato de metrica
            alts = ["x" * n for n in q["n"]]
            cor  = q["c"]
        else:                                            # banco completo
            alts = q.get("alternativas") or q.get("options") or []
            cor  = q.get("corretas")
            cor  = q.get("correct") if cor is None else cor
            if isinstance(cor, int): cor = [cor]
        if not alts or not cor: continue
        out.append(dict(id=q.get("id", ""),
                        dominio=q.get("dominio") or q.get("domain") or q.get("d", ""),
                        alts=alts, cor=sorted(cor)))
    return out

def barra(frac, largura=28):
    n = round(frac * largura)
    return "#" * n + "." * (largura - n)

# ------------------------------------------------------------------- teste 1

def teste_posicao(qs):
    """A resposta certa se concentra em alguma letra?

    Se 95% das respostas sao 'A', quem marcar tudo A passa sem saber nada.
    Alvo: distribuicao uniforme, 100/n por letra."""
    n_alt = max(len(q["alts"]) for q in qs)
    cont  = Counter()
    for q in qs:
        for c in q["cor"]:
            cont[c] += 1
    total = sum(cont.values())
    esperado = 100.0 / n_alt
    linhas, pior = [], 0.0
    for i in range(n_alt):
        pct = 100.0 * cont[i] / total if total else 0
        pior = max(pior, abs(pct - esperado))
        linhas.append((LETRAS[i], cont[i], pct))
    # nota do chute: quanto acerta quem marca sempre a letra mais comum
    melhor_letra = max(range(n_alt), key=lambda i: cont[i])
    chute = 100.0 * cont[melhor_letra] / len(qs)
    return dict(linhas=linhas, esperado=esperado, desvio_max=pior,
                chute_pct=chute, chute_letra=LETRAS[melhor_letra],
                passou=pior <= 5.0)

# ------------------------------------------------------------------- teste 2

def teste_comprimento(qs):
    """A alternativa certa e sistematicamente a mais longa?

    Este teste tem DOIS niveis, porque sao dois riscos diferentes.

    (a) PISTA VISIVEL — a certa se destaca a ponto de saltar aos olhos.
        E' o que um ser humano fazendo a prova consegue explorar.
        Criterio: 15+ caracteres a mais que a segunda maior E 10% maior.
        Alvo: nota da heuristica indistinguivel do acaso.

    (b) INCLINACAO ESTATISTICA — a certa e' a maior por poucos caracteres,
        de forma consistente. Ninguem percebe isso lendo; um script que
        conta caracteres percebe. Nao afeta quem faz a prova de boa fe,
        mas e' um defeito real do banco e nos publicamos o numero.
    """
    VIS_CAR, VIS_PCT = 15, 0.10
    vis_certa = vis_distrator = mais_longa = 0
    razoes, deltas = [], []
    for q in qs:
        comps = [len(a) for a in q["alts"]]
        ordem = sorted(range(len(comps)), key=lambda i: -comps[i])
        i1, i2 = ordem[0], ordem[1]
        if comps[i1] - comps[i2] >= VIS_CAR and comps[i1] >= comps[i2] * (1 + VIS_PCT):
            # SO conta como pista explorav	el se quem se destaca e a CERTA.
            # Distrator gigante e defeito de equilibrio, nao vazamento: a
            # heuristica "marque a maior" leva a pessoa para longe da resposta.
            # Publicamos os dois numeros para ninguem ter que confiar em mim.
            if i1 in q["cor"]: vis_certa += 1
            else:              vis_distrator += 1
        c = q["cor"][0]
        outras = [comps[i] for i in range(len(comps)) if i not in q["cor"]]
        if not outras: continue
        m = statistics.mean(outras)
        if comps[c] == max(comps): mais_longa += 1
        razoes.append(comps[c] / m if m else 1)
        deltas.append(comps[c] - m)

    n = len(qs)
    esperado = 100.0 / statistics.mean([len(q["alts"]) for q in qs])
    return dict(pct_mais_longa=100.0 * mais_longa / n, esperado=esperado,
                razao=statistics.mean(razoes), delta=statistics.mean(deltas),
                vis_certa=vis_certa, vis_distrator=vis_distrator, n=n,
                passou_visivel=vis_certa <= n * 0.01,
                passou_tilt=abs(100.0 * mais_longa / n - esperado) <= 8)

# ------------------------------------------------------------------- teste 3

def teste_chute(qs, rodadas=2000, semente=300):
    """Quanto tira quem chuta de verdade?

    Simula chute aleatorio uniforme. O resultado tem que bater com o acaso
    matematico (25% em 4 alternativas). Se der muito acima, alguma pista
    que os outros testes nao pegaram esta ajudando o chutador."""
    rnd = random.Random(semente)
    notas = []
    for _ in range(rodadas):
        acertos = sum(1 for q in qs if rnd.randrange(len(q["alts"])) in q["cor"])
        notas.append(100.0 * acertos / len(qs))
    media = statistics.mean(notas)
    teorico = 100.0 * statistics.mean([1/len(q["alts"]) for q in qs])
    return dict(media=media, desvio=statistics.stdev(notas), teorico=teorico,
                rodadas=rodadas, passou=abs(media - teorico) <= 2.0)


# ------------------------------------------------------------------- teste 4

ABSOLUTOS = re.compile(
    r"\b(sempre|nunca|todos?|toda[s]?|nenhum[ao]?|apenas|somente"
    r"|only|always|never|all of|none|every)\b", re.I)

def teste_absolutos(qs, rodadas=2000, semente=300):
    """A alternativa certa foge de 'sempre', 'nunca', 'apenas'?

    Heuristica de cursinho: alternativa com termo absoluto costuma ser a
    errada, porque o autor usa o exagero para fabricar o distrator. Quem
    conhece o truque elimina essas e chuta no resto.

    Medimos exatamente essa estrategia. Se ela render bem acima do acaso,
    o banco esta ensinando a decorar o truque em vez do assunto."""
    rnd = random.Random(semente)
    n_certa = n_errada = tot_errada = 0
    for q in qs:
        for i, a in enumerate(q["alts"]):
            if i in q["cor"]:
                n_certa += bool(ABSOLUTOS.search(a))
            else:
                tot_errada += 1
                n_errada += bool(ABSOLUTOS.search(a))
    notas = []
    for _ in range(rodadas):
        ok = 0
        for q in qs:
            cand = [i for i, a in enumerate(q["alts"]) if not ABSOLUTOS.search(a)]
            if not cand: cand = list(range(len(q["alts"])))
            if rnd.choice(cand) in q["cor"]: ok += 1
        notas.append(100.0 * ok / len(qs))
    media = statistics.mean(notas)
    teorico = 100.0 * statistics.mean([1 / len(q["alts"]) for q in qs])
    return dict(pct_certa=100.0 * n_certa / len(qs),
                pct_errada=100.0 * n_errada / tot_errada,
                media=media, teorico=teorico,
                passou=abs(media - teorico) <= 4.0)

# ---------------------------------------------------------------------- saida

# ---------------------------------------------------------------- rotulos
# O relatorio era impresso so' em portugues, e o `_gerar_pagina.py` embutia a
# MESMA saida nas duas paginas. Resultado: o comprador ingles, na pagina de
# EUR 147 que existe para provar rigor, lia "PISTA DE COMPRIMENTO", "PASSOU"
# e "SIMULACAO DE CHUTE". A pagina de auditoria era a unica peca do projeto
# que nunca tinha sido conferida por idioma, porque o texto dela nao mora num
# HTML — nasce de um print().
R = {
 "pt": dict(
  cab="AUDITORIA", n="questoes",
  t1="[1] POSICAO DO GABARITO",
  t1p="Pergunta: da para passar marcando sempre a mesma letra?",
  esperado_letra="esperado por letra", desvio="maior desvio",
  chute_letra="nota de quem marca so {L}",
  crit1="criterio: desvio <= 5 pp",
  t2="[2] PISTA DE COMPRIMENTO",
  t2a="(a) VISIVEL — da para explorar de olho, fazendo a prova?",
  vis_certa="a CERTA se destaca em", vis_dist="um DISTRATOR se destaca em",
  de="de", nota_certa="<- e o que da para explorar",
  nota_dist="<- desequilibrio, nao vazamento",
  crit2a="criterio: certa em destaque no maximo 1% dos itens",
  t2b="(b) INCLINACAO — da para explorar contando caracteres?",
  mais_longa="certa e a mais longa", acaso="esperado pelo acaso",
  razao="razao certa/erradas", neutro="1.00 = neutro",
  delta="diferenca media", car="caracteres",
  crit2b="criterio: desvio <= 8 pp",
  t3="[3] SIMULACAO DE CHUTE",
  t3p="Pergunta: quanto tira quem nao sabe nada? ({r} simulacoes)",
  chute_media="nota media do chute", dp="dp",
  teorico="acaso matematico", crit3="criterio: diferenca <= 2 pp",
  t4="[4] TERMOS ABSOLUTOS",
  t4p="Pergunta: da para passar fugindo de 'sempre', 'nunca', 'apenas'?",
  abs_certa="absolutos nas alternativas certas",
  abs_errada="absolutos nas erradas",
  abs_nota="nota de quem elimina e chuta", acaso_curto="acaso",
  crit4="criterio: diferenca <= 4 pp",
  passou="PASSOU", falhou="FALHOU", aberto="ABERTO",
  resultado="RESULTADO", limpo="SEM PISTA EXPLORAVEL POR QUEM FAZ A PROVA",
  sujo="PISTA EXPLORAVEL — CORRIGIR",
  ress1="Ressalva: a inclinacao estatistica de (2b) continua acima do acaso.",
  ress2="Nao e exploravel a olho nu, mas e um defeito real. Numero publicado acima."),
 "en": dict(
  cab="AUDIT", n="questions",
  t1="[1] ANSWER KEY POSITION",
  t1p="Question: can you pass by always picking the same letter?",
  esperado_letra="expected per letter", desvio="largest deviation",
  chute_letra="score of always picking {L}",
  crit1="criterion: deviation <= 5 pp",
  t2="[2] LENGTH CUE",
  t2a="(a) VISIBLE — can you exploit it by eye, sitting the exam?",
  vis_certa="the CORRECT one stands out in", vis_dist="a DISTRACTOR stands out in",
  de="of", nota_certa="<- this is what can be exploited",
  nota_dist="<- imbalance, not a leak",
  crit2a="criterion: correct standing out in at most 1% of items",
  t2b="(b) TILT — can you exploit it by counting characters?",
  mais_longa="correct is the longest", acaso="expected by chance",
  razao="length ratio correct/wrong", neutro="1.00 = neutral",
  delta="mean difference", car="characters",
  crit2b="criterion: deviation <= 8 pp",
  t3="[3] GUESSING SIMULATION",
  t3p="Question: what do you score knowing nothing? ({r} simulations)",
  chute_media="mean guessing score", dp="sd",
  teorico="mathematical chance", crit3="criterion: difference <= 2 pp",
  t4="[4] ABSOLUTE TERMS",
  t4p="Question: can you pass by avoiding 'always', 'never', 'only'?",
  abs_certa="absolutes in correct options",
  abs_errada="absolutes in wrong options",
  abs_nota="score of eliminating then guessing", acaso_curto="chance",
  crit4="criterion: difference <= 4 pp",
  passou="PASSED", falhou="FAILED", aberto="OPEN",
  resultado="RESULT", limpo="NO CUE EXPLOITABLE BY SOMEONE SITTING THE EXAM",
  sujo="EXPLOITABLE CUE — FIX IT",
  ress1="Caveat: the statistical tilt in (2b) is still above chance.",
  ress2="Not exploitable by eye, but a real defect. The number is published above."),
}


def _l(rotulo, valor, largura=34):
    """Rotulo + pontinhos + valor, alinhado por LARGURA e nao por pontos
       digitados a mao — que era o que impedia traduzir sem quebrar o desenho."""
    pontos = "." * max(1, largura - len(rotulo))
    return f"      {rotulo} {pontos} {valor}"


def relatorio(caminho, idioma="pt"):
    t = R[idioma]
    qs = carregar(caminho)
    print("=" * 68)
    print(f"  {t['cab']} — {caminho}")
    print(f"  {len(qs)} {t['n']}")
    print("=" * 68)

    t1 = teste_posicao(qs)
    print(f"\n{t['t1']}")
    print(f"    {t['t1p']}\n")
    for letra, n, pct in t1["linhas"]:
        print(f"      {letra}  {n:4d}  {pct:5.1f}%  {barra(pct/100)}")
    print()
    print(_l(t["esperado_letra"], f"{t1['esperado']:.1f}%"))
    print(_l(t["desvio"], f"{t1['desvio_max']:.1f} pp"))
    print(_l(t["chute_letra"].format(L=t1["chute_letra"]), f"{t1['chute_pct']:.1f}%"))
    print(f"      {t['passou'] if t1['passou'] else t['falhou']}  ({t['crit1']})")

    t2 = teste_comprimento(qs)
    print(f"\n{t['t2']}")
    print(f"    {t['t2a']}\n")
    print(_l(t["vis_certa"], f"{t2['vis_certa']} {t['de']} {t2['n']}   {t['nota_certa']}"))
    print(_l(t["vis_dist"], f"{t2['vis_distrator']} {t['de']} {t2['n']}   {t['nota_dist']}"))
    print(f"      {t['passou'] if t2['passou_visivel'] else t['falhou']}  ({t['crit2a']})")
    print(f"\n    {t['t2b']}\n")
    print(_l(t["mais_longa"], f"{t2['pct_mais_longa']:.1f}%"))
    print(_l(t["acaso"], f"{t2['esperado']:.1f}%"))
    print(_l(t["razao"], f"{t2['razao']:.2f}x  ({t['neutro']})"))
    print(_l(t["delta"], f"{t2['delta']:+.1f} {t['car']}"))
    print(f"      {t['passou'] if t2['passou_tilt'] else t['aberto']}  ({t['crit2b']})")

    t3 = teste_chute(qs)
    print(f"\n{t['t3']}")
    print(f"    {t['t3p'].format(r=t3['rodadas'])}\n")
    print(_l(t["chute_media"], f"{t3['media']:.1f}%  ({t['dp']} {t3['desvio']:.1f})"))
    print(_l(t["teorico"], f"{t3['teorico']:.1f}%"))
    print(f"      {t['passou'] if t3['passou'] else t['falhou']}  ({t['crit3']})")

    t4 = teste_absolutos(qs)
    print(f"\n{t['t4']}")
    print(f"    {t['t4p']}\n")
    print(_l(t["abs_certa"], f"{t4['pct_certa']:.1f}%"))
    print(_l(t["abs_errada"], f"{t4['pct_errada']:.1f}%"))
    print(_l(t["abs_nota"], f"{t4['media']:.1f}%  ({t['acaso_curto']} {t4['teorico']:.1f}%)"))
    print(f"      {t['passou'] if t4['passou'] else t['falhou']}  ({t['crit4']})")

    ok = t1["passou"] and t2["passou_visivel"] and t3["passou"] and t4["passou"]
    print("\n" + "=" * 68)
    print(f"  {t['resultado']}: {t['limpo'] if ok else t['sujo']}")
    if ok and not t2["passou_tilt"]:
        print(f"  {t['ress1']}")
        print(f"  {t['ress2']}")
    print("=" * 68)
    return dict(arquivo=caminho, n=len(qs), posicao=t1, comprimento=t2,
                chute=t3, absolutos=t4, limpo=ok)


if __name__ == "__main__":
    alvos = sys.argv[1:] or ["../banco/00_banco_completo.json"]
    res = [relatorio(a) for a in alvos]
    if len(res) > 1:
        print("\n\nRESUMO")
        for r in res:
            print(f"  {r['arquivo']:44s} {r['n']:5d} q  "
                  f"{'limpo' if r['limpo'] else 'VIES'}")
