"""
Project Together — powered by AI: a MUDANÇA — registrar, decidir e virar plano.

O QUE ISTO RESOLVE (Eduardo, 2026-07-29)
  "Criar um prompt que eu possa incluir novas entregas dentro de uma fase existente, ou
   ainda criar uma nova fase. (...) que a IA fará todo o detalhamento incluindo o passo 3
   (riscos) e o passo 4 (cronograma e orçamento) (...) além de permitir gerar uma nova
   baseline."

POR QUE NO PASSO 9, E NÃO NO PASSO 2
  A fronteira não é o assunto, é a LINHA DE BASE. Antes da aprovação, acrescentar escopo
  é ELABORAÇÃO PROGRESSIVA e o lugar dela é o Passo 2 — o detalhe aparece quando você
  chega perto, e ninguém precisa autorizar nada. Depois da aprovação, o MESMO gesto é uma
  MUDANÇA: tem impacto a medir, decisão a registrar e uma linha de base nova a capturar.
  Se o Passo 2 pudesse acrescentar depois da aprovação, o escopo cresceria sem análise de
  impacto e a linha de base viraria ficção — exatamente o que o curso ensina a não fazer.
  (O Passo 4 já roteia assim desde 28/07; aqui o outro lado da mesma fronteira.)

O CICLO DE UMA MUDANÇA — três rodadas do Passo 9
  1ª  você conta o que mudou e o que quer que a IA detalhe -> sai o prompt
  2ª  você cola a resposta -> nasce o cartão MC-001 na lista "Mudanças", com o impacto
      e o detalhamento DENTRO dele. O quadro NÃO cresce ainda: escopo que ninguém
      autorizou não é escopo, é rascunho.
  3ª  você registra a DECISÃO do patrocinador. APROVADA -> aí sim o detalhamento vira
      plano (escopo, riscos, cronograma, orçamento) e uma linha de base nova é capturada.

ONDE A ENTREGA NOVA ENTRA NA EAP
  Pelo CÓDIGO: `apos: 2.1` = irmã seguinte de 2.1 (o pai vem junto, é o pai do 2.1).
  A EAP é reordenada de verdade no quadro, os códigos seguintes SOBEM (2.2 vira 2.3) — a
  decisão do Eduardo em 29/07 — e o campo "Código EAP" de cada cartão é reescrito, junto
  com o prefixo das tarefas de execução ("↳ 2.2 · ") e os documentos.
"""
import datetime
import os
import re

import entradas
import passos
import quadro
import team_check
from pa_client import (api, criar_cartoes, garantir_dependencia, list_cards,
                       ordenar_cartoes, update_card)
from runner import ok, step, warn
from idioma import T, Tf

MARCA = "🔄"
MARCA_ORIGEM = T("Nasceu da mudança")       # fica na descrição do cartão criado por uma mudança
_SAIDA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_saida", "mudancas")

DECISOES = ("APROVADA", "REJEITADA", "ADIADA")
_RE_CODIGO = re.compile(r"\bMC-(\d{1,4})\b", re.I)
_RE_DECISAO = re.compile(r"(?im)^\s*DECIS[AÃ]O\s*:\s*([A-Za-zÀ-ú]+)")
_RE_CAMPO = re.compile(r"\s*[|·]\s*")


# ─────────────────────────────────────────────────────────────────────────────
# O CÓDIGO DA MUDANÇA — MC-001, MC-002...
# ─────────────────────────────────────────────────────────────────────────────
def codigo_do_cartao(titulo):
    m = _RE_CODIGO.search(titulo or "")
    return f"MC-{int(m.group(1)):03d}" if m else ""


def proximo_codigo(ids):
    """O próximo MC-NNN livre. É a alça ESTÁVEL da mudança — o cartão pode ser renomeado,
    o código não muda, e é por ele que o escopo novo diz de onde veio."""
    usados = [int(_RE_CODIGO.search(c.get("titulo") or "").group(1))
              for c in _cartoes(ids) if _RE_CODIGO.search(c.get("titulo") or "")]
    return f"MC-{(max(usados) + 1) if usados else 1:03d}"


def _cartoes(ids):
    lid = ids.get("MUDANCAS")
    if not lid:
        return []
    return [c for c in list_cards(lid)
            if (c.get("titulo") or "").strip()
            and not quadro.e_guia((c.get("titulo") or ""))]


def decisao_do_cartao(card):
    """A decisão registrada no cartão ('APROVADA'|'REJEITADA'|'ADIADA') ou "".

    Lê o texto do cartão — então vale tanto a decisão que o Passo 9 escreveu quanto a que
    o patrocinador digitou direto no ProjectAdm ("DECISÃO: APROVADA"). O quadro é a fonte;
    o script só ajuda."""
    txt = entradas.texto_do_cartao(card.get("descricao")) or ""
    m = _RE_DECISAO.search(txt)
    if not m:
        return ""
    v = entradas._sem_acento(m.group(1)).upper()
    for d in DECISOES:
        if entradas._sem_acento(d).upper().startswith(v[:6]):
            return d
    return ""


def pendentes(ids):
    """As mudanças registradas que ainda esperam a decisão do patrocinador."""
    return [c for c in _cartoes(ids) if not decisao_do_cartao(c)]


def todas(ids):
    """Todas as mudanças registradas, decididas ou não (a lista "Mudanças")."""
    return _cartoes(ids)


# ─────────────────────────────────────────────────────────────────────────────
# GUARDAR O DETALHAMENTO ATÉ A DECISÃO
#
# O detalhamento fica em DOIS lugares, de propósito: no cartão (é lá que o patrocinador
# lê o que está aprovando) e num arquivo em _saida/mudancas/ (é de lá que o script lê na
# hora de aplicar). O motivo do arquivo: a descrição do cartão tem teto de ~10 000 BYTES
# na API do ProjectAdm — um detalhamento generoso é capado, e aplicar um plano capado
# perderia pacote em silêncio.
# ─────────────────────────────────────────────────────────────────────────────
def _arquivo(codigo):
    return os.path.join(_SAIDA, f"{codigo}.txt")


def guardar_detalhe(codigo, blocos):
    """Grava os blocos [P9.2]/[P9.3]/[P9.4] desta mudança em _saida/mudancas/MC-NNN.txt."""
    if not blocos:
        return None
    os.makedirs(_SAIDA, exist_ok=True)
    texto = "\n\n".join(f"[{rot}]\n{txt}" for rot, txt in sorted(blocos.items()))
    with open(_arquivo(codigo), "w", encoding="utf-8") as f:
        f.write(texto + "\n")
    return _arquivo(codigo)


def ler_detalhe(codigo, card=None):
    """Os blocos guardados desta mudança: {'P9.2': texto, ...}.

    Prefere o ARQUIVO (íntegro); se ele não existir — outro computador, pasta limpa —
    cai para o texto do cartão, que pode estar capado, e avisa quem chamou."""
    caminho = _arquivo(codigo)
    if os.path.exists(caminho):
        with open(caminho, encoding="utf-8") as f:
            return _separar_blocos(f.read()), "arquivo"
    txt = entradas.texto_do_cartao((card or {}).get("descricao")) or ""
    return _separar_blocos(txt), "cartão"


