# Conferência dos concluintes em computação, engenharia e ciências do Mapa de CT&I da SECTI-RJ
# Página: https://mapa.fanara.com.br/diagnostico#cap-formacao-t (capítulos "Formar e empregar" e
# "Como e onde se forma")
# Tabela conferida: https://mapa.fanara.com.br/dados/censo-superior-rj.csv
#
# O script parte dos microdados do Censo da Educação Superior do INEP, refaz as somas do mapa e compara
# com a tabela publicada, coluna por coluna. Usa só o R que vem instalado (pacotes base, utils e tools).
# Baixa um arquivo de cerca de 460 MB e leva alguns minutos.
#
# Uso:
#   Rscript censo-superior-rj.R            baixa o zip do INEP numa pasta temporária
#   Rscript censo-superior-rj.R <pasta>    usa o que já estiver nessa pasta: o arquivo de cursos já
#                                          extraído (MICRODADOS_CADASTRO_CURSOS_AAAA.CSV, em qualquer
#                                          subpasta) ou o zip; sem nenhum dos dois, baixa o zip para lá
# Para conferir outra cópia da tabela: CENSO_TABELA=<caminho ou URL> Rscript censo-superior-rj.R
#
# Fonte: INEP, Censo da Educação Superior, microdados, arquivo de cursos (MICRODADOS_CADASTRO_CURSOS_AAAA.CSV),
# https://www.gov.br/inep/pt-br/acesso-a-informacao/dados-abertos/microdados/censo-da-educacao-superior
# Download: https://download.inep.gov.br/microdados/microdados_censo_da_educacao_superior_AAAA.zip
# O ano é o da coluna ano da tabela. Quando o arquivo de cursos sai do zip, o script confere o MD5 que o
# próprio INEP publica no zip (dados/md5_microdados_ed_superior_AAAA.txt).
#
# Método (o mesmo de tools/diagnostico/presencial.py, que gerou os números do mapa):
#   - linhas do arquivo de cursos com SG_UF = "RJ" (UF do local de oferta);
#   - áreas gerais da CINE (CO_CINE_AREA_GERAL, sem espaços) 05 ciências naturais, matemática e estatística,
#     06 computação e tecnologias da informação e comunicação, 07 engenharia, produção e construção;
#   - soma de QT_CONC (concluintes; vazio vale zero) por CO_MUNICIPIO, o município do local de oferta. No
#     curso a distância, é o município do polo;
#   - TP_MODALIDADE_ENSINO = 1 é presencial, separado por TP_REDE (1 pública, 2 privada); 2 é a distância;
#   - o estado é a soma dos 92 municípios; município com concluintes nos microdados que falte na tabela
#     conta como divergência;
#   - taxa por 100 mil habitantes = concluintes presenciais / população * 100.000, com a população da
#     própria tabela (estimativa do IBGE, conferida pelo script populacao-rj.R desta mesma página).
#
# Colunas da tabela (uma linha por território; o estado vem primeiro, com código 33):
#   ano                                      ano do Censo da Educação Superior
#   codigo_ibge, municipio                   código do IBGE (7 dígitos; 33 para o estado) e nome
#   ano_populacao, populacao                 ano e valor da estimativa de população usada nas taxas
#   concluintes_presenciais                  concluintes de cursos presenciais nas três áreas
#   concluintes_presenciais_publica          idem, em instituições públicas (federais, estaduais e municipais)
#   concluintes_presenciais_privada          idem, em instituições privadas
#   concluintes_a_distancia                  concluintes de cursos a distância nas três áreas, no município do polo
#   concluintes_presenciais_100mil           taxas por 100 mil habitantes, com três casas decimais e vírgula
#   concluintes_presenciais_publica_100mil   decimal
#   concluintes_presenciais_privada_100mil
#
# Contagens são comparadas exatamente. Taxas, com tolerância de meia unidade da terceira casa (0,0005): o
# mapa arredonda em Python, que desempata o 5 final de outro jeito que o R, e as duas regras estão certas.
#
# Ao final, o script refaz os números citados no texto do diagnóstico e verifica se o INEP já publicou o zip
# de um ano mais novo que o da tabela. A última linha resume a conferência para o robô que a roda todo mês:
#   #conferencia valores=<comparados> divergentes=<diferentes> tabela=<ano da tabela> fonte=<ano mais novo no INEP>
# Saída do Rscript: 0 quando todos os valores batem, 1 quando algum não bate, 2 quando não foi possível conferir
# (download que falhou, arquivo com MD5 diferente do publicado, tabela ilegível).

