# -*- coding: utf-8 -*-
"""
Project Together — a GUARDA DA EQUIPE: a IA não inventa gente.

O PROBLEMA, num caso real
    Quadro 324 (FIRE, Eduardo, 05/08/2026). O Passo 4 pediu à IA a duração, as datas e o
    responsável de cada pacote de trabalho. A equipe do projeto tinha DUAS pessoas. A IA
    devolveu treze pacotes com `responsável: Ana` — e uma linha de orçamento para
    "Operations Coordinator (Ana)". Ana não existe. O script gravou tudo, e o erro só
    apareceu no Passo 5, no relatório de conferência, um dia depois.

    Isso não é um defeito da IA: é o que ela faz quando lhe falta o dado. Um plano
    precisa de um dono por pacote; se o roster não chegou até ela (ou se ela não o levou
    a sério), ela inventa um nome plausível para não devolver um campo vazio.

O QUE ESTE MÓDULO FAZ
    Confere o responsável ANTES de escrever no quadro, contra a lista FECHADA de quem
    pode ser dono de trabalho — os filhos do cartão "EQUIPE — quem faz" / "TEAM — who
    does the work" da lista Partes. Quem não está lá é um ESTRANHO, e o aluno resolve na
    hora, com uma pergunta por nome:

        [!] The AI named 1 person who is NOT on your team:
              • Ana — owner of 13 work package(s)

            Ana — who really owns this work?
              1. Investor & Portfolio Manager (Eduardo)
              2. Investment Advisor (Nephew)
              3. leave it open ("[to define]") — decide later
              4. Ana IS on my team — add her to the Stakeholders list
            Your choice [3]:

    Escolhida a opção, os itens são corrigidos NA MEMÓRIA e o passo segue: o quadro nasce
    certo, e não "certo depois que alguém percebeu".

POR QUE UMA PERGUNTA POR NOME, E NÃO UMA POR PACOTE
    Uma IA que inventa um nome inventa o MESMO nome muitas vezes (Ana apareceu 13 vezes).
    Perguntar por pacote seriam treze perguntas iguais — e o aluno desiste na terceira.

A OPÇÃO 4 EXISTE PORQUE ÀS VEZES A IA ESTÁ CERTA
    Quem esqueceu de pôr alguém no Passo 1 foi o aluno, e a IA leu o nome no texto da
    justificativa. Nesse caso o conserto é o contrário: a pessoa entra na equipe. É a
    mesma escolha, feita pelo dono do projeto — a máquina AJUDA, a responsabilidade é
    dele.

QUEM DECIDE O QUE É "SER DA EQUIPE"
    Uma definição só, aqui, usada tanto por este guarda (na entrada) quanto pela
    auditoria do Passo 5 (na conferência). Duas definições que discordam seriam pior que
    nenhuma: o passo aprovaria o que o relatório depois cobra.
"""
import re

import entradas
import quadro
from idioma import T, Tf
from runner import warn

# "Ainda não decidi quem" NÃO é um estranho: é uma pendência legítima, que a auditoria
# do Passo 5 cobra por outro caminho ("pacote sem responsável"). Nos dois idiomas — o
# pacote EN escreve "[to define]".
# "(a confirmar)" entrou em 18/08/2026: é o que o próprio Passo 3 escreve na linha
# "Dono:" de uma PREMISSA que ainda não tem quem a confirme. Sem ele nesta lista, o
# guarda tratava o nosso próprio texto como uma pessoa inventada pela IA e abria o menu
# "quem é o dono de verdade?" para um nome que ninguém escreveu — em toda releitura.
PENDENTE = {"", "?", "-", "definir", "a definir", "por definir", "a confirmar",
            "to define", "to be defined", "tbd", "to assign", "to be assigned",
            "to confirm", "to be confirmed"}

PLACEHOLDER = "[definir]"      # o que gravamos quando o aluno escolhe "decido depois"


def placeholder():
    """O texto de "ainda não decidi", no idioma do aluno."""
    return T("[definir]")


