import ast
import asyncio
import csv
import secrets
import time
from datetime import date
from io import StringIO
from urllib.parse import quote, unquote
from app.gerador_csv import gerar_csv_final
from app.bernoulli import transformar_vetor_keepedu_para_bernoulli, EXTENSOES_PLANILHA, PASTA_BERNOULLI
from app.keepedu_importacao import (
    contar_alunos_importacao,
    contar_imagens_importacao,
    enviar_cartao_keepedu,
    enviar_imagem_keepedu,
    enviar_todas_imagens_keepedu,
    importar_imagens_folha_resposta,
    importar_lista_frequencia_keepedu,
    salvar_lista_frequencia_pdf,
    importar_respostas_presenciais,
    processar_mock_importacao_keepedu,
    simular_importacao_respostas_presenciais,
)
from app.keepedu_auth import autenticar_keepedu
from app.validacao_cadastral import (
    alterar_id_prova_do_lote,
    cartao_enviado_keepedu_completo,
    carregar_envios_keepedu,
    carregar_validacao_cadastral_para_exibicao,
    gerar_validacao_cadastral,
    leitura_bloqueada_id_avaliacao,
    obter_validacao_cadastral_atual,
    resolver_envio_keepedu,
    rotulo_envio_keepedu,
    resumo_status_lote_keepedu,
    salvar_correcao_manual,
    remover_correcao_manual,
)
import json
import os
from html import escape
from app.auth import gerenciar_lembre_me, verificar_autologin_cookie, remover_token_banco
from app.auth import (
    salvar_sessao_usuario,
    obter_sessao_usuario,
    remover_sessao_usuario
)

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.dirname(BASE_DIR)

from app.settings import (
    APP_ENABLE_AUTH,
    FRONTEND_PUBLIC_URL,
    ID_PROVA_KEEPEDU,
    KEEPEDU_LOGIN_SCHOOL,
    KEEPEDU_LOGIN_URL,
    KEEPEDU_READING_BASE_URL,
    PASTA_PROCESSAMENTOS,
    PASTA_UPLOADS_TEMP,
    app_modo_api,
    nome_banco_para_escola,
    institute_id_para_escola,
    url_gestor_home_para_escola,
    url_gestor_para_escola,
)

MAX_UPLOAD_FILES = 300
MAX_FILE_SIZE_MB = 15
MAX_TOTAL_UPLOAD_SIZE_MB = 512
from app.sso import consumir_token_sso, extrair_dados_usuario_sso, sso_habilitado
from app.tenant import apply_tenant_from_session, run_in_threadpool, set_tenant_context
from app.concurrency import agendar_trabalho_pesado
from app.progresso import criar_job, obter_job, atualizar_job, registrar_evento_job
from app.services import processar_pasta_temporaria_com_progresso
from app.avaliacao_contexto import (
    limpar_contexto_na_sessao,
    aplicar_contexto_na_sessao,
    contexto_avaliacao_da_sessao,
    contexto_exibicao_avaliacao,
    contexto_para_processamento,
    descricao_avaliacao_para_exibicao,
    listar_simulados_disponiveis,
)
from app.keepedu_avaliacoes import buscar_listagem_avaliacoes
from app.keepedu_cadastros import (
    listar_series_por_unidade,
    listar_turmas_por_serie,
    listar_unidades_keepedu,
    listar_usuarios_keepedu,
)

from fastapi import APIRouter, Request, UploadFile, File, Form, Response, BackgroundTasks
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse, Response
from starlette.background import BackgroundTask
from pydantic import BaseModel
from pathlib import Path

from app.services import (
    carregar_json,
    carregar_leituras_lote,
    caminho_leituras,
    caminho_pasta_processamento,
    gerar_csv_log_virtual,
    listar_processamentos,
    listar_processamentos_recentes,
    listar_imagens_debug,
    resumo_processamento,
    processar_pasta_local,
    processar_uploads,
    caminho_log,
    caminho_correcoes_web,
    aplicar_correcoes_web_nas_leituras,
    reprocessar_imagem_processamento,
    deletar_leitura_processamento,
    deletar_processamento,
    reprocessar_lote_com_progresso,
    resolver_chave_leitura,
)
from app.historico_pos_processamento import (
    filtrar_e_paginar_lotes_historico,
    gerar_imagem_base_cartao,
    gerar_slug_historico_lote,
    gerar_visualizacao_cartao_keepedu,
    gerar_visualizacao_folha_keepedu_bytes,
    montar_dados_pos_processamento,
    obter_identificacao_prova_lote,
    resolver_nome_lote_historico,
    resumo_estatisticas_globais_processamento,
)
from app.keepedu_folha_resposta import baixar_imagem_folha_resposta, ver_folha_resposta_keepedu
from app.alunos_client import buscar_aluno_por_id, buscar_aluno_por_ra
from app.validacao_cadastral import detectar_id_prova_do_processamento
from app.kanban import (
    automacao_arquivo_bernoulli_gerado,
    automacao_envio_keepedu,
    automacao_inicio_vetores_keepedu,
    automacao_lote_aberto,
    automacao_processamento_iniciado,
    atualizar_card,
    atualizar_coluna,
    criar_coluna,
    excluir_card,
    excluir_coluna,
    historico_processamentos_do_card,
    historico_processamentos_por_referencia,
    listar_pendencias_kanban,
    pendencias_do_card,
    listar_etapas,
    montar_board,
    montar_coluna_board,
    registrar_entrega,
)
from app.kanban_relatorio import gerar_relatorio_pdf

router = APIRouter()
EXTENSOES_PERMITIDAS = {".jpg", ".jpeg", ".png"}


def url_frontend(caminho: str) -> str:
    """Monta uma URL da aplicação React sem acoplar as APIs ao servidor de telas."""
    return f"{FRONTEND_PUBLIC_URL}/{str(caminho or '').lstrip('/')}"

def descricao_avaliacao_exibicao(request: Request, nome_processamento: str = "") -> str:
    """Rótulo da avaliação exibido nas telas de lote (sessão do Painel ou meta do lote)."""
    return descricao_avaliacao_para_exibicao(request.session, nome_processamento)


def _contexto_avaliacao_ui(
    request: Request,
    nome_processamento: str = "",
    id_fallback: str = "",
) -> dict:
    exibicao = contexto_exibicao_avaliacao(
        request.session,
        nome_processamento=nome_processamento,
        id_fallback=id_fallback,
    )
    # O destino desta integração é definido pelo ambiente configurado, não
    # pela sessão: https://{KEEPEDU_LOGIN_SCHOOL}.keepedu.com.br/...
    # Assim, Great School e Propósito permanecem segmentados pelo .env.
    school = str(KEEPEDU_LOGIN_SCHOOL).strip().lower()
    identificador_avaliacao = str(exibicao.get("id_avaliacao") or id_fallback or "").strip()
    url_visualizacao_avaliacao_keepedu = (
        f"{url_gestor_para_escola(school)}"
        f"avaliacoes/visualizar-avaliacao-simulado-anexado/"
        f"{quote(identificador_avaliacao, safe='')}"
        if identificador_avaliacao
        else ""
    )
    return {
        "id_avaliacao": exibicao.get("id_avaliacao") or id_fallback,
        "descricao_avaliacao": (
            exibicao.get("tipo_prova")
            or exibicao.get("nome_simulado")
            or descricao_avaliacao_exibicao(request, nome_processamento)
        ),
        "avaliacao_exibicao": exibicao,
        "url_visualizacao_avaliacao_keepedu": url_visualizacao_avaliacao_keepedu,
    }


def _contexto_painel(request: Request, estatisticas: dict | None = None) -> dict:
    ctx_sessao = contexto_avaliacao_da_sessao(request.session)
    estatisticas = dict(estatisticas or {})
    return {
        "avaliacao_contexto": ctx_sessao,
        "avaliacao_exibicao": contexto_exibicao_avaliacao(request.session),
        # O catalogo KeepEdu continua sob demanda. Os indicadores locais sao
        # consultados no banco antes da primeira renderizacao.
        "simulados_disponiveis": [],
        "listagem_avaliacoes_erro": "",
        "listagem_avaliacoes_total": 0,
        "listagem_avaliacoes_ano": "",
        "estatisticas_painel": {
            "avaliacoes_processadas": int(
                estatisticas.get("avaliacoes_processadas") or 0
            ),
            "avaliacoes_total": int(
                estatisticas.get("avaliacoes_total") or 0
            ),
            "cartoes_processados": int(
                estatisticas.get("cartoes_processados") or 0
            ),
        },
    }


def _usuario_para_interface(request: Request) -> dict:
    """Normaliza variantes de identidade deixadas pelo login local ou SSO."""
    sessao = request.session
    usuario = sessao.get("user") or sessao.get("usuario") or {}
    if not isinstance(usuario, dict):
        usuario = {}
    nome = next((str(valor).strip() for valor in (
        sessao.get("user_name"), sessao.get("username"), sessao.get("userName"),
        sessao.get("nome"), sessao.get("nome_usuario"), sessao.get("login"),
        usuario.get("name"), usuario.get("nome"), usuario.get("username"),
        sessao.get("user_email"), sessao.get("email"), sessao.get("usuario_id"),
    ) if str(valor or "").strip()), "Operador OMR")
    perfil = next((str(valor).strip() for valor in (
        sessao.get("user_role"), sessao.get("perfil"), sessao.get("role"),
        usuario.get("role"), usuario.get("perfil"),
    ) if str(valor or "").strip()), "Administrador")
    return {
        "nome": nome,
        "escola": sessao.get("school") or sessao.get("keepedu_school") or KEEPEDU_LOGIN_SCHOOL,
        "perfil": perfil,
    }


class VerFolhaRespostaPayload(BaseModel):
    aluno_id: str | int | None = None
    simulado_id: str | int | None = None
    ra: str = ""
    chave_imagem: str = ""


class CorrecoesPayload(BaseModel):
    correcoes: dict


class ReprocessarImagemPayload(BaseModel):
    nome_imagem: str
    pontos_cantos: dict | None = None


class KanbanEntregaPayload(BaseModel):
    titulo: str
    id_avaliacao: str
    responsavel_entrega: str
    responsavel_correcao: str = ""
    total_cartoes: int = 0
    data_entrega: str = ""
    data_previsao_entrega: str = ""
    unidade: str = ""
    serie: str = ""
    turma: str = ""
    horario_envio: str = ""


class KanbanCardPayload(BaseModel):
    etapa: str | None = None
    posicao: int | None = None
    titulo: str | None = None
    id_avaliacao: str | None = None
    responsavel_entrega: str | None = None
    responsavel_correcao: str | None = None
    total_cartoes: int | None = None
    data_entrega: str | None = None
    data_previsao_entrega: str | None = None
    unidade: str | None = None
    serie: str | None = None
    turma: str | None = None
    status_prazo: str | None = None
    horario_envio: str | None = None


class KanbanColunaPayload(BaseModel):
    slug: str | None = None
    titulo: str | None = None
    cor: str | None = None
    ordem: int | None = None
    eh_inicial: bool | None = None
    eh_final: bool | None = None
    ativo: bool | None = None


class KanbanAutomacaoProcessamentoPayload(BaseModel):
    id_avaliacao: str
    nome_processamento: str = ""
    usuario: str = ""
    total_cartoes: int | None = None
    unidade: str = ""


class KanbanAutomacaoLotePayload(BaseModel):
    nome_processamento: str
    id_avaliacao: str = ""


class DeletarLeituraPayload(BaseModel):
    nome_imagem: str


class AlterarIdProvaPayload(BaseModel):
    id_prova_novo: str
    id_prova_antigo: str = ""


class ImportarImagemPayload(BaseModel):
    nome_imagem: str | None = None
    id_aluno: str | int | None = None
    id_aval: str | int | None = None
    modelo_ia: str | None = None


class ImportarTodasKeepEduPayload(BaseModel):
    id_aval: str | int | None = None
    modelo_ia: str | None = None


class ImportarRespostasPayload(BaseModel):
    idAval: str | int | None = None
    diaAval: str | int | None = None
    usuario_id: str | int | None = None
    idAluno: str | int | None = None
    nome_processamento: str | None = None
    modelo_ia : str | None = None


def listar_processamentos_local():
    return listar_processamentos()


def _resolver_lote_por_id_avaliacao(id_avaliacao: str) -> str | None:
    """Localiza o lote mais recente associado ao ID exibido na URL."""
    alvo = str(id_avaliacao or "").strip()
    if not alvo.isdigit():
        return None
    for nome_lote in listar_processamentos():
        try:
            leituras = carregar_leituras_lote(nome_lote)
            encontrado = detectar_id_prova_do_processamento(leituras, nome_lote)
            if str(encontrado or "").strip() == alvo:
                return nome_lote
        except Exception:
            continue
    return None

@router.get("/historico", response_class=HTMLResponse)
@router.get("/historico/", response_class=HTMLResponse)
async def historico_processos(request: Request, q: str = "", page: int = 1):
    return RedirectResponse(url=url_frontend("/historico"), status_code=307)


@router.get("/api/historico/processos")
async def api_historico_processos(q: str = "", page: int = 1):
    """Contrato do historico React, sem URLs acopladas às telas legadas."""
    dados = await run_in_threadpool(filtrar_e_paginar_lotes_historico, q, page, 12)
    avaliacoes = []
    for avaliacao in dados.get("avaliacoes") or []:
        processamentos = []
        for processamento in avaliacao.get("processamentos") or []:
            processamentos.append(
                {
                    chave: valor
                    for chave, valor in processamento.items()
                    if chave not in {"url_detalhe", "url_processamento", "url_correcao", "url_validacao"}
                }
            )
        avaliacoes.append({**avaliacao, "processamentos": processamentos})

    return JSONResponse(
        {
            "avaliacoes": avaliacoes,
            "total": dados.get("total", 0),
            "page": dados.get("page", 1),
            "total_pages": dados.get("total_pages", 1),
        }
    )


def _resolver_lote_historico(identificador: str) -> str | None:
    ident = extrair_nome_lote_seguro(identificador)
    nome = resolver_nome_lote_historico(ident)
    if nome:
        return nome
    if montar_dados_pos_processamento(ident):
        return ident
    return None


@router.post("/historico/{nome_processamento}/excluir")
async def historico_excluir_lote(nome_processamento: str):
    nome_limpo = _resolver_lote_historico(nome_processamento)
    if not nome_limpo:
        return JSONResponse(
            {"status": "erro", "erro": "Lote não encontrado."},
            status_code=404,
        )
    try:
        return JSONResponse(deletar_processamento(nome_limpo))
    except FileNotFoundError:
        return JSONResponse(
            {"status": "erro", "erro": "Lote não encontrado."},
            status_code=404,
        )
    except ValueError as exc:
        return JSONResponse(
            {"status": "erro", "erro": str(exc)},
            status_code=400,
        )
    except Exception as exc:
        return JSONResponse(
            {"status": "erro", "erro": str(exc)},
            status_code=500,
        )