options(timeout = 3600)
# Códigos de saída só no Rscript; dentro do RStudio (source), o script mostra o resultado e não fecha a sessão.
if (!interactive()) options(error = function() quit(save = "no", status = 2))
# Sessão sem UTF-8 (locale C, comum em servidor) corta a leitura no primeiro acento e daria divergência falsa.
if (is.na(iconv("é", "UTF-8", ""))) stop("rode numa sessão UTF-8, por exemplo: LANG=C.UTF-8 Rscript censo-superior-rj.R")
# O INEP omite o certificado intermediário. O workflow fornece um bundle validado e com fingerprint fixo.
baixar_http <- function(url, destino) {
  curl <- Sys.which("curl")
  if (!nzchar(curl)) return(tryCatch(download.file(url, destino, mode = "wb", quiet = TRUE) == 0,
                                     error = function(e) FALSE))
  cacert <- Sys.getenv("CURL_CA_BUNDLE")
  ca_args <- if (nzchar(cacert)) c("--cacert", shQuote(cacert)) else character()
  codigo <- tryCatch(system2(curl, c("--fail", "--location", "--silent", "--show-error", ca_args,
    "--retry", "3", "--connect-timeout", "30", "--max-time", as.character(getOption("timeout")),
    "--output", shQuote(destino), shQuote(url)), stdout = FALSE, stderr = ""), error = function(e) 1L)
  if (!identical(as.integer(codigo), 0L)) message("curl falhou (código ", codigo, ") em ", url)
  identical(as.integer(codigo), 0L) && file.exists(destino) && file.info(destino)$size > 0
}

status_http <- function(url) {
  curl <- Sys.which("curl")
  if (!nzchar(curl)) {
    return(tryCatch(attr(curlGetHeaders(url, timeout = 30L), "status"), error = function(e) NA_integer_))
  }
  cacert <- Sys.getenv("CURL_CA_BUNDLE")
  ca_args <- if (nzchar(cacert)) c("--cacert", shQuote(cacert)) else character()
  retorno <- tryCatch(system2(curl, c("--head", "--location", "--silent", "--show-error", ca_args,
    "--max-time", "30", "--output", if (.Platform$OS.type == "windows") "NUL" else "/dev/null",
    "--write-out", "%{http_code}", shQuote(url)), stdout = TRUE, stderr = ""), error = function(e) character())
  if (!length(retorno)) return(NA_integer_)
  status <- suppressWarnings(as.integer(tail(retorno, 1)))
  if (length(status) != 1L || is.na(status) || status == 0L) NA_integer_ else status
}

args <- commandArgs(trailingOnly = TRUE)
pasta <- if (length(args) >= 1) args[1] else tempdir()
tabela <- Sys.getenv("CENSO_TABELA")
if (tabela == "") tabela <- "https://mapa.fanara.com.br/dados/censo-superior-rj.csv"
inep <- "https://download.inep.gov.br/microdados/microdados_censo_da_educacao_superior_%d.zip"
contagens <- c("concluintes_presenciais", "concluintes_presenciais_publica", "concluintes_presenciais_privada",
               "concluintes_a_distancia")
taxas <- paste0(contagens[1:3], "_100mil")

if (grepl("^https?://", tabela)) {
  local <- file.path(tempdir(), "censo-superior-rj.csv")
  download.file(tabela, local, mode = "wb", quiet = TRUE)
  tabela <- local
}
publicado <- read.csv2(tabela, fileEncoding = "UTF-8-BOM", colClasses = "character")
if (!all(c("ano", "codigo_ibge", "populacao", contagens, taxas) %in% names(publicado))) stop("tabela sem as colunas esperadas")
ano <- unique(publicado$ano)
if (length(ano) != 1 || !grepl("^[0-9]{4}$", ano)) stop("a tabela deve ter um só ano, e tem: ", paste(ano, collapse = ", "))
ano <- as.integer(ano)