# ── QUEM É QUEM: casar um nome escrito à mão com uma pessoa cadastrada ───────
# Vive AQUI, e não em quem chama, porque este módulo é o dono da pergunta "quem é esta
# pessoa?". Três lugares dependem da mesma resposta — o vínculo com o usuário do
# ProjectAdm (Passo 1), a atribuição da tarefa (Passos 4 e 7) e o endereço do e-mail
# (Passo 7) —, e três respostas diferentes seriam três bugs diferentes.
def _limpo(nome):
    """O nome comparável: sem acento, sem caixa, sem o "[…]" que o quadro pendura."""
    return entradas._sem_acento(
        re.sub(r"\s*\[[^\]]*\]\s*$", "", (nome or "")).strip()).strip()


def _dentro_de(curto, longo):
    """`curto` aparece dentro de `longo` como PALAVRA INTEIRA? (os dois já normalizados)

    Palavra inteira, e não substring solta: é o que separa "Ana" de "Susana" — a mesma
    razão pela qual "ana" casava dentro de "m-ANA-ger".
    """
    if not curto or not longo:
        return False
    return re.search(rf"(?<![0-9a-z]){re.escape(curto)}(?![0-9a-z])", longo) is not None


def casar_pessoa(nome, candidatos):
    """`nome` (como alguém escreveu) -> o candidato correspondente, ou None.

    ⚠️ AQUI O CASAMENTO É ESTRITO, E ISSO É DE PROPÓSITO. O `e_da_equipe()` deste mesmo
    módulo é tolerante de propósito — errar lá custa um aviso. Aqui decide PARA QUEM VAI
    UM E-MAIL e A QUEM SE ATRIBUI UMA TAREFA; errar custa mandar a lista de uma pessoa
    para outra. Provado no quadro 324 (07/08/2026): o dono "Eduardo" casava por
    semelhança com "Portfolio Owner (Eduardo)" E com "Investor & Portfolio Manager
    (Eduardo)", e o segundo recebeu um rascunho endereçado ao primeiro.

    A regra: nome igual ganha; senão, um tem de estar DENTRO do outro como palavra
    inteira, e casar com UM ÚNICO candidato. Dois plausíveis = ambíguo = None, e quem
    chamou avisa em vez de escolher por conta própria.

    OS DOIS SENTIDOS SÃO NECESSÁRIOS, e é por isso que a unicidade é que segura a regra:
      • dono "Ana" no cronograma  ->  cartão "Ana Ribeiro (dev)"        (o nome é o curto)
      • cartão "Portfolio Owner (Eduardo)"  ->  usuário "Eduardo"       (o nome é o longo)
    O que NÃO passa é o caso que motivou tudo isto: "Investor & Portfolio Manager
    (Eduardo)" e "Portfolio Owner (Eduardo)" não estão um dentro do outro — são duas
    pessoas —, e o bare "Eduardo" cai nas duas, o que o torna ambíguo. A comparação é
    literal (palavra inteira), NUNCA o `apelidos()`, que reduz os dois a "Eduardo".

    ⚠️ `candidatos` TEM DE SER A LISTA INTEIRA — nunca um subconjunto "só os que
    interessam". É a lista inteira que revela a ambiguidade.
    """
    if not (nome or "").strip() or e_pendente(entradas._sem_acento(nome)):
        return None
    alvo = _limpo(nome)
    for cand in candidatos:
        if _limpo(cand) == alvo:
            return cand
    parecidos = [cand for cand in candidatos
                 if _limpo(cand) and (_dentro_de(_limpo(cand), alvo)
                                      or _dentro_de(alvo, _limpo(cand)))]
    return parecidos[0] if len(parecidos) == 1 else None


def e_pendente(nome):
    return entradas._sem_acento(nome or "").strip("[]()<>«»\"' ").strip() in PENDENTE


def _palavra_inteira(agulha, palheiro):
    """`agulha` aparece em `palheiro` como PALAVRA INTEIRA.

    Nunca com `in` cru: no quadro 324 a equipe era "Investor & Portfolio Manager
    (Eduardo)" e a IA escreveu "Ana" — e `"ana" in "...manager..."` é True. O guarda
    aprovava a pessoa inventada, em silêncio."""
    return bool(re.search(rf"(?<![0-9a-z]){re.escape(agulha)}(?![0-9a-z])", palheiro))