def _separar_blocos(texto):
    """{rotulo: corpo} dos blocos [P9.2]/[P9.3]/[P9.4] de um texto."""
    partes = re.split(r"^\s*\[(P9(?:\.\d+)?)\]\s*", texto or "", flags=re.M | re.I)
    out = {}
    for i in range(1, len(partes) - 1, 2):
        rot = partes[i].strip().upper()
        corpo = partes[i + 1].strip()
        if corpo:
            out[rot] = (out.get(rot, "") + "\n" + corpo).strip()
    return out


# ─────────────────────────────────────────────────────────────────────────────
# REGISTRAR A MUDANÇA (2ª rodada) — o cartão MC-NNN
# ─────────────────────────────────────────────────────────────────────────────
def texto_do_cartao_da_mudanca(codigo, bloco_p9, blocos_detalhe, partes_pedidas):
    """(titulo, descricao) do cartão da mudança, a partir do bloco [P9] da IA."""
    secs = entradas.secoes(bloco_p9, ["SOLICITACAO", "SOLICITAÇÃO", "IMPACTO",
                                      "RECOMENDACAO", "RECOMENDAÇÃO"])
    solicitacao = (secs.get("solicitacao", "") or secs.get("solicitação", "")).strip()
    impacto = entradas.itens(secs.get("impacto", ""))
    recomendacao = (secs.get("recomendacao", "") or secs.get("recomendação", "")).strip()
    resumo = (solicitacao.splitlines()[0] if solicitacao else "Mudança").strip()
    titulo = f"{MARCA} {codigo} · {resumo}"[:250]

    linhas = [f"Solicitada em {datetime.date.today().isoformat()}",
              "DECISÃO: (aguardando o patrocinador — APROVADA | REJEITADA | ADIADA)", ""]
    if solicitacao:
        linhas += ["SOLICITAÇÃO:", solicitacao, ""]
    if impacto:
        linhas.append("IMPACTO:")
        linhas += [f"• {i.get('_','')}" for i in impacto]
        linhas.append("")
    if recomendacao:
        linhas += ["RECOMENDAÇÃO (a decisão é do patrocinador):", recomendacao, ""]
    if blocos_detalhe:
        quais = ", ".join(partes_pedidas or [])
        linhas += [f"DETALHAMENTO PRONTO ({quais}) — vira plano SE for aprovada:", ""]
        for rot, txt in sorted(blocos_detalhe.items()):
            linhas += [f"[{rot}]", txt, ""]
        linhas.append(T("Para aplicar: rode o Passo 9 e registre a decisão APROVADA."))
    else:
        linhas.append(T("Sem detalhamento nesta rodada: o Passo 9 registrou só a análise "
                      "de impacto."))
    return titulo, "\n".join(linhas).strip()


def registrar(ids, cartoes, log=step):
    """Cria os cartões das mudanças na lista Mudanças. `cartoes` = [(titulo, desc)]."""
    return passos.escrever_cartoes(ids, "MUDANCAS",
                                   [(t, d, None) for t, d in cartoes], log=log)


def gravar_decisao(card, decisao, quem="", motivo=""):
    """Escreve a decisão do patrocinador no cartão da mudança (e o cartão passa a ser a
    evidência: quem decidiu, quando e por quê)."""
    txt = entradas.texto_do_cartao(card.get("descricao")) or ""
    hoje = datetime.date.today().isoformat()
    nova = (f"DECISÃO: {decisao} em {hoje}"
            + (f", por {quem}" if quem else "")
            + (f" — {motivo}" if motivo else ""))
    if _RE_DECISAO.search(txt):
        txt = _RE_DECISAO.sub(nova, txt, count=1)
    else:
        txt = nova + "\n" + txt
    update_card(card["id"], descricao=txt)
    return txt


# ─────────────────────────────────────────────────────────────────────────────
# APLICAR O ESCOPO NOVO — a inserção posicional e a renumeração
# ─────────────────────────────────────────────────────────────────────────────
_LINHA_FASE = re.compile(r"(?i)^\s*fase\s*\d*\s*[—\-–:.)]+\s*(.+?)\s*$")
_LINHA_ENTREGA = re.compile(r"(?i)^\s*entrega\s*\d*\s*[—\-–:.)]+\s*(.+?)\s*$")

# ─────────────────────────────────────────────────────────────────────────────
# REMOVER ESCOPO — porque uma mudança TIRA tanto quanto põe (Eduardo, 2026-07-30)
#
# Até aqui o Passo 9 só sabia ACRESCENTAR, e isso é meia verdade sobre controle de
# mudanças: repriorizar um projeto quase sempre significa que alguma coisa sai. Sem
# isto, o aluno que recebia "faremos só nas 6 filiais que já têm estrutura" ficava com
# o escopo ANTIGO e o NOVO no mesmo quadro, e o plano passava a mentir — o cronograma
# somava trabalho que ninguém mais ia fazer.
#
# O QUE UMA REMOÇÃO SIGNIFICA NUMA EAP: tirar um item é tirar o RAMO dele. Uma entrega
# sem os seus pacotes não é meia entrega, é um plano quebrado. Então mostramos o ramo
# inteiro (e as tarefas de execução penduradas nele) ANTES, contamos os cartões, e só
# apagamos depois de o aluno confirmar. Escopo aprovado só sai por decisão de gente.
# ─────────────────────────────────────────────────────────────────────────────
# NOS DOIS IDIOMAS — o prompt EN do Passo 9 manda a IA escrever "REMOVE:" e fecha a seção
# com "PHASE:"/"DELIVERABLE:"/"SCHEDULE:"/"BUDGET:". Com os rótulos só em português, o
# pacote inglês lia a seção REMOVER como se ela nunca tivesse acabado: tudo o que a IA
# escreveu DEPOIS dela (as entregas e os pacotes NOVOS) entrava na lista do que REMOVER,
# e o que ela mandava acrescentar sumia. Mesma família do e_grupo_equipe()/e_guia().
_LINHA_REMOVER = re.compile(r"(?i)^\s*(REMOVER|EXCLUIR|RETIRAR|REMOVE|DELETE|DROP)\s*:?\s*$")
# um "cabeçalho" que fecha a seção REMOVER: (qualquer outra seção conhecida do bloco)
_FECHA_REMOVER = re.compile(
    r"(?i)^\s*(FASE|ENTREGA|ACRESCENTAR|INCLUIR|CRONOGRAMA|ORCAMENTO|OR[ÇC]AMENTO|"
    r"RISCOS|PREMISSAS|RESTRICOES|RESTRI[ÇC][ÕO]ES|"
    r"PHASE|DELIVERABLE|ADD|SCHEDULE|BUDGET|RISKS|ASSUMPTIONS|CONSTRAINTS)\s*[:.]|^\s*\[P\d")