# O arquivo de cursos: já extraído na pasta, ou tirado do zip (baixado se faltar). Nos dois casos, se o
# arquivo de MD5 do INEP estiver na pasta, o arquivo de cursos tem de bater com ele.
arquivo_cursos <- function() {
  nome <- sprintf("MICRODADOS_CADASTRO_CURSOS_%d.CSV", ano)
  procurar <- function(padrao) list.files(pasta, pattern = padrao, recursive = TRUE, full.names = TRUE, ignore.case = TRUE)
  achados <- procurar(paste0("^", nome, "$"))
  if (length(achados) > 0) {
    message("arquivo de cursos já extraído: ", achados[1])
    conferir_md5(achados[1], procurar(sprintf("^md5.*%d.*\\.txt$", ano)), nome)
    return(achados[1])
  }
  zip <- file.path(pasta, basename(sprintf(inep, ano)))
  if (!file.exists(zip)) {
    # Baixa com outro nome e só renomeia no fim: download interrompido não deixa zip pela metade na pasta.
    parcial <- paste0(zip, ".part")
    for (tentativa in 1:3) {
      ok <- baixar_http(sprintf(inep, ano), parcial)
      if (ok && file.rename(parcial, zip)) break
      unlink(parcial)
      message("download do zip do INEP falhou (tentativa ", tentativa, ")")
    }
    if (!file.exists(zip)) stop("não foi possível baixar ", sprintf(inep, ano))
  }
  conteudo <- unzip(zip, list = TRUE)$Name
  dentro <- conteudo[grepl(paste0("/", nome, "$"), conteudo, ignore.case = TRUE)]
  md5 <- conteudo[grepl("md5[^/]*\\.txt$", conteudo, ignore.case = TRUE)]
  if (length(dentro) != 1) stop(zip, ": arquivo de cursos não encontrado no zip")
  # O MD5 sai primeiro: se a extração do arquivo de cursos for interrompida, a próxima rodada acha o
  # arquivo pela metade e o MD5 acusa.
  if (length(md5) == 1) unzip(zip, files = md5, exdir = pasta, junkpaths = TRUE)
  unzip(zip, files = dentro, exdir = pasta, junkpaths = TRUE)
  extraido <- file.path(pasta, basename(dentro))
  conferir_md5(extraido, file.path(pasta, basename(md5)), nome)
  extraido
}

conferir_md5 <- function(arquivo, md5, nome) {
  if (length(md5) == 0 || !file.exists(md5[1])) {
    message(nome, ": sem o arquivo de MD5 do INEP na pasta; integridade não conferida")
    return(invisible())
  }
  linhas <- readLines(md5[1], warn = FALSE)
  esperado <- tolower(sub("^([0-9a-fA-F]{32}).*", "\\1", linhas[grepl(nome, linhas, ignore.case = TRUE)]))
  obtido <- unname(tools::md5sum(arquivo))
  if (length(esperado) != 1 || esperado != obtido) {
    stop(nome, ": MD5 ", obtido, " diferente do publicado pelo INEP; apague o arquivo da pasta e rode de novo")
  }
  message(nome, ": MD5 igual ao publicado pelo INEP (", obtido, ")")
}

# Lê só as seis colunas usadas, todas como texto (o arquivo tem cerca de 200). Aspas só duplas: nome de
# curso com apóstrofo não pode abrir campo.
ler_cursos <- function(arquivo) {
  usadas <- c("SG_UF", "CO_MUNICIPIO", "CO_CINE_AREA_GERAL", "TP_MODALIDADE_ENSINO", "TP_REDE", "QT_CONC")
  cabecalho <- strsplit(readLines(arquivo, n = 1, encoding = "latin1"), ";", fixed = TRUE)[[1]]
  if (!all(usadas %in% cabecalho)) stop(arquivo, ": faltam colunas ", paste(setdiff(usadas, cabecalho), collapse = ", "))
  classes <- ifelse(cabecalho %in% usadas, "character", "NULL")
  read.table(arquivo, sep = ";", header = TRUE, quote = "\"", comment.char = "", colClasses = classes,
             fileEncoding = "latin1", na.strings = character(0))
}

d <- ler_cursos(arquivo_cursos())
message("arquivo de cursos: ", nrow(d), " linhas, ", sum(d$SG_UF == "RJ"), " no RJ")
d <- d[d$SG_UF == "RJ" & trimws(d$CO_CINE_AREA_GERAL) %in% c("05", "06", "07"), ]
d$QT_CONC <- ifelse(d$QT_CONC == "", 0, as.numeric(d$QT_CONC))
presencial <- d$TP_MODALIDADE_ENSINO == "1"
if (any(presencial & !(d$TP_REDE %in% c("1", "2")))) stop("curso presencial com TP_REDE fora de 1 e 2")
soma <- function(filtro) tapply(d$QT_CONC[filtro], d$CO_MUNICIPIO[filtro], sum)

# Os municípios da tabela e os que têm curso nos microdados: o que faltar de um lado não bate.
codigo_valido <- grepl("^33[0-9]{5}$", d$CO_MUNICIPIO)
if (!all(codigo_valido)) {
  message(sum(!codigo_valido), " linhas do RJ sem código de município válido (", sum(d$QT_CONC[!codigo_valido]),
          " concluintes), fora da soma do estado, como no mapa")
}
municipios <- union(publicado$codigo_ibge[publicado$codigo_ibge != "33"], d$CO_MUNICIPIO[codigo_valido])
por_municipio <- function(s) { v <- s[municipios]; v[is.na(v)] <- 0; as.numeric(v) }
refeito <- data.frame(codigo_ibge = municipios,
                      concluintes_presenciais = por_municipio(soma(presencial)),
                      concluintes_presenciais_publica = por_municipio(soma(presencial & d$TP_REDE == "1")),
                      concluintes_presenciais_privada = por_municipio(soma(presencial & d$TP_REDE == "2")),
                      concluintes_a_distancia = por_municipio(soma(!presencial)))