def apelidos(nome):
    """Por quais textos este membro pode ser citado num campo "responsável:".

    O Passo 1 escreve a equipe como PAPEL (NOME) — "Investor & Portfolio Manager
    (Eduardo)". Quem é a PESSOA é o que está entre parênteses, e só esse pedaço vira
    palavra solta: assim "Eduardo" casa, mas "manager" e "advisor" (pedaços do PAPEL)
    não — era dali que vinham os falsos "está no time"."""
    m = re.search(r"\(([^)]+)\)", nome or "")
    base = (m.group(1) if m else (nome or "")).strip()
    return {c for c in ({nome, base} | set(re.findall(r"[0-9a-z]{3,}", entradas._sem_acento(base))))
            if c and len(c) >= 3}


def roster(ids):
    """Os nomes dos membros da EQUIPE — a lista FECHADA de quem pode ser dono de
    trabalho. Vazia = o aluno ainda não montou a equipe (e aí não há o que conferir:
    sem roster, acusar todo mundo de estranho seria ruído)."""
    nomes = []
    for l in (quadro.texto_da_equipe(ids) or "").splitlines():
        base = re.sub(r"\s*\[[^\]]*\]\s*$", "", l.strip().lstrip("•-* ").strip())
        if base:
            nomes.append(base)
    return nomes


def e_da_equipe(responsavel, nomes):
    """O texto "responsável" cita alguém da lista fechada? (match tolerante)"""
    r = entradas._sem_acento(responsavel or "")
    if e_pendente(r):
        return True
    for nome in nomes:
        n = entradas._sem_acento(nome)
        if len(n) < 3:
            continue
        if _palavra_inteira(n, r) or any(_palavra_inteira(a, r) for a in
                                         {entradas._sem_acento(x) for x in apelidos(nome)}):
            return True
    return False


def estranhos(itens, nomes, campo="responsavel"):
    """{nome inventado -> [itens que o citam]}, na ordem em que apareceram.

    Sem roster devolve {} — ver `roster()`."""
    fora = {}
    if not nomes:
        return fora
    for it in itens or ():
        v = (it.get(campo) or "").strip() if isinstance(it, dict) else ""
        if not v or e_pendente(v) or e_da_equipe(v, nomes):
            continue
        fora.setdefault(v, []).append(it)
    return fora


def _adicionar_a_equipe(ids, nome):
    """Cria a pessoa como parte interessada DENTRO do grupo "EQUIPE — quem faz".

    É o caminho da opção 4: a IA leu um nome real que o aluno esqueceu de cadastrar no
    Passo 1. Devolve True quando o cartão nasceu."""
    from pa_client import api, list_cards, get_card
    lid = ids.get("PARTES")
    if not lid:
        return False
    grupo = next((c for c in list_cards(lid) if quadro.e_grupo_equipe(c.get("titulo"))), None)
    if not grupo:
        return False
    desc = T("Papel: (a definir — escreva aqui o que esta pessoa faz no projeto)\n"
             "O que espera do projeto: (a definir)\n"
             "Poder: (a definir) · Interesse: (a definir)\n"
             "e-mail: (escreva aqui o e-mail desta pessoa)\n\n"
             "Esta parte entrou pelo guarda da equipe: a sua IA citou este nome como "
             "responsável por trabalho e você confirmou que ele é do time.")
    try:
        api("POST", "/cartoes", {"lista_id": lid, "titulo": nome[:250],
                                 "descricao": desc, "cartao_pai_id": grupo["id"]})
        return True
    except Exception as e:
        warn(Tf('não consegui criar a parte "{0}" ({1}) — crie no quadro à mão.', nome, e))
        return False