@router.post("/historico/{nome_processamento}/ver-folha-resposta")
async def historico_ver_folha_resposta(
    nome_processamento: str,
    payload: VerFolhaRespostaPayload,
):
    nome_limpo = _resolver_lote_historico(nome_processamento)
    if not nome_limpo:
        return JSONResponse({"status": "erro", "erro": "Lote não encontrado."}, status_code=404)

    aluno_id = str(payload.aluno_id or "").strip()
    if not aluno_id or not str(aluno_id).isdigit():
        ra = str(payload.ra or "").strip()
        if ra and ra != "—":
            consulta = buscar_aluno_por_ra(ra)
            if consulta.get("encontrado") and consulta.get("id"):
                aluno_id = str(consulta["id"]).strip()

    simulado_id = str(payload.simulado_id or "").strip()
    if not simulado_id or not simulado_id.isdigit():
        identificacao = obter_identificacao_prova_lote(nome_limpo)
        simulado_id = str(identificacao.get("id_avaliacao") or "").strip()

    resultado = ver_folha_resposta_keepedu(aluno_id, simulado_id)
    if not resultado.get("ok"):
        return JSONResponse(
            {"status": "erro", "erro": resultado.get("erro") or "Falha ao consultar a folha."},
            status_code=400,
        )

    chave = str(payload.chave_imagem or "").strip()
    fallback_url = ""

    resposta: dict = {
        "status": "ok",
        "success": bool(resultado.get("success")),
        "imagem_url": "",
        "mensagem": resultado.get("mensagem") or "",
        "aluno_id": resultado.get("aluno_id"),
        "simulado_id": resultado.get("simulado_id"),
        "fonte": "",
        "fallback_url": fallback_url,
    }

    if resultado.get("success") and resultado.get("aluno_id") and resultado.get("simulado_id"):
        params = f"aluno_id={resultado['aluno_id']}&simulado_id={resultado['simulado_id']}"
        if chave:
            params += f"&chave={quote(chave, safe='')}"
        resposta["imagem_url"] = f"/historico/{nome_limpo}/folha-keepedu?{params}"
        resposta["fonte"] = "keepedu"
    elif fallback_url:
        resposta["fonte"] = "local"

    return JSONResponse(resposta)


@router.get("/historico/{nome_processamento}/folha-keepedu")
async def historico_folha_keepedu_imagem(
    nome_processamento: str,
    aluno_id: str = "",
    simulado_id: str = "",
    chave: str = "",
):
    nome_limpo = _resolver_lote_historico(nome_processamento)
    if not nome_limpo:
        return JSONResponse({"erro": "Lote não encontrado."}, status_code=404)

    chave_limpa = unquote(str(chave or "").strip())
    if chave_limpa:
        imagem = gerar_visualizacao_folha_keepedu_bytes(
            nome_limpo,
            chave_limpa,
            aluno_id,
            simulado_id,
        )
        if imagem:
            return Response(
                content=imagem,
                media_type="image/jpeg",
                headers={"Cache-Control": "no-store, max-age=0"},
            )

    resultado = ver_folha_resposta_keepedu(aluno_id, simulado_id)
    if not resultado.get("ok"):
        return JSONResponse(
            {"erro": resultado.get("erro") or "Falha ao consultar a folha no KeepEdu."},
            status_code=400,
        )
    if not resultado.get("success") or not resultado.get("imagem_url"):
        return JSONResponse(
            {"erro": resultado.get("mensagem") or "Folha de resposta não encontrada no KeepEdu."},
            status_code=404,
        )

    try:
        conteudo, content_type = baixar_imagem_folha_resposta(resultado["imagem_url"])
    except ValueError as exc:
        return JSONResponse({"erro": str(exc)}, status_code=502)

    return Response(
        content=conteudo,
        media_type=content_type,
        headers={"Cache-Control": "no-store, max-age=0"},
    )


@router.get("/historico/{nome_processamento}/visualizar/{chave_imagem:path}")
async def historico_visualizar_cartao(nome_processamento: str, chave_imagem: str):
    nome_limpo = _resolver_lote_historico(nome_processamento)
    if not nome_limpo:
        return JSONResponse({"erro": "Lote não encontrado."}, status_code=404)
    imagem = gerar_visualizacao_cartao_keepedu(nome_limpo, chave_imagem)
    if not imagem:
        return JSONResponse({"erro": "Visualização não disponível para este cartão."}, status_code=404)
    return Response(
        content=imagem,
        media_type="image/jpeg",
        headers={"Cache-Control": "no-store, max-age=0"},
    )


@router.get("/historico/{nome_processamento}", response_class=HTMLResponse)
async def historico_pos_processamento(request: Request, nome_processamento: str, page: int = 1):
    nome_limpo = _resolver_lote_historico(nome_processamento)
    if not nome_limpo:
        return HTMLResponse(
            "<h1>Processamento não encontrado.</h1>"
            "<p>O lote solicitado não possui arquivos de histórico.</p>",
            status_code=404,
        )

    slug_canonico = gerar_slug_historico_lote(nome_limpo)
    ident_entrada = extrair_nome_lote_seguro(nome_processamento)
    if (
        not ident_entrada.startswith("processamento_")
        and ident_entrada != slug_canonico
    ):
        return RedirectResponse(url=f"/historico/{slug_canonico}", status_code=303)

    por_pagina = 50
    dados = await run_in_threadpool(
        montar_dados_pos_processamento,
        nome_limpo,
        pagina=page,
        por_pagina=por_pagina,
    )
    if not dados:
        return HTMLResponse(
            "<h1>Processamento não encontrado.</h1>"
            "<p>O lote solicitado não possui arquivos de histórico.</p>",
            status_code=404,
        )

    identificacao = dados.get("identificacao") or {}
    total_pages = int(dados.get("total_pages") or 1)
    pagina = int(dados.get("page") or 1)
    return render_template(
        request,
        "historico_pos_processamento.html",
        {
            "nome_processamento": nome_limpo,
            "slug_historico": slug_canonico,
            "dados": dados,
            "page": pagina,
            "total_pages": total_pages,
            "historico_detalhe": True,
            **_contexto_avaliacao_ui(
                request,
                nome_limpo,
                identificacao.get("id_avaliacao") or "",
            ),
        },
    )


@router.get("/validacao/")
@router.get("/validacao")
async def tratar_validacao_vazia():
    lotes = listar_processamentos_local()
    if lotes:
        return RedirectResponse(url=f"/validacao/{lotes[0]}", status_code=303)
    return RedirectResponse(url="/historico", status_code=303)

def carregar_leitura(nome_avaliacao: str, em_json: bool = True):
    """
    Resolve o arquivo principal do lote a partir do diretório configurado.
    """
    caminho_principal = caminho_leituras(nome_avaliacao)
    if caminho_principal.exists():
        return caminho_principal

    nome_puro = nome_avaliacao.replace("processamento_", "")
    candidatos = [
        PASTA_PROCESSAMENTOS / nome_avaliacao / "leituras_omr.json",
        PASTA_PROCESSAMENTOS / nome_puro / "leituras_omr.json",
        PASTA_PROCESSAMENTOS / f"{nome_avaliacao}.json",
        PASTA_PROCESSAMENTOS / f"{nome_puro}.json",
    ]

    for candidato in candidatos:
        if candidato.exists() and candidato.is_file():
            return candidato

    return caminho_principal


def render_template(request: Request, nome_template: str, contexto: dict, status_code: int = 200):
    if app_modo_api():
        return JSONResponse(
            status_code=404,
            content={
                "status": "erro",
                "codigo": "ui_desabilitada",
                "mensagem": "Este host é somente API. Use o frontend React.",
                "frontend": FRONTEND_PUBLIC_URL,
            },
        )
    resposta = request.app.state.templates.TemplateResponse(
        request,
        nome_template,
        contexto,
        status_code=status_code
    )
    resposta.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
    resposta.headers["Pragma"] = "no-cache"
    resposta.headers["Expires"] = "0"
    return resposta


def _destino_pos_login(destino: str | None) -> str:
    texto = str(destino or "").strip()
    if texto.startswith(FRONTEND_PUBLIC_URL):
        return texto

    if not texto.startswith("/") or texto.startswith("//"):
        texto = "/painel"

    # Rotas do React MPA (sem sufixo -react) + aliases legados.
    legado_para_react = {
        "/": "/painel",
        "/painel": "/painel",
        "/painel-react": "/painel",
        "/kanban": "/kanban",
        "/kanban-react": "/kanban",
        "/historico": "/historico",
        "/historico-react": "/historico",
        "/bernoulli": "/bernoulli",
        "/bernoulli-react": "/bernoulli",
    }
    caminho = legado_para_react.get(texto, texto)

    if app_modo_api():
        return url_frontend(caminho)
    return caminho


def _resultado_job_imagens(resultado: dict | None):
    if not isinstance(resultado, dict):
        return {}
    return {
        "status": resultado.get("status", ""),
        "total": int(resultado.get("total", 0) or 0),
        "importadas": int(resultado.get("importadas", 0) or 0),
        "erros": int(resultado.get("erros", 0) or 0),
        "mensagem": resultado.get("mensagem", []),
    }


def _executar_envio_imagens_em_background(
    job_id: str,
    nome_avaliacao: str,
    id_aval,
    id_aluno=None,
    executar_limpeza: bool = False,
):
    def progresso_callback(
        percentual,
        mensagem,
        arquivo_atual="",
        resultado=None,
        tipo=None,
        titulo=None,
        descricao="",
        atualizar_evento=False,
    ):
        atualizar_job(
            job_id,
            status="processando",
            percentual=percentual,
            mensagem=mensagem,
            arquivo_atual=arquivo_atual,
            resultado=_resultado_job_imagens(resultado),
        )
        if titulo:
            registrar_evento_job(
                job_id,
                tipo or "info",
                titulo,
                descricao or mensagem,
                arquivo=arquivo_atual,
                atualizar_por_arquivo=atualizar_evento,
            )

    try:
        atualizar_job(
            job_id,
            status="processando",
            percentual=1,
            mensagem="Preparando o envio das imagens...",
        )
        resultado = importar_imagens_folha_resposta(
            nome_processamento=nome_avaliacao,
            id_aval=id_aval,
            id_aluno=id_aluno,
            progresso_callback=progresso_callback,
            executar_limpeza=executar_limpeza,
        )
        resultado_job = _resultado_job_imagens(resultado)
        status_resultado = resultado.get("status")

        if status_resultado == "erro" and not int(resultado.get("importadas") or 0):
            atualizar_job(
                job_id,
                status="erro",
                percentual=100,
                mensagem="Envio de imagens encerrado com erro.",
                erro=" | ".join((resultado.get("mensagem") or [])[:3]) or "Falha no envio de imagens.",
                resultado=resultado_job,
            )
            return

        atualizar_job(
            job_id,
            status="concluido",
            percentual=100,
            mensagem=f"Envio de imagens concluído ({resultado.get('importadas', 0)}/{resultado.get('total', 0)}).",
            resultado=resultado_job,
        )
        if int(resultado.get("importadas", 0) or 0) > 0:
            try:
                automacao_envio_keepedu(
                    nome_avaliacao,
                    str(id_aval or ""),
                )
            except Exception:
                pass
    except Exception as e:
        atualizar_job(
            job_id,
            status="erro",
            percentual=100,
            mensagem="Erro durante o envio de imagens.",
            erro=str(e),
        )


def _resultado_job_frequencia(resultado: dict | None):
    if not isinstance(resultado, dict):
        return {}
    return {
        "status": resultado.get("status", ""),
        "idSimulado": resultado.get("idSimulado"),
        "nome_arquivo": resultado.get("nome_arquivo", ""),
        "mensagem": resultado.get("mensagem", ""),
    }


def _executar_envio_frequencia_em_background(
    job_id: str,
    nome_avaliacao: str,
    id_simulado: str,
    caminho_arquivo: str,
):
    def progresso_callback(
        percentual,
        mensagem,
        arquivo_atual="",
        resultado=None,
        tipo=None,
        titulo=None,
        descricao="",
        atualizar_evento=False,
    ):
        atualizar_job(
            job_id,
            status="processando",
            percentual=percentual,
            mensagem=mensagem,
            arquivo_atual=arquivo_atual,
            resultado=_resultado_job_frequencia(resultado),
        )
        if titulo:
            registrar_evento_job(
                job_id,
                tipo or "warning",
                titulo,
                descricao or mensagem,
                arquivo=arquivo_atual,
                atualizar_por_arquivo=atualizar_evento,
            )

    try:
        atualizar_job(
            job_id,
            status="processando",
            percentual=1,
            mensagem="Preparando envio de frequência...",
        )
        resultado = importar_lista_frequencia_keepedu(
            nome_avaliacao,
            id_simulado=id_simulado,
            caminho_arquivo=caminho_arquivo,
            progresso_callback=progresso_callback,
        )
        resultado_job = _resultado_job_frequencia(resultado)
        if resultado.get("status") == "erro":
            atualizar_job(
                job_id,
                status="erro",
                percentual=100,
                mensagem=resultado.get("mensagem") or "Falha no envio de frequência.",
                erro=resultado.get("mensagem") or "Falha no envio de frequência.",
                resultado=resultado_job,
            )
            return

        atualizar_job(
            job_id,
            status="concluido",
            percentual=100,
            mensagem=resultado.get("mensagem") or "Lista de frequência enviada com sucesso.",
            resultado=resultado_job,
        )
    except Exception as exc:
        atualizar_job(
            job_id,
            status="erro",
            percentual=100,
            mensagem="Erro durante o envio de frequência.",
            erro=str(exc),
        )
    finally:
        # O PDF é somente uma ponte para o job em segundo plano. Nunca deve
        # permanecer no lote após a tentativa de transmissão ao KeepEdu.
        try:
            Path(caminho_arquivo).unlink(missing_ok=True)
        except OSError:
            pass
def _resultado_job_importacao(resultado: dict | None):
    if not isinstance(resultado, dict):
        return {}
    return {
        "status": resultado.get("status", ""),
        "success": bool(resultado.get("success")),
        "modo": resultado.get("modo", ""),
        "idAval": resultado.get("idAval"),
        "id_aluno": resultado.get("id_aluno"),
        "total": int(resultado.get("total", 0) or 0),
        "importadas": int(resultado.get("importadas", 0) or 0),
        "erros": int(resultado.get("erros", 0) or 0),
        "mensagem": resultado.get("mensagem", []),
        "pendencias_avaliacao": resultado.get("pendencias_avaliacao", []),
        "validacao_atualizada": bool(resultado.get("validacao_atualizada")),
        "arquivo_relatorio": resultado.get("arquivo_relatorio", ""),
        "arquivos_lote": resultado.get("arquivos_lote", []),
        "inicio_lote": resultado.get("inicio_lote"),
        "fim_lote": resultado.get("fim_lote"),
    }