refeito <- rbind(data.frame(codigo_ibge = "33", t(colSums(refeito[contagens]))), refeito)

conferir <- merge(publicado, refeito, by = "codigo_ibge", all = TRUE, suffixes = c("", ".refeito"))
populacao <- as.numeric(conferir$populacao)
for (i in seq_along(taxas)) conferir[[paste0(taxas[i], ".refeito")]] <- conferir[[paste0(contagens[i], ".refeito")]] / populacao * 1e5

total <- 0
erros <- 0
for (col in c(contagens, taxas)) {
  a <- as.numeric(sub(",", ".", conferir[[col]], fixed = TRUE))
  b <- as.numeric(conferir[[paste0(col, ".refeito")]])
  ok <- if (col %in% taxas) abs(a - b) <= 0.0005 + 1e-9 else a == b
  ok[is.na(ok)] <- FALSE
  diferentes <- which(!ok)
  total <- total + length(ok)
  erros <- erros + length(diferentes)
  cat(sprintf("%-42s %4d de %4d batem\n", col, length(ok) - length(diferentes), length(ok)))
  for (i in head(diferentes, 5)) {
    cat(sprintf("    %s %s: publicado %s, refeito %s\n", conferir$codigo_ibge[i],
                ifelse(is.na(conferir$municipio[i]), "(fora da tabela)", conferir$municipio[i]),
                ifelse(is.na(a[i]), "(ausente)", format(a[i], scientific = FALSE)),
                ifelse(is.na(b[i]), "(ausente)", format(round(b[i], 4), scientific = FALSE))))
  }
}

milhar <- function(x) format(x, big.mark = ".", decimal.mark = ",", scientific = FALSE)
e <- refeito[refeito$codigo_ibge == "33", ]
cat(sprintf("\nNúmeros citados no texto do diagnóstico, refeitos dos microdados de %d:\n", ano))
cat(sprintf("  concluintes presenciais nessas áreas: %s (%s em instituições públicas, %s em privadas)\n",
            milhar(e$concluintes_presenciais), milhar(e$concluintes_presenciais_publica), milhar(e$concluintes_presenciais_privada)))
cat(sprintf("  a distância: %s, ou %s%% dos %s concluintes\n", milhar(e$concluintes_a_distancia),
            round(e$concluintes_a_distancia / (e$concluintes_a_distancia + e$concluintes_presenciais) * 100),
            milhar(e$concluintes_a_distancia + e$concluintes_presenciais)))

# Edição mais nova: o INEP publica um zip por ano. Pergunta pelos três anos seguintes ao da tabela, com prazo
# de 30 segundos: 200 é zip publicado, 404 é zip que não existe, e qualquer outra resposta (ou nenhuma) fica
# sem resposta. Um zip encontrado vale mesmo que outro ano não responda; sem nenhum encontrado, só se afirma
# que a tabela está em dia quando os três anos responderam 404. Senão, "NA", e a conferência segue.
seguintes <- ano + 1:3
existe <- vapply(seguintes, function(y) {
  for (tentativa in 1:2) {  # o servidor do INEP às vezes derruba a conexão; uma segunda tentativa costuma passar
    status <- status_http(sprintf(inep, y))
    if (identical(status, 200L)) return(TRUE)
    if (identical(status, 404L)) return(FALSE)
  }
  NA
}, logical(1))
ultima <- if (any(existe, na.rm = TRUE)) as.character(max(seguintes[which(existe)])) else if (anyNA(existe)) "NA" else as.character(ano)
cat(if (ultima == "NA") "Não foi possível consultar no INEP se há edição mais nova.\n"
    else if (ultima == as.character(ano)) sprintf("A tabela usa a edição mais recente da fonte (%d).\n", ano)
    else sprintf("Edição mais nova na fonte: %s; a tabela usa %d.\n", ultima, ano))
if (erros == 0) {
  cat(sprintf("Conferência: todos os %d valores batem.\n", total))
} else {
  cat(sprintf("Conferência: %d de %d valores não batem.\n", erros, total))
}
cat(sprintf("#conferencia valores=%d divergentes=%d tabela=%d fonte=%s\n", total, erros, ano, ultima))
if (!interactive()) quit(save = "no", status = if (erros == 0) 0 else 1)