def conferir(itens, ids, campo="responsavel", perguntar=input, nomes=None, o_que=None):
    """AVISA e CONSERTA os responsáveis inventados, antes de o passo escrever no quadro.

    `itens` é a lista de dicionários que o passo acabou de ler da resposta da IA — ela é
    corrigida NO LUGAR. Devolve (quantos itens mudaram, {estranho: escolha}).

    `perguntar` é injetável para o teste rodar sem gente na frente; `o_que` é o nome do
    que está sendo atribuído ("work package(s)", "risk(s)", "action(s)") só para a
    mensagem ficar honesta em cada passo.
    """
    nomes = roster(ids) if nomes is None else nomes
    fora = estranhos(itens, nomes, campo)
    if not fora:
        return 0, {}

    o_que = o_que or T("item(ns)")
    warn(Tf("a sua IA citou {0} pessoa(s) que NÃO está(ão) na sua equipe:", len(fora)))
    for nome, seus in fora.items():
        print(Tf("        • {0} — responsável por {1} {2}", nome, len(seus), o_que))
    print(T("      Só quem está no cartão \"EQUIPE — quem faz\" pode ser dono de trabalho."))

    trocados, escolhas = 0, {}
    for nome, seus in fora.items():
        print()
        print(Tf("      {0} — quem é o dono de verdade deste trabalho?", nome))
        for i, membro in enumerate(nomes, 1):
            print(f"        {i}. {membro}")
        opcao_aberto = len(nomes) + 1
        opcao_incluir = len(nomes) + 2
        print(Tf("        {0}. deixar em aberto (\"{1}\") — decido depois",
                 opcao_aberto, placeholder()))
        print(Tf("        {0}. {1} É da minha equipe — inclua na lista Partes",
                 opcao_incluir, nome))
        try:
            bruto = perguntar(Tf("      A sua escolha [{0}]: ", opcao_aberto)).strip()
        except (EOFError, KeyboardInterrupt):
            bruto = ""
        escolha = int(bruto) if bruto.isdigit() else opcao_aberto

        if 1 <= escolha <= len(nomes):
            novo = nomes[escolha - 1]
        elif escolha == opcao_incluir and _adicionar_a_equipe(ids, nome):
            escolhas[nome] = nome
            continue                      # o nome passou a ser válido: nada a trocar
        else:
            novo = placeholder()

        for it in seus:
            it[campo] = novo
            trocados += 1
        escolhas[nome] = novo
    return trocados, escolhas


# ── O VÍNCULO COM A CONTA DO PROJECTADM (Passo 1 — o QUEM) ──────────────────
# Eduardo, 07/08/2026: "important that we assigned this emails with the ProjectAdm user,
# and if it is not possible, we can inform the email too. This assignment should be done
# at Passo 1 - Who, and when it is a projectadm user, the task should assigned to the
# user."
#
# POR QUE ISTO FALTAVA E O QUE ELE DESTRAVA
#   O Passo 4 JA sabia atribuir a tarefa ao usuario do ProjectAdm: ele le o usuario que
#   esta no CARTAO DA PESSOA (`membros`) e o poe como membro do cartao da tarefa. So que
#   NINGUEM nunca punha esse usuario no cartao da pessoa — entao o mapa saia vazio e a
#   corrente inteira ficava morta. Este e' o elo que faltava: no Passo 1, quando a parte
#   interessada e' um usuario do ProjectAdm, o cartao dela passa a APONTAR para a conta.
#
#   Com o elo no lugar: o Passo 4 atribui os pacotes, o Passo 7 atribui as atividades e
#   manda o e-mail para o endereco DA CONTA (que a pessoa mantem, e o aluno nao digita).

# ── LIGAR (OU NÃO) A EQUIPE ÀS CONTAS: a decisão é do aluno, e mora no .env ──
#
# POR QUE ISTO VIROU OPCIONAL (Eduardo, 18/08/2026, quadro 293/Ativalog)
#     O Passo 1 perguntava, UMA VEZ POR PESSOA, qual é a conta dela no ProjectAdm. Num
#     projeto com seis partes na equipe e nenhuma delas com conta — que é o caso normal
#     de quem planeja sozinho, ou de quem usa o curso para desenhar um projeto que ainda
#     não tem time montado — são seis menus para responder "pular" seis vezes, a cada
#     rodada do Passo 1. O valor da ligação é real (atribuir a tarefa, achar o e-mail),
#     mas ele não é para todo mundo, e o preço estava sendo cobrado de todo mundo.
#
#     Então a ligação passa a ser uma escolha declarada no .env, ao lado das outras
#     decisões do projeto — e não uma pergunta que reaparece a cada execução.
#
# OS TRÊS ESTADOS, e por que três e não dois
#     SIM   liga o que é inequívoco e PERGUNTA o resto        (o de sempre; padrão)
#     AUTO  liga só o que casa sozinho, sem perguntar nada    (o silencioso)
#     NAO   não toca nas contas                               (o desligado)
#     O AUTO existe porque o caro é a PERGUNTA, não a ligação: quem já tem a conta com o
#     mesmo nome (ou com o e-mail escrito no cartão) ganha o vínculo de graça, e ninguém
#     é interrompido por isso. Desligar tudo por causa da pergunta seria jogar fora o
#     que já funcionava sem custo nenhum.
CHAVE_LIGACAO = "LINK_TEAM_ACCOUNTS"


