"""
Project Together — powered by AI: CAMPOS PERSONALIZADOS dos riscos (a UX de correção).

O PROBLEMA que isto resolve
  Um risco guarda Probabilidade, Impacto, Prioridade e Dono. Digitar isso no meio da
  descrição é frágil: o editor do ProjectAdm junta linhas, o aluno erra o formato, e a
  Prioridade não se calcula. Corrigir vira um quebra-cabeça.

A SOLUÇÃO (a que o Eduardo pediu)
  Probabilidade e Impacto viram CAMPOS DE SELEÇÃO (dropdowns) no cartão; a Prioridade é um
  NÚMERO que o python calcula (Prob × Impacto); o Dono é um dropdown com a sua EQUIPE. O
  aluno só escolhe no menu do cartão — nada de texto livre. Filtra pela etiqueta 🔎 Revisar,
  abre o cartão, escolhe nos dropdowns. Pronto.

COMO CONVIVEM O CAMPO E O TEXTO
  O TEXTO da descrição continua sendo a fonte que a auditoria e o Registro leem. Os campos
  são a superfície editável. Duas pontes, só de código:
    • semear(): a 1ª vez (Passo 3) popula os dropdowns a partir do texto.
    • aplicar(): o que o aluno mudou nos dropdowns volta para o texto (e recalcula a
      Prioridade). Roda no Passo 5, antes de auditar — então a auditoria vê o que o aluno
      acabou de escolher.
  O campo (dropdown) manda: é onde o aluno edita agora.

ADITIVO e DEFENSIVO: se o servidor não expõe campos personalizados (ou o quadro não os
tem), tudo degrada para o modo texto — nada quebra. Custom fields em produção desde
2026-06-17 (ProjectAdm), API: /quadros/{id}/campos-personalizados e /cartoes/{id}/
campos-valores/{campoId}.
"""
import re

import entradas
import quadro
from pa_client import (list_campos, criar_campo, atualizar_campo, campos_valores,
                       set_campo_valor, limpar_campo_valor, list_cards, update_card,
                       opcoes_lidas, escopo_do_campo)
from idioma import T, Tf, escolher

# Os rótulos EXATOS das opções de seleção — os MESMOS da planilha 'Registro dos pontos de
# atenção' (quadro._ROT_PROB/_ROT_IMP): o número na frente mostra a escala 1..5 dentro do
# próprio menu, e quadro.nivel_1a5 continua reconhecendo o valor na leitura.
PROB_LABELS = [quadro._ROT_PROB[n] for n in range(1, 6)]     # 1-Muito baixa .. 5-Muito alta
IMP_LABELS = [quadro._ROT_IMP[n] for n in range(1, 6)]       # 1-Muito baixo .. 5-Muito alto

# ─────────────────────────────────────────────────────────────────────────────
# O NOME DO CAMPO NO QUADRO — no idioma do pacote (Eduardo, 2026-08-05: "you should
# use only english terms in EN course").
#
# Até aqui o nome era português fixo: o aluno de língua inglesa via "Probabilidade" e
# "Dono" no menu do cartão enquanto o curso, em inglês, mandava escolher "Probability"
# e "Owner". A tradução já existia no texto; faltava no QUADRO.
#
# DUAS CAMADAS, e é a distinção que faz isto ser seguro:
#   · a CHAVE INTERNA continua em português ("Probabilidade"...) — ela é identificador de
#     código, não texto do aluno: é a chave do dicionário que semear()/aplicar()/_grava()
#     usam. Traduzir a chave espalharia o idioma por todo o módulo sem ganho nenhum.
#   · o NOME é o rótulo que vai para a API e aparece no cartão. Esse, sim, é do aluno.
#
# E o campo é RECONHECIDO pelos DOIS nomes. Um quadro montado pelo pacote antigo (ou pelo
# pacote PT, e aberto pelo EN) tem "Probabilidade" gravado: sem os apelidos, o curso não
# acharia o campo e criaria um SEGUNDO ao lado, órfão dos valores que o aluno já escolheu.
# Reconhecer os dois é o que torna a mudança aditiva — nada duplica, nada se perde.
# ─────────────────────────────────────────────────────────────────────────────
NOMES = ("Probabilidade", "Impacto", "Prioridade", "Dono")   # chaves INTERNAS
_NOMES_EN = {"Probabilidade": "Probability", "Impacto": "Impact",
             "Prioridade": "Priority", "Dono": "Owner"}