def _executar_importacao_respostas_em_background(
    job_id: str,
    nome_avaliacao: str,
    payload: ImportarRespostasPayload,
    modo: str,
):
    def progresso_callback(
        percentual,
        mensagem,
        arquivo_atual="",
        resultado=None,
        tipo=None,
        titulo=None,
        descricao="",
        atualizar_evento=False,
    ):
        resultado_job = _resultado_job_importacao(resultado)
        pendencias_novas = resultado_job.get("pendencias_avaliacao") or []
        resultado_anterior = obter_job(job_id).get("resultado") or {}
        if not resultado_job.get("arquivos_lote") and resultado_anterior.get("arquivos_lote"):
            resultado_job["arquivos_lote"] = resultado_anterior["arquivos_lote"]
            resultado_job["inicio_lote"] = resultado_anterior.get("inicio_lote")
            resultado_job["fim_lote"] = resultado_anterior.get("fim_lote")
        pendencias_anteriores = resultado_anterior.get("pendencias_avaliacao") or []
        if pendencias_novas:
            pendencias_acumuladas = {
                str(item.get("imagem") or ""): item
                for item in pendencias_anteriores
                if isinstance(item, dict) and item.get("imagem")
            }
            for item in pendencias_novas:
                if isinstance(item, dict) and item.get("imagem"):
                    pendencias_acumuladas[str(item["imagem"])] = item
            resultado_job["pendencias_avaliacao"] = list(pendencias_acumuladas.values())
            resultado_job["validacao_atualizada"] = True
        elif pendencias_anteriores:
            resultado_job["pendencias_avaliacao"] = pendencias_anteriores
            resultado_job["validacao_atualizada"] = True
        atualizar_job(
            job_id,
            status="processando",
            percentual=percentual,
            mensagem=mensagem,
            arquivo_atual=arquivo_atual,
            resultado=resultado_job,
        )
        if titulo:
            registrar_evento_job(
                job_id,
                tipo or "info",
                titulo,
                descricao or mensagem,
                arquivo=arquivo_atual,
                atualizar_por_arquivo=atualizar_evento,
            )

    try:
        atualizar_job(
            job_id,
            status="processando",
            percentual=1,
            mensagem="Preparando envio de Vetores...",
        )
        registrar_evento_job(
            job_id,
            "warning",
            "Envio iniciado",
            f"Avaliação {payload.idAval or 'não identificada'} em modo {modo}.",
        )

        if modo != "simulacao":
            try:
                automacao_inicio_vetores_keepedu(
                    nome_avaliacao,
                    str(payload.idAval or ""),
                )
            except Exception:
                pass

        # PASSAGEM POSICIONAL PURA: Passando 'nome_avaliacao' direto como primeiro argumento
        # Isso ignora se o nome interno é 'nome_processamento' ou 'nome_avaliacao' e preenche a posição correta.
        if modo == "simulacao":
            resultado = simular_importacao_respostas_presenciais(
                nome_avaliacao,  # <--- Sem chaves, direto na primeira posição
                id_aval=payload.idAval,
                dia_aval=payload.diaAval,
                usuario_id=payload.usuario_id,
                id_aluno=payload.idAluno,
                progresso_callback=progresso_callback,
                # modelo_ia=payload.modelo_ia,
            )
        else:
            resultado = importar_respostas_presenciais(
                nome_avaliacao,  # <--- Sem chaves, direto na primeira posição
                id_aval=payload.idAval,
                dia_aval=payload.diaAval,
                usuario_id=payload.usuario_id,
                id_aluno=payload.idAluno,
                progresso_callback=progresso_callback,
                # modelo_ia=payload.modelo_ia,
            )

        resultado_job = _resultado_job_importacao(resultado)
        status_resultado = resultado.get("status")

        if status_resultado == "erro":
            atualizar_job(
                job_id,
                status="erro",
                percentual=100,
                mensagem="Envio de Vetores encerrado com erro.",
                erro=" | ".join(resultado.get("mensagem", [])[:3]) or "Erro na importação.",
                resultado=resultado_job,
            )
            registrar_evento_job(
                job_id,
                "error",
                "Envio com erro",
                " | ".join(resultado.get("mensagem", [])[:3]) or "Falha na importação.",
            )
            return

        atualizar_job(
            job_id,
            status="concluido",
            percentual=100,
            mensagem=(
                "Simulação concluída."
                if modo == "simulacao" else
                "Progresso de envio de Vetores concluído."
            ),
            resultado=resultado_job,
        )
        registrar_evento_job(
            job_id,
            "success" if status_resultado == "ok" else "warning",
            "Envio finalizado",
            f"{resultado.get('importadas', 0)}/{resultado.get('total', 0)} importada(s), "
            f"{resultado.get('erros', 0)} erro(s).",
        )
    except Exception as erro:
        atualizar_job(
            job_id,
            status="erro",
            percentual=100,
            mensagem="Erro ao executar envio de Vetores.",
            erro=str(erro),
        )
        registrar_evento_job(
            job_id,
            "error",
            "Falha no envio",
            str(erro),
        )

async def salvar_uploads_em_pasta(arquivos: list[UploadFile], pasta_upload):
    if len(arquivos) > MAX_UPLOAD_FILES:
        raise ValueError(
            f"Limite excedido: envie no máximo {MAX_UPLOAD_FILES} arquivo(s) por processamento."
        )

    total_salvos = 0
    total_bytes = 0
    limite_total_bytes = MAX_TOTAL_UPLOAD_SIZE_MB * 1024 * 1024
    limite_arquivo_bytes = MAX_FILE_SIZE_MB * 1024 * 1024

    for arquivo in arquivos:
        nome_arquivo = os.path.basename(arquivo.filename)
        if not nome_arquivo:
            continue

        extensao = os.path.splitext(nome_arquivo)[1].lower()
        if extensao not in EXTENSOES_PERMITIDAS:
            continue

        conteudo = await arquivo.read()
        tamanho_arquivo = len(conteudo)

        if tamanho_arquivo > limite_arquivo_bytes:
            raise ValueError(
                f"O arquivo {nome_arquivo} excede o limite de {MAX_FILE_SIZE_MB} MB."
            )

        total_bytes += tamanho_arquivo
        if total_bytes > limite_total_bytes:
            raise ValueError(
                f"O envio excedeu o limite total de {MAX_TOTAL_UPLOAD_SIZE_MB} MB."
            )

        destino = pasta_upload / nome_arquivo
        with open(destino, "wb") as f:
            f.write(conteudo)

        total_salvos += 1
    return total_salvos


async def salvar_imagem_upload_unitaria(arquivo: UploadFile, pasta_upload: Path) -> Path:
    """Salva uma imagem do lote, aplicando os mesmos limites do upload em lote."""
    nome_arquivo = os.path.basename(arquivo.filename or "")
    if not nome_arquivo:
        raise ValueError("Arquivo enviado sem nome.")
    if os.path.splitext(nome_arquivo)[1].lower() not in EXTENSOES_PERMITIDAS:
        raise ValueError(f"Formato não suportado: {nome_arquivo}")

    conteudo = await arquivo.read()
    if len(conteudo) > MAX_FILE_SIZE_MB * 1024 * 1024:
        raise ValueError(f"O arquivo {nome_arquivo} excede o limite de {MAX_FILE_SIZE_MB} MB.")

    arquivos_existentes = [item for item in pasta_upload.iterdir() if item.is_file()]
    if len(arquivos_existentes) >= MAX_UPLOAD_FILES:
        raise ValueError(f"Limite excedido: envie no máximo {MAX_UPLOAD_FILES} arquivo(s) por processamento.")
    total_atual = sum(item.stat().st_size for item in arquivos_existentes)
    if total_atual + len(conteudo) > MAX_TOTAL_UPLOAD_SIZE_MB * 1024 * 1024:
        raise ValueError(f"O envio excedeu o limite total de {MAX_TOTAL_UPLOAD_SIZE_MB} MB.")

    destino = pasta_upload / nome_arquivo
    contador = 2
    while destino.exists():
        destino = pasta_upload / f"{Path(nome_arquivo).stem}_{contador}{Path(nome_arquivo).suffix}"
        contador += 1
    with open(destino, "wb") as arquivo_destino:
        arquivo_destino.write(conteudo)
    return destino


async def salvar_arquivo_upload(arquivo: UploadFile, pasta_destino):
    nome_arquivo = os.path.basename(arquivo.filename or "")
    if not nome_arquivo:
        raise ValueError("Arquivo enviado sem nome.")

    extensao = os.path.splitext(nome_arquivo)[1].lower()
    if extensao not in EXTENSOES_PLANILHA:
        raise ValueError(f"Formato não suportado: {nome_arquivo}")

    conteudo = await arquivo.read()
    destino = pasta_destino / nome_arquivo
    with open(destino, "wb") as f:
        f.write(conteudo)
    return destino


@router.get("/")
async def index():
    if app_modo_api():
        return JSONResponse(
            {
                "status": "ok",
                "servico": "reading-keepedu-api",
                "frontend": FRONTEND_PUBLIC_URL,
                "healthz": "/healthz",
            }
        )
    return RedirectResponse(
        url=url_frontend("/painel"),
        status_code=303
    )


@router.get("/sso")
async def sso_entrada(request: Request, token: str = "", next: str = "/painel"):
    if not APP_ENABLE_AUTH or not sso_habilitado():
        return RedirectResponse(url="/login", status_code=303)

    auth_data = consumir_token_sso(token)
    if not auth_data:
        school = KEEPEDU_LOGIN_SCHOOL
        return RedirectResponse(
            url=f"{url_gestor_para_escola(school)}login?erro=sso_expirado",
            status_code=303,
        )

    dados = extrair_dados_usuario_sso(auth_data)
    if not dados.get("usuario_id") or not dados.get("school"):
        school = KEEPEDU_LOGIN_SCHOOL
        return RedirectResponse(
            url=f"{url_gestor_para_escola(school)}login?erro=sso_invalido",
            status_code=303,
        )

    institute = str(dados.get("institute") or institute_id_para_escola(dados["school"]) or "").strip()
    banco_escola = nome_banco_para_escola(dados["school"])
    usuario_id = dados["usuario_id"]

    set_tenant_context(dados["school"], institute)

    session_id_cookie = str(request.session.get("session_id") or "").strip()
    usuario_cookie = str(request.session.get("usuario_id") or "").strip()
    sessao_banco = obter_sessao_usuario(usuario_id, banco_escola)
    if (
        session_id_cookie
        and usuario_cookie == str(usuario_id)
        and sessao_banco == session_id_cookie
    ):
        session_id = session_id_cookie
    elif sessao_banco:
        session_id = str(sessao_banco)
    else:
        session_id = secrets.token_hex(32)

    request.session.clear()
    request.session["authenticated"] = True
    request.session["session_id"] = session_id
    request.session["usuario_id"] = usuario_id
    request.session["user_email"] = dados.get("email") or ""
    request.session["user_name"] = dados.get("name") or ""
    request.session["user_role"] = dados.get("perfil") or ""
    request.session["school"] = dados["school"]
    request.session["keepedu_school"] = dados["school"]
    if dados.get("school_name"):
        request.session["school_name"] = dados["school_name"]
    if institute:
        request.session["keepedu_institute"] = institute
    request.session["gestor_logout_url"] = dados.get("logout_url") or (
        f"{url_gestor_para_escola(dados['school'])}logout"
    )
    request.session["gestor_login_url"] = f"{url_gestor_para_escola(dados['school'])}login"
    request.session["gestor_home_url"] = dados.get("gestor_home_url") or url_gestor_home_para_escola(
        dados["school"]
    )
    request.session["auth_source"] = "sso_keepedu"

    salvar_sessao_usuario(usuario_id, session_id, banco_escola)
    request.session["_sessao_validada_em"] = time.time()

    return RedirectResponse(
        url=_destino_pos_login(next),
        status_code=303,
    )


@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str = "/"):
    if not APP_ENABLE_AUTH:
        return RedirectResponse(url="/", status_code=303)

    if sso_habilitado():
        usuario_id = request.session.get("usuario_id")
        session_id = request.session.get("session_id")
        if usuario_id and session_id:
            apply_tenant_from_session(request.session)
            sessao_banco = obter_sessao_usuario(usuario_id)
            if sessao_banco and sessao_banco == session_id:
                return RedirectResponse(
                    url=_destino_pos_login(next),
                    status_code=303,
                )

        school = str(request.session.get("school") or KEEPEDU_LOGIN_SCHOOL).strip().lower()
        return RedirectResponse(
            url=f"{url_gestor_para_escola(school)}login",
            status_code=303,
        )

    usuario_id = request.session.get("usuario_id")
    session_id = request.session.get("session_id")

    if usuario_id and session_id:
        sessao_banco = obter_sessao_usuario(usuario_id)

        if sessao_banco and sessao_banco == session_id:
            return RedirectResponse(
                url=_destino_pos_login(next),
                status_code=303
            )

    # 2. FLUXO DO AUTO-LOGIN: Se não tiver sessão, checa o Cookie de "Lembre-me"
    usuario_id_valido = verificar_autologin_cookie(request)

    if usuario_id_valido:

        session_id = secrets.token_hex(32)

        request.session["authenticated"] = True
        request.session["session_id"] = session_id
        request.session["usuario_id"] = usuario_id_valido

        salvar_sessao_usuario(
            usuario_id_valido,
            session_id
        )
        request.session["_sessao_validada_em"] = time.time()

        request.session["auth_source"] = "keepedu_remember"

        return RedirectResponse(
            url=_destino_pos_login(next),
            status_code=303
        )

    # 3. Se não houver cookie ou o token estiver vencido, renderiza a tela normalmente
    return render_template(
        request,
        "login.html",
        {
            "erro_login": "",
            "email_login": "",
            "next_url": _destino_pos_login(next),
            "keepedu_login_habilitado": bool(KEEPEDU_LOGIN_URL),
            "keepedu_school": KEEPEDU_LOGIN_SCHOOL,
        },
    )


@router.post("/login", response_class=HTMLResponse)
async def login_submit(
    request: Request,
    response: Response, # <--- ADICIONADO PARA MANIPULAR OS COOKIES
    email: str = Form(""),
    senha: str = Form(""),
    lembre_me: bool = Form(False), # <--- CAPTURA O CHECKBOX DO HTML
    next_url: str = Form("/"),
):
    if not APP_ENABLE_AUTH:
        return RedirectResponse(url="/", status_code=303)

    if sso_habilitado():
        school = str(request.session.get("school") or KEEPEDU_LOGIN_SCHOOL).strip().lower()
        return RedirectResponse(
            url=f"{url_gestor_para_escola(school)}login",
            status_code=303,
        )

    print("CHEGOU NO LOGIN SUBMIT")
    sucesso, mensagem, dados_usuario = autenticar_keepedu(email, senha)

    print("DADOS_USUARIO:")
    print(dados_usuario)
    if not sucesso:
        return render_template(
            request,
            "login.html",
            {
                "erro_login": mensagem or "Não foi possível entrar.",
                "email_login": str(email or "").strip(),
                "next_url": _destino_pos_login(next_url),
                "keepedu_login_habilitado": bool(KEEPEDU_LOGIN_URL),
                "keepedu_school": KEEPEDU_LOGIN_SCHOOL,
            },
            status_code=401,
        )

    # Limpa a sessão local antiga
    # Limpa sessão antiga
    request.session.clear()

    # Gera session_id
    import secrets
    session_id = secrets.token_hex(32)

    # Obtém usuário
    usuario_id = str(dados_usuario.get("id") or "")

    # Salva sessão
    request.session["authenticated"] = True
    request.session["session_id"] = session_id
    request.session["usuario_id"] = usuario_id

    request.session["user_email"] = str(
        dados_usuario.get("email") or email or ""
    ).strip()

    request.session["user_name"] = str(
    dados_usuario.get("name") or ""
    ).strip()
    request.session["user_role"] = str(
        dados_usuario.get("perfil") or dados_usuario.get("role") or ""
    ).strip()
    school = str(dados_usuario.get("school") or KEEPEDU_LOGIN_SCHOOL).strip().lower()
    request.session["school"] = school
    request.session["keepedu_school"] = school
    request.session["keepedu_institute"] = institute_id_para_escola(school)
    set_tenant_context(school, request.session["keepedu_institute"])

    print("NOME FINAL:", request.session["user_name"])

    print("SESSION USER:")
    print(request.session.get("user_name"))

    print("SESSION USER:")
    print(request.session.get("user_name"))

    request.session["auth_source"] = str(
        dados_usuario.get("origem") or "keepedu"
    ).strip()


    # Salva no banco/cache
    salvar_sessao_usuario(
        usuario_id,
        session_id
    )
    request.session["_sessao_validada_em"] = time.time()

    redirecionamento = RedirectResponse(
        url=_destino_pos_login(next_url),
        status_code=303
    )

    if lembre_me and dados_usuario:
        usuario_id = str(dados_usuario.get("id") or "")
        gerenciar_lembre_me(redirecionamento, usuario_id)

    return redirecionamento


@router.get("/logout")
async def logout(request: Request, response: Response, slo: int = 0):
    apply_tenant_from_session(request.session)

    school = str(
        request.query_params.get("school")
        or request.session.get("school")
        or request.session.get("keepedu_school")
        or KEEPEDU_LOGIN_SCHOOL
        or "proposito"
    ).strip().lower()
    gestor_login_url = f"{url_gestor_para_escola(school)}login"
    gestor_logout_url = f"{url_gestor_para_escola(school)}logout"
    url_logout_sessao = str(request.session.get("gestor_logout_url") or "").strip()
    if url_logout_sessao and f"{school}.keepedu.com.br" in url_logout_sessao.lower():
        gestor_logout_url = url_logout_sessao

    token_cookie = request.cookies.get("remember_token")
    if token_cookie:
        remover_token_banco(token_cookie)

    usuario_id = request.session.get("usuario_id")
    if usuario_id:
        remover_sessao_usuario(usuario_id)

    request.session.clear()

    if sso_habilitado() and APP_ENABLE_AUTH:
        if slo:
            destino = str(request.query_params.get("login") or "").strip() or gestor_login_url
            resposta = RedirectResponse(url=destino, status_code=303)
            resposta.delete_cookie("remember_token")
            return resposta

        voltar = (
            f"{KEEPEDU_READING_BASE_URL}/logout?slo=1"
            f"&school={quote(school, safe='')}"
            f"&login={quote(gestor_login_url, safe='')}"
        )
        separador = "&" if "?" in gestor_logout_url else "?"
        destino = (
            f"{gestor_logout_url}{separador}slo=1&return={quote(voltar, safe='')}"
        )
        resposta = RedirectResponse(url=destino, status_code=303)
        resposta.delete_cookie("remember_token")
        return resposta

    resposta = RedirectResponse(url=url_frontend("/painel") if app_modo_api() else "/login", status_code=303)
    resposta.delete_cookie("remember_token")
    return resposta