# "2.1.3 Obra civil" / "2.1.3 — Obra civil" -> ('2.1.3', 'Obra civil'). O código é a
# referência mais segura (nome muda, código não), mas aceitamos só o nome também.
_REF_COM_CODIGO = re.compile(r"^\s*(\d+(?:\.\d+)*)\s*[—\-–:.)]?\s*(.*)$")


def alvos_de_remocao(texto):
    """As referências que a seção REMOVER: do [P9.2] manda tirar do escopo.

    Devolve [{'codigo': '2.1.3', 'nome': 'Obra civil...'}, ...] — um dos dois pode vir
    vazio (o aluno/IA pode citar só o código ou só o nome).
    """
    alvos, dentro = [], False
    for linha in (texto or "").splitlines():
        s = linha.strip()
        if not s:
            continue
        if _LINHA_REMOVER.match(s):
            dentro = True
            continue
        if not dentro:
            continue
        if _FECHA_REMOVER.match(s):
            dentro = False
            continue
        corpo = entradas._texto_item(linha) if entradas._e_item(linha) else s
        corpo = _campos_da_cauda(corpo)[0].strip()
        if not corpo:
            continue
        m = _REF_COM_CODIGO.match(corpo)
        if m and m.group(1):
            alvos.append({"codigo": m.group(1), "nome": (m.group(2) or "").strip()})
        else:
            alvos.append({"codigo": "", "nome": corpo})
    return alvos


def _texto_sem_remocao(texto):
    """O bloco [P9.2] SEM a seção REMOVER:.

    Obrigatório antes de entregar o texto ao parser da EAP: os itens a remover vêm em
    linhas '• ...' e, sem esta poda, cada um deles seria lido como um pacote de trabalho
    NOVO — a mudança removeria e recriaria a mesma coisa na mesma rodada.
    """
    saida, dentro = [], False
    for linha in (texto or "").splitlines():
        s = linha.strip()
        if _LINHA_REMOVER.match(s):
            dentro = True
            continue
        if dentro:
            if not s:
                continue
            if _FECHA_REMOVER.match(s):
                dentro = False
            else:
                continue
        saida.append(linha)
    return "\n".join(saida)


def _campos_da_cauda(texto):
    """'Nome | apos: 2.1 | dentro de: 2' -> ('Nome', {'apos': '2.1', 'dentro de': '2'})"""
    partes = _RE_CAMPO.split(texto or "")
    nome, campos = partes[0].strip(), {}
    for p in partes[1:]:
        if ":" in p:
            k, v = p.split(":", 1)
            campos[entradas._sem_acento(k).strip()] = v.strip()
    return nome, campos


def posicoes_do_bloco(texto):
    """{('fase'|'entrega'|'pacote', nome sem acento): {'apos':..., 'dentro de':...}}

    O `entradas.eap` (o parser do Passo 2, que reusamos para a hierarquia) descarta os
    campos que não conhece — e é justamente neles que vem a POSIÇÃO. Então lemos a
    posição aqui, numa passada própria, e devolvemos o texto limpo para ele."""
    pos = {}
    for linha in (texto or "").splitlines():
        s = linha.strip()
        if not s:
            continue
        if entradas._e_item(linha):
            nome, campos = _campos_da_cauda(entradas._texto_item(linha))
            nome = re.split(r"\s+[—\-–]\s+aceita", nome, flags=re.I)[0].strip()
            tipo = "pacote"
        else:
            mf, me = _LINHA_FASE.match(s), _LINHA_ENTREGA.match(s)
            if not (mf or me):
                continue
            nome, campos = _campos_da_cauda((mf or me).group(1))
            tipo = "fase" if mf else "entrega"
        if campos:
            pos[(tipo, entradas._sem_acento(nome))] = campos
    return pos


def _texto_sem_posicao(texto):
    """O mesmo bloco, com os campos de posição retirados das linhas FASE:/ENTREGA: —
    para o parser do Passo 2 ler o NOME limpo (nos itens ele já ignora campos extras)."""
    saida = []
    for linha in (texto or "").splitlines():
        s = linha.strip()
        m = _LINHA_FASE.match(s) or _LINHA_ENTREGA.match(s)
        if m and not entradas._e_item(linha):
            nome, _campos = _campos_da_cauda(m.group(1))
            rot = "FASE" if _LINHA_FASE.match(s) else "ENTREGA"
            saida.append(f"{rot}: {nome}")
        else:
            saida.append(linha)
    return "\n".join(saida)


def _indice(nos):
    return ({n["codigo"]: n for n in nos},
            {entradas._sem_acento(n["titulo"]): n for n in nos})


def _declarados_mais_de_uma_vez(texto):
    """Os nomes de FASE:/ENTREGA: que o bloco [P9.2] declara DUAS OU MAIS vezes.

    Por que isto importa (Eduardo, projeto Ativalog, 2026-07-29): a hierarquia casa
    pelo NOME. Duas linhas 'ENTREGA: Dormitórios construídos e equipados em cada filial'
    não criam duas entregas — criam UMA, e tudo o que vinha embaixo das duas acaba
    pendurado nela. Foi assim que um pedido de "dois lotes, com um bloco por filial"
    virou uma lista plana de 28 pacotes numa entrega só, sem lote e sem filial, e nada
    na tela disse que aquilo tinha acontecido.

    Detectar é barato; adivinhar a intenção, não. Então nós AVISAMOS e deixamos a
    decisão com quem sabe o que pediu — que é o dono do projeto, não o script.
    """
    vistos, repetidos = {}, []
    for linha in (texto or "").splitlines():
        s = linha.strip()
        if entradas._e_item(linha):
            continue
        m = _LINHA_FASE.match(s) or _LINHA_ENTREGA.match(s)
        if not m:
            continue
        nome, _campos = _campos_da_cauda(m.group(1))
        chave = entradas._sem_acento(nome)
        if not chave:
            continue
        vistos[chave] = vistos.get(chave, 0) + 1
        if vistos[chave] == 2:
            repetidos.append(nome.strip())
    return repetidos