def nome_do_campo(chave):
    """A chave interna -> o nome que o campo tem NO QUADRO, no idioma do pacote."""
    return escolher(chave, _NOMES_EN.get(chave))


def _apelidos(chave):
    """Todos os nomes que este campo pode ter no quadro (PT e EN), normalizados.
    É o que faz o curso RECONHECER um campo criado por um pacote do outro idioma."""
    return {entradas._sem_acento(n).lower()
            for n in (chave, _NOMES_EN.get(chave, chave)) if n}


def _achar(existentes, chave):
    """O campo do quadro que corresponde a esta chave — por qualquer um dos seus nomes."""
    for apelido in _apelidos(chave):
        if apelido in existentes:
            return existentes[apelido]
    return None


# ─────────────────────────────────────────────────────────────────────────────
# ONDE O CAMPO APARECE (Eduardo, 2026-08-05)
#
# O campo personalizado era do QUADRO INTEIRO — nascia vazio em todo cartão de toda
# lista. Era o custo conhecido, e o pedido R1 do RECOMENDACAO_CAMPOS_PERSONALIZADOS.md.
# O Henry entregou `listas_ids`, e agora cada campo mora só onde faz sentido:
#   · Probabilidade · Impacto · Prioridade · Dono  →  a lista de RISCOS
#   · Código EAP                                   →  a lista do ESCOPO
# Fora dali o cartão volta a ser o que era antes dos campos existirem.
#
# CONSERTA QUEM JÁ EXISTE: um quadro montado antes disto tem os campos no quadro inteiro
# (`listas_ids: []`). Quando o curso passa por eles, o escopo é aplicado — em campo que
# JÁ EXISTE, o que muda é só onde ele aparece; os valores que o aluno escolheu ficam
# onde estão (é o mesmo campo, o mesmo id).
#
# DEGRADA, NÃO QUEBRA: servidor que ainda não conheça `listas_ids` ignora a chave e o
# campo continua do quadro inteiro — exatamente o comportamento anterior.
# ─────────────────────────────────────────────────────────────────────────────
def _escopo_dos_riscos(ids):
    """A lista onde os campos de risco devem aparecer ([] quando ainda não se sabe)."""
    lid = (ids or {}).get("RISCOS")
    return [lid] if lid else []


def _ajustar_escopo(ex, escopo, nome, log):
    """Põe o campo que já existe na(s) lista(s) certa(s). Nunca derruba quem chama."""
    if not escopo or not ex or not ex.get("id"):
        return
    if escopo_do_campo(ex) == [int(x) for x in escopo]:
        return                                    # já está no lugar — não gasta escrita
    try:
        atualizar_campo(ex["id"], listas_ids=escopo)
        log(Tf("  campo '{0}' agora aparece só na lista onde é usado.", nome))
    except Exception as e:
        log(Tf("  [aviso] não consegui limitar o campo '{0}' a uma lista ({1}); ele segue "
               "no quadro inteiro.", nome, e))


def _rotulo_dono(titulo):
    """O rótulo do membro da equipe DENTRO do menu. Curto, e sempre um TRECHO do título do
    cartão da equipe.

    A razão é de MENU: uma linha inteira de equipe ('Equipe de suporte do ProjectAdm que é
    o software de gestão adotado pelo curso (Henry & Claude - Henry)') não cabe num
    dropdown. Manter o rótulo como um trecho do título é o que faz a conferência do
    responsável continuar reconhecendo a pessoa (auditoria._e_do_time casa por substring).

    Havia uma segunda razão que CAIU em 30/07: o validador da API separava as opções por
    vírgula, então uma opção com vírgula dentro nunca podia ser gravada (422). Hoje ela
    grava (medido). O corte na vírgula fica só pelo tamanho — e mudar os rótulos agora
    trocaria as opções de um menu onde já há escolhas feitas, então não se mexe."""
    m = re.search(r"\(([^)]*)\)\s*$", titulo)                 # '... (Henry)' -> 'Henry'
    base = m.group(1) if (m and m.group(1).strip()) else titulo
    return base.split(",")[0].strip()[:60]