@router.get("/kanban", response_class=HTMLResponse)
async def kanban(request: Request):
    if app_modo_api():
        return RedirectResponse(url=url_frontend("/kanban"), status_code=307)
    # Mantém a tela legada disponível no backend para instalações que ainda
    # publicam a aplicação sem o servidor React separado.
    return render_template(
        request,
        "Kanban.html",
        {"board_json": json.dumps(montar_board(), ensure_ascii=False)},
    )


@router.get("/api/kanban/board")
async def api_kanban_board():
    # Threadpool: montar_board é sync/pesado e não pode travar o event loop
    # enquanto listagem/usuarios/unidades também rodam no /kanban.
    board = await run_in_threadpool(montar_board)
    return JSONResponse(board)


@router.get("/api/kanban/board/colunas/{etapa_id}")
async def api_kanban_coluna_board(etapa_id: str):
    """Carrega uma etapa sem aguardar a consulta das demais colunas."""
    coluna = await run_in_threadpool(montar_coluna_board, etapa_id)
    if coluna is None:
        return JSONResponse({"erro": "Coluna não encontrada."}, status_code=404)
    return JSONResponse(coluna)


async def _board_apos_mutacao(**payload):
    """Resposta padrão de mutação do Kanban sem bloquear outras rotas."""
    board = await run_in_threadpool(montar_board)
    return JSONResponse({**payload, "board": board})


@router.get("/api/kanban/colunas")
async def api_kanban_listar_colunas():
    return JSONResponse({"status": "ok", "colunas": listar_etapas(incluir_inativas=True)})


@router.post("/api/kanban/colunas")
async def api_kanban_criar_coluna(payload: KanbanColunaPayload):
    try:
        if not payload.slug or not payload.titulo:
            return JSONResponse(
                {"status": "erro", "mensagem": "Informe slug e titulo da coluna."},
                status_code=400,
            )
        coluna = criar_coluna(
            slug=payload.slug,
            titulo=payload.titulo,
            cor=payload.cor or "#788394",
            ordem=payload.ordem,
            eh_inicial=bool(payload.eh_inicial),
            eh_final=bool(payload.eh_final),
        )
        return await _board_apos_mutacao(status="ok", coluna=coluna)
    except ValueError as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=400)
    except Exception as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=500)


@router.patch("/api/kanban/colunas/{slug}")
async def api_kanban_atualizar_coluna(slug: str, payload: KanbanColunaPayload):
    try:
        alteracoes = payload.model_dump(exclude_unset=True)
        alteracoes.pop("slug", None)
        if not alteracoes:
            return JSONResponse(
                {"status": "erro", "mensagem": "Nenhum campo informado para atualização."},
                status_code=400,
            )
        coluna = atualizar_coluna(slug, **alteracoes)
        if not coluna:
            return JSONResponse({"status": "erro", "mensagem": "Coluna não encontrada."}, status_code=404)
        return await _board_apos_mutacao(status="ok", coluna=coluna)
    except ValueError as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=400)
    except Exception as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=500)


@router.delete("/api/kanban/colunas/{slug}")
async def api_kanban_excluir_coluna(slug: str):
    try:
        removida = excluir_coluna(slug)
        if not removida:
            return JSONResponse({"status": "erro", "mensagem": "Coluna não encontrada."}, status_code=404)
        return await _board_apos_mutacao(status="ok")
    except ValueError as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=400)
    except Exception as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=500)


@router.post("/api/kanban/entrega")
async def api_kanban_entrega(payload: KanbanEntregaPayload):
    try:
        card = registrar_entrega(
            titulo=payload.titulo,
            id_avaliacao=payload.id_avaliacao,
            responsavel_entrega=payload.responsavel_entrega,
            responsavel_correcao=payload.responsavel_correcao,
            total_cartoes=payload.total_cartoes,
            data_entrega=payload.data_entrega,
            data_previsao_entrega=payload.data_previsao_entrega,
            unidade=payload.unidade,
            serie=payload.serie,
            turma=payload.turma,
            horario_envio=payload.horario_envio,
        )
        return await _board_apos_mutacao(status="ok", card=card)
    except ValueError as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=400)
    except Exception as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=500)


@router.delete("/api/kanban/cards/{card_id}")
async def api_kanban_excluir(card_id: str):
    try:
        if not excluir_card(card_id):
            return JSONResponse(
                {"status": "erro", "mensagem": "Card não encontrado."},
                status_code=404,
            )
        return await _board_apos_mutacao(status="ok")
    except Exception as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=500)


@router.get("/api/lotes/relatorio/pdf")
async def api_lotes_relatorio_pdf(
    data_inicio: str = "",
    data_fim: str = "",
    avaliacao: str = "",
    unidade: str = "",
    serie: str = "",
    turma: str = "",
    responsavel_entrega: str = "",
    responsavel_correcao: str = "",
    etapa: str = "",
    situacao: str = "",
):
    """Gera o relatório a partir dos dados já persistidos nos cards do Kanban."""
    try:
        filtros = {
            "data_inicio": data_inicio, "data_fim": data_fim, "avaliacao": avaliacao,
            "unidade": unidade, "serie": serie, "turma": turma,
            "responsavel_entrega": responsavel_entrega,
            "responsavel_correcao": responsavel_correcao,
            "etapa": etapa, "situacao": situacao,
        }
        conteudo, nome_arquivo = await run_in_threadpool(gerar_relatorio_pdf, filtros)
        return Response(
            content=conteudo,
            media_type="application/pdf",
            headers={"Content-Disposition": f'attachment; filename="{nome_arquivo}"'},
        )
    except Exception as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=500)


@router.get("/api/kanban/historico-processamentos")
async def api_kanban_historico_processamentos_query(
    card_id: str = "",
    id_avaliacao: str = "",
):
    historico = await run_in_threadpool(
        historico_processamentos_por_referencia,
        card_id=card_id,
        id_avaliacao=id_avaliacao,
    )
    if historico is None:
        return JSONResponse(
            {"status": "erro", "mensagem": "Informe card_id ou id_avaliacao."},
            status_code=400,
        )
    return JSONResponse({"status": "ok", "historico_processamentos": historico})


@router.get("/api/kanban/cards/{card_id}/historico-processamentos")
async def api_kanban_historico_processamentos(card_id: str, id_avaliacao: str = ""):
    historico = await run_in_threadpool(
        historico_processamentos_do_card,
        card_id,
        id_avaliacao=id_avaliacao,
    )
    if historico is None:
        return JSONResponse(
            {"status": "erro", "mensagem": "Card não encontrado."},
            status_code=404,
        )
    return JSONResponse({"status": "ok", "historico_processamentos": historico})


@router.get("/api/kanban/pendencias")
async def api_kanban_pendencias_query(card_id: str = "", id_avaliacao: str = ""):
    pendencias = await run_in_threadpool(
        pendencias_do_card,
        card_id,
        id_avaliacao=id_avaliacao,
    )
    if pendencias is None:
        return JSONResponse(
            {"status": "erro", "mensagem": "Card não encontrado."},
            status_code=404,
        )
    return JSONResponse({"status": "ok", "pendencias": pendencias})


@router.get("/api/kanban/pendencias/csv")
async def api_kanban_baixar_pendencias():
    """Exporta as pendências abertas do Kanban para acompanhamento externo."""
    colunas = [
        ("aluno", "Aluno"),
        ("ra", "RA"),
        ("id_aluno", "ID do aluno"),
        ("codigo_barras", "Código de barras"),
        ("arquivo_cartao", "Arquivo do cartão"),
        ("motivo", "Motivo da pendência"),
        ("id_avaliacao", "ID da avaliação"),
        ("lote", "Lote"),
        ("etapa", "Etapa"),
        ("unidade", "Unidade"),
        ("serie", "Série"),
        ("turma", "Turma"),
        ("responsavel_entrega", "Responsável pela entrega"),
        ("responsavel_correcao", "Responsável pela correção"),
        ("link_acao", "Link da ação"),
    ]
    arquivo = StringIO(newline="")
    escritor = csv.DictWriter(arquivo, fieldnames=[campo for campo, _ in colunas], extrasaction="ignore")
    escritor.writerow({campo: titulo for campo, titulo in colunas})
    escritor.writerows(listar_pendencias_kanban())
    nome_arquivo = f"pendencias-kanban-{date.today():%Y-%m-%d}.csv"
    return Response(
        content="\ufeff" + arquivo.getvalue(),
        media_type="text/csv; charset=utf-8",
        headers={"Content-Disposition": f'attachment; filename="{nome_arquivo}"'},
    )


@router.get("/api/kanban/cards/{card_id}/pendencias")
async def api_kanban_pendencias(card_id: str, id_avaliacao: str = ""):
    pendencias = await run_in_threadpool(
        pendencias_do_card,
        card_id,
        id_avaliacao=id_avaliacao,
    )
    if pendencias is None:
        return JSONResponse(
            {"status": "erro", "mensagem": "Card não encontrado."},
            status_code=404,
        )
    return JSONResponse({"status": "ok", "pendencias": pendencias})


@router.patch("/api/kanban/cards/{card_id}")
async def api_kanban_atualizar(card_id: str, payload: KanbanCardPayload):
    try:
        alteracoes = payload.model_dump(exclude_unset=True)
        if not alteracoes:
            return JSONResponse(
                {"status": "erro", "mensagem": "Nenhum campo informado para atualização."},
                status_code=400,
            )
        card = atualizar_card(card_id, **alteracoes)
        if not card:
            return JSONResponse({"status": "erro", "mensagem": "Card não encontrado."}, status_code=404)
        return await _board_apos_mutacao(status="ok", card=card)
    except ValueError as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=400)
    except Exception as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=500)


@router.post("/api/kanban/automacao/processamento-iniciado")
async def api_kanban_automacao_processamento(
    request: Request,
    payload: KanbanAutomacaoProcessamentoPayload,
):
    usuario = str(payload.usuario or request.session.get("user_name") or "").strip()
    card = automacao_processamento_iniciado(
        payload.id_avaliacao,
        payload.nome_processamento,
        usuario=usuario,
        total_cartoes=payload.total_cartoes,
        unidade=payload.unidade,
    )
    return JSONResponse({
        "status": "ok",
        "movido": bool(card),
        "card": card,
    })


@router.post("/api/kanban/automacao/lote-aberto")
async def api_kanban_automacao_lote(payload: KanbanAutomacaoLotePayload):
    card = automacao_lote_aberto(payload.nome_processamento, payload.id_avaliacao)
    return JSONResponse({
        "status": "ok",
        "movido": bool(card),
        "card": card,
    })


@router.post("/api/kanban/automacao/inicio-vetores-keepedu")
async def api_kanban_automacao_inicio_vetores(payload: KanbanAutomacaoLotePayload):
    card = automacao_inicio_vetores_keepedu(payload.nome_processamento, payload.id_avaliacao)
    return JSONResponse({
        "status": "ok",
        "movido": bool(card),
        "card": card,
    })


@router.post("/api/kanban/automacao/envio-keepedu")
async def api_kanban_automacao_envio_keepedu(payload: KanbanAutomacaoLotePayload):
    card = automacao_envio_keepedu(payload.nome_processamento, payload.id_avaliacao)
    return JSONResponse({
        "status": "ok",
        "movido": bool(card),
        "card": card,
    })


@router.get("/bernoulli", response_class=HTMLResponse)
async def bernoulli(request: Request):
    sufixo = f"?{request.url.query}" if request.url.query else ""
    return RedirectResponse(url=f"{url_frontend('/bernoulli')}{sufixo}", status_code=307)


@router.get("/bernoulli-react", response_class=HTMLResponse)
async def bernoulli_react():
    return RedirectResponse(url=url_frontend("/bernoulli"), status_code=307)


def _rotulo_dia_bernoulli(dia: str) -> str:
    if str(dia) == "redacao":
        return "Redação"
    return "1 dia" if str(dia) == "1" else "2 dia"


def _montar_logs_bernoulli(resultado: dict, nome_arquivo_entrada: str, nome_arquivo_download: str):
    intervalo = (
        "5 competências convertidas de 0–200 para 0–5"
        if resultado.get("dia") == "redacao"
        else f"q{resultado.get('primeira_questao', 1)} a q{resultado.get('ultima_questao', 90)}"
    )
    return [
        {"tipo": "info", "titulo": "Arquivo recebido", "descricao": nome_arquivo_entrada},
        {"tipo": "info", "titulo": "Modo selecionado", "descricao": f"{_rotulo_dia_bernoulli(resultado.get('dia', '1'))} | {intervalo}"},
        {"tipo": "success", "titulo": "Estrutura validada", "descricao": f"{resultado['questoes_encontradas_no_keepedu']} campo(s) reconhecido(s)."},
        {"tipo": "success", "titulo": "Consulta Bernoulli concluida", "descricao": f"{resultado['rmb_encontrados']} RMB(s) localizado(s) e {resultado['sem_rmb']} pendencia(s) de inscricao."},
        {"tipo": "success", "titulo": "Arquivo pronto", "descricao": nome_arquivo_download}
    ]


def _processar_arquivo_bernoulli(caminho_keepedu, nome_arquivo_entrada: str, dia: str, progresso_callback=None):
    resultado = transformar_vetor_keepedu_para_bernoulli(
        caminho_arquivo_keepedu=caminho_keepedu, dia=dia, progresso_callback=progresso_callback
    )
    nome_saida_real = os.path.basename(str(resultado["caminho_saida"]))
    if resultado["dia"] == "redacao":
        nome_arquivo_download = f"vetor_bernoulli_redacao_{resultado['linhas']}lin_competencias.xlsx"
    else:
        nome_arquivo_download = f"vetor_bernoulli_dia{resultado['dia']}_{resultado['linhas']}lin_q{resultado['primeira_questao']}-q{resultado['ultima_questao']}.xlsx"

    return {
        "resultado": resultado,
        "nome_arquivo_entrada": nome_arquivo_entrada,
        "nome_arquivo_real": nome_saida_real,
        "nome_arquivo_download": nome_arquivo_download,
        "download_url": f"/bernoulli/download/{nome_saida_real}",
        "logs": _montar_logs_bernoulli(resultado, nome_arquivo_entrada, nome_arquivo_download)
    }


def _resultado_job_bernoulli(dados: dict):
    return {
        "resultado": dados["resultado"],
        "nome_arquivo_entrada": dados["nome_arquivo_entrada"],
        "nome_arquivo_real": dados["nome_arquivo_real"],
        "nome_arquivo_download": dados["nome_arquivo_download"],
        "download_url": dados["download_url"]
    }