def modo_ligacao(c=None):
    """"SIM" · "AUTO" · "NAO" — o que fazer com as contas do ProjectAdm no Passo 1.

    Lê do .env; qualquer coisa não reconhecida cai no padrão ("SIM"), porque um erro de
    digitação não pode desligar um recurso em silêncio. Nunca levanta: sem .env legível
    o Passo 1 segue como sempre seguiu.
    """
    try:
        from env import cfg
        v = str((c or cfg()).get(CHAVE_LIGACAO, "") or "").strip().upper()
    except (Exception, SystemExit):
        return "SIM"
    if v in ("AUTO", "AUTOMATICO", "AUTOMÁTICO", "AUTOMATIC"):
        return "AUTO"
    if v in ("NAO", "NÃO", "N", "NO", "0", "OFF", "FALSE"):
        return "NAO"
    return "SIM"


def usuarios_do_projeto(board_id=None):
    """[{id, nome, email}] — os usuários do ProjectAdm que podem ser atribuídos a um
    cartão deste quadro (os membros do PROJETO a que o quadro pertence).

    É a lista FECHADA da atribuição, do mesmo jeito que a EQUIPE é a lista fechada do
    responsável. Devolve [] quando a API não responde: sem ela o curso continua inteiro,
    só que com o endereço digitado à mão (que é o plano B do Eduardo).
    """
    from pa_client import _board_id, api
    try:
        quadro_ = api("GET", f"/quadros/{_board_id(board_id)}").get("dados") or {}
        projeto_id = quadro_.get("projeto_id") or quadro_.get("portfolio_id")
        if not projeto_id:
            return []
        membros = api("GET", f"/projetos/{projeto_id}/membros").get("dados") or []
    except Exception:
        return []
    saida, vistos = [], set()
    for m in membros:
        uid = str(m.get("usuario_id") or m.get("id") or "").strip()
        if not uid or uid in vistos:
            continue
        vistos.add(uid)
        saida.append({"id": uid,
                      "nome": (m.get("nome") or "").strip(),
                      "email": (m.get("email") or "").strip()})
    return saida


def cartoes_da_equipe(ids):
    """[(cartao_resumo, cartao_completo)] dos membros da EQUIPE — os filhos do cartão
    "EQUIPE — quem faz". O completo é necessário porque só o GET de CARTÃO traz o pai e
    os `membros` (ver reference_projectadm_card_hierarchy)."""
    from concurrent.futures import ThreadPoolExecutor

    from pa_client import get_card, list_cards
    lid = ids.get("PARTES")
    if not lid:
        return []
    cards = list_cards(lid)
    equipe = next((c for c in cards if quadro.e_grupo_equipe(c.get("titulo"))), None)
    if not equipe:
        return []
    equipe_id = str(equipe["id"])
    with ThreadPoolExecutor(max_workers=6) as ex:
        detalhes = list(ex.map(lambda c: (c, get_card(c["id"])), cards))
    return [(c, full) for c, full in detalhes
            if str(full.get("cartao_pai_id")) == equipe_id]


_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")


def email_escrito(card):
    """O endereço que já está escrito na linha "e-mail:" do cartão ("" se não há).

    Extrai o ENDEREÇO, não a linha inteira: ela costuma vir com "(a definir)",
    "<ana@x.com>" ou "mailto:". Um "To:" inválido no rascunho do Passo 7 é um e-mail que
    não sai — e que não avisa que não saiu.
    """
    import vocabulario
    txt = entradas.texto_do_cartao(card.get("descricao", "")) or ""
    m = re.search(rf"^[ \t]*(?:{vocabulario.ROTULOS_PARTE['email']})[ \t]*:(.*)$",
                  txt, re.I | re.M)
    achado = _EMAIL_RE.search(m.group(1)) if m else None
    return achado.group(0) if achado else ""