def _equipe_nomes(ids):
    """Os rótulos do menu Dono, na ordem da equipe e sem repetir. Quando dois membros
    encurtam para o mesmo rótulo, o segundo cai para o começo do próprio título."""
    out, vistos = [], set()
    for l in (quadro.texto_da_equipe(ids) or "").splitlines():
        b = re.sub(r"\s*\[[^\]]*\]\s*$", "", l.strip().lstrip("•-* ").strip())
        if not b:
            continue
        for cand in (_rotulo_dono(b), b.split(",")[0].strip()[:60], b.replace(",", " ")[:60]):
            if cand and cand.lower() not in vistos:
                out.append(cand)
                vistos.add(cand.lower())
                break
    return out


def opcoes_dos_campos(ids):
    """{nome do campo -> lista de opções} dos campos de SELEÇÃO. A Prioridade não entra:
    é número calculado. O Dono sai da EQUIPE do projeto (lista fechada)."""
    opc = {"Probabilidade": PROB_LABELS, "Impacto": IMP_LABELS}
    equipe = _equipe_nomes(ids)
    if equipe:
        opc["Dono"] = equipe
    return opc


# A "janela de valores" MORREU em 2026-07-30 (item 0d resolvido pelo Henry).
# Ela existia por um desencontro do ProjectAdm: a TELA só desenhava o menu com as opções em
# array JSON, e o VALIDADOR da API só aceitava um valor com as opções em vírgula — então,
# durante os segundos em que o python gravava, o curso trocava o formato do campo e depois
# devolvia. Agora o servidor guarda sempre JSON e valida contra ele (medido: valor válido
# 200, inválido 422, e opção COM vírgula dentro também grava). Gravar valor não mexe mais
# no campo — um efeito colateral a menos no quadro do aluno, e a escolha dele nunca fica
# num formato intermediário se um cartão falhar no meio.


def _opcao_valida(valor, labels):
    """O valor é uma das opções do menu? (sem acento/caixa). Devolve o rótulo EXATO ou "".
    Guarda contra o lixo que a tela gravava quando o menu vinha grudado: o cartão ficava
    com a lista inteira de opções como se fosse o valor escolhido.

    Se não bater exato, aceita quando o texto identifica UM único rótulo (o Dono escrito
    no cartão costuma ser o nome da pessoa, e a opção é a linha inteira da equipe —
    'Equipe de produção (Claude - Edu)'). Dois candidatos = ambíguo = nada."""
    v = entradas._sem_acento((valor or "").strip())
    if not v:
        return ""
    for l in labels:
        if entradas._sem_acento(l) == v:
            return l
    perto = [l for l in labels
             if v in entradas._sem_acento(l) or entradas._sem_acento(l) in v]
    return perto[0] if len(perto) == 1 else ""


def label_prob(v):
    """'muito baixa'/'2'/'alta' -> o rótulo exato da opção ('Muito baixa'...)."""
    n = quadro.nivel_1a5(v)
    return PROB_LABELS[n - 1] if n else (v or "")


def label_imp(v):
    n = quadro.nivel_1a5(v)
    return IMP_LABELS[n - 1] if n else (v or "")