def _pai_e_posicao(campos, por_codigo, por_titulo, pai_padrao=None):
    """(pai_id, depois_de_id) a partir de `apos:`/`dentro de:`.

    `apos: 2.1`      -> mesmo pai do 2.1, logo depois dele  (o caso normal)
    `dentro de: 2`   -> primeiro filho do 2
    nada             -> `pai_padrao` (a fase/entrega que o próprio texto declarou), no fim
    """
    def _acha(v):
        # AS CHAVES DO MOLDE VOLTAM NA RESPOSTA. O formato do prompt e' escrito com
        # {espaco a preencher}, e a IA — em toda IA que testamos — devolve `apos: {a fase
        # do Lote 1}` de volta, com as chaves. Sem tirar isto, a busca procurava um cartao
        # chamado literalmente "{a fase do Lote 1}", nao achava, e a posicao era descartada
        # em silencio. (Mesma familia do "<angulo>" que o cartao comia — ver
        # feedback_no_angle_placeholders_in_cards.)
        v = re.sub(r"^[\{\[<]+\s*|\s*[\}\]>]+$", "", (v or "").strip())
        # "fim"/"end" = "põe no fim", não é o nome de um cartão. Sem o "end", o pacote EN
        # saía procurando uma fase chamada literalmente "end" e descartava a posição.
        if not v or v.lower() in ("fim", "final", "-", "end", "the end", "last"):
            return None
        return por_codigo.get(v) or por_titulo.get(entradas._sem_acento(v))

    # `after:`/`inside:` são o que o prompt EN pede; `apos:`/`dentro de:` o que o PT pede.
    # Os dois são lidos aqui — é o MESMO campo, e um quadro montado num idioma pode ser
    # continuado no outro.
    irmao = _acha(campos.get("apos") or campos.get("depois de")
                  or campos.get("after") or campos.get("following"))
    if irmao:
        cod_pai = ".".join(irmao["codigo"].split(".")[:-1])
        pai = por_codigo.get(cod_pai) if cod_pai else None
        return (pai["id"] if pai else None), irmao["id"]
    dentro = _acha(campos.get("dentro de") or campos.get("dentro")
                   or campos.get("inside") or campos.get("within") or campos.get("in"))
    if dentro:
        return dentro["id"], None
    return (pai_padrao["id"] if pai_padrao else None), None


def _filhos_reais(lid):
    """{id do pai: [ids dos filhos]} da lista de escopo INTEIRA — inclusive as tarefas de
    execução do Passo 7, que a árvore da EAP não mostra.

    A remoção precisa desta visão, e a EAP não serve: apagar um pacote sem apagar as
    tarefas de execução penduradas nele deixaria cartões órfãos no quadro, apontando para
    um pai que não existe mais.
    """
    from concurrent.futures import ThreadPoolExecutor

    from pa_client import get_card
    cards = [c for c in list_cards(lid) if (c.get("titulo") or "").strip()]
    if not cards:
        return {}

    def _pai(c):
        try:
            p = get_card(c["id"]).get("cartao_pai_id")
        except Exception:
            p = None
        return str(c["id"]), (str(p) if p is not None else None)

    with ThreadPoolExecutor(max_workers=8) as ex:
        pares = list(ex.map(_pai, cards))
    filhos = {}
    for cid, pai in pares:
        if pai:
            filhos.setdefault(pai, []).append(cid)
    return filhos


def remover_escopo(ids, texto, nos, codigo_mc, confirmar=None, log=step):
    """Tira do quadro o escopo que a mudança aprovada ELIMINA (a seção REMOVER: do [P9.2]).

    `confirmar(pergunta)` é obrigatório para que algo seja apagado: sem ele nada sai.
    Quem chama de um script interativo passa a função de perguntar; qualquer outro
    chamador (um teste, um automatismo) simplesmente não remove nada — o padrão de um
    apagamento tem de ser NÃO.

    Devolve {'removidos': [(codigo, titulo)], 'cartoes': n, 'ids': set, 'nao_achados': []}.
    """
    lid = ids.get("ESCOPO")
    alvos = alvos_de_remocao(texto) if lid else []
    if not alvos:
        return {}

    por_codigo, por_titulo = _indice(nos)
    escolhidos, nao_achados, divergentes = {}, [], []
    for a in alvos:
        # O NOME MANDA, O CODIGO SO' AJUDA (Eduardo, projeto Ativalog, 2026-07-30).
        # Antes desta versao o codigo tinha precedencia, e isso apagou o escopo errado:
        # o [P9.2] da MC-002 foi escrito ANTES de a MC-001 ser aplicada, a EAP renumerou
        # no meio do caminho, e "2.1.1 Reforma estrutural de Barueri" foi resolvido pelo
        # 2.1.1 do quadro — que a essa altura era outro cartao. Cinco itens que ninguem
        # mandou tirar sairam, e os dois que a mudanca nomeava ficaram.
        #
        # O codigo da EAP e' POSICIONAL: ele muda sozinho toda vez que alguem insere,
        # remove ou reordena um item — e entre a rodada que ESCREVE o detalhamento e a
        # rodada que o APLICA pode passar um dia inteiro e outra mudanca. O nome nao muda
        # sozinho: e' o que o patrocinador leu e aprovou. Entao o nome decide, e o codigo
        # vira conferencia — quando os dois discordam, nos avisamos em vez de escolher em
        # silencio.
        pelo_nome = por_titulo.get(entradas._sem_acento(a["nome"])) if a["nome"] else None
        pelo_codigo = por_codigo.get(a["codigo"]) if a["codigo"] else None
        no = pelo_nome or pelo_codigo
        if not no:
            nao_achados.append((a["codigo"] + " " + a["nome"]).strip())
            continue
        if pelo_nome and pelo_codigo and str(pelo_nome["id"]) != str(pelo_codigo["id"]):
            divergentes.append((a["codigo"], pelo_codigo["titulo"],
                                pelo_nome["codigo"], pelo_nome["titulo"]))
        escolhidos[str(no["id"])] = no

    if divergentes:
        log(Tf("  [aviso] em {0} linha(s) o CÓDIGO e o NOME apontam para cartões diferentes — "
               "a sua EAP foi renumerada depois que este detalhamento foi escrito.", len(divergentes)))
        log(T("          Vou pelo NOME (é o que foi aprovado). Confira:"))
        for cod, tit_cod, cod_nome, tit_nome in divergentes[:6]:
            log(Tf('            pediu "{0} {1}" — hoje o {2} é "{3}"; "{4}" hoje é o {5}', cod, tit_nome[:40], cod, tit_cod[:34], tit_nome[:34], cod_nome))
        if len(divergentes) > 6:
            log(Tf("            ... e mais {0} linha(s).", len(divergentes) - 6))

    if nao_achados:
        log(Tf("  [aviso] não achei na EAP: {0}{1}", ', '.join(nao_achados[:6]),
               f" (+{len(nao_achados) - 6})" if len(nao_achados) > 6 else ""))
        log(T("          (nada foi apagado por causa disso — confira o código ou o nome)"))
    if not escolhidos:
        return {"removidos": [], "cartoes": 0, "ids": set(), "nao_achados": nao_achados}

    # O RAMO INTEIRO: quem sai leva os descendentes. Primeiro pela EAP (código), depois
    # pelo pai/filho real, que pega também as tarefas de execução do Passo 7.
    marcados = dict(escolhidos)
    for no in list(escolhidos.values()):
        prefixo = no["codigo"] + "."
        for n in nos:
            if n["codigo"].startswith(prefixo):
                marcados[str(n["id"])] = n
    filhos = _filhos_reais(lid)
    fila = list(marcados)
    while fila:
        for filho in filhos.get(fila.pop(), []):
            if filho not in marcados:
                marcados[filho] = None          # tarefa de execução (fora da EAP)
                fila.append(filho)

    da_eap = [(n["codigo"], n["titulo"]) for n in marcados.values() if n]
    da_eap.sort(key=lambda x: [int(p) for p in x[0].split(".") if p.isdigit()])
    extras = sum(1 for n in marcados.values() if n is None)

    print(T("\n  A MUDANÇA REMOVE ESTE ESCOPO DO SEU PLANO:"))
    for cod, tit in da_eap[:25]:
        print(f"     {cod:<10} {tit[:60]}")
    if len(da_eap) > 25:
        print(Tf("     ... e mais {0} item(ns) da EAP.", len(da_eap) - 25))
    if extras:
        print(Tf("     + {0} tarefa(s) de execução penduradas nesses itens.", extras))
    print(Tf('\n   São {0} cartão(ões) no total, e isso NÃO tem desfazer: o que sair some do '
             'quadro,', len(marcados)))
    print(T("  do cronograma e dos documentos. (O que já foi feito e você quer preservar, "
          "tire\n  desta lista antes — reveja o [P9.2] e rode de novo.)"))

    if not confirmar or not confirmar(Tf("Remover estes {0} cartão(ões) agora?", len(marcados))):
        print(T("  Ok — não removi nada. O escopo NOVO da mudança entra do mesmo jeito, "
              "logo abaixo;\n  o antigo fica no plano junto com ele. Se a intenção era "
              "substituir, o seu cronograma\n  vai somar os dois — remova os antigos no "
              "ProjectAdm, ou rode o Passo 9 de novo."))
        return {"removidos": [], "cartoes": 0, "ids": set(), "nao_achados": nao_achados,
                "recusado": True}

    from pa_client import delete_card
    # do mais FUNDO para o mais raso: apagar um pai antes do filho deixaria o filho órfão
    # se a API não fizer cascata (e não sabemos que faz).
    def _prof(cid):
        n = marcados.get(cid)
        return len(n["codigo"].split(".")) if n else 99      # tarefas de exec.: sempre antes

    apagados = 0
    for cid in sorted(marcados, key=_prof, reverse=True):
        try:
            delete_card(cid)
            apagados += 1
        except Exception as e:
            warn(Tf("não consegui apagar o cartão {0}: {1}", cid, e))
    log(Tf("  {0} cartão(ões) removido(s) do escopo pela {1}.", apagados, codigo_mc))
    from pa_client import limpar_cache
    limpar_cache()
    return {"removidos": da_eap, "cartoes": apagados, "ids": set(marcados),
            "nao_achados": nao_achados}