def processar_arquivo_bernoulli_em_background(
    job_id: str,
    caminho_keepedu,
    nome_arquivo_entrada: str,
    dia: str,
    kanban_card_id: str = "",
    kanban_id_avaliacao: str = "",
):
    pasta_tmp = caminho_keepedu.parent
    def progresso_callback(percentual, mensagem, tipo=None, titulo=None, descricao="", resultado=None):
        atualizar_job(job_id, status="processando", percentual=percentual, mensagem=mensagem, arquivo_atual=nome_arquivo_entrada, resultado=resultado)
        if titulo:
            registrar_evento_job(job_id, tipo or "info", titulo, descricao or mensagem, arquivo=nome_arquivo_entrada)

    try:
        atualizar_job(job_id, status="processando", percentual=5, mensagem=f"Arquivo recebido. Preparando processamento...", arquivo_atual=nome_arquivo_entrada)
        registrar_evento_job(job_id, "info", "Arquivo recebido", nome_arquivo_entrada, arquivo=nome_arquivo_entrada)

        dados = _processar_arquivo_bernoulli(caminho_keepedu=caminho_keepedu, nome_arquivo_entrada=nome_arquivo_entrada, dia=dia, progresso_callback=progresso_callback)

        registrar_evento_job(job_id, "success", "Arquivo pronto", dados["nome_arquivo_download"], arquivo=nome_arquivo_entrada)
        atualizar_job(job_id, status="concluido", percentual=100, mensagem="Simulado Bernoulli gerado com sucesso.", arquivo_atual=nome_arquivo_entrada, resultado=_resultado_job_bernoulli(dados))
        try:
            automacao_arquivo_bernoulli_gerado(kanban_card_id, kanban_id_avaliacao)
        except Exception:
            pass
    except Exception as erro:
        atualizar_job(job_id, status="erro", percentual=100, mensagem="Erro ao gerar o Simulado Bernoulli.", arquivo_atual=nome_arquivo_entrada, erro=str(erro))
    finally:
        for arquivo in pasta_tmp.glob("*"):
            try: arquivo.unlink(missing_ok=True)
            except Exception: pass
        try: pasta_tmp.rmdir()
        except Exception: pass


@router.post("/bernoulli/processar")
async def bernoulli_processar(
    arquivo_keepedu: UploadFile = File(...),
    dia: str = Form("1"),
    kanban_card_id: str = Form(""),
    kanban_id_avaliacao: str = Form(""),
):
    try:
        dados = await executar_processamento_bernoulli(arquivo_keepedu, dia=dia)
        try:
            automacao_arquivo_bernoulli_gerado(kanban_card_id, kanban_id_avaliacao)
        except Exception:
            pass
        return FileResponse(
            str(PASTA_BERNOULLI / dados["nome_arquivo_real"]),
            filename=dados["nome_arquivo_download"],
            media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            background=BackgroundTask(
                (PASTA_BERNOULLI / dados["nome_arquivo_real"]).unlink,
                missing_ok=True,
            ),
        )
    except Exception as e:
        return HTMLResponse(f"<h1>Erro</h1><p>{escape(str(e))}</p>", status_code=400)


@router.post("/bernoulli/processar-ajax")
async def bernoulli_processar_ajax(
    background_tasks: BackgroundTasks,
    arquivo_keepedu: UploadFile = File(...),
    dia: str = Form("1"),
    kanban_card_id: str = Form(""),
    kanban_id_avaliacao: str = Form(""),
):
    job_id = criar_job(total_arquivos=1)
    pasta_tmp = PASTA_UPLOADS_TEMP / f"bernoulli_{job_id}"
    pasta_tmp.mkdir(parents=True, exist_ok=True)
    try:
        caminho_keepedu = await salvar_arquivo_upload(arquivo_keepedu, pasta_tmp)
        nome_arquivo_entrada = os.path.basename(arquivo_keepedu.filename or caminho_keepedu.name)
        atualizar_job(job_id, status="processando", percentual=3, mensagem="Aguardando início...", arquivo_atual=nome_arquivo_entrada)
        agendar_trabalho_pesado(
            processar_arquivo_bernoulli_em_background,
            job_id,
            caminho_keepedu,
            nome_arquivo_entrada,
            dia,
            kanban_card_id,
            kanban_id_avaliacao,
        )
        return JSONResponse({"status": "ok", "job_id": job_id})
    except Exception as erro:
        atualizar_job(job_id, status="erro", percentual=100, mensagem="Erro", erro=str(erro))
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=400)


@router.get("/bernoulli/download/{nome_arquivo}")
async def bernoulli_download(nome_arquivo: str):
    nome_seguro = os.path.basename(nome_arquivo or "")
    caminho_saida = (PASTA_BERNOULLI / nome_seguro).resolve()
    if not caminho_saida.exists():
        return HTMLResponse("<h1>Arquivo não encontrado.</h1>", status_code=404)
    return FileResponse(
        str(caminho_saida),
        filename=nome_seguro,
        background=BackgroundTask(caminho_saida.unlink, missing_ok=True),
    )


@router.post("/nova-avaliacao-pasta")
async def rota_nova_avaliacao_pasta(pasta_origem: str = Form(...)):
    try:
        nome_avaliacao = processar_pasta_local(pasta_origem)
        return RedirectResponse(url=f"/avaliacao/{nome_avaliacao}", status_code=303)
    except Exception as e:
        return HTMLResponse(f"<h1>Erro</h1><p>{escape(str(e))}</p>", status_code=400)


# CORREÇÃO AQUI: Esta rota agora responde com JSON Puro para alinhar com o JavaScript AJAX
@router.get("/api/keepedu/usuarios")
async def api_keepedu_usuarios(refresh: bool = False):
    # KeepEdu usa requests síncrono; threadpool permite paralelismo real no /kanban.
    resultado = await run_in_threadpool(listar_usuarios_keepedu, force=bool(refresh))
    status = 200 if resultado.get("success") else 502
    return JSONResponse(resultado, status_code=status)


@router.get("/api/keepedu/unidades")
async def api_keepedu_unidades(refresh: bool = False):
    resultado = await run_in_threadpool(listar_unidades_keepedu, force=bool(refresh))
    status = 200 if resultado.get("success") else 502
    return JSONResponse(resultado, status_code=status)


def _resolver_unidade_id(unidade_id: str = "", unidade: str = "") -> str:
    id_resolvido = str(unidade_id or "").strip()
    if id_resolvido:
        return id_resolvido

    unidade_nome = str(unidade or "").strip()
    if not unidade_nome:
        return ""

    cadastro = listar_unidades_keepedu()
    for item in cadastro.get("unidades") or []:
        if str(item.get("nome") or "").strip() == unidade_nome:
            return str(item.get("id") or "").strip()
    return ""


@router.get("/api/keepedu/series")
async def api_keepedu_series(
    unidade: str = "",
    unidade_id: str = "",
    refresh: bool = False,
):
    def _carregar():
        return listar_series_por_unidade(
            unidade_id=_resolver_unidade_id(unidade_id, unidade),
            force=bool(refresh),
        )

    resultado = await run_in_threadpool(_carregar)
    return JSONResponse(resultado, status_code=200)


@router.get("/api/keepedu/turmas")
async def api_keepedu_turmas(
    unidade: str = "",
    unidade_id: str = "",
    serie_id: str = "",
    refresh: bool = False,
):
    def _carregar():
        return listar_turmas_por_serie(
            unidade_id=_resolver_unidade_id(unidade_id, unidade),
            serie_id=str(serie_id or "").strip(),
            force=bool(refresh),
        )

    resultado = await run_in_threadpool(_carregar)
    return JSONResponse(resultado, status_code=200)


@router.get("/api/avaliacoes/listagem")
async def api_avaliacoes_listagem(
    request: Request,
    ano: str | None = None,
    refresh: bool = False,
):
    """
    Lista avaliações da KeepEdu (mesma fonte do select do Painel).
    Query: ?ano=2026&refresh=1
    """
    resultado = await run_in_threadpool(
        buscar_listagem_avaliacoes,
        ano_avaliacao=ano,
        force=bool(refresh),
    )
    status = 200 if resultado.get("success") else 502
    return JSONResponse(resultado, status_code=status)


@router.get("/api/painel/resumo")
async def api_painel_resumo(request: Request):
    """Resumo adiado para não bloquear a primeira pintura do Painel."""
    # KeepEdu + varredura de lotes em paralelo (não sequencial).
    listagem, estatisticas = await asyncio.gather(
        run_in_threadpool(buscar_listagem_avaliacoes),
        run_in_threadpool(resumo_estatisticas_globais_processamento),
    )
    total_avaliacoes = int(listagem.get("total") or 0)
    processadas = int(estatisticas.get("avaliacoes_processadas") or 0)
    contrato_react = _contexto_painel(request, {
        **estatisticas,
        "avaliacoes_total": total_avaliacoes,
    })

    return JSONResponse({
        "avaliacoes_total": total_avaliacoes,
        "avaliacoes_processadas": processadas,
        "cartoes_processados": int(estatisticas.get("cartoes_processados") or 0),
        "percentual": round((processadas / total_avaliacoes * 100) if total_avaliacoes else 0),
        "estatisticas": contrato_react["estatisticas_painel"],
        "avaliacao": contrato_react["avaliacao_contexto"],
        "usuario": _usuario_para_interface(request),
    })


@router.get("/api/usuario-atual")
async def api_usuario_atual(request: Request):
    return JSONResponse({"usuario": _usuario_para_interface(request)})
    """Dados leves da sessão para a toolbar das telas React."""
    return JSONResponse({
        "usuario": {
            "nome": request.session.get("user_name") or request.session.get("user_email") or request.session.get("usuario_id") or "Operador OMR",
            "escola": request.session.get("school") or request.session.get("keepedu_school") or KEEPEDU_LOGIN_SCHOOL,
            "perfil": request.session.get("user_role") or request.session.get("perfil") or "Administrador",
        }
    })
    return JSONResponse({
        # Campos atuais do Painel Jinja.
        "avaliacoes_total": total_avaliacoes,
        "avaliacoes_processadas": processadas,
        "cartoes_processados": int(estatisticas.get("cartoes_processados") or 0),
        "percentual": round((processadas / total_avaliacoes * 100) if total_avaliacoes else 0),
        # Contrato do Painel React.
        "estatisticas": contrato_react["estatisticas_painel"],
        "avaliacao": contrato_react["avaliacao_contexto"],
        "usuario": {
            "nome": request.session.get("user_name") or request.session.get("user_email") or request.session.get("usuario_id") or "Operador OMR",
            "escola": request.session.get("school") or request.session.get("keepedu_school") or KEEPEDU_LOGIN_SCHOOL,
            "perfil": request.session.get("user_role") or request.session.get("perfil") or "Administrador",
        },
    })


@router.post("/painel/configurar-avaliacao")
async def painel_configurar_avaliacao(request: Request, id_avaliacao: str = Form("")):
    try:
        id_texto = str(id_avaliacao or "").strip()
        if not id_texto:
            ctx = limpar_contexto_na_sessao(request.session)
            return JSONResponse({"status": "ok", "avaliacao": ctx})
        ctx = aplicar_contexto_na_sessao(request.session, id_avaliacao)
        return JSONResponse({"status": "ok", "avaliacao": ctx})
    except ValueError as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=400)
    except Exception as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=500)


@router.post("/processar-ajax")
async def rota_nova_avaliacao_upload(
    request: Request,
    background_tasks: BackgroundTasks,
    arquivos: list[UploadFile] = File(...),
):
    ctx_proc = contexto_para_processamento(request.session)
    job_id = criar_job(total_arquivos=len(arquivos))
    pasta_upload = PASTA_UPLOADS_TEMP / f"upload_{job_id}"
    pasta_upload.mkdir(parents=True, exist_ok=True)

    try:
        total_salvos = await salvar_uploads_em_pasta(arquivos, pasta_upload)
        if total_salvos == 0:
            atualizar_job(job_id, status="erro", percentual=100, mensagem="Nenhuma imagem válida.", erro="Sem arquivos válidos.")
            return JSONResponse({"status": "erro", "job_id": job_id, "mensagem": "Nenhuma imagem válida foi enviada."})

        atualizar_job(job_id, status="processando", percentual=5, mensagem=f"{total_salvos} imagem(ns) recebida(s). Lendo...")
        usuario_nome = str(request.session.get("user_name") or "").strip()
        id_avaliacao = str(ctx_proc.get("id_avaliacao_esperado") or "")
        nome_simulado = str(ctx_proc.get("nome_simulado") or "")
        meta_avaliacao = dict(ctx_proc.get("meta_avaliacao") or {})
        try:
            automacao_processamento_iniciado(
                id_avaliacao,
                unidade=str(meta_avaliacao.get("unidade") or ""),
            )
        except Exception:
            pass
        agendar_trabalho_pesado(
            processar_pasta_temporaria_com_progresso,
            job_id,
            pasta_upload,
            id_avaliacao,
            nome_simulado,
            meta_avaliacao,
            usuario_nome,
        )
        # A avaliação pertence a este lote; não deve permanecer como padrão do próximo fluxo.
        limpar_contexto_na_sessao(request.session)

        return JSONResponse({"status": "ok", "job_id": job_id})
    except Exception as e:
        atualizar_job(job_id, status="erro", percentual=100, mensagem="Erro", erro=str(e))
        return JSONResponse({"status": "erro", "job_id": job_id, "mensagem": str(e)})


@router.post("/processar-upload-unitario")
async def processar_upload_unitario(
    request: Request,
    arquivo: UploadFile = File(...),
    lote_upload_id: str = Form(""),
):
    """Recebe um cartão por requisição para evitar timeout em lotes grandes."""
    lote_id = str(lote_upload_id or "").strip()
    if not lote_id:
        lote_id = secrets.token_urlsafe(24)
    if not lote_id.replace("-", "").replace("_", "").isalnum() or len(lote_id) > 64:
        return JSONResponse({"status": "erro", "mensagem": "Identificador de upload inválido."}, status_code=400)

    pasta_upload = PASTA_UPLOADS_TEMP / f"upload_{lote_id}"
    pasta_upload.mkdir(parents=True, exist_ok=True)
    try:
        destino = await salvar_imagem_upload_unitaria(arquivo, pasta_upload)
        return JSONResponse({"status": "ok", "lote_upload_id": lote_id, "arquivo": destino.name})
    except ValueError as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=400)
    except Exception as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=500)


@router.post("/processar-upload-lote")
async def processar_upload_lote(
    request: Request,
    arquivos: list[UploadFile] = File(...),
    lote_upload_id: str = Form(""),
):
    """Recebe pequenos grupos de cartões para reduzir requisições consecutivas."""
    if not arquivos or len(arquivos) > 5:
        return JSONResponse({"status": "erro", "mensagem": "Envie de 1 a 5 imagens por lote."}, status_code=400)

    lote_id = str(lote_upload_id or "").strip() or secrets.token_urlsafe(24)
    if not lote_id.replace("-", "").replace("_", "").isalnum() or len(lote_id) > 64:
        return JSONResponse({"status": "erro", "mensagem": "Identificador de upload inválido."}, status_code=400)

    pasta_upload = PASTA_UPLOADS_TEMP / f"upload_{lote_id}"
    pasta_upload.mkdir(parents=True, exist_ok=True)
    try:
        salvos = [await salvar_imagem_upload_unitaria(arquivo, pasta_upload) for arquivo in arquivos]
        return JSONResponse({
            "status": "ok",
            "lote_upload_id": lote_id,
            "arquivos": [arquivo.name for arquivo in salvos],
        })
    except ValueError as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=400)
    except Exception as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=500)