def garantir_campos_risco(ids, log=lambda _m: None, criar=None):
    """Cria (idempotente) os campos Probabilidade/Impacto/Prioridade/Dono no quadro e
    devolve {nome: campo_id}. Falha SEGURA: se a API não expuser campos, devolve {} e o
    curso segue no modo texto (nunca derruba o passo).

    QUANDO ELES NASCEM: no PASSO 3, junto com os pontos de atenção — nunca antes
    (Eduardo, 2026-08-05: "os campos deveriam ser criados só junto com o Passo 3").
    Campo personalizado ainda é do QUADRO: ele aparece, VAZIO, em todo cartão de toda
    lista. Criado cedo demais, o aluno abre o cartão da justificativa no Passo 1 e vê
    Probabilidade/Impacto/Dono num quadro que ainda não tem um risco sequer — e paga um
    preço que não se desfaz: o menu Dono sai da EQUIPE, que só existe depois do Passo 1,
    então ele nasce SEM opções e vira campo de TEXTO. Campo criado não muda mais de tipo:
    aquele aluno nunca mais tem o dropdown de Dono.

    Por isso CRIAR exige que já exista ao menos UM ponto de atenção na lista Riscos — que
    é exatamente o que o Passo 3 escreve antes de chamar `sincronizar()`. Quem passa por
    aqui antes (o painel da tecla P, que roda desde o Passo 1) segue funcionando: ele só
    não CRIA — lê e conserta o que já existe. `criar=True/False` força a decisão (testes).

    Também CONSERTA um campo que já existe: se as opções gravadas não são as esperadas
    — porque a equipe mudou, ou porque o campo foi criado antes de 2026-07-23, quando o
    curso gravava as opções num formato que a tela não sabia separar (o menu aparecia
    como uma linha só, com tudo grudado) — reescreve as opções no formato de menu.

    O NOME do campo no quadro sai no idioma do pacote (`nome_do_campo`), mas o campo é
    reconhecido pelos DOIS nomes: quadro montado em português e aberto pelo pacote inglês
    reaproveita o campo que já existe, em vez de criar um segundo ao lado."""
    try:
        existentes = {entradas._sem_acento((c.get("nome") or "").strip()).lower(): c
                      for c in list_campos()}
    except Exception as e:
        log(Tf("  [aviso] campos personalizados indisponíveis ({0}); sigo no modo texto.", e))
        return {}
    opcoes = opcoes_dos_campos(ids)
    especs = [
        ("Probabilidade", "selecao", opcoes.get("Probabilidade")),
        ("Impacto", "selecao", opcoes.get("Impacto")),
        ("Prioridade", "numero", None),
        ("Dono", "selecao" if opcoes.get("Dono") else "texto", opcoes.get("Dono")),
    ]
    # A hora de criar. `_pontos` devolve [] nos dois casos de "ainda não é hora": a lista
    # Riscos nem existe (antes de o Passo 1 escolher a abordagem) ou ela só tem o
    # cartão-guia. Só custa uma leitura quando falta algum campo — quadro já configurado
    # não paga nada.
    if criar is None:
        faltam = [c for c, _t, _o in especs if not _achar(existentes, c)]
        criar = bool(faltam) and bool(_pontos(ids))
    escopo = _escopo_dos_riscos(ids)
    out = {}
    for chave, tipo, opc in especs:
        nome = nome_do_campo(chave)
        ex = _achar(existentes, chave)
        if ex:
            out[chave] = ex.get("id")
            if opc and ex.get("id") and opcoes_lidas(ex.get("opcoes_selecao")) != list(opc):
                try:
                    atualizar_campo(ex["id"], opcoes_selecao=list(opc))
                    log(Tf("  menu do campo '{0}' atualizado ({1} opções).", nome, len(opc)))
                except Exception as e:
                    log(Tf("  [aviso] não atualizei o menu de '{0}' ({1}).", nome, e))
            _ajustar_escopo(ex, escopo, nome, log)
            continue
        if not criar:
            continue
        try:
            cid = criar_campo(nome, tipo, opcoes_selecao=(list(opc) if opc else None),
                              listas_ids=escopo)
            if cid:
                out[chave] = cid
                log(Tf("  campo '{0}' criado na lista de riscos.", nome))
        except Exception as e:
            log(Tf("  [aviso] não criei o campo '{0}' ({1}).", nome, e))
    return out


def _pontos(ids):
    """Os pontos de atenção da lista Riscos: (cartão, texto limpo, tipo).

    Vêm os QUATRO tipos, não só Risco/Problema. Os campos personalizados são do QUADRO —
    aparecem em todo cartão —, então o aluno também escolhe o Dono numa premissa. Filtrar
    aqui por Risco/Problema (como era até 23/07) fazia essa escolha ser silenciosamente
    ignorada: o Eduardo respondeu 'Dono' no menu da premissa 14062 e o texto continuou
    dizendo '(a confirmar)'. Quem usa decide o que faz com cada tipo."""
    lid = ids.get("RISCOS")
    if not lid:
        return []
    out = []
    for c in list_cards(lid):
        titulo = (c.get("titulo") or "").strip()
        if not titulo or quadro.e_guia(titulo):
            continue
        txt = entradas.texto_do_cartao(c.get("descricao")) or ""
        out.append((c, txt, quadro._tipo_do_ponto(titulo, txt)))
    return out