def aplicar_escopo(ids, texto, codigo_mc, log=step, confirmar=None):
    """Escreve no quadro o escopo NOVO de uma mudança aprovada, na posição pedida — e tira
    o que ela ELIMINA (seção REMOVER:, só com `confirmar`).

    Devolve {'fases':n, 'entregas':n, 'pacotes':n, 'renumerados':[(antes, depois, título)],
    'removidos':[(codigo, titulo)]}.
    """
    lid = ids.get("ESCOPO")
    if not lid or not (texto or "").strip():
        return {}
    nos, _tem_fases = quadro.arvore_da_eap(ids)
    por_codigo, por_titulo = _indice(nos)
    antes = {str(n["id"]): n["codigo"] for n in nos}

    # REMOVER VEM ANTES DE ACRESCENTAR, e a ordem importa: os `apos:`/`dentro de:` do
    # bloco falam da EAP como ela está AGORA, e nós já resolvemos essas referências para
    # IDs de cartão (por_codigo/por_titulo, montados acima). ID não renumera — então
    # tirar itens primeiro não estraga o endereço de quem entra depois. O que precisa
    # sumir dos índices é só o que foi apagado (senão criaríamos um filho de um pai morto).
    rem = remover_escopo(ids, texto, nos, codigo_mc, confirmar=confirmar, log=log) or {}
    if rem.get("ids"):
        for chave, idx in (("codigo", por_codigo), ("titulo", por_titulo)):
            for k in [k for k, v in idx.items() if str(v["id"]) in rem["ids"]]:
                idx.pop(k, None)
        nos = [n for n in nos if str(n["id"]) not in rem["ids"]]
        antes = {k: v for k, v in antes.items() if k not in rem["ids"]}

    pos = posicoes_do_bloco(texto)
    itens = entradas.eap(_texto_sem_posicao(_texto_sem_remocao(texto)))
    if not itens:
        if rem.get("cartoes"):
            # uma mudança que SÓ tira escopo é uma mudança legítima — e antes desta versão
            # ela caía no "não li nenhum pacote" e não fazia nada.
            criados = {"fases": 0, "entregas": 0, "pacotes": 0,
                       "removidos": rem.get("removidos", [])}
            from pa_client import limpar_cache
            limpar_cache()
            novos_nos, _tf = quadro.arvore_da_eap(ids)
            criados["renumerados"] = [(antes[str(n["id"])], n["codigo"], n["titulo"])
                                      for n in novos_nos if str(n["id"]) in antes
                                      and antes[str(n["id"])] != n["codigo"]]
            criados["nos"] = novos_nos
            return criados
        log(T("  [aviso] não li nenhum pacote de trabalho no bloco [P9.2]."))
        return {}

    # NOME REPETIDO = UM CARTÃO SÓ. Dizer isto ANTES de escrever é o que separa "o
    # plano ficou diferente do que você pediu" de "o plano ficou diferente do que você
    # pediu e ninguém avisou".
    for nome in _declarados_mais_de_uma_vez(_texto_sem_remocao(texto)):
        log(Tf('  [aviso] "{0}" foi declarado mais de uma vez no [P9.2] — na EAP o nome é a '
               'identidade, então isso vira UM item só, com tudo pendurado nele.', nome))
        log(T("          Se a intenção era ter dois grupos (dois lotes, duas etapas), peça à "
            "IA nomes diferentes e rode de novo."))

    origem = f"{MARCA_ORIGEM} {codigo_mc} (Passo 9)."
    criados = {"fases": 0, "entregas": 0, "pacotes": 0,
               "removidos": rem.get("removidos", [])}
    depois_de = {}                     # id novo -> id do irmão depois do qual ele entra
    id_da_fase, id_da_entrega = {}, {}

    def _criar(titulo, desc, pai, campos):
        pai_id, irmao = _pai_e_posicao(campos, por_codigo, por_titulo, pai)
        corpo = {"lista_id": lid, "titulo": titulo[:250], "descricao": desc}
        if pai_id:
            corpo["cartao_pai_id"] = pai_id
        novo = api("POST", "/cartoes", corpo)["dados"]["id"]
        if irmao:
            depois_de[str(novo)] = str(irmao)
        return novo

    # 1) FASES novas
    for f in dict.fromkeys(i.get("fase", "").strip() for i in itens if i.get("fase")):
        no = por_titulo.get(entradas._sem_acento(f))
        if no:
            id_da_fase[f] = no["id"]
            log(Tf('  [reusada] fase {0} "{1}" — o que vier abaixo dela entra AQUI DENTRO (o '
                   'nome já existia na sua EAP).', no['codigo'], no['titulo'][:44]))
            continue
        campos = pos.get(("fase", entradas._sem_acento(f)), {})
        id_da_fase[f] = _criar(f, Tf("Fase do ciclo de vida (PMBOK 8): agrupa as entregas "
                                     "desta etapa.\n{0}", origem), None, campos)
        criados["fases"] += 1
        log(Tf("  [criada]  fase: {0}", f))

    # 2) ENTREGAS novas (ou reaproveitadas, quando a mudança só acrescenta pacotes)
    for e in dict.fromkeys(i.get("entrega", "").strip() for i in itens if i.get("entrega")):
        no = por_titulo.get(entradas._sem_acento(e))
        if no:
            id_da_entrega[e] = no["id"]
            log(Tf('  [reusada] entrega {0} "{1}" — os pacotes abaixo dela entram AQUI DENTRO '
                   '(o nome já existia na sua EAP).', no['codigo'], no['titulo'][:40]))
            continue
        # A fase é a do PRIMEIRO pacote desta entrega — não "a primeira fase que
        # aparecer nela". O parser do Passo 2 faz BACKFILL: uma linha FASE: escrita
        # depois de um pacote preenche a fase dele para trás, então o último pacote de
        # uma entrega pode acabar carregando a fase SEGUINTE. Pegar a primeira ocorrência
        # não-vazia jogava a entrega para dentro da fase errada (visto no auto-teste).
        fases = [(i.get("fase") or "").strip() for i in itens
                 if (i.get("entrega") or "").strip() == e]
        fase = fases[0] if fases else ""
        pai = {"id": id_da_fase[fase]} if fase and fase in id_da_fase else None
        campos = pos.get(("entrega", entradas._sem_acento(e)), {})
        id_da_entrega[e] = _criar(e, f"Entrega nascida de uma mudança aprovada. Os pacotes "
                                     f"de trabalho dela são as subtarefas deste cartão.\n"
                                     f"{origem}", pai, campos)
        criados["entregas"] += 1
        log(Tf("  [criada]  entrega: {0}", e))

    # 3) PACOTES de trabalho
    for i in itens:
        nome = (i.get("pacote") or "").strip()
        if not nome or por_titulo.get(entradas._sem_acento(nome)):
            continue
        e = (i.get("entrega") or "").strip()
        pai = {"id": id_da_entrega[e]} if e in id_da_entrega else None
        campos = pos.get(("pacote", entradas._sem_acento(nome)), {})
        desc = (f"Critério de aceitação: {i.get('aceitacao','')}\n\n{origem}")
        _criar(nome, desc, pai, campos)
        criados["pacotes"] += 1
        log(Tf("  [criado]  pacote: {0}", nome))

    if not (criados["fases"] or criados["entregas"] or criados["pacotes"]
            or criados["removidos"]):
        return {"fases": 0, "entregas": 0, "pacotes": 0, "renumerados": [],
                "removidos": []}

    # 4) POSICIONAR de fato: reordena a lista para cada novo cair depois do seu irmão
    _ordem_da_eap(lid, depois_de, log=log)

    # 5) os códigos mudaram — quem estava depois do novo SOBE (2.2 vira 2.3). É a decisão
    #    do Eduardo (29/07): a entrega entra onde faz sentido, e o código acompanha.
    from pa_client import limpar_cache
    limpar_cache()
    novos_nos, _tf = quadro.arvore_da_eap(ids)
    renumerados = [(antes[str(n["id"])], n["codigo"], n["titulo"])
                   for n in novos_nos
                   if str(n["id"]) in antes and antes[str(n["id"])] != n["codigo"]]
    criados["renumerados"] = renumerados
    criados["nos"] = novos_nos
    return criados