@router.post("/processar-upload-finalizar")
async def processar_upload_finalizar(request: Request, background_tasks: BackgroundTasks, lote_upload_id: str = Form(...)):
    """Inicia o processamento depois que todos os cartões foram enviados individualmente."""
    ctx_proc = contexto_para_processamento(request.session)
    lote_id = str(lote_upload_id or "").strip()
    if not lote_id.replace("-", "").replace("_", "").isalnum() or len(lote_id) > 64:
        return JSONResponse({"status": "erro", "mensagem": "Identificador de upload inválido."}, status_code=400)
    pasta_upload = PASTA_UPLOADS_TEMP / f"upload_{lote_id}"
    total_salvos = len([item for item in pasta_upload.glob("*") if item.is_file()]) if pasta_upload.is_dir() else 0
    if not total_salvos:
        return JSONResponse({"status": "erro", "mensagem": "Nenhuma imagem válida foi enviada."}, status_code=400)

    job_id = criar_job(total_arquivos=total_salvos)
    atualizar_job(job_id, status="processando", percentual=5, mensagem=f"{total_salvos} imagem(ns) recebida(s). Lendo...")
    usuario_nome = str(request.session.get("user_name") or "").strip()
    id_avaliacao = str(ctx_proc.get("id_avaliacao_esperado") or "")
    meta_avaliacao = dict(ctx_proc.get("meta_avaliacao") or {})
    try:
        automacao_processamento_iniciado(
            id_avaliacao,
            unidade=str(meta_avaliacao.get("unidade") or ""),
        )
    except Exception:
        pass
    agendar_trabalho_pesado(processar_pasta_temporaria_com_progresso, job_id, pasta_upload, id_avaliacao, str(ctx_proc.get("nome_simulado") or ""), meta_avaliacao, usuario_nome)
    limpar_contexto_na_sessao(request.session)
    return JSONResponse({"status": "ok", "job_id": job_id})