# PRIORIDADE: quem preencheu, leva. Decisão do Eduardo em 23/07 — o menu aparece em TODO
# cartão da lista (os campos são do quadro), então qualquer ponto de atenção com
# probabilidade e impacto escolhidos ganha a prioridade calculada, inclusive premissa e
# restrição. A máquina não engole o que a pessoa respondeu.
# (O que continua valendo só para Risco/Problema é a COBRANÇA: a auditoria exige avaliação
#  deles, e não de premissa/restrição — preencher ali é opção, não dever.)


def _grava(card_id, campos, nome, valor, log):
    cid = campos.get(nome)
    if cid and valor not in (None, "", "?"):
        try:
            set_campo_valor(card_id, cid, valor)
        except Exception as e:
            log(Tf("  [aviso] campo '{0}' não gravou ({1}).", nome, e))


def semear(ids, campos, log=lambda _m: None):
    """text -> field: popula os dropdowns a partir do texto dos riscos, mas SÓ os campos
    que ainda estão VAZIOS (não sobrescreve o que o aluno já escolheu — por isso é seguro
    rodar sempre). Também semeia a Prioridade calculada. Devolve quantos cartões visitou.

    Um valor que NÃO é uma das opções do menu conta como vazio e é APAGADO do cartão: é
    lixo do menu grudado (a tela chegou a gravar a lista inteira de opções como valor)."""
    if not campos:
        return 0
    opcoes = opcoes_dos_campos(ids)
    n = 0
    for c, txt, tipo in _pontos(ids):
        try:
            atuais = campos_valores(c["id"])
        except Exception:
            atuais = {}

        def _vazio(nome):
            """O campo está (ou ficou) vazio? De quebra, NORMALIZA: um valor que
            significa uma opção mas está escrito de outro jeito ('Muito baixa' quando
            a opção é '1-Muito baixa') vira o rótulo exato — senão o menu abre sem
            nada selecionado, como se a escolha tivesse se perdido."""
            cid = campos.get(nome)
            if not cid:
                return False                   # campo não existe: nada a semear
            v = atuais.get(str(cid))
            if v in (None, ""):
                return True
            if nome not in opcoes:
                return False
            exato = _opcao_valida(v, opcoes[nome])
            if not exato:
                limpar_campo_valor(c["id"], cid)   # valor que não é opção: lixo
                return True
            if exato != str(v).strip():
                _grava(c["id"], campos, nome, exato, log)
            return False

        prob = quadro._val_desc(txt, r"probabilidade|urg[eê]ncia")
        imp = quadro._val_desc(txt, r"impacto")
        dono = quadro._val_desc(txt, r"dono|respons[aá]vel")
        if _vazio("Probabilidade"):
            _grava(c["id"], campos, "Probabilidade",
                   _opcao_valida(label_prob(prob), PROB_LABELS), log)
        if _vazio("Impacto"):
            _grava(c["id"], campos, "Impacto",
                   _opcao_valida(label_imp(imp), IMP_LABELS), log)
        if _vazio("Prioridade"):
            num, _n = quadro.prioridade_do_risco(prob, imp)
            if num:
                _grava(c["id"], campos, "Prioridade", num, log)
        if _vazio("Dono") and dono and entradas._sem_acento(dono) not in ("a confirmar", "definir", "a definir"):
            _grava(c["id"], campos, "Dono", _opcao_valida(dono, opcoes.get("Dono", [])), log)
        n += 1
    return n