def _ordem_da_eap(lid, depois_de, log=step):
    """Reescreve a ordem da lista como uma leitura em PROFUNDIDADE da EAP — pai, ramo do
    pai, próximo pai — pondo cada cartão novo logo depois do irmão que ele pediu.

    POR QUE A VERSÃO ANTERIOR NÃO FUNCIONAVA (Eduardo, projeto Ativalog, 2026-07-30).
    O `apos: 1` do bloco pedia "a fase nova entra logo depois da fase 1" — e a fase nova
    saiu com o código 5, no fim da EAP, sem que nada avisasse. A causa: o código da EAP
    não vem da posição na lista, vem da ORDEM ENTRE IRMÃOS (`arvore_da_eap` ordena os
    filhos de cada pai pelo campo `ordem`), e a lista que o Passo 2 escreve está em
    LARGURA — todas as fases, depois todas as entregas, depois todos os pacotes. Então as
    4 fases existentes ocupam as posições 1 a 4, e QUALQUER posição depois do ramo da
    fase 1 (a 19ª, no caso) é um número maior que o das outras 3 fases: a fase nova
    ordenava depois de todas elas. Para uma raiz, a inserção posicional era impossível.

    A CORREÇÃO É PÔR A LISTA NA ORDEM DA ÁRVORE. Quando a lista é uma leitura em
    profundidade, "vir antes na lista" e "vir antes entre irmãos" passam a ser a mesma
    coisa em TODOS os níveis — e aí `apos:` funciona para fase, entrega e pacote com a
    mesma regra. De quebra a lista passa a ser legível: ela lê como a EAP.

    Não muda o código de ninguém que já existia: a ordem relativa entre irmãos é
    preservada. O que muda é só onde o cartão novo entra."""
    from concurrent.futures import ThreadPoolExecutor

    from pa_client import get_card
    try:
        atual = [str(c["id"]) for c in list_cards(lid)]
        if not atual:
            return

        def _pai(cid):
            try:
                p = get_card(cid).get("cartao_pai_id")
            except Exception:
                p = None
            return cid, (str(p) if p is not None else None)

        with ThreadPoolExecutor(max_workers=8) as ex:
            pais = dict(ex.map(_pai, atual))

        nossos = set(atual)
        filhos, raizes = {}, []
        for cid in atual:                       # a ordem ATUAL desempata os irmãos
            p = pais.get(cid)
            if p and p in nossos:
                filhos.setdefault(p, []).append(cid)
            else:
                raizes.append(cid)

        posicionados = 0
        for novo, irmao in (depois_de or {}).items():
            p = pais.get(novo)
            grupo = filhos.get(p) if (p and p in nossos) else raizes
            if not grupo or novo not in grupo or irmao not in grupo:
                # o `apos:` apontou para um cartão de outro pai — não dá para ser "o
                # irmão seguinte" de quem não é irmão. Fica onde está, e o aviso sai
                # abaixo em vez de o aluno descobrir pelo código errado.
                log(Tf("  [aviso] não consegui pôr um cartão novo logo depois do irmão pedido "
                       "(o `apos:` aponta para fora do mesmo pai) — ele entrou no fim do grupo "
                       "dele."))
                continue
            grupo.remove(novo)
            grupo.insert(grupo.index(irmao) + 1, novo)
            posicionados += 1

        ordem, vistos = [], set()

        def _descer(cid):
            if cid in vistos:                   # ciclo de `cartao_pai_id` (já visto no PA)
                return
            vistos.add(cid)
            ordem.append(cid)
            for f in filhos.get(cid, []):
                _descer(f)

        for r in raizes:
            _descer(r)
        ordem += [cid for cid in atual if cid not in vistos]     # salvaguarda: ninguém some

        if ordem != atual:
            ordenar_cartoes(lid, ordem)
            log(T("  EAP reordenada na ordem da árvore")
                + (Tf(": {0} cartão(ões) na posição que você pediu.", posicionados)
                   if posicionados else "."))
    except Exception as e:
        warn(Tf("não consegui reordenar a EAP ({0}) — os cartões novos ficaram no fim da "
                "lista, e o código deles reflete essa posição. Arraste no quadro e rode a "
                "tecla R para acertar os códigos.", e))