def escrever_email_no_cartao(card, endereco):
    """Grava/atualiza a linha "e-mail:" na descrição do cartão da pessoa.

    Atualiza a linha que já existe em vez de acrescentar uma segunda — duas linhas para o
    mesmo dado é o pior fim, porque a pessoa edita uma e o sistema lê a outra
    (`DEV_STANDARDS`, migrar sem quebrar). Cartão anterior a 07/08/2026 não tem a linha:
    nesses, ela entra antes do rodapé do Passo 6.
    """
    import vocabulario
    from pa_client import update_card
    base = entradas.texto_do_cartao(card.get("descricao", "")) or ""
    linha = Tf("e-mail: {0}", (endereco or "").strip())
    padrao = re.compile(rf"^[ \t]*(?:{vocabulario.ROTULOS_PARTE['email']})[ \t]*:.*$",
                        re.I | re.M)
    novo, trocas = padrao.subn(linha, base, count=1)
    if not trocas:
        corte = len(novo)
        for marca in ("── Plano de engajamento", "── Engagement plan"):
            i = novo.find(marca)
            if i >= 0:
                corte = min(corte, i)
        novo = (novo[:corte].rstrip() + "\n" + linha + "\n\n" + novo[corte:].strip()).strip()
    if novo.strip() == base.strip():
        return True                                   # já estava assim: nada a gravar
    try:
        update_card(card["id"], descricao=novo)
        return True
    except Exception:
        return False


def vincular_usuarios(ids, perguntar=input, log=None, board_id=None, modo=None):
    """Liga cada membro da EQUIPE à conta dele no ProjectAdm. É o QUEM do Passo 1.

    Casa sozinho o que é inequívoco (`casar_pessoa`) e PERGUNTA o resto — nunca escolhe
    uma conta por semelhança: atribuir a tarefa (e mandar o e-mail) à pessoa errada é
    pior do que não atribuir. Quem não tem conta segue pelo plano B: o endereço digitado
    na linha "e-mail:" do cartão.

    `modo` ("SIM"/"AUTO"/"NAO") vem do .env quando não informado — ver `modo_ligacao`.
    Em "NAO" ela sai na porta, sem gastar uma chamada de API sequer.

    Devolve (vinculados, ja_vinculados, sem_conta).
    """
    from pa_client import assign_member
    from runner import ok, step
    log = log or step
    modo = modo or modo_ligacao()
    if modo == "NAO":
        return 0, 0, []
    equipe = cartoes_da_equipe(ids)
    if not equipe:
        return 0, 0, []
    usuarios = usuarios_do_projeto(board_id)
    # Uma conta pode estar NO CARTAO sem aparecer em GET /projetos/{id}/membros (o Henry, no
    # quadro 326: usuario 7 no cartao, ausente da lista do projeto). Sem juntar essas contas
    # aqui, o menu nao tem como oferecer justamente a pessoa que o aluno ja ligou a mao — e
    # ele fica sem saida nenhuma dentro do script.
    conhecidos = {u["id"] for u in usuarios}
    for _c, _full in equipe:
        u = usuario_do_cartao(_full)
        if u and u["id"] and u["id"] not in conhecidos:
            conhecidos.add(u["id"])
            usuarios.append(u)
    if not usuarios:
        log(T("não consegui ler os usuários do ProjectAdm agora — a equipe segue com o "
              "e-mail digitado no cartão de cada pessoa."))
        return 0, 0, [(c.get("titulo") or "").strip() for c, _f in equipe]

    por_nome = {u["nome"]: u for u in usuarios if u["nome"]}
    vinculados = ja = 0
    sem_conta = []
    pendentes = []                            # (cartao, cartao_completo, titulo)
    for c, full in equipe:
        titulo = (c.get("titulo") or "").strip()
        if full.get("membros"):
            ja += 1
            # a conta pode ter trocado de e-mail desde a última vez: o cartão acompanha.
            # A lista do projeto vem primeiro (é a viva); o endereço gravado no próprio
            # cartão é o plano B — sem ele, quem está no cartão mas não na lista do projeto
            # ficava com a linha "e-mail:" em branco para sempre.
            conta = usuario_do_cartao(full) or {}
            atual = next((x for x in usuarios if x["id"] == conta.get("id")), None)
            endereco = (atual or {}).get("email") or conta.get("email") or ""
            if endereco:
                escrever_email_no_cartao(full, endereco)
            continue
        # 1) o e-mail JÁ ESCRITO no cartão identifica a conta sem nenhuma ambiguidade —
        #    é o caminho mais forte, e o único que não depende de como o nome foi escrito
        escrito = email_escrito(full)
        alvo = next((u for u in usuarios
                     if u["email"] and escrito and u["email"].lower() == escrito.lower()),
                    None)
        # 2) senão, o nome — e só quando casa com UMA conta só
        if not alvo:
            casado = casar_pessoa(titulo, list(por_nome))
            alvo = por_nome.get(casado) if casado else None
        if alvo and assign_member(c["id"], alvo["id"]):
            escrever_email_no_cartao(full, alvo["email"])
            vinculados += 1
            ok(Tf('   {0} → conta "{1}" <{2}>', titulo, alvo["nome"], alvo["email"]))
            continue
        if escrito:
            sem_conta.append(titulo)          # tem e-mail à mão: o plano B já está de pé
            continue
        pendentes.append((c, full, titulo))

    # No modo AUTO ninguém é interrompido: quem não casou sozinho fica para o plano B
    # (o e-mail escrito no cartão), exatamente como quem responde "pular" no menu.
    if modo == "AUTO":
        return vinculados, ja, sem_conta + [t for _c, _f, t in pendentes]

    for c, full, titulo in pendentes:
        escolhido = _perguntar_conta(titulo, usuarios, perguntar)
        if escolhido is None:
            sem_conta.append(titulo)
        elif isinstance(escolhido, str):      # um e-mail digitado (o plano B)
            escrever_email_no_cartao(full, escolhido)
            sem_conta.append(titulo)
            ok(Tf("   {0} → e-mail {1} (sem conta no ProjectAdm)", titulo, escolhido))
        elif assign_member(c["id"], escolhido["id"]):
            escrever_email_no_cartao(full, escolhido["email"])
            vinculados += 1
            ok(Tf('   {0} → conta "{1}" <{2}>', titulo,
                  escolhido["nome"], escolhido["email"]))
        else:
            sem_conta.append(titulo)
    return vinculados, ja, sem_conta