@router.get("/avaliacao/{nome_avaliacao}", response_class=HTMLResponse)
async def ver_avaliacao(request: Request, nome_avaliacao: str, page: int = 1):
    return RedirectResponse(
        url=url_frontend(f"/processamento/{quote(nome_avaliacao, safe='')}"),
        status_code=307,
    )

    from app.lotes_store import obter_estado_lote

    correcoes_salvas = obter_estado_lote(nome_avaliacao).get("correcoes_web") or {}
    if correcoes_salvas:
        aplicar_correcoes_web_nas_leituras(nome_avaliacao, correcoes_salvas)
    imagens_debug = listar_imagens_debug(nome_avaliacao)
    resumo = resumo_processamento(nome_avaliacao)
    leituras = carregar_leituras_lote(nome_avaliacao)
    id_avaliacao = detectar_id_prova_do_processamento(leituras, nome_avaliacao) or ""
    por_pagina = 50
    total_imagens = len(imagens_debug)
    total_pages = max(1, (total_imagens + por_pagina - 1) // por_pagina)
    pagina = max(1, min(int(page or 1), total_pages))
    imagens_pagina = imagens_debug[(pagina - 1) * por_pagina : pagina * por_pagina]
    return render_template(
        request,
        "processamento.html",
        {
            "nome_processamento": nome_avaliacao,
            "imagens_debug": imagens_pagina,
            "total_imagens_debug": total_imagens,
            "page": pagina,
            "total_pages": total_pages,
            "resumo": resumo,
            # As leituras sÃ£o buscadas pela API apÃ³s o primeiro paint; nÃ£o
            # serializamos o lote inteiro no HTML inicial.
            "leituras": {},
            "status_lote": resumo_status_lote_keepedu(nome_avaliacao),
            **_contexto_avaliacao_ui(request, nome_avaliacao, id_avaliacao),
        },
    )


@router.get("/avaliacao/{nome_avaliacao}/contexto-ajax")
async def contexto_avaliacao_processamento_ajax(request: Request, nome_avaliacao: str):
    """Contexto do hero do processamento legado, em formato consumivel pelo React."""
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    if not nome_limpo:
        return JSONResponse({"status": "erro", "mensagem": "Lote invalido."}, status_code=400)

    leituras = carregar_leituras_lote(nome_limpo)
    id_avaliacao = detectar_id_prova_do_processamento(leituras, nome_limpo) or ""
    contexto = _contexto_avaliacao_ui(request, nome_limpo, str(id_avaliacao))
    return JSONResponse({
        "status": "ok",
        "id_avaliacao": str(contexto["id_avaliacao"] or id_avaliacao),
        "avaliacao_exibicao": contexto["avaliacao_exibicao"],
    })


@router.get("/api/processamentos/{nome_avaliacao}")
async def api_processamento_react(request: Request, nome_avaliacao: str, page: int = 1, por_pagina: int = 12):
    """Dados do lote para o painel React; requer um lote valido na URL."""
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    if not nome_limpo:
        return JSONResponse({"status": "erro", "mensagem": "Lote invalido."}, status_code=400)

    por_pagina_limpo = max(1, min(int(por_pagina or 12), 50))
    pagina_pedida = int(page or 1)

    def _carregar() -> dict:
        imagens = listar_imagens_debug(nome_limpo)
        resumo = resumo_processamento(nome_limpo)
        leituras = carregar_leituras_lote(nome_limpo)
        id_avaliacao = detectar_id_prova_do_processamento(leituras, nome_limpo) or ""
        total = len(imagens)
        total_pages = max(1, (total + por_pagina_limpo - 1) // por_pagina_limpo)
        pagina = max(1, min(pagina_pedida, total_pages))
        return {
            "status": "ok",
            "lote": nome_limpo,
            "id_avaliacao": str(id_avaliacao),
            "resumo": resumo,
            "status_lote": resumo_status_lote_keepedu(nome_limpo),
            "envios_keepedu": carregar_envios_keepedu(nome_limpo),
            "imagens": imagens[(pagina - 1) * por_pagina_limpo : pagina * por_pagina_limpo],
            "total_imagens": total,
            "page": pagina,
            "total_pages": total_pages,
        }

    payload = await run_in_threadpool(_carregar)
    contexto_avaliacao = _contexto_avaliacao_ui(request, nome_limpo, str(payload.get("id_avaliacao") or ""))
    payload["id_avaliacao"] = str(contexto_avaliacao["id_avaliacao"] or payload.get("id_avaliacao") or "")
    payload["avaliacao"] = contexto_avaliacao["avaliacao_exibicao"]
    return JSONResponse(payload)


@router.get("/imagem/{nome_avaliacao}/{nome_imagem}")
async def servir_imagem(nome_avaliacao: str, nome_imagem: str, revisao: str = ""):
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    nome_imagem_limpo = str(nome_imagem or "").strip().lstrip("/")
    if not nome_limpo or not nome_imagem_limpo:
        return JSONResponse({"erro": "Imagem não encontrada."}, status_code=404)

    # Mantém no event loop (como antes): precisa do ContextVar de school/institute
    # da sessão. Threadpool sem tenant devolvia canvas em branco (HTTP 200).
    priorizar_keepedu = str(revisao or "").strip().lower() == "keepedu"
    imagem = gerar_visualizacao_cartao_keepedu(
        nome_limpo,
        nome_imagem_limpo,
        priorizar_keepedu=priorizar_keepedu,
    )
    if not imagem:
        return JSONResponse({"erro": "Imagem não encontrada."}, status_code=404)
    return Response(
        content=imagem,
        media_type="image/jpeg",
        headers={"Cache-Control": "no-store, max-age=0"},
    )


@router.get("/imagem-base/{nome_avaliacao}/{nome_imagem}")
async def servir_imagem_base(nome_avaliacao: str, nome_imagem: str, galeria: bool = False):
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    nome_imagem_limpo = str(nome_imagem or "").strip().lstrip("/")
    if not nome_limpo or not nome_imagem_limpo:
        return JSONResponse({"erro": "Imagem não encontrada."}, status_code=404)

    # Comportamento anterior: galeria não espera KeepEdu; usa local/debug_omr.
    imagem = gerar_imagem_base_cartao(
        nome_limpo,
        nome_imagem_limpo,
        permitir_espelho_keepedu=not galeria,
        largura_maxima=640 if galeria else None,
    )
    if not imagem:
        return JSONResponse({"erro": "Imagem não encontrada."}, status_code=404)
    return Response(
        content=imagem,
        media_type="image/jpeg",
        # A imagem base local deve ser reaproveitada na navegação anterior/próxima.
        # A visualização pós-KeepEdu permanece na rota /imagem com no-store.
        headers={"Cache-Control": "private, max-age=3600"},
    )


@router.get("/leituras/{nome_avaliacao}")
async def obter_leituras(nome_avaliacao: str):
    from app.lotes_store import obter_estado_lote

    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    if not nome_limpo:
        return JSONResponse({})

    def _carregar() -> dict:
        correcoes_salvas = obter_estado_lote(nome_limpo).get("correcoes_web") or {}
        if correcoes_salvas:
            aplicar_correcoes_web_nas_leituras(nome_limpo, correcoes_salvas)
        return carregar_leituras_lote(nome_limpo)

    return JSONResponse(await run_in_threadpool(_carregar))


@router.get("/download-log/{nome_avaliacao}")
async def download_log_virtual(nome_avaliacao: str):
    caminho = caminho_log(nome_avaliacao)
    if caminho.exists():
        return FileResponse(str(caminho), filename=f"log_avaliacao_{nome_avaliacao}.csv")

    conteudo = gerar_csv_log_virtual(nome_avaliacao)
    if not conteudo.strip():
        return JSONResponse({"erro": "Log nao encontrado."}, status_code=404)

    return Response(
        content=conteudo,
        media_type="text/csv; charset=utf-8",
        headers={"Content-Disposition": f'attachment; filename=\"log_avaliacao_{nome_avaliacao}.csv\"'},
    )


@router.get("/download-log-arquivo/{nome_avaliacao}")
async def download_log_arquivo(nome_avaliacao: str):
    caminho = caminho_log(nome_avaliacao)
    if not caminho.exists():
        return JSONResponse({"erro": "Log não encontrado."}, status_code=404)
    return FileResponse(str(caminho), filename=f"log_avaliacao_{nome_avaliacao}.csv")


@router.get("/correcoes/{nome_avaliacao}")
async def obter_correcoes(nome_avaliacao: str):
    from app.lotes_store import obter_estado_lote

    return JSONResponse(
        obter_estado_lote(nome_avaliacao).get("correcoes_web") or {},
        headers={"Cache-Control": "no-store"},
    )


@router.get("/api/correcao/{nome_avaliacao}")
async def api_correcao_react(request: Request, nome_avaliacao: str, page: int = 1):
    """Contrato unico do editor React, usando as mesmas fontes do legado."""
    from app.lotes_store import obter_estado_lote

    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    if not nome_limpo:
        return JSONResponse({"status": "erro", "mensagem": "Lote invalido."}, status_code=400)

    pagina_pedida = int(page or 1)

    def _carregar() -> dict:
        estado = obter_estado_lote(nome_limpo)
        correcoes = estado.get("correcoes_web") or {}
        if correcoes:
            aplicar_correcoes_web_nas_leituras(nome_limpo, correcoes)

        leituras = carregar_leituras_lote(nome_limpo)
        imagens = listar_imagens_debug(nome_limpo)
        id_avaliacao = detectar_id_prova_do_processamento(leituras, nome_limpo) or ""
        por_pagina = 50
        total = len(imagens)
        total_pages = max(1, (total + por_pagina - 1) // por_pagina)
        pagina = max(1, min(pagina_pedida, total_pages))
        inicio = (pagina - 1) * por_pagina
        return {
            "status": "ok",
            "lote": nome_limpo,
            "id_avaliacao": str(id_avaliacao),
            "resumo": resumo_processamento(nome_limpo),
            "leituras": leituras if isinstance(leituras, dict) else {},
            "correcoes": correcoes if isinstance(correcoes, dict) else {},
            "envios_keepedu": carregar_envios_keepedu(nome_limpo),
            "imagens": imagens[inicio : inicio + por_pagina],
            "total_imagens": total,
            "page": pagina,
            "total_pages": total_pages,
        }

    payload = await run_in_threadpool(_carregar)
    payload["avaliacao"] = _contexto_avaliacao_ui(
        request,
        nome_limpo,
        str(payload.get("id_avaliacao") or ""),
    )["avaliacao_exibicao"]
    return JSONResponse(payload)


@router.get("/api/validacao/{nome_avaliacao}")
async def api_validacao_react(request: Request, nome_avaliacao: str, page: int = 1):
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    dados = await run_in_threadpool(carregar_validacao_cadastral_para_exibicao, nome_limpo)
    validacoes = dados.get("validacoes") if isinstance(dados, dict) else None
    if not isinstance(validacoes, dict):
        return JSONResponse({"status": "erro", "mensagem": "Lote não encontrado."}, status_code=404)
    itens = []
    envios_keepedu = carregar_envios_keepedu(nome_limpo)
    for nome, registro in validacoes.items():
        item = dict(registro or {})
        aluno = item.pop("aluno_api", None)
        if aluno:
            item["nome_aluno"] = getattr(aluno, "nome", None) or (aluno.get("nome") if isinstance(aluno, dict) else "")
        envio = resolver_envio_keepedu(
            nome,
            str(item.get("arquivo_original") or item.get("imagem") or "").strip(),
            envios_keepedu,
        )
        item["envio_keepedu"] = envio or {}
        item["vetores_enviados_keepedu"] = bool(envio and envio.get("vetores"))
        item["imagens_enviadas_keepedu"] = bool(envio and envio.get("imagens"))
        item["rotulo_envio_keepedu"] = rotulo_envio_keepedu(envio)
        itens.append({"nome": nome, **item})
    por_pagina = 50
    total = len(itens)
    total_pages = max(1, (total + por_pagina - 1) // por_pagina)
    pagina = max(1, min(int(page or 1), total_pages))
    inicio = (pagina - 1) * por_pagina
    resumo = dados.get("resumo") or {}
    id_avaliacao = str(resumo.get("id_prova_processamento") or "")
    contexto_avaliacao = _contexto_avaliacao_ui(request, nome_limpo, id_avaliacao)
    avaliacao_exibicao = {
        **(contexto_avaliacao["avaliacao_exibicao"] or {}),
        "url_visualizacao_avaliacao_keepedu": contexto_avaliacao.get("url_visualizacao_avaliacao_keepedu", ""),
        "keepedu_school": KEEPEDU_LOGIN_SCHOOL,
    }
    return JSONResponse({
        "status": "ok", "lote": nome_limpo, "id_avaliacao": id_avaliacao,
        "avaliacao": avaliacao_exibicao,
        "resumo": resumo, "itens": itens[inicio:inicio + por_pagina], "total": total,
        "page": pagina, "total_pages": total_pages,
        "envios_keepedu": envios_keepedu,
        "keepedu_school": KEEPEDU_LOGIN_SCHOOL,
        "url_visualizacao_avaliacao_keepedu": contexto_avaliacao.get("url_visualizacao_avaliacao_keepedu", ""),
    })


@router.post("/correcoes/{nome_avaliacao}")
async def salvar_correcoes(nome_avaliacao: str, payload: CorrecoesPayload):
    from app.lotes_store import obter_estado_lote, salvar_estado_lote

    estado = obter_estado_lote(nome_avaliacao)
    estado["correcoes_web"] = payload.correcoes
    salvar_estado_lote(nome_avaliacao, estado)
    alteracoes = aplicar_correcoes_web_nas_leituras(nome_avaliacao, payload.correcoes)
    return {"status": "ok", "leituras_corrigidas": alteracoes}


@router.post("/reprocessar-imagem/{nome_avaliacao}")
async def rota_reprocessar_imagem(nome_avaliacao: str, payload: ReprocessarImagemPayload):
    try:
        bloqueio = _resposta_bloqueio_keepedu(nome_avaliacao, payload.nome_imagem)
        if bloqueio:
            return bloqueio
        return JSONResponse(reprocessar_imagem_processamento(nome_processamento=nome_avaliacao, nome_imagem=payload.nome_imagem, pontos_cantos=payload.pontos_cantos))
    except Exception as e:
        return JSONResponse({"status": "erro", "mensagem": str(e)}, status_code=400)


@router.post("/deletar-leitura/{nome_avaliacao}")
async def rota_deletar_leitura(nome_avaliacao: str, payload: DeletarLeituraPayload):
    try:
        bloqueio = _resposta_bloqueio_keepedu(nome_avaliacao, payload.nome_imagem)
        if bloqueio:
            return bloqueio
        return JSONResponse(deletar_leitura_processamento(nome_avaliacao, payload.nome_imagem))
    except FileNotFoundError as e:
        return JSONResponse({"status": "erro", "mensagem": str(e)}, status_code=404)
    except Exception as e:
        return JSONResponse({"status": "erro", "mensagem": str(e)}, status_code=400)


@router.post("/reprocessar-lote-ajax/{nome_avaliacao:path}")
async def rota_reprocessar_lote_ajax(nome_avaliacao: str, background_tasks: BackgroundTasks):
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    if not nome_limpo:
        return JSONResponse({"status": "erro", "mensagem": "Lote inválido."}, status_code=400)

    pasta_lote = caminho_pasta_processamento(nome_limpo)
    if False:  # Lotes consolidados nao possuem pasta por processamento.
        return JSONResponse({"status": "erro", "mensagem": "Lote não encontrado."}, status_code=404)

    leituras = carregar_leituras_lote(nome_limpo)
    total_leituras = len(leituras) if isinstance(leituras, dict) else 0
    if total_leituras <= 0:
        return JSONResponse(
            {"status": "erro", "mensagem": "Nenhuma imagem restante para reprocessar neste lote."},
            status_code=404,
        )

    if isinstance(leituras, dict):
        for nome_imagem, dados_cartao in leituras.items():
            if cartao_enviado_keepedu_completo(nome_limpo, nome_imagem, dados_cartao):
                return JSONResponse(
                    {
                        "status": "erro",
                        "mensagem": (
                            "Há cartões já enviados ao KeepEdu neste lote. "
                            "Para alterá-los, faça um novo processamento."
                        ),
                    },
                    status_code=400,
                )

    job_id = criar_job(total_arquivos=max(total_leituras, 1))
    try:
        atualizar_job(job_id, status="processando", percentual=1, mensagem="Iniciando reprocessamento...")
        agendar_trabalho_pesado(reprocessar_lote_com_progresso, job_id, nome_limpo)
        return JSONResponse({"status": "ok", "job_id": job_id})
    except Exception as e:
        atualizar_job(job_id, status="erro", percentual=100, mensagem="Erro", erro=str(e))
        return JSONResponse({"status": "erro", "mensagem": str(e)}, status_code=400)


@router.post("/avaliacao/{nome_avaliacao}/alterar-id-prova-ajax")
async def rota_alterar_id_prova_ajax(
    request: Request,
    nome_avaliacao: str,
    payload: AlterarIdProvaPayload,
):
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    if not nome_limpo:
        return JSONResponse({"status": "erro", "mensagem": "Lote inválido."}, status_code=400)

    try:
        resultado = alterar_id_prova_do_lote(
            nome_limpo,
            payload.id_prova_novo,
            payload.id_prova_antigo,
        )
        exibicao = contexto_exibicao_avaliacao(
            request.session,
            nome_processamento=nome_limpo,
            id_fallback=resultado.get("id_prova_novo", ""),
        )
        resultado["avaliacao_exibicao"] = exibicao
        return JSONResponse(resultado)
    except ValueError as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=400)
    except Exception as erro:
        return JSONResponse(
            {"status": "erro", "mensagem": f"Não foi possível alterar o ID: {erro}"},
            status_code=500,
        )


@router.get("/gerar-csv/{nome_avaliacao}")
async def rota_gerar_csv(request: Request, nome_avaliacao: str, forcar: bool = False):
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    try:
        resultado = gerar_csv_final(nome_limpo, forcar=forcar)
    except FileNotFoundError as erro:
        return JSONResponse({"status": "erro", "mensagem": str(erro)}, status_code=404)

    if resultado.get("status") != "ok":
        return JSONResponse(resultado, status_code=400)

    aceita_json = "application/json" in (request.headers.get("accept") or "").lower()
    if aceita_json:
        return JSONResponse({
            "status": "ok",
            "download_url": f"/download-csv/{nome_limpo}",
            "gerado_com_pendencias": bool(resultado.get("gerado_com_pendencias")),
            "total_pendencias": int(resultado.get("total_pendencias") or 0),
        })

    return RedirectResponse(url=f"/download-csv/{nome_limpo}", status_code=303)


@router.get("/download-csv/{nome_avaliacao}")
async def rota_download_csv(nome_avaliacao: str):
    try:
        resultado = gerar_csv_final(nome_avaliacao, forcar=True)
    except FileNotFoundError as erro:
        return JSONResponse({"erro": str(erro)}, status_code=404)

    if resultado.get("status") != "ok":
        return JSONResponse(resultado, status_code=400)

    return Response(
        content=resultado.get("arquivo_bytes") or b"",
        media_type="text/csv; charset=utf-8",
        headers={
            "Content-Disposition": f'attachment; filename="{resultado.get("nome_arquivo") or f"{nome_avaliacao}_csv_final.csv"}"'
        },
    )


@router.post("/importar-respostas-presenciais")
async def rota_importar_respostas_presenciais(payload: ImportarRespostasPayload):

    # Pegamos o nome que veio no payload ou usamos o idAval como fallback seguro
    nome_lote = payload.nome_processamento or f"avaliacao_{payload.idAval}"

    # PASSO 1 — Importa vetores/respostas
    resultado = await run_in_threadpool(
        importar_respostas_presenciais,
        nome_lote,
        id_aval=payload.idAval,
        dia_aval=payload.diaAval,
        usuario_id=payload.usuario_id,
        id_aluno=payload.idAluno,
    )

    if resultado.get("status") == "erro":
        return JSONResponse(resultado, status_code=400)

    return JSONResponse({
        "status": "ok",
        "vetores": resultado,
    })


@router.post("/importar-respostas-presenciais-ajax/{nome_avaliacao}")
async def rota_importar_respostas_presenciais_ajax(nome_avaliacao: str, payload: ImportarRespostasPayload, background_tasks: BackgroundTasks):
    try:
        total_arquivos = contar_alunos_importacao(nome_avaliacao, id_aluno=payload.idAluno)
    except FileNotFoundError as exc:
        return JSONResponse({"status": "erro", "mensagem": str(exc)}, status_code=404)
    job_id = criar_job(total_arquivos=total_arquivos)
    agendar_trabalho_pesado(_executar_importacao_respostas_em_background, job_id, nome_avaliacao, payload, "importacao")
    return JSONResponse({"status": "ok", "job_id": job_id, "mensagem": "Envio de Vetores iniciado."})


@router.post("/simular-importacao-respostas-presenciais/{nome_avaliacao}")
async def rota_simular_importacao_respostas_presenciais(nome_avaliacao: str, payload: ImportarRespostasPayload):
    resultado = await run_in_threadpool(
        simular_importacao_respostas_presenciais,
        nome_avaliacao=nome_avaliacao,
        id_aval=payload.idAval,
        dia_aval=payload.diaAval,
        usuario_id=payload.usuario_id,
        id_aluno=payload.idAluno,
    )
    if resultado.get("status") == "erro": return JSONResponse(resultado, status_code=400)
    return JSONResponse(resultado)


@router.post("/simular-importacao-respostas-presenciais-ajax/{nome_avaliacao}")
async def rota_simular_importacao_respostas_presenciais_ajax(nome_avaliacao: str, payload: ImportarRespostasPayload, background_tasks: BackgroundTasks):
    try:
        total_arquivos = contar_alunos_importacao(nome_avaliacao, id_aluno=payload.idAluno)
    except FileNotFoundError as exc:
        return JSONResponse({"status": "erro", "mensagem": str(exc)}, status_code=404)
    job_id = criar_job(total_arquivos=total_arquivos)
    agendar_trabalho_pesado(_executar_importacao_respostas_em_background, job_id, nome_avaliacao, payload, "simulacao")
    return JSONResponse({"status": "ok", "job_id": job_id, "mensagem": "Simulação de Vetores iniciada."})


@router.post("/mock/importar-respostas-presenciais")
async def mock_importar_respostas_presenciais(request: Request):
    try: dados = await request.json()
    except Exception: return JSONResponse({"success": False, "mensagem": ["JSON inválido."]}, status_code=400)
    status_code, resposta = processar_mock_importacao_keepedu(dados)
    return JSONResponse(resposta, status_code=status_code)


@router.post("/validacao/{nome_avaliacao}/importar-frequencia-ajax")
async def rota_importar_frequencia_ajax(
    nome_avaliacao: str,
    background_tasks: BackgroundTasks,
    lista_de_frequencia: UploadFile = File(...),
    idSimulado: str = Form(...),
):
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    nome_original = (lista_de_frequencia.filename or "").strip()

    if not nome_original:
        return JSONResponse({"status": "erro", "mensagem": "Nenhum arquivo selecionado."}, status_code=400)

    if not nome_original.lower().endswith(".pdf"):
        return JSONResponse({"status": "erro", "mensagem": "O arquivo deve estar em formato PDF."}, status_code=400)

    conteudo = await lista_de_frequencia.read()
    if not conteudo:
        return JSONResponse({"status": "erro", "mensagem": "O arquivo enviado está vazio."}, status_code=400)

    caminho = salvar_lista_frequencia_pdf(nome_limpo, conteudo)
    job_id = criar_job(total_arquivos=1)
    agendar_trabalho_pesado(
        _executar_envio_frequencia_em_background,
        job_id,
        nome_limpo,
        idSimulado,
        str(caminho),
    )
    # Resposta imediata: o envio à KeepEdu roda em background com job_id para polling.
    return JSONResponse({
        "status": "ok",
        "job_id": job_id,
        "mensagem": "Envio de frequência iniciado.",
    })


@router.get("/validacao/{nome_avaliacao}/status-envios-ajax")
async def rota_status_envios_validacao_ajax(nome_avaliacao: str):
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    dados_validacao = obter_validacao_cadastral_atual(nome_limpo)
    itens = {}
    for nome_imagem, validacao in (dados_validacao.get("validacoes") or {}).items():
        itens[nome_imagem] = {
            "rotulo_envio_keepedu": validacao.get("rotulo_envio_keepedu") or "",
            "enviado_keepedu_completo": bool(validacao.get("enviado_keepedu_completo")),
            "envio_keepedu": validacao.get("envio_keepedu") or {},
        }
    return JSONResponse({"status": "ok", "itens": itens})


@router.post("/validacao/consultar-aluno")
async def rota_consultar_aluno_por_id(request: Request):
    try:
        payload = await request.json()
    except (ValueError, json.JSONDecodeError):
        payload = {}

    aluno_id = str((payload or {}).get("aluno_id") or "").strip()
    ra = str((payload or {}).get("ra") or "").strip()
    resultado = await run_in_threadpool(buscar_aluno_por_id, aluno_id, ra)
    if not resultado.get("encontrado"):
        return JSONResponse(
            {"status": "erro", "mensagem": resultado.get("motivo") or "Aluno não encontrado."},
            status_code=404,
        )
    return JSONResponse({"status": "ok", "aluno": resultado})


@router.post("/validacao/consultar-alunos-pagina")
async def rota_consultar_alunos_da_pagina(request: Request):
    """Carrega nomes opcionais sem bloquear a resposta da paginacao."""
    try:
        payload = await request.json()
    except (ValueError, json.JSONDecodeError):
        payload = {}

    itens_recebidos = (payload or {}).get("itens") or []
    itens: list[dict[str, str]] = []
    chaves_vistas: set[str] = set()
    for item in itens_recebidos[:10] if isinstance(itens_recebidos, list) else []:
        if not isinstance(item, dict):
            continue
        aluno_id = "".join(ch for ch in str(item.get("aluno_id") or "") if ch.isdigit())
        ra = "".join(ch for ch in str(item.get("ra") or "") if ch.isdigit())
        chave = str(item.get("chave") or f"{aluno_id}:{ra}").strip()
        if not aluno_id or not chave or chave in chaves_vistas:
            continue
        chaves_vistas.add(chave)
        itens.append({"chave": chave, "aluno_id": aluno_id, "ra": ra})

    def consultar_itens() -> dict[str, dict]:
        resultados: dict[str, dict] = {}
        for item in itens:
            resultados[item["chave"]] = buscar_aluno_por_id(item["aluno_id"], item["ra"])
        return resultados

    alunos = await run_in_threadpool(consultar_itens) if itens else {}
    return JSONResponse({"status": "ok", "alunos": alunos})


@router.post("/validacao/manual/{nome_avaliacao}")
async def rota_salvar_validacao_manual(request: Request, nome_avaliacao: str, nome_imagem: str = Form(...), id_aluno_manual: str = Form(...)):
    try:
        registro = salvar_correcao_manual(nome_processamento=nome_avaliacao, nome_imagem=nome_imagem, valor_manual=id_aluno_manual)
        aceita_json = "application/json" in (request.headers.get("accept") or "").lower() or (request.headers.get("x-requested-with") or "").lower() == "fetch"
        if aceita_json: return JSONResponse({"status": "ok", "nome_imagem": nome_imagem, "registro": registro})
        return RedirectResponse(url=f"/validacao/{nome_avaliacao}", status_code=303)
    except Exception as e:
        return JSONResponse({"status": "erro", "mensagem": str(e)}, status_code=400)


@router.post("/validacao/manual/remover/{nome_avaliacao}")
async def rota_remover_validacao_manual(request: Request, nome_avaliacao: str, nome_imagem: str = Form(...)):
    try:
        resultado = remover_correcao_manual(nome_processamento=nome_avaliacao, nome_imagem=nome_imagem)
        aceita_json = "application/json" in (request.headers.get("accept") or "").lower() or (request.headers.get("x-requested-with") or "").lower() == "fetch"
        if aceita_json:
            return JSONResponse({
                "status": "ok",
                "nome_imagem": nome_imagem,
                "validacao": resultado.get("validacao", {}),
            })
        return RedirectResponse(url=f"/validacao/{nome_avaliacao}", status_code=303)
    except Exception as e:
        return JSONResponse({"status": "erro", "mensagem": str(e)}, status_code=400)


@router.get("/status-processamento/{job_id}")
async def status_processamento(job_id: str):
    return JSONResponse(obter_job(job_id))


@router.post("/nova-avaliacao-ajax")
async def rota_nova_avaliacao_ajax(
    request: Request,
    background_tasks: BackgroundTasks,
    arquivos: list[UploadFile] = File(...),
):
    ctx_proc = contexto_para_processamento(request.session)
    if not ctx_proc.get("id_avaliacao_esperado"):
        return JSONResponse(
            {
                "status": "erro",
                "mensagem": "Configure o simulado/avaliação no Painel antes de iniciar o processamento.",
            },
            status_code=400,
        )

    job_id = criar_job(total_arquivos=len(arquivos))
    pasta_upload = PASTA_UPLOADS_TEMP / f"upload_{job_id}"
    pasta_upload.mkdir(parents=True, exist_ok=True)
    try:
        total_salvos = await salvar_uploads_em_pasta(arquivos, pasta_upload)
        if total_salvos == 0:
            return JSONResponse({"status": "erro", "job_id": job_id, "mensagem": "Nenhuma imagem válida."})
        atualizar_job(job_id, status="processando", percentual=5, mensagem="Lendo imagens...")
        usuario_nome = str(request.session.get("user_name") or "").strip()
        id_avaliacao = str(ctx_proc["id_avaliacao_esperado"])
        nome_simulado = str(ctx_proc.get("nome_simulado") or "")
        meta_avaliacao = dict(ctx_proc.get("meta_avaliacao") or {})
        try:
            automacao_processamento_iniciado(
                id_avaliacao,
                unidade=str(meta_avaliacao.get("unidade") or ""),
            )
        except Exception:
            pass
        agendar_trabalho_pesado(
            processar_pasta_temporaria_com_progresso,
            job_id,
            pasta_upload,
            id_avaliacao,
            nome_simulado,
            meta_avaliacao,
            usuario_nome,
        )
        # A avaliação pertence a este lote; não deve permanecer como padrão do próximo fluxo.
        limpar_contexto_na_sessao(request.session)
        return JSONResponse({"status": "ok", "job_id": job_id})
    except Exception as e:
        return JSONResponse({"status": "erro", "job_id": job_id, "mensagem": str(e)})

# Rota de compatibilidade para a tela de monitoramento/detalhes
@router.get("/processamento/{nome_avaliacao}")
async def redirecionar_processamento_antigo(nome_avaliacao: str):
    return RedirectResponse(
        url=url_frontend(f"/processamento/{quote(nome_avaliacao, safe='')}"),
        status_code=307,
    )

# Rota de compatibilidade caso o sistema tente monitorar o progresso com o nome antigo
@router.get("/processando/{job_id}", response_class=HTMLResponse)
async def tela_processando_compatibilidade(request: Request, job_id: str):
    return render_template(request, "processando.html", {"job_id": job_id})

def extrair_nome_lote_seguro(parametro: str) -> str:
    """
    Decodifica a URL e extrai o nome do lote mesmo se o frontend
    enviar um dicionário Python formatado como string.
    """
    texto_puro = unquote(parametro).strip()

    # Se o frontend enviou um dicionário mascarado de string (ex: {'nome': '...'})
    if texto_puro.startswith("{") and ("['nome']" in texto_puro or "'nome'" in texto_puro):
        try:
            dados_dict = ast.literal_eval(texto_puro)
            if isinstance(dados_dict, dict) and "nome" in dados_dict:
                return str(dados_dict["nome"])
        except Exception:
            pass

    return texto_puro

@router.get("/validacao/{nome_processamento}/visualizar/{chave_imagem:path}")
async def validacao_visualizar_cartao(nome_processamento: str, chave_imagem: str):
    nome_limpo = extrair_nome_lote_seguro(nome_processamento)
    imagem = gerar_visualizacao_cartao_keepedu(nome_limpo, chave_imagem)
    if not imagem:
        return JSONResponse({"erro": "Visualização não disponível para este cartão."}, status_code=404)
    return Response(
        content=imagem,
        media_type="image/jpeg",
        headers={"Cache-Control": "no-store, max-age=0"},
    )


@router.get("/validacao/{nome_avaliacao:path}", response_class=HTMLResponse)
async def validacao_cadastral(request: Request, nome_avaliacao: str, page: int = 1):
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    return RedirectResponse(
        url=url_frontend(f"/validacao/{quote(nome_limpo, safe='')}"),
        status_code=307,
    )

    # Limpa a sujeira do link se ela existir
    identificador_entrada = extrair_nome_lote_seguro(nome_avaliacao)
    nome_limpo = identificador_entrada
    if identificador_entrada.isdigit():
        nome_limpo = await run_in_threadpool(_resolver_lote_por_id_avaliacao, identificador_entrada) or identificador_entrada

    # Se detectou que veio o dicionário no link, faz o redirecionamento limpo
    if not identificador_entrada.isdigit() and nome_limpo != nome_avaliacao:
        return RedirectResponse(url=f"/validacao/{nome_limpo}", status_code=303)

    # A paginação é interna via fetch. Em reload normal, remove qualquer ?page=
    # antigo da URL e reabre sempre a primeira página.
    paginacao_ajax = (request.headers.get("x-requested-with") or "").lower() == "fetch"
    if not paginacao_ajax:
        if "page" in request.query_params:
            return RedirectResponse(url=f"/validacao/{nome_limpo}", status_code=303)
        page = 1

    # A leitura atual segue como fonte de verdade. O snapshot é reconstruído
    # apenas quando as leituras mudam; as demais páginas reutilizam o resultado.
    dados_validacao = await run_in_threadpool(
        carregar_validacao_cadastral_para_exibicao,
        nome_limpo,
    )
    if not isinstance(dados_validacao, dict) or not isinstance(dados_validacao.get("validacoes"), dict):
        return HTMLResponse("<h1>Avaliação não encontrada.</h1><p>O lote solicitado não existe no diretório.</p>", status_code=404)

    resumo_bruto = dados_validacao.get("resumo")
    resumo = resumo_bruto if isinstance(resumo_bruto, dict) else {}
    id_avaliacao = resumo.get("id_prova_processamento") or ""
    if not id_avaliacao and not paginacao_ajax:
        leituras_lote = await run_in_threadpool(carregar_leituras_lote, nome_limpo)
        id_avaliacao = detectar_id_prova_do_processamento(leituras_lote, nome_limpo) or ""
    id_aval_formulario = id_avaliacao or ID_PROVA_KEEPEDU
    # Preserve o identificador do processamento na URL. Uma avaliação pode ter
    # vários lotes; trocar aqui para o ID da avaliação fazia a próxima
    # requisição resolver outro lote (com leituras já excluídas ainda presentes).
    todas_validacoes = dados_validacao["validacoes"]
    por_pagina = 50
    total_validacoes = len(todas_validacoes)
    total_pages = max(1, (total_validacoes + por_pagina - 1) // por_pagina)
    pagina = max(1, min(int(page or 1), total_pages))
    inicio = (pagina - 1) * por_pagina
    validacoes_pagina = dict(list(todas_validacoes.items())[inicio : inicio + por_pagina])
    template_resposta = (
        "partials/validacao_registros.html"
        if paginacao_ajax
        else "validacao.html"
    )
    contexto_resposta = {
        "nome_processamento": nome_limpo,
        "resumo": resumo,
        "validacoes": validacoes_pagina,
        "total_validacoes": total_validacoes,
        "page": pagina,
        "total_pages": total_pages,
    }
    if not paginacao_ajax:
        contexto_resposta.update({
            "importacao_defaults": {"id_aval": id_aval_formulario},
            **_contexto_avaliacao_ui(request, nome_limpo, id_avaliacao),
        })
    return render_template(request, template_resposta, contexto_resposta)


def _resolver_nome_imagem_keepedu(validacoes: dict, nome_imagem: str | None, id_aluno: str | int | None) -> str | None:
    if nome_imagem:
        return nome_imagem

    id_aluno_texto = str(id_aluno or "").strip()
    if not id_aluno_texto:
        return None

    for nome, validacao in validacoes.items():
        if str(validacao.get("id_final") or "").strip() == id_aluno_texto:
            return nome
    return None


def _resposta_bloqueio_leitura_incompativel(nome_avaliacao: str, nome_imagem: str) -> JSONResponse | None:
    leituras = carregar_leituras_lote(nome_avaliacao)
    chave = resolver_chave_leitura(leituras, nome_imagem)
    dados = leituras.get(chave or nome_imagem) or {}
    if leitura_bloqueada_id_avaliacao(dados):
        return JSONResponse(
            {
                "status": "erro",
                "mensagem": (
                    "Cartão incompatível com a avaliação do lote. "
                    "Exclua a leitura e reprocesse o lote antes de enviar."
                ),
            },
            status_code=400,
        )
    return None


def _resposta_bloqueio_keepedu(nome_avaliacao: str, nome_imagem: str) -> JSONResponse | None:
    leituras = carregar_leituras_lote(nome_avaliacao)
    chave = resolver_chave_leitura(leituras, nome_imagem)
    dados = leituras.get(chave or nome_imagem) or {}
    if cartao_enviado_keepedu_completo(nome_avaliacao, chave or nome_imagem, dados):
        return JSONResponse(
            {
                "status": "erro",
                "mensagem": (
                    "Este cartão já foi enviado ao KeepEdu. "
                    "Para alterá-lo, faça um novo processamento."
                ),
            },
            status_code=400,
        )
    return None


@router.post("/correcao/enviar-keepedu/{nome_avaliacao}")
async def rota_enviar_imagem_keepedu(nome_avaliacao: str, payload: ImportarImagemPayload):
    dados_validacao = obter_validacao_cadastral_atual(nome_avaliacao)
    validacoes = dados_validacao.get("validacoes", {})
    nome_imagem = _resolver_nome_imagem_keepedu(validacoes, payload.nome_imagem, payload.id_aluno)

    if not nome_imagem:
        return JSONResponse(
            {"status": "erro", "mensagem": "Informe a imagem ou um ID de aluno validado."},
            status_code=400,
        )

    bloqueio = _resposta_bloqueio_leitura_incompativel(nome_avaliacao, nome_imagem)
    if bloqueio:
        return bloqueio

    resultado = await run_in_threadpool(
        enviar_cartao_keepedu,
        nome_avaliacao,
        nome_imagem,
        id_aval=payload.id_aval,
        modelo_ia=payload.modelo_ia,
    )

    if resultado.get("status") == "erro":
        return JSONResponse(resultado, status_code=400)

    return JSONResponse(resultado)


@router.post("/correcao/enviar-imagens-keepedu/{nome_avaliacao}")
async def rota_enviar_imagem_keepedu_validacao(nome_avaliacao: str, payload: ImportarImagemPayload):
    dados_validacao = obter_validacao_cadastral_atual(nome_avaliacao)
    validacoes = dados_validacao.get("validacoes", {})
    nome_imagem = _resolver_nome_imagem_keepedu(validacoes, payload.nome_imagem, payload.id_aluno)

    if not nome_imagem:
        return JSONResponse(
            {"status": "erro", "mensagem": "Informe a imagem ou um ID de aluno validado."},
            status_code=400,
        )

    resultado = await run_in_threadpool(
        enviar_imagem_keepedu,
        nome_avaliacao,
        nome_imagem,
        id_aval=payload.id_aval,
    )

    if resultado.get("status") == "erro":
        return JSONResponse(resultado, status_code=400)

    return JSONResponse(resultado)


@router.post("/correcao/enviar-imagens-keepedu-todas/{nome_avaliacao}")
async def rota_enviar_todas_imagens_keepedu_validacao(nome_avaliacao: str, payload: ImportarTodasKeepEduPayload):
    resultado = await run_in_threadpool(
        enviar_todas_imagens_keepedu,
        nome_avaliacao,
        id_aval=payload.id_aval,
    )

    if resultado.get("status") == "erro" and not resultado.get("importadas"):
        return JSONResponse(resultado, status_code=400)

    return JSONResponse(resultado)


@router.post("/correcao/enviar-imagens-keepedu-ajax/{nome_avaliacao}")
async def rota_enviar_imagem_keepedu_ajax(
    nome_avaliacao: str,
    payload: ImportarImagemPayload,
    background_tasks: BackgroundTasks,
):
    dados_validacao = obter_validacao_cadastral_atual(nome_avaliacao)
    validacoes = dados_validacao.get("validacoes", {})
    nome_imagem = _resolver_nome_imagem_keepedu(validacoes, payload.nome_imagem, payload.id_aluno)

    if not nome_imagem:
        return JSONResponse(
            {"status": "erro", "mensagem": "Informe a imagem ou um ID de aluno validado."},
            status_code=400,
        )

    validacao_imagem = validacoes.get(nome_imagem, {})
    id_aluno = validacao_imagem.get("id_final")
    total_arquivos = contar_imagens_importacao(nome_avaliacao, id_aluno=id_aluno)
    job_id = criar_job(total_arquivos=max(total_arquivos, 1))
    agendar_trabalho_pesado(
        _executar_envio_imagens_em_background,
        job_id,
        nome_avaliacao,
        payload.id_aval,
        id_aluno,
        True,
    )
    return JSONResponse({"status": "ok", "job_id": job_id, "mensagem": "Envio de imagem iniciado."})


@router.post("/correcao/enviar-imagens-keepedu-todas-ajax/{nome_avaliacao}")
async def rota_enviar_todas_imagens_keepedu_ajax(
    nome_avaliacao: str,
    payload: ImportarTodasKeepEduPayload,
    background_tasks: BackgroundTasks,
):
    total_arquivos = contar_imagens_importacao(nome_avaliacao)
    if total_arquivos <= 0:
        return JSONResponse(
            {
                "status": "erro",
                "mensagem": "Nenhuma imagem processada com ID validado para envio.",
            },
            status_code=400,
        )

    job_id = criar_job(total_arquivos=total_arquivos)
    agendar_trabalho_pesado(
        _executar_envio_imagens_em_background,
        job_id,
        nome_avaliacao,
        payload.id_aval,
        None,
        True,
    )
    return JSONResponse({"status": "ok", "job_id": job_id, "mensagem": "Envio de imagens iniciado."})

@router.get("/correcao/{nome_avaliacao:path}", response_class=HTMLResponse)
async def correcao(request: Request, nome_avaliacao: str, page: int = 1):
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)
    return RedirectResponse(
        url=url_frontend(f"/correcao/{quote(nome_limpo, safe='')}"),
        status_code=307,
    )

    # Limpa a sujeira do link se ela existir
    nome_limpo = extrair_nome_lote_seguro(nome_avaliacao)

    # Se detectou que veio o dicionário no link, faz o redirecionamento limpo
    if nome_limpo != nome_avaliacao:
        return RedirectResponse(url=f"/correcao/{nome_limpo}", status_code=303)

    paginacao_ajax = (request.headers.get("x-requested-with") or "").lower() == "fetch"
    imagens_debug = listar_imagens_debug(nome_limpo)
    por_pagina = 50
    total_imagens = len(imagens_debug)
    total_pages = max(1, (total_imagens + por_pagina - 1) // por_pagina) if total_imagens else 1
    pagina = max(1, min(int(page or 1), total_pages))
    imagens_pagina = imagens_debug[(pagina - 1) * por_pagina : pagina * por_pagina]

    contexto = {
        "nome_processamento": nome_limpo,
        "imagens_debug": imagens_pagina,
        "total_imagens_debug": total_imagens,
        "page": pagina,
        "total_pages": total_pages,
        "envios_keepedu": carregar_envios_keepedu(nome_limpo),
    }
    if paginacao_ajax:
        return render_template(request, "partials/correcao_lista.html", contexto)

    leituras = carregar_leituras_lote(nome_limpo)
    id_avaliacao = detectar_id_prova_do_processamento(leituras, nome_limpo) or ""
    try:
        automacao_lote_aberto(nome_limpo, id_avaliacao)
    except Exception:
        pass

    contexto.update(_contexto_avaliacao_ui(request, nome_limpo, id_avaliacao))
    return render_template(request, "correcao.html", contexto)

@router.get("/painel", response_class=HTMLResponse)
async def painel(request: Request):
    return RedirectResponse(url=url_frontend("/painel"), status_code=307)


@router.get("/api/painel/react-resumo-legacy")
async def api_painel_react_resumo_legacy(request: Request):
    """Contrato inicial do Painel React.

    A tela nova consome apenas este resumo; o fluxo operacional permanece no
    Painel legado até que seus formulários sejam migrados individualmente.
    """
    estatisticas = await run_in_threadpool(resumo_estatisticas_globais_processamento)
    contexto = _contexto_painel(request, estatisticas)
    return JSONResponse({
        "estatisticas": contexto["estatisticas_painel"],
        "avaliacao": contexto["avaliacao_contexto"],
        "usuario": {
            "nome": request.session.get("user_name") or request.session.get("user_email") or "Operador OMR",
            "escola": request.session.get("school") or request.session.get("keepedu_school") or KEEPEDU_LOGIN_SCHOOL,
        },
    })


@router.get("/painel-react", response_class=HTMLResponse)
async def painel_react():
    return RedirectResponse(url=url_frontend("/painel"), status_code=307)


@router.get("/kanban-react", response_class=HTMLResponse)
async def kanban_react():
    return RedirectResponse(url=url_frontend("/kanban"), status_code=307)


@router.get("/historico-react", response_class=HTMLResponse)
async def historico_react():
    return RedirectResponse(url=url_frontend("/historico"), status_code=307)


@router.get("/processamento-react/{nome_avaliacao}", response_class=HTMLResponse)
async def processamento_react(nome_avaliacao: str):
    return RedirectResponse(
        url=url_frontend(f"/processamento/{quote(nome_avaliacao, safe='')}"),
        status_code=307,
    )



@router.get("/correcao-react/{nome_avaliacao}", response_class=HTMLResponse)
async def correcao_react(nome_avaliacao: str):
    return RedirectResponse(
        url=url_frontend(f"/correcao/{quote(nome_avaliacao, safe='')}"),
        status_code=307,
    )



@router.get("/validacao-react/{nome_avaliacao}", response_class=HTMLResponse)
async def validacao_react(nome_avaliacao: str):
    return RedirectResponse(
        url=url_frontend(f"/validacao/{quote(nome_avaliacao, safe='')}"),
        status_code=307,
    )