# ─────────────────────────────────────────────────────────────────────────────
# APLICAR RISCOS / CRONOGRAMA / ORÇAMENTO
# ─────────────────────────────────────────────────────────────────────────────
def aplicar_riscos(ids, texto, codigo_mc, log=step):
    """Os riscos novos da mudança viram cartões na lista Riscos e Premissas."""
    if not (texto or "").strip():
        return 0
    pontos = passos.pontos_de_atencao(texto)
    if not pontos:
        return 0
    # Mesma guarda do Passo 3: o dono da resposta a um risco sai da lista fechada.
    team_check.conferir(pontos, ids, campo="dono", o_que=T("ponto(s) de atenção"))
    cartoes, _cont = passos.cartoes_de_riscos(pontos)
    marcados = [(t, f"{d}\n{MARCA_ORIGEM} {codigo_mc} (Passo 9).", p) for t, d, p in cartoes]
    return passos.escrever_cartoes(ids, "RISCOS", marcados, log=log)


_TIPO_DEP = {"ti": "finish_to_start", "ii": "start_to_start",
             "tt": "finish_to_finish", "it": "start_to_finish"}


def _predecessores(txt):
    """'Pacote A (TI), Pacote B (II)' -> [('Pacote A','finish_to_start'), ...]"""
    out = []
    for parte in re.split(r"\s*,\s*", (txt or "").strip()):
        if not parte or parte in ("-", "—"):
            continue
        m = re.search(r"\(([A-Za-z]{2})\)\s*$", parte)
        tipo = _TIPO_DEP.get((m.group(1) if m else "ti").lower(), "finish_to_start")
        nome = (parte[:m.start()] if m else parte).strip()
        if nome:
            out.append((nome, tipo))
    return out


def aplicar_cronograma(ids, texto, log=step):
    """Datas, responsável e dependências dos pacotes NOVOS (seção CRONOGRAMA do [P9.4])."""
    secs = entradas.secoes(texto, ["CRONOGRAMA", "ORCAMENTO", "ORÇAMENTO"])
    linhas = entradas.itens(secs.get("cronograma", ""))
    if not linhas:
        return 0, 0
    # Mesma guarda do Passo 4: o pacote NOVO que a mudança traz também tem de nascer com
    # um dono que existe.
    team_check.conferir(linhas, ids, o_que=T("pacote(s) de trabalho"))
    nos, _tf = quadro.arvore_da_eap(ids)
    _por_codigo, por_titulo = _indice(nos)
    datados = n_dep = 0
    for it in linhas:
        nome = (it.get("_") or "").strip()
        no = por_titulo.get(entradas._sem_acento(nome))
        if not no:
            log(Tf('  [aviso] "{0}" não está na EAP — pulei (o nome tem de ser o mesmo do '
                   'cartão).', nome[:40]))
            continue
        campos = {}
        if it.get("inicio"):
            campos["data_inicio"] = it["inicio"][:10]
        if it.get("fim"):
            campos["data_vencimento"] = it["fim"][:10]
        if campos:
            try:
                update_card(no["id"], **campos)
                datados += 1
            except Exception as e:
                warn(Tf('não consegui datar "{0}": {1}', nome[:34], e))
        extra = []
        if it.get("duracao"):
            extra.append(Tf("Duração estimada: {0}", it['duracao']))
        if it.get("responsavel"):
            extra.append(Tf("Responsável: {0}", it['responsavel']))
        if it.get("depende de"):
            extra.append(Tf("Depende de: {0}", it['depende de']))
        if extra:
            try:
                atual = entradas.texto_do_cartao(
                    (api("GET", f"/cartoes/{no['id']}").get("dados") or {}).get("descricao")) or ""
                update_card(no["id"], descricao=(atual + "\n" + "\n".join(extra)).strip())
            except Exception:
                pass
        for pred_nome, tipo in _predecessores(it.get("depende de", "")):
            pred = por_titulo.get(entradas._sem_acento(pred_nome))
            if pred and str(pred["id"]) != str(no["id"]):
                try:
                    if garantir_dependencia(pred["id"], no["id"], tipo):
                        n_dep += 1
                except Exception:
                    pass
    return datados, n_dep


def aplicar_orcamento(ids, texto, codigo_mc, log=step):
    """Os itens de custo novos da mudança viram cartões na lista Orçamento."""
    secs = entradas.secoes(texto, ["CRONOGRAMA", "ORCAMENTO", "ORÇAMENTO"])
    linhas = entradas.itens(secs.get("orcamento", "") or secs.get("orçamento", ""))
    if not linhas:
        return 0, 0.0
    cartoes, total = [], 0.0
    for it in linhas:
        nome = (it.get("_") or "").strip()
        if not nome:
            continue
        bruto = str(it.get("valor", "")).strip()
        try:
            num = re.sub(r"[^\d.,]", "", bruto).replace(".", "").replace(",", ".")
            v = float(num or 0)
            total += -v if bruto.startswith("-") else v
        except Exception:
            pass
        pacote = (it.get("pacote") or "").strip()
        # Mesmos rótulos do Passo 4 (04_cronograma_orcamento) — e pelo mesmo motivo:
        # o cartão de custo nascia em português também no pacote EN.
        desc = (Tf("Tipo: {0}", it.get('tipo', '?')) + "\n"
                + Tf("Valor: {0}", it.get('valor', '?')) + "\n"
                + Tf("Quando: {0}", it.get('quando', '?'))
                + ("\n" + Tf("Pacote: {0}", pacote) if pacote and pacote != "-" else "")
                + f"\n{MARCA_ORIGEM} " + Tf("{0} (Passo 9).", codigo_mc))
        cartoes.append((nome[:250], desc, None))
    return passos.escrever_cartoes(ids, "ORCAMENTO", cartoes, log=log), total