def _perguntar_conta(titulo, usuarios, perguntar):
    """Menu: uma conta do ProjectAdm · digitar um e-mail · pular.

    Devolve o dict do usuário, uma string (o e-mail digitado) ou None (pulou).
    """
    print()
    print(Tf("      {0} — qual é a conta desta pessoa no ProjectAdm?", titulo))
    for i, u in enumerate(usuarios, 1):
        # A conta pode não ter NOME no ProjectAdm — e aí o `nome` que a API devolve é o
        # próprio endereço. Imprimir os dois lado a lado repete a mesma coisa duas vezes
        # e faz o aluno duvidar se são duas contas diferentes.
        nome = (u.get("nome") or "").strip()
        email = (u.get("email") or "").strip()
        print(f"        {i}. {nome} <{email}>"
              if nome and nome.lower() != email.lower() else f"        {i}. {email}")
    op_email = len(usuarios) + 1
    op_pular = len(usuarios) + 2
    print(Tf("        {0}. não tem conta no ProjectAdm — eu informo o e-mail", op_email))
    print(Tf("        {0}. pular por enquanto", op_pular))
    # POR QUE a lista é essa e não outra. Ela são os membros do PORTFÓLIO — quem não foi
    # convidado para lá não aparece aqui, por mais que tenha conta no ProjectAdm. Sem esta
    # linha o aluno procura a pessoa numa lista que nunca vai tê-la e não tem como saber
    # onde mexer (foi exatamente o que aconteceu com o Henry no quadro 331).
    print(T("      (a lista são os membros do seu portfólio no ProjectAdm — para alguém"))
    print(T("       aparecer aqui, adicione a pessoa ao portfólio no site e rode de novo)"))
    try:
        bruto = (perguntar(Tf("      A sua escolha [{0}]: ", op_pular)) or "").strip()
    except (EOFError, KeyboardInterrupt):
        return None
    escolha = int(bruto) if bruto.isdigit() else op_pular
    if 1 <= escolha <= len(usuarios):
        return usuarios[escolha - 1]
    if escolha == op_email:
        try:
            resp = (perguntar(Tf("      e-mail de {0}: ", titulo)) or "").strip()
        except (EOFError, KeyboardInterrupt):
            return None
        achado = _EMAIL_RE.search(resp)
        return achado.group(0) if achado else None
    return None