def aplicar(ids, campos, log=lambda _m: None):
    """field -> text: traz o que o aluno editou nos DROPDOWNS de volta para o texto (a
    fonte que a auditoria e o Registro leem) e recalcula a Prioridade (número + campo +
    cor do cartão). Devolve quantos cartões atualizou. Só toca cartão cujos campos têm
    valor (o aluno usou o dropdown) — e só acredita em valor que É uma das opções do
    menu, para nunca escrever lixo dentro do plano.

    Vale para os QUATRO tipos: quem preencheu probabilidade e impacto — mesmo numa
    premissa — leva a prioridade calculada (decisão do Eduardo, 23/07)."""
    if not campos:
        return 0
    opcoes = opcoes_dos_campos(ids)
    n = 0
    for c, txt, tipo in _pontos(ids):
        try:
            vals = campos_valores(c["id"])
        except Exception:
            continue
        fv = {}
        for nome, cid in campos.items():
            v = vals.get(str(cid)) if cid else None
            if v in (None, ""):
                continue
            fv[nome] = _opcao_valida(v, opcoes[nome]) if nome in opcoes else v
        fv = {k: v for k, v in fv.items() if v}
        if not fv:
            continue                       # aluno não mexeu nos campos deste cartão
        prob, imp, dono = fv.get("Probabilidade"), fv.get("Impacto"), fv.get("Dono")
        num, nivel = quadro.prioridade_do_risco(prob or "", imp or "")
        novo = quadro.espelhar_risco_no_texto(txt, prob=prob, imp=imp, num=num,
                                              nivel=nivel, dono=dono)
        if num and campos.get("Prioridade"):
            _grava(c["id"], campos, "Prioridade", num, log)   # devolve a prioridade calculada ao campo
        mudou = {}
        if novo.strip() != txt.strip():
            mudou["descricao"] = novo
        # Só grava a cor quando ela MUDA. Sem esta comparação, todo cartão avaliado
        # entrava na conta em toda execução: o painel dizia "10 cartões atualizados"
        # com o quadro parado, e gastava 10 escritas para reescrever o mesmo valor
        # (visto no 324 em 07/08/2026, ao rodar o painel duas vezes seguidas).
        if nivel and (c.get("prioridade") or "").strip().lower() != nivel:
            mudou["prioridade"] = nivel     # cor do cartão (urgente/alta/media/baixa)
        if mudou:
            try:
                update_card(c["id"], **mudou)
                n += 1
            except Exception as e:
                log(Tf('  [aviso] não sincronizei "{0}" ({1}).', (c.get('titulo') or '')[:34], e))
    return n


def ha_pontos_de_atencao(ids):
    """Já existe ao menos UM ponto de atenção (risco, problema, premissa ou restrição) na
    lista Riscos? É a pergunta "já é hora de os campos nascerem?" — quem responde sim é o
    Passo 3, e só ele. Serve para quem precisa EXPLICAR a espera ao aluno; o
    `garantir_campos_risco` pergunta isto por conta própria."""
    return bool(_pontos(ids))


def sincronizar(ids, log=lambda _m: None):
    """O atalho: garante os campos e faz as duas pontes (semear + aplicar). Devolve
    (campos, semeados, aplicados). Roda no Passo 3, no Passo 5 e no configurar_campos.py."""
    campos = garantir_campos_risco(ids, log=log)
    if not campos:
        return {}, 0, 0
    return campos, semear(ids, campos, log=log), aplicar(ids, campos, log=log)


# ─────────────────────────────────────────────────────────────────────────────
# O CÓDIGO DA EAP — um CAMPO só dele (Eduardo, 2026-07-29)
#
# "Você vai criar um novo campo para armazenar o código da EAP ou já existe no
#  ProjectAdm? Não deixe essa informação dentro de outro campo, por favor."
#
# Não existe campo nativo: o cartão do ProjectAdm tem id, cartao_pai_id, título,
# descrição, ordem e datas — o WBS da tela é DERIVADO de cartao_pai_id + ordem, e o
# código (1, 1.2, 1.2.3) não é guardado em lugar nenhum. Até aqui o curso o calculava
# na hora (quadro.arvore_da_eap) e ele só aparecia nos DOCUMENTOS; no quadro, o aluno
# não via o código de coisa nenhuma.
#
# Agora ele tem CAMPO PRÓPRIO — "Código EAP", tipo texto. Não vai no título (poluiria o
# kanban e o título já tem teto de 255), não vai na descrição (lá é texto que a pessoa
# edita; código é da máquina). Quem escreve é sempre o curso, a partir da árvore: quando
# a EAP nasce (Passo 2), quando cresce (Passo 4), quando muda (Passo 9) e quando o aluno
# reordena o quadro à mão (tecla R).
#
# CUSTO CONHECIDO: campo personalizado ainda é do QUADRO — ele aparece (vazio) em todo
# cartão de toda lista. É o mesmo incômodo dos campos de risco; o pedido de escopo por
# lista (`listas_ids[]`) está em RECOMENDACAO_CAMPOS_PERSONALIZADOS.md.
# ─────────────────────────────────────────────────────────────────────────────
# O nome sai no idioma do pacote (o texto do curso, em inglês, já chamava este campo de
# "WBS code" enquanto o quadro mostrava "Código EAP"), e o campo é reconhecido pelos DOIS
# nomes — ver o bloco sobre nomes no topo do módulo.
_CODIGO_PT = "Código EAP"
_CODIGO_EN = "WBS code"
_CODIGO_APELIDOS = {entradas._sem_acento(n).lower() for n in (_CODIGO_PT, _CODIGO_EN)}