def desfazer_aplicacao(ids, codigo_mc, confirmar=None, log=step):
    """Tira do quadro TUDO o que uma mudança aprovada escreveu — escopo, riscos e custos.

    Existe porque aplicar uma mudança é a única operação do curso que espalha cartões por
    três listas de uma vez. Quando o detalhamento da IA sai errado (a estrutura não é a
    que você pediu), sem isto o único caminho era caçar cartão por cartão no ProjectAdm,
    ou zerar o Passo 2 e perder a EAP inteira junto com o Passo 4.

    É seguro porque não adivinha nada: cada cartão nascido de uma mudança carrega
    "Nasceu da mudança MC-NNN (Passo 9)" na descrição, escrito por nós na hora de criar.
    Só sai o que tem essa marca — o que já estava no plano antes fica.

    A mudança em si (o cartão MC-NNN na lista Mudanças) NÃO é apagada: ela é o registro
    de que a decisão aconteceu, e registro de decisão não se apaga. Fica com a marca de
    que a aplicação foi desfeita.
    """
    marca = f"{MARCA_ORIGEM} {codigo_mc}"
    achados = []
    for chave in ("ESCOPO", "RISCOS", "ORCAMENTO"):
        lid = ids.get(chave)
        if not lid:
            continue
        for c in list_cards(lid):
            if marca in (c.get("descricao") or ""):
                achados.append((chave, str(c["id"]), (c.get("titulo") or "").strip()))
    if not achados:
        print(Tf('\n   Não achei nenhum cartão nascido da {0}. (Ou ela nunca foi aplicada,\n   '
                 'ou já foi desfeita.)', codigo_mc))
        return 0

    # o RAMO: se um item removido tem filhos (pacotes de uma entrega da mudança, tarefas
    # de execução criadas depois), eles saem junto — senão sobrariam órfãos.
    ids_alvo = {cid for _ch, cid, _t in achados}
    filhos = _filhos_reais(ids["ESCOPO"]) if ids.get("ESCOPO") else {}
    titulo_por_id = {cid: t for _ch, cid, t in achados}
    fila = list(ids_alvo)
    while fila:
        for f in filhos.get(fila.pop(), []):
            if f not in ids_alvo:
                ids_alvo.add(f)
                fila.append(f)

    print(Tf('\n  DESFAZER A {0} — isto sai do seu quadro:', codigo_mc))
    por_lista = {}
    for ch, cid, t in achados:
        por_lista.setdefault(ch, []).append(t)
    _ROT = {"ESCOPO": "EAP → Cronograma", "RISCOS": "Riscos e Premissas",
            "ORCAMENTO": "Orçamento"}
    for ch, titulos in por_lista.items():
        print(Tf("     {0}: {1} cartão(ões)", _ROT.get(ch, ch), len(titulos)))
        for t in titulos[:8]:
            print(f"        • {t[:62]}")
        if len(titulos) > 8:
            print(Tf("        ... e mais {0}.", len(titulos) - 8))
    filhotes = len(ids_alvo) - len(achados)
    if filhotes:
        print(Tf("     + {0} cartão(ões) pendurado(s) neles (subtarefas).", filhotes))
    print(Tf('\n  Total: {0} cartão(ões). Não tem desfazer do desfazer.', len(ids_alvo)))
    print(Tf('  O cartão da {0} na lista Mudanças CONTINUA — a decisão do patrocinador\n   é '
             'história do projeto, e história não se apaga.', codigo_mc))

    if not confirmar or not confirmar(Tf("Desfazer a {0} agora?", codigo_mc)):
        print(T("  Ok — não mexi em nada."))
        return 0

    from pa_client import delete_card, limpar_cache
    apagados = 0
    # filhos primeiro (os que não estão em `achados` são subtarefas dos que estão)
    for cid in sorted(ids_alvo, key=lambda c: c in titulo_por_id):
        try:
            delete_card(cid)
            apagados += 1
        except Exception as e:
            warn(Tf("não consegui apagar o cartão {0}: {1}", cid, e))
    limpar_cache()
    _marcar_desfeita(ids, codigo_mc, apagados, log=log)
    log(Tf("  {0} cartão(ões) da {1} removido(s).", apagados, codigo_mc))
    return apagados


def _marcar_desfeita(ids, codigo_mc, quantos, log=step):
    """Anota no cartão da mudança que a aplicação dela foi desfeita (e quando)."""
    card = next((c for c in _cartoes(ids)
                 if codigo_do_cartao(c.get("titulo") or "") == codigo_mc), None)
    if not card:
        return
    quando = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
    txt = entradas.texto_do_cartao(card.get("descricao")) or ""
    nota = (f"\n\n⏪ APLICAÇÃO DESFEITA em {quando}: {quantos} cartão(ões) que esta "
            f"mudança tinha criado foram removidos do quadro. A decisão registrada acima "
            f"continua valendo — o que foi desfeito foi a ESCRITA no plano, para ser "
            f"refeita com o detalhamento corrigido.")
    try:
        update_card(card["id"], descricao=txt + nota)
    except Exception as e:
        log(Tf("  [aviso] não consegui anotar o desfazer no cartão da {0}: {1}", codigo_mc, e))


# ─────────────────────────────────────────────────────────────────────────────
# O FECHO: códigos, tarefas e documentos
# ─────────────────────────────────────────────────────────────────────────────
def fechar_o_plano(ids, log=step):
    """Depois de aplicar uma mudança: sobe as datas (rollup), reescreve o campo
    "Código EAP", dá o código novo ao prefixo das tarefas de execução e regera os
    documentos. Devolve um resumo em texto do que mexeu."""
    from pa_client import limpar_cache
    limpar_cache()
    resumo = []
    try:
        n_roll = len(quadro.rollup_datas(ids))
        if n_roll:
            resumo.append(f"{n_roll} entrega(s)/fase(s) com as datas atualizadas")
    except Exception as e:
        warn(Tf("não consegui subir as datas agora ({0}).", e))

    nos = []
    try:
        import campos
        nos, n_cod = campos.sincronizar_codigos(ids, log=log)
        if n_cod:
            resumo.append(f"{n_cod} cartão(ões) com o campo \"Código EAP\" atualizado")
    except Exception as e:
        warn(Tf("não consegui atualizar o campo Código EAP ({0}).", e))

    try:
        if not nos:
            nos, _tf = quadro.arvore_da_eap(ids)
        mapa = {str(n["id"]): n["codigo"] for n in nos}
        n_tar = passos.normalizar_tarefas(ids["ESCOPO"], mapa,
                                          marca=quadro.MARCA_EXEC_P7, log=log)
        if n_tar:
            resumo.append(f"{n_tar} tarefa(s) de execução com o código novo no título")
    except Exception as e:
        warn(Tf("não consegui renomear as tarefas de execução ({0}).", e))

    try:
        import artefatos
        # Regerar 14 documentos de uma vez faz o MESMO aviso sair varias vezes: o do
        # orcamento sem pacote, por exemplo, nasce no Orcamento e de novo no Cronograma,
        # porque os dois leem a mesma ligacao. Ler o mesmo paragrafo duas vezes seguidas
        # faz o aluno procurar a diferenca que nao existe — dizemos uma vez.
        _ditos = set()

        def _uma_vez(m):
            if m not in _ditos:
                _ditos.add(m)
                warn(m)

        gerados = artefatos.gerar_disponiveis(ids, abordagem=quadro.abordagem_do_env(),
                                              log=_uma_vez)
        if gerados:
            resumo.append(f"{len(gerados)} documento(s) regerado(s)")
    except Exception as e:
        warn(Tf("não consegui regerar os documentos ({0}).", e))
    return "; ".join(resumo)