def usuario_do_cartao(full_card):
    """O usuário do ProjectAdm ligado ao cartão da pessoa ({id, nome, email}) ou None."""
    membros = (full_card or {}).get("membros") or []
    if not membros:
        return None
    u = membros[0]                            # 1 conta por pessoa
    return {"id": str(u.get("id") or u.get("usuario_id") or ""),
            "nome": (u.get("nome") or "").strip(),
            "email": (u.get("email") or "").strip()}


def contas_da_equipe(ids):
    """{titulo do cartao da pessoa: {id, nome, email}} — so' quem TEM conta ligada.

    E' o mapa que transforma o responsavel (texto) numa atribuicao REAL. Vazio quando
    ninguem foi ligado ainda (rode o Passo 1) — e ai a atribuicao simplesmente nao
    acontece, sem derrubar nada.
    """
    saida = {}
    for c, full in cartoes_da_equipe(ids):
        u = usuario_do_cartao(full)
        titulo = (c.get("titulo") or "").strip()
        if titulo and u and u.get("id"):
            saida[titulo] = u
    return saida


def conta_do_responsavel(responsavel, contas_por_titulo):
    """A conta do ProjectAdm de quem o texto `responsavel` cita, ou None.

    Estrito (`casar_pessoa`), com UMA concessao que a realidade pediu: quando o nome cai
    em VARIOS cartoes que apontam para A MESMA CONTA, nao ha ambiguidade de fato — e' a
    mesma pessoa em dois papeis. No quadro 324, "Eduardo" e ao mesmo tempo o
    "Portfolio Owner (Eduardo)" e o "Investor & Portfolio Manager (Eduardo)", e os dois
    cartoes sao a conta 6. Atribuir a conta 6 esta certo; o que continua proibido e
    escolher UM dos dois cartoes (foi dai que saiu o e-mail para a pessoa errada).
    """
    if not (responsavel or "").strip() or not contas_por_titulo:
        return None
    titulo = casar_pessoa(responsavel, list(contas_por_titulo))
    if titulo:
        return contas_por_titulo[titulo]
    alvo = _limpo(responsavel)
    if e_pendente(entradas._sem_acento(responsavel)):
        return None
    cands = [c for t, c in contas_por_titulo.items()
             if _limpo(t) and (_dentro_de(_limpo(t), alvo) or _dentro_de(alvo, _limpo(t)))]
    return cands[0] if cands and len({c["id"] for c in cands}) == 1 else None


def atribuir_responsavel(card_id, conta, contas_da_equipe_ids):
    """Deixa o cartao com UMA UNICA conta da equipe: a `conta`.

    Numa re-rodada em que o responsavel mudou, tira a conta ANTIGA — mas nunca mexe em
    quem NAO e da equipe (um usuario que o aluno pos a mao no cartao fica). Idempotente.
    Devolve True se, ao fim, a conta esta no cartao.
    """
    from pa_client import assign_member, get_card, unassign_member
    alvo = str((conta or {}).get("id") or "")
    if not alvo:
        return False
    try:
        atuais = {str(m.get("id")) for m in (get_card(card_id).get("membros") or [])}
    except Exception:
        return False
    for uid in atuais:
        if uid != alvo and uid in contas_da_equipe_ids:
            unassign_member(card_id, uid)
    if alvo in atuais:
        return True
    return assign_member(card_id, alvo)


def atribuir_lote(ids, pares, log=None):
    """Atribui a conta certa a varios cartoes de uma vez. `pares` = [(card_id, responsavel)].

    Devolve (atribuidos, sem_conta) — `sem_conta` sao os responsaveis que nao tem conta
    ligada (ou que ficaram ambiguos): o cartao segue com o nome na descricao, que e o
    comportamento que o curso sempre teve.
    """
    contas = contas_da_equipe(ids)
    if not contas:
        return 0, sorted({r for _c, r in pares if (r or "").strip()})
    uids = {c["id"] for c in contas.values()}
    atribuidos, sem = 0, set()
    for card_id, responsavel in pares:
        if not card_id or not (responsavel or "").strip():
            continue
        conta = conta_do_responsavel(responsavel, contas)
        if not conta:
            if not e_pendente(entradas._sem_acento(responsavel)):
                sem.add(responsavel)
            continue
        if atribuir_responsavel(card_id, conta, uids):
            atribuidos += 1
    if log and atribuidos:
        log(Tf("{0} cartao(oes) atribuido(s) a conta do responsavel no ProjectAdm.",
               atribuidos))
    return atribuidos, sorted(sem)