def nome_do_campo_codigo():
    """O nome do campo do código da EAP no quadro, no idioma do pacote.

    Função e não constante, como `nome_do_campo()`: resolver no IMPORT amarraria o nome ao
    idioma que estava valendo quando o módulo entrou — e é exatamente isso que um teste
    (ou um `--idioma`) precisa poder trocar."""
    return escolher(_CODIGO_PT, _CODIGO_EN)


def garantir_campo_codigo(ids=None, log=lambda _m: None):
    """Cria (idempotente) o campo do código da EAP no quadro e devolve o id (ou None).

    Com `ids`, o campo mora só na lista do ESCOPO — é lá que o código da EAP significa
    alguma coisa. Sem `ids` (chamada antiga), fica como estava: do quadro inteiro.

    Falha SEGURA, como todo o resto deste módulo: sem campos personalizados no
    servidor, o curso segue — o código continua nos documentos e na árvore."""
    lid = (ids or {}).get("ESCOPO")
    escopo = [lid] if lid else []
    nome = nome_do_campo_codigo()
    try:
        for c in list_campos():
            if entradas._sem_acento((c.get("nome") or "").strip()).lower() in _CODIGO_APELIDOS:
                _ajustar_escopo(c, escopo, nome, log)
                return c.get("id")
    except Exception as e:
        log(Tf("  [aviso] campos personalizados indisponíveis ({0}); o código da EAP fica só "
               "nos documentos.", e))
        return None
    try:
        cid = criar_campo(nome, "texto", listas_ids=escopo)
        if cid:
            log(Tf("  campo '{0}' criado na lista do escopo.", nome))
        return cid
    except Exception as e:
        log(Tf("  [aviso] não criei o campo '{0}' ({1}).", nome, e))
        return None


def gravar_codigos(nos, campo_id=None, log=lambda _m: None, ids=None):
    """Escreve o código de cada nó da EAP no campo "Código EAP" do seu cartão.

    `nos` = a árvore de quadro.arvore_da_eap(). Só grava o que MUDOU (o campo já lido é
    comparado), então rodar sempre é barato e idempotente — e depois de uma renumeração
    (o Passo 9 insere uma entrega no meio) todos os códigos deslocados se corrigem
    sozinhos. Devolve quantos cartões foram atualizados."""
    campo_id = campo_id or garantir_campo_codigo(ids=ids, log=log)
    if not campo_id or not nos:
        return 0
    from concurrent.futures import ThreadPoolExecutor

    def _um(n):
        cid, codigo = n.get("id"), (n.get("codigo") or "").strip()
        if not cid or not codigo:
            return 0
        try:
            if (campos_valores(cid) or {}).get(str(campo_id), "") == codigo:
                return 0                      # já está certo — não gasta escrita
            set_campo_valor(cid, campo_id, codigo)
            return 1
        except Exception as e:
            log(Tf("  [aviso] código '{0}' não gravou ({1}).", codigo, e))
            return 0
    with ThreadPoolExecutor(max_workers=8) as ex:
        return sum(ex.map(_um, nos))


def sincronizar_codigos(ids, nos=None, log=lambda _m: None):
    """Lê a EAP do quadro e põe cada código no campo "Código EAP". Devolve (nós, gravados).

    É o ponto único por onde o código chega ao quadro — chamado pelos Passos 2, 4 e 9 e
    pela tecla R. Nunca derruba quem chama."""
    try:
        if nos is None:
            nos, _tem_fases = quadro.arvore_da_eap(ids)
        return nos, gravar_codigos(nos, log=log, ids=ids)
    except Exception as e:
        log(Tf("  [aviso] não consegui gravar os códigos da EAP agora ({0}).", e))
        return (nos or []), 0
