"""
Project Together — powered by AI: ARTEFATOS — gera os documentos do projeto A PARTIR
DO QUADRO (Word/Excel), sem instalar nada.

A IDEIA (o "grande valor", Eduardo, redesign 2026-07)
  Os cartoes do quadro JA sao o seu projeto. Entao o mesmo Python que le o quadro
  pode DEVOLVER os documentos prontos, no visual do template do curso — sem voce
  copiar nada. O aluno responde no quadro; o script escreve o Termo de Abertura.

  Passo 1  -> Termo de Abertura do Projeto (.docx)   <-- feito aqui
  (os demais artefatos da abordagem preditiva entram por este mesmo registro:
   Passo 2 -> Dicionario da EAP.xlsx, Passo 3 -> Pontos de atencao.xlsx,
   Passo 8 -> Status Report.docx, Passo 9 -> Registro de mudancas.xlsx,
   Passo 10 -> Licoes Aprendidas.docx.)

COMO
  Le a lista INPUTS (as 7 perguntas do 5W2H) pela API — a MESMA fonte da verdade
  dos prompts (quadro.respostas_do_termo). Cada cartao vira uma secao do template
  (motor generico em documentos.py). O template vem no pacote (modelos/).

  Zero-instalacao: documentos.py escreve o .docx so com a biblioteca padrao.
"""
import datetime
import os
import re

import entradas
import prompts
import quadro
import vocabulario
from documentos import Bloco, blocos_de_texto, preencher_docx, preencher_xlsx
from idioma import T, Tf, escolher, lang


# ── Os NOMES DAS ABAS de cada template, por idioma ───────────────────────────
# Eduardo renomeou as abas dos modelos ingleses em 06/08/2026 (`Riscos&Problemas` ->
# `Risks & Issues`, `EAP` -> `WBS`...), usando o nome que a Capa de cada planilha já
# anunciava. O Excel reescreve sozinho as fórmulas, os nomes definidos, os dropdowns e
# os links da capa quando se renomeia uma aba — mas NÃO o código, que pede a aba pelo
# NOME. Sem esta tabela, `preencher_xlsx` levanta "aba não encontrada" e os Passos
# 2/3/4/6/9 param de gerar no pacote inglês.
#
# É TABELA PARALELA, não T(): nome de aba é DADO ESTRUTURADO (a mesma regra que
# `quadro_en.py` segue para os cartões). Também evita pôr chaves curtas e ambíguas
# como "Capa" e "EAP" no catálogo de frases, onde elas colidiriam com texto do aluno.
#
# A FONTE DA VERDADE É O TEMPLATE: mudou a aba no .xlsx, muda aqui junto — e o teste
# `saidas_en_completas.py` falha se os dois saírem de sincronia.
_ABAS_PT = {"capa": "Capa", "eap": "EAP", "riscos": "Riscos&Problemas",
            "cronograma": "Cronograma", "partes": "PartesInteressadas",
            "mudancas": "Solicitacoes", "acoes": "Acoes", "grafico": "Grafico"}
_ABAS_EN = {"capa": "Cover", "eap": "WBS", "riscos": "Risks & Issues",
            "cronograma": "Schedule", "partes": "Stakeholders",
            "mudancas": "Requests", "acoes": "Actions", "grafico": "Graph"}
ABAS = escolher(_ABAS_PT, _ABAS_EN)


def _trocas_capa(projeto):
    """O nome do projeto na CAPA do template — pelos dois nomes do placeholder.

    O modelo em portugues traz "Nome do Projeto" e o traduzido traz "Project Name".
    `_trocar_celulas` só troca a célula cujo texto BATE EXATO, então mandar só um dos
    dois deixava a capa do pacote inglês com o texto do template ("Project Name") no
    lugar do nome do projeto (Eduardo, Dicionário da EAP do FIRE, 05/08/2026).
    Mandar os dois cobre também o caso em que o pacote EN cai no modelo PT porque a
    variante traduzida daquele documento ainda não existe."""
    return {"Nome do Projeto": projeto, "Project Name": projeto}

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

# Hoje ninguem le' esta tabela — ela ficou de uma versao anterior do Termo de Abertura.
# Fica traduzida mesmo assim: uma tabela PT-only esquecida num modulo que GERA DOCUMENTO
# e' uma armadilha, e quem for usa-la um dia nao vai desconfiar de nada (foi assim que o
# "COMO RODAR" em portugues chegou aos 11 cartoes do quadro em ingles).
_ABORD_LABEL = {"PREDITIVA": T("Preditiva"), "AGIL": T("Ágil")}


def _template(nome):
    """Acha um template do curso (no pacote do aluno — modelos/ — ou no repo — Modelos/)."""
    candidatos = [
        os.path.join(HERE, "modelos", nome),
        os.path.join(HERE, "Modelos", nome),
        # repo (rodando de dentro de courses/project-together-ai/):
        os.path.abspath(os.path.join(HERE, "..", "Modelos", nome)),
    ]
    for c in candidatos:
        if os.path.exists(c):
            return c
    raise FileNotFoundError("template do curso nao encontrado: " + nome)


def _template_tap():
    """Acha o template do Termo de Abertura (no pacote do aluno ou no repo)."""
    return _template(T("Termo de Abertura do Projeto v5W2H.docx"))


def _nome_projeto():
    """O nome do projeto (do .env; senao o nome do quadro; senao 'Meu Projeto')."""
    from env import cfg
    projeto = (cfg().get("PROJECT") or "").strip()
    if not projeto or projeto.upper().startswith("ESCREVA"):
        try:
            from pa_client import board_name
            projeto = (board_name() or "Meu Projeto").strip()
        except Exception:
            projeto = "Meu Projeto"
    return projeto


def _saida_dir():
    d = os.path.join(HERE, "_saida", "documentos")
    os.makedirs(d, exist_ok=True)
    return d


def _slug_arquivo(nome):
    nome = re.sub(r'[<>:"/\\|?*]', "", (nome or "").strip()) or "projeto"
    return nome[:80]


def _ou_vazio(blocos):
    return blocos or [Bloco.paragrafo(T("(ainda não preenchido no seu quadro)"))]


def _carimbo(n_passo, detalhe, saida=None):
    """A linha "de onde veio este documento", no vocabulario do curso que esta rodando.

    O MESMO gerador serve aos dois cursos, e o numero dele MUDA de um para o outro:
    `gerar_registro_partes` e' o Passo 6 do Project Together e o Processo 2 do PMBOK 8
    Together. Ate' 14/08/2026 o numero estava escrito a mao aqui, e o aluno de 40 recebia
    o Registro das Partes assinado "Passo 6" — um passo que o curso dele nao tem. Passa-se
    o numero do Project Together (onde o gerador nasceu); quem traduz para o outro curso e'
    o catalogo, que sabe as duas curadorias.

    `saida` = o nome da saida do PMBOK; e' o que permite achar o numero no curso de 40
    quando ninguem declarou qual processo esta rodando (documento gerado pela tecla D).
    """
    import catalogo_pmbok8 as cat
    return Tf("Gerado a partir do quadro no ProjectAdm ({0} · {1})",
              cat.rotulo_de_origem(n_passo, saida), detalhe)


def _referencia(n_passo, saida):
    """"Passo 4" ou "Processo 12" — para CITAR outro passo/processo dentro de um texto.

    Diferente do _carimbo(): aqui quem manda e' a saida citada, nunca o que roda agora.
    """
    import catalogo_pmbok8 as cat
    return cat.rotulo_da_saida(n_passo, saida)


# ── cartao do 5W2H -> blocos do documento ────────────────────────────────────
def _okr_blocos(texto):
    d = entradas.okr(texto)
    objetivos = (d or {}).get("objetivos") or []
    out = []
    for i, o in enumerate(objetivos, 1):
        rot = (Tf("Objetivo {0}: ", i) if len(objetivos) > 1 else T("Objetivo: "))
        out.append(Bloco.sub(rot + (o.get("objetivo") or "").strip()))
        for j, kr in enumerate(o.get("key_results") or [], 1):
            meta = (kr.get("meta") or "").strip()
            selo = T(" (meta provisória)") if kr.get("meta_provisoria") else ""
            linha = f"KR {i}.{j}: {(kr.get('kr') or '').strip()}"
            if meta:
                linha += Tf(" — meta: {0}", meta) + selo
            out.append(Bloco.item(linha))
        out.append(Bloco.vazio())
    return out


# ── Um blocão de texto -> parágrafos que se leem ─────────────────────────────
# O ONDE (4.6) e o QUANTO (6) chegam do quadro como UM parágrafo de 200 palavras: a IA
# responde num fôlego só, e `blocos_de_texto` quebra por \n — que ali não existe. No
# documento isso vira um paredão que ninguém lê (Eduardo, FIRE, 06/08/2026).
#
# Quebramos por FRASE, agrupando até `_LIMITE_PARAGRAFO` caracteres, e promovemos a
# enumeração embutida — "exclui: (1) ...; (2) ..." — a itens de lista, que é a forma
# que essa frase já tinha na cabeça de quem a escreveu. Nada é reescrito nem descartado:
# é o MESMO texto, só respirando. Texto que já vem com quebras passa intacto.
_LIMITE_PARAGRAFO = 320
_ENUMERACAO = re.compile(r"(?<!^)\s*(?=\((?:\d{1,2}|[a-z])\)\s)")


def _paragrafos_por_frase(trecho):
    """Um trecho corrido -> parágrafos de até `_LIMITE_PARAGRAFO`, quebrando por frase.

    Quebra também no ponto-e-vírgula: num texto de escopo ele quase sempre separa duas
    ideias inteiras ("...over 10 years; margin -20%/+60% to account for..."), e sem isso
    o parágrafo passa de 400 caracteres sem nenhum respiro."""
    out, buf = [], ""
    for frase in re.split(r"(?<=[.!?;])\s+", trecho):
        frase = frase.strip()
        if not frase:
            continue
        if buf and len(buf) + len(frase) + 1 > _LIMITE_PARAGRAFO:
            out.append(Bloco.paragrafo(buf))
            buf = ""
        buf = (buf + " " + frase).strip()
    if buf:
        out.append(Bloco.paragrafo(buf))
    return out


def _blocos_legiveis(texto):
    """4.6/6: o texto do cartão em parágrafos (e itens) que se leem — sem reescrever nada."""
    txt = (texto or "").strip()
    linhas = [l.strip() for l in txt.splitlines() if l.strip()]
    if len(linhas) != 1 or len(linhas[0]) <= _LIMITE_PARAGRAFO:
        return blocos_de_texto(txt)          # já tem estrutura própria: não mexe

    # A ENUMERAÇÃO VEM PRIMEIRO. "it excludes: (1) …; (2) …; … (6) …" é uma LISTA que a
    # IA escreveu em prosa; se a gente quebrasse por frase antes, o ponto-e-vírgula de
    # dentro dela partiria a lista no meio e os itens acabariam espalhados por dois
    # parágrafos. Achando o "(n)" no texto inteiro, a lista sai inteira — e só o que
    # sobra fora dela é que se quebra por frase.
    pedacos = [p.strip() for p in _ENUMERACAO.split(linhas[0]) if p.strip()]
    if len(pedacos) > 2:
        out = _paragrafos_por_frase(pedacos[0])
        # O último "(n)" leva junto tudo o que vem DEPOIS da lista ("...são considerados.
        # Aplicando elaboração progressiva, ..."), porque não há um próximo "(n)" para
        # encerrá-lo. Corta na primeira frase: o item fica com o item, e o texto que
        # continua o parágrafo volta a ser parágrafo.
        ultimo, _, resto = pedacos[-1].partition(". ")
        out += [Bloco.item(p.rstrip(";").strip()) for p in pedacos[1:-1]]
        out.append(Bloco.item((ultimo + ("." if resto else "")).rstrip(";").strip()))
        if resto.strip():
            out += _paragrafos_por_frase(resto.strip())
        return out
    return _paragrafos_por_frase(linhas[0]) or blocos_de_texto(txt)


def _entregas_blocos(texto):
    # entradas.entregas ja limpa o lixo "(serve ao )" e recupera o KR.
    out = []
    for e in entradas.entregas(texto):
        nome = (e.get("nome") or "").strip()
        if not nome:
            continue
        kr = (e.get("kr") or "").strip()
        out.append(Bloco.item(nome + (Tf("  (serve ao {0})", kr) if kr else "")))
    return out


def _requisitos_blocos(texto):
    out = []
    for e in entradas.entregas(texto):
        nome = (e.get("nome") or "").strip()
        if not nome:
            continue
        out.append(Bloco.sub(nome))
        req = (e.get("requisito") or "").strip()
        ace = (e.get("aceitacao") or "").strip()
        if req:
            out.append(Bloco.item(Tf("Requisito: {0}", req)))
        if ace:
            out.append(Bloco.item(Tf("Critério de aceitação: {0}", ace)))
        if not req and not ace:
            out.append(Bloco.item(T("(requisito a detalhar)")))
    return out


_GRUPOS = [("cliente", T("Clientes")), ("equipe", T("Equipe")),
           ("demais", T("Demais partes interessadas"))]


def _partes_blocos(texto):
    partes = entradas.partes(texto)
    out = []
    for chave, rotulo in _GRUPOS:
        doGrupo = [p for p in partes if (p.get("grupo") or "demais").lower() == chave]
        if not doGrupo:
            continue
        out.append(Bloco.sub(rotulo))
        for p in doGrupo:
            nome = (p.get("nome") or "").strip()
            papel = (p.get("papel") or "").strip()
            linha = nome + (f" — {papel}" if papel else "")
            out.append(Bloco.item(linha))
            det = []
            if p.get("espera"):
                det.append(Tf("espera: {0}", p["espera"].strip()))
            if p.get("poder") or p.get("interesse"):
                det.append(Tf("poder: {0} · interesse: {1}",
                              (p.get("poder") or "-").strip(),
                              (p.get("interesse") or "-").strip()))
            if det:
                out.append(Bloco.paragrafo("    " + " | ".join(det)))
        out.append(Bloco.vazio())
    return out


# O TIPO de um ponto de atenção é CANÔNICO em português dentro do código (quadro.py
# devolve "Risco"/"Problema"/"Premissa"/"Restrição"/"Ameaça"/"Oportunidade" e todo `if`
# compara com isso). Aqui ele vira TEXTO PARA O ALUNO — e no pacote inglês tem de sair
# Risk/Issue/Assumption/Constraint/Threat/Opportunity, que é o domínio que a coluna Tipo
# do template valida (a lista da aba Param, nos dois idiomas).
def _tipo_ponto(tipo):
    """'Restrição' -> 'Constraint' no pacote inglês; o próprio texto no português."""
    return {"Risco": T("Risco"), "Problema": T("Problema"),
            "Premissa": T("Premissa"), "Restrição": T("Restrição"),
            "Ameaça": T("Ameaça"), "Oportunidade": T("Oportunidade")}.get(tipo, tipo or "")


def _riscos_blocos(texto):
    out = []
    for r in entradas.riscos(texto):
        risco = (r.get("risco") or "").strip()
        if not risco:
            continue
        out.append(Bloco.item(risco))
        prob = (r.get("probabilidade") or "").strip()
        imp = (r.get("impacto") or r.get("gravidade") or "").strip()
        resp = (r.get("resposta") or "").strip()
        det = []
        if prob or imp:
            det.append(Tf("probabilidade: {0} · impacto: {1}", prob or "-", imp or "-"))
        if resp:
            det.append(Tf("resposta: {0}", resp))
        if det:
            out.append(Bloco.paragrafo("    " + " | ".join(det)))
    return out


def _marcos_blocos(texto):
    out = []
    for m in entradas.marcos(texto):
        marco = (m.get("marco") or "").strip()
        if not marco:
            continue
        quando = (m.get("quando") or "").strip()
        out.append(Bloco.item(marco + (f" — {quando}" if quando else "")))
    return out


def gerar_termo_de_abertura(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Le o quadro e gera o Termo de Abertura (.docx) no visual do template.

    Devolve (caminho, avisos). Nao lanca por secao vazia — um termo em construcao
    tambem gera (as secoes ainda em branco aparecem como '(ainda nao preenchido)').
    """
    from env import cfg
    c = cfg()
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or c.get("PROJECT") or "").strip()
    if not projeto or projeto.upper().startswith("ESCREVA"):
        try:
            from pa_client import board_name
            projeto = (board_name() or "Meu Projeto").strip()
        except Exception:
            projeto = "Meu Projeto"

    resp = quadro.respostas_do_termo(ids)   # {rotulo: texto do aluno}, ja sem HTML/guia

    def g(rot):
        return (resp.get(rot) or "").strip()

    mapa = [
        (T("justificativa do projeto"), _ou_vazio(blocos_de_texto(g("1.1")))),
        (T("objetivos e key results"),  _ou_vazio(_okr_blocos(g("1.2")))),
        (T("produtos, servicos ou resultados esperados"), _ou_vazio(_entregas_blocos(g("2.1")))),
        (T("requisitos"),               _ou_vazio(_requisitos_blocos(g("2.2")))),
        (T("who? quem"),                _ou_vazio(_partes_blocos(g("3")))),
        (T("abordagem de desenvolvimento"), _ou_vazio(blocos_de_texto(g("4.1")))),
        (T("restricoes"),               _ou_vazio(blocos_de_texto(g("4.2"), como_itens=True))),
        (T("premissas"),                _ou_vazio(blocos_de_texto(g("4.3"), como_itens=True))),
        (T("riscos"),                   _ou_vazio(_riscos_blocos(g("4.4")))),
        (T("estrutura analitica do projeto"), _ou_vazio(_entregas_blocos(g("4.5")))),
        (T("onde? contexto"),           _ou_vazio(_blocos_legiveis(g("4.6")))),
        (T("when? quando"),             _ou_vazio(_marcos_blocos(g("5")))),
        (T("how much? quanto"),         _ou_vazio(_blocos_legiveis(g("6")))),
    ]

    hoje = datetime.date.today().strftime("%d/%m/%Y")
    versao = ("1.0", hoje, "",
              _carimbo(1, prompts.nome_abordagem(abordagem), "Termo de abertura do projeto"))
    saida = os.path.join(_saida_dir(), Tf("Termo de Abertura - {0}.docx", _slug_arquivo(projeto)))
    caminho, avisos = preencher_docx(
        _template_tap(), mapa, saida,
        cabecalho={T("Nome do Projeto"): projeto}, versao=versao,
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


# ── O dinheiro do Passo 4 pousando na EAP do Passo 2 (coluna Custo) ──────────
# Eduardo, 2026-07-28: "quando for gerar o orcamento no passo 4, atualizar tambem a
# coluna O - Custos do Dicionario da EAP.xlsx". O orcamento e a EAP deixam de ser duas
# listas paralelas: cada item de custo diz a que PACOTE pertence (campo "pacote", que o
# prompt do Passo 4 agora pede), e o Dicionario mostra quanto custa cada entrega.
_COD_EAP = re.compile(r"^\s*(\d+(?:\.\d+)*)\s*[-–—:·)]?\s*")


def _sem_codigo(texto):
    """'1.2.1 Scripts validados' -> 'scripts validados' (sem acento, para casar)."""
    return entradas._sem_acento(_COD_EAP.sub("", (texto or "").strip()))


def _no_do_custo(alvo, nos, por_codigo, por_titulo, tolerante):
    """O nó da EAP a que um custo pertence — pelo CÓDIGO ('1.2.1'), pelo TÍTULO exato,
    ou (só quando o aluno/IA escreveu o campo `pacote:`) por um trecho ÚNICO do nome.

    `tolerante=False` quando o "alvo" é o próprio nome do item de custo (um palpite
    nosso, não uma afirmação da IA): aí só casamento exato vale — pendurar dinheiro na
    entrega errada é pior do que deixá-lo no orçamento geral."""
    alvo = (alvo or "").strip()
    if not alvo or alvo in ("-", "—"):
        return None
    m = _COD_EAP.match(alvo)
    if m and m.group(1) in por_codigo:
        return por_codigo[m.group(1)]
    chave = _sem_codigo(alvo)
    if chave in por_titulo:
        return por_titulo[chave]
    if tolerante and len(chave) >= 6:
        cands = [n for k, n in por_titulo.items() if chave in k or k in chave]
        if len(cands) == 1:
            return cands[0]
    return None


def _custo_por_codigo(ids, nos, log=lambda _m: None):
    """{codigo da EAP: (custo_direto, custo_total)} + os itens de custo que não pousaram
    em nenhum pacote. O TOTAL de uma entrega/fase inclui os filhos (rollup), como no
    Dicionário da EAP: quem lê a linha "1.2 Encontros" vê o que a entrega inteira custa.
    """
    itens = quadro.orcamento_do_quadro(ids)
    if not itens:
        return {}, []
    por_codigo = {n["codigo"]: n for n in nos}
    por_titulo = {}
    for n in nos:
        por_titulo.setdefault(_sem_codigo(n["titulo"]), n)
    direto, fora = {}, []
    for it in itens:
        valor = it.get("valor_num") or 0.0
        pacote = (it.get("pacote") or "").strip()
        # 1º o que a IA disse (campo `pacote:`); 2º o próprio nome do item de custo —
        # que é como um projeto feito ANTES deste campo existir ainda aproveita a coluna.
        no = _no_do_custo(pacote, nos, por_codigo, por_titulo, tolerante=True)
        if no is None and not pacote:
            no = _no_do_custo(it.get("nome"), nos, por_codigo, por_titulo, tolerante=False)
        if no is None:
            fora.append(it)
            continue
        direto[no["codigo"]] = direto.get(no["codigo"], 0.0) + valor
    # rollup: do nível mais fundo para o mais raso, cada nó soma no seu pai (1.2.1 -> 1.2)
    total = {n["codigo"]: direto.get(n["codigo"], 0.0) for n in nos}
    for n in sorted(nos, key=lambda x: -x["nivel"]):
        pai = n["codigo"].rsplit(".", 1)[0]
        if pai != n["codigo"] and pai in total:
            total[pai] += total[n["codigo"]]
    return {cod: (direto.get(cod, 0.0), total.get(cod, 0.0)) for cod in total}, fora


def _formula_custo(nos, i, custos, linha_de):
    """O que vai na coluna Custo da linha do nó `i`: um NÚMERO (folha) ou uma FÓRMULA
    VIVA `=SUM(O6,O7)` sobre os filhos DIRETOS (entrega/fase) — assim, se o aluno mudar
    o custo de um pacote no Excel, a entrega e a fase se recalculam sozinhas (o mesmo
    padrão da Prioridade no Registro dos pontos de atenção)."""
    no = nos[i]
    dir_, tot = custos.get(no["codigo"], (0.0, 0.0))
    filhos = [j for j, x in enumerate(nos)
              if x["codigo"].rsplit(".", 1)[0] == no["codigo"] and x["codigo"] != no["codigo"]]
    if not filhos:
        return round(dir_, 2) if dir_ else ""
    if not tot:
        return ""
    refs = ",".join(f"O{linha_de(j)}" for j in filhos)
    formula = f"=SUM({refs})" if not dir_ else f"={round(dir_, 2)}+SUM({refs})"
    return (formula, round(tot, 2))


def gerar_dicionario_da_eap(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Le a EAP do quadro e gera o Dicionario da EAP em Word (.docx) e Excel (.xlsx),
    no visual dos templates do curso, com a NUMERACAO e a HIERARQUIA corretas.

    So' faz sentido na abordagem PREDITIVA (a EAP; o agil usa Product Backlog).
    Devolve (caminhos, avisos) — caminhos = [docx, xlsx]; ou (None, []) quando ainda
    nao ha EAP no quadro (o Passo 2 nao rodou) — nada a gerar.
    """
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    if abordagem != "PREDITIVA":
        return None, []
    nos, tem_fases = quadro.arvore_da_eap(ids)
    if not nos:
        return None, []

    projeto = (projeto or _nome_projeto()).strip()
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    tem_custo = any(it.get("valor_num") for it in quadro.orcamento_do_quadro(ids))
    versao = ("1.0", hoje, "",
              _carimbo(2, T("EAP") + (Tf(" · custos do {0}",
                                         _referencia(4, "Linha de base dos custos"))
                                      if tem_custo else ""),
                       "EAP Estrutura Analítica do Projeto"))
    saidas, avisos = [], []

    # ── DOCX: fase (Titulo 1) · entrega (Titulo 2) · pacote (subtitulo + criterio) ──
    blocos = []
    for no in nos:
        rot = f"{no['codigo']}  {no['titulo']}"
        if no["tipo"] == "fase":
            blocos.append(Bloco.h1(rot))
        elif no["tipo"] == "entrega":
            # o titulo acompanha a PROFUNDIDADE: numa EAP de 4 niveis, a entrega de
            # nivel 3 nao pode sair com o mesmo peso visual da de nivel 2 (o leitor
            # perderia a hierarquia que o codigo 2.1.1 esta dizendo).
            nivel_doc = no["nivel"] if tem_fases else no["nivel"] + 1
            blocos.append((Bloco.h1, Bloco.h2, Bloco.h3)[min(nivel_doc, 3) - 1](rot))
        else:  # pacote de trabalho (a FOLHA) — o nivel que tem criterio de aceitacao
            blocos.append(Bloco.sub(rot))
            if no["aceitacao"]:
                blocos.append(Bloco.paragrafo(Tf("Critério de aceitação: {0}", no["aceitacao"])))
            blocos.append(Bloco.vazio())
    saida_docx = os.path.join(_saida_dir(),
                              Tf("Dicionário da EAP - {0}.docx", _slug_arquivo(projeto)))
    cam, av = preencher_docx(
        _template(T("Dicionario da EAP.docx")), [], saida_docx,
        intervalos=[(T("Fase 1"), T("Informações Adicionais"), blocos)],
        cabecalho={T("Nome do Projeto"): projeto}, versao=versao,
        propriedades={"subject": projeto})
    saidas.append(cam)
    for a in av:
        log(Tf("  [aviso docx] {0}", a))

    # ── XLSX: uma linha por elemento da EAP (Cod. EAP · Nome · Aceitacao · Custo) ──
    # A coluna O (Custo) vem do ORCAMENTO do Passo 4: cada item de custo diz a que pacote
    # pertence, e a entrega/fase soma os filhos por formula viva.
    custos, fora = _custo_por_codigo(ids, nos, log=log)
    linhas = []
    for i, no in enumerate(nos):
        recuo = "   " * (no["nivel"] - 1)          # arvore visual na coluna do nome
        linha = {"B": no["codigo"],
                 "C": recuo + no["titulo"],
                 "E": no["aceitacao"]}
        if custos:
            custo = _formula_custo(nos, i, custos, lambda j: 4 + j)   # 1a linha de dados = 4
            if custo != "":
                linha["O"] = custo
        linhas.append(linha)
    if fora:
        total_fora = sum(it.get("valor_num") or 0.0 for it in fora)
        log(Tf("  [custo] {0} item(ns) do orçamento ({1}) não estão ligados a um pacote da "
               "EAP e ficaram só no Orçamento do projeto: {2}{3}. Para vê-los na coluna "
               "Custo, escreva no cartão do item, na lista Orçamento, uma linha "
               "\"Pacote: <nome exato do pacote da EAP>\".",
               len(fora), _reais(total_fora),
               "; ".join((it.get("nome") or "")[:40] for it in fora[:4]),
               " …" if len(fora) > 4 else ""))
    saida_xlsx = os.path.join(_saida_dir(),
                              Tf("Dicionário da EAP - {0}.xlsx", _slug_arquivo(projeto)))
    cam2, av2 = preencher_xlsx(
        _template(T("Dicionario da EAP.xlsx")), saida_xlsx,
        aba=ABAS["eap"], primeira_linha=4, linhas=linhas,
        trocas=_trocas_capa(projeto), versao=versao, aba_capa=ABAS["capa"],
        formatos={"O": FORMATO_MOEDA})
    saidas.append(cam2)
    for a in av2:
        log(Tf("  [aviso xlsx] {0}", a))

    return saidas, avisos


# A COLUNA DO ID DO CARTAO no 'Registro dos pontos de atencao'. Fica DEPOIS da ultima
# coluna do modelo (P = Comentarios), com uma coluna de respiro no meio: ela nao e' dado
# de projeto, e' o elo que faz a planilha voltar para o quadro (importar_riscos.py).
# Um unico lugar define a letra — o gerador escreve, o importador le.
COLUNA_ID = "R"

# As colunas do registro que sao DA PLANILHA, e nao do quadro: nenhum campo do cartao
# corresponde a elas. Elas sao preservadas de uma geracao para a outra (casadas pelo id
# do cartao) — senao a tecla D apagaria, toda vez, o que o aluno escreveu ali.
COLUNAS_SO_DA_PLANILHA = ("J", "M", "N", "P")   # impacto detalhado · previsao · status · comentarios


def caminho_do_registro_pontos(projeto=None):
    """Onde mora o 'Registro dos pontos de atencao' deste projeto.

    Uma regra so' para o nome do arquivo: quem GERA e quem LE de volta
    (importar_riscos.py) tem de chegar ao mesmo caminho. Duas montagens do mesmo nome
    divergem no dia em que o nome mudar."""
    return os.path.join(
        _saida_dir(),
        Tf("Registro dos pontos de atenção - {0}.xlsx",
           _slug_arquivo((projeto or _nome_projeto()).strip())))


def _preservado_do_registro(caminho, aba):
    """{id_do_cartao: {coluna: valor}} das colunas que so' existem na planilha.

    Le o registro ANTERIOR (se houver) para que regenerar nao apague o que o aluno
    escreveu nas colunas que o quadro nao guarda. Nunca levanta: sem arquivo anterior,
    ou com um arquivo que nao da' para ler, simplesmente nao ha o que preservar."""
    from documentos import ler_xlsx
    try:
        linhas = ler_xlsx(caminho, aba, primeira_linha=3)
    except Exception:
        return {}
    out = {}
    for l in linhas:
        cid = (l.get(COLUNA_ID) or "").strip()
        guardar = {c: l[c] for c in COLUNAS_SO_DA_PLANILHA if l.get(c)}
        if cid and guardar:
            out[cid] = guardar
    return out


def gerar_registro_pontos_de_atencao(ids, abordagem=None, projeto=None,
                                     log=lambda _m: None):
    """Le a lista 'Riscos e Premissas' do quadro e gera o 'Registro dos pontos de
    atencao' (.xlsx) no visual do template do curso, com a coluna PRIORIDADE
    (Probabilidade/Urgencia x Impacto) calculada por FORMULA VIVA — igual ao template:
    se o aluno reavaliar a probabilidade ou o impacto no Excel, a Prioridade se recalcula.

    Serve as duas abordagens (o risco existe em ambas). Devolve (caminho, avisos), ou
    (None, []) quando ainda nao ha ponto de atencao no quadro (o Passo 3 nao rodou)."""
    pontos = quadro.riscos_do_quadro(ids)
    if not pontos:
        return None, []

    # ORDENA PELA PRIORIDADE antes de salvar (Eduardo, 2026-07-28). O registro não é um
    # inventário: é uma FILA DE TRABALHO. Quem abre a planilha tem de ler primeiro o que
    # pode matar o projeto — não o que a IA por acaso listou primeiro. Prioridade =
    # probabilidade/urgência × impacto (a mesma conta da fórmula viva da coluna D).
    # Premissa e Restrição não têm prioridade: vão depois dos riscos, na ordem do quadro.
    # A ordenação é ESTÁVEL — empate mantém a ordem em que estão no seu quadro.
    def _ordem(p):
        prio, _nivel = quadro.prioridade_do_risco(p.get("prob_ou_urgencia"), p.get("impacto"))
        return (1, 0) if not prio else (0, -prio)

    pontos = sorted(pontos, key=_ordem)

    projeto = (projeto or _nome_projeto()).strip()
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    saida = caminho_do_registro_pontos(projeto)
    preservado = _preservado_do_registro(saida, ABAS["riscos"])

    linhas = []
    for i, p in enumerate(pontos):
        r = 3 + i                                   # 1a linha de DADOS da aba = linha 3
        tipo = p.get("tipo") or "Risco"
        _np, rot_prob = quadro.escala_probabilidade(p.get("prob_ou_urgencia"))
        _ni, rot_imp = quadro.escala_impacto(p.get("impacto"))
        prio, _nivel = quadro.prioridade_do_risco(
            p.get("prob_ou_urgencia"), p.get("impacto"))
        # Leva formula quem TEM avaliacao — e todo Risco/Problema, avaliado ou nao (a IA
        # pode ter deixado "?"). Premissa/Restricao SEM avaliacao fica com D vazio; se o
        # aluno avaliou (o menu do cartao aparece em todo ponto de atencao), a prioridade
        # dele vai junto — decisao do Eduardo, 23/07.
        com_prioridade = vocabulario.avaliavel(tipo) or bool(prio)
        # D = Prioridade = Prob/Urg x Impacto (LEFT pega o "4" de "4-Alta"). Formula VIVA:
        # se o aluno reavaliar H ou I no proprio Excel, a Prioridade se recalcula sozinha
        # (com o valor em cache quando ja da' pra calcular).
        formula_d = (f"=IF(ISTEXT(H{r}),LEFT(H{r},1),H{r})"
                     f"*IF(ISTEXT(I{r}),LEFT(I{r},1),I{r})")
        linhas.append({
            "B": ("=ROW()-2", i + 1),               # Cod.
            # Tipo e Status sao LISTAS VALIDADAS do template: no pacote ingles a coluna
            # so' aceita Risk/Issue/Assumption/Constraint e Ok/Pending/In progress.
            # Escrever "Risco"/"Pendente" ali quebra o dropdown (Eduardo, 06/08/2026).
            "C": _tipo_ponto(tipo),                 # Tipo (lista validada)
            "D": (formula_d, prio) if com_prioridade else "",  # Prioridade (formula viva)
            "F": hoje,                               # Data de Identificacao
            "G": p.get("risco") or "",               # Descricao
            "H": rot_prob,                           # Urgencia / Probabilidade (validada)
            "I": rot_imp,                            # Impacto (validado)
            "K": p.get("resposta") or "",            # Descricao da acao
            "L": p.get("dono") or "",                # Responsavel
            "N": T("Pendente"),                      # Status (lista validada)
            # R = O ID DO CARTAO, fora da tabela (18/08/2026). E' o que permite a VOLTA:
            # o aluno ajusta os riscos no Excel — que e' onde e' facil ajusta-los — e o
            # importar_riscos.py sabe, sem adivinhar, qual cartao do quadro e' cada
            # linha, mesmo que ele reescreva a descricao inteira ou reordene tudo.
            # Linha nova (id em branco) vira cartao novo. Ver COLUNA_ID.
            COLUNA_ID: str(p.get("id") or ""),
        })
        # o que o aluno escreveu nas colunas que so' vivem na planilha volta para o
        # lugar dele, na linha DESTE cartao (a ordem por prioridade pode ter mudado)
        linhas[-1].update(preservado.get(str(p.get("id") or ""), {}))

    versao = ("1.0", hoje, "",
              _carimbo(3, T("Pontos de atenção"), "Registro dos riscos"))
    cam, avisos = preencher_xlsx(
        _template(T("Registro dos pontos de atencao.xlsx")), saida,
        aba=ABAS["riscos"], primeira_linha=3, linhas=linhas,
        trocas=_trocas_capa(projeto), versao=versao, aba_capa=ABAS["capa"],
        # O FILTRO JA' LIGADO, em TODAS as colunas da aba — B (Cód.) até P (Comentários).
        # Ordenado por prioridade + filtrável é o que faz do registro uma ferramenta de
        # reunião: "só as ameaças", "só o que é do Fulano", "só o que está Pendente"
        # (Eduardo, 18/08/2026). O cabeçalho da aba é a linha 2 — e o filtro para em P,
        # de propósito: a coluna do id não é dado de reunião.
        autofiltro=("B", "P", 2),
        celulas={f"{COLUNA_ID}2": T("cartão (não edite)")})
    for a in avisos:
        log(Tf("  [aviso xlsx] {0}", a))
    return cam, avisos


# O formato de número da coluna Custo do Dicionário da EAP. É irmão do `_reais()`: lá o
# valor vira TEXTO já formatado, aqui o valor continua NÚMERO (soma e filtra no Excel) e
# quem formata é o próprio Excel. Os dois têm de falar a mesma moeda — o `_reais()` já
# saía em dólar no pacote inglês e esta coluna continuava em R$ (FIRE, 06/08/2026).
FORMATO_MOEDA = escolher('"R$"\\ #,##0.00', '"$"#,##0.00')


def _reais(v):
    """12500.0 -> 'R$ 12.500,00' (pt-BR) — e '$12,500.00' no pacote inglês.

    O quadro guarda só o NÚMERO (o cartão de custo diz "Value: 5000"), então a moeda é
    uma decisão do documento. Um orçamento em inglês saía com R$ mesmo num projeto em
    dólar (Eduardo, FIRE, 06/08/2026): o símbolo brasileiro era o único que existia.
    Para trocar a moeda do pacote inglês, mude aqui — é o único lugar."""
    if lang() == "EN":
        return "$" + f"{float(v or 0):,.2f}"
    s = f"{float(v or 0):,.2f}".replace(",", "§").replace(".", ",").replace("§", ".")
    return "R$ " + s


def gerar_orcamento(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Le a lista 'Orçamento [QUANTO]' do quadro e gera 'Orçamento do projeto.docx'
    no visual do template do curso. Devolve (caminho, avisos), ou (None, []) quando
    ainda não há item de custo (o Passo 4 não rodou o orçamento)."""
    itens = quadro.orcamento_do_quadro(ids)
    if not itens:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"

    est = []
    for it in itens:
        v = _reais(it["valor_num"]) if it["valor_num"] else (it.get("valor") or "")
        partes = " · ".join(p for p in (it.get("tipo"), v, it.get("quando")) if p)
        est.append(Bloco.item(it["nome"] + (f" — {partes}" if partes else "")))

    total = sum(it["valor_num"] for it in itens)
    reservas = sum(it["valor_num"] for it in itens
                   if "reserv" in entradas._sem_acento(it.get("tipo", ""))
                   or "conting" in entradas._sem_acento(it.get("nome", "")))
    base = total - reservas

    linha_base = [Bloco.paragrafo(Tf("Custo base das estimativas (sem reservas): {0}.", _reais(base)))]
    if reservas:
        linha_base.append(Bloco.paragrafo(
            Tf("Reserva de contingência (proporcional aos riscos do Passo 3): {0}.", _reais(reservas))))
    linha_base.append(Bloco.paragrafo(
        Tf("Linha de base dos custos (base + reservas): {0}.", _reais(total))))

    por_periodo = {}
    for it in itens:
        k = (it.get("quando") or "").strip() or "sem período definido"
        por_periodo[k] = por_periodo.get(k, 0.0) + it["valor_num"]
    fluxo = [Bloco.item(f"{k}: {_reais(v)}") for k, v in sorted(por_periodo.items())]

    mapa = [
        (T("objetivos deste documento"),
         [Bloco.paragrafo(Tf("Detalhar o orçamento do projeto {0}: como as estimativas foram "
                             "feitas, a linha de base dos custos e o fluxo de caixa previsto.", projeto))]),
        (T("bases das estimativas"),
         [Bloco.paragrafo(T("Estimativas por analogia e opinião especializada, apoiadas pela IA, a "
                          "partir das entregas da EAP (Passo 2) e dos riscos (Passo 3). Cada item "
                          "traz o seu tipo (pessoas, compra, serviço, licença ou reserva), o valor "
                          "e o período previsto."))]),
        (T("estimativas de custos das atividades e dos recursos"), _ou_vazio(est)),
        (T("linha de base dos custos"), linha_base),
        (T("requisitos de recursos financeiros do projeto"),
         [Bloco.paragrafo(Tf("Recurso financeiro total necessário para o projeto: {0}.", _reais(total)))]),
        (T("fluxo de caixa do projeto"), _ou_vazio(fluxo)),
    ]

    hoje = datetime.date.today().strftime("%d/%m/%Y")
    versao = ("1.0", hoje, "",
              _carimbo(4, prompts.nome_abordagem(abordagem), "Linha de base dos custos"))
    saida = os.path.join(_saida_dir(), Tf("Orçamento do projeto - {0}.docx", _slug_arquivo(projeto)))
    caminho, avisos = preencher_docx(
        _template(T("Orcamento do projeto.docx")), mapa, saida,
        cabecalho={T("Nome do Projeto"): projeto}, versao=versao,
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


def gerar_cronograma(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Le a EAP + as datas do quadro e gera 'Cronograma do Projeto.xlsx': a TABELA do
    cronograma (Cód. EAP · Atividade indentada · Início · Término · Duração · Predecessor
    · Responsável · % · Linha de base). O Gantt VISUAL (barras + setas de dependência)
    vive no ProjectAdm (a lista ESCOPO já é o cronograma). Devolve (caminho, avisos), ou
    (None, []) quando ainda não há EAP com datas (o Passo 4 não datou os pacotes)."""
    wbs = quadro.cronograma_do_quadro(ids)
    if not wbs or not any(l.get("ini") or l.get("fim") for l in wbs):
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    linhas = []
    for l in wbs:
        indent = "    " * max(0, l["nivel"] - 1)
        linhas.append({
            "B": l["codigo"],
            "C": indent + l["titulo"],
            "D": l.get("ini", ""),
            "E": l.get("fim", ""),
            "F": l.get("duracao", ""),
            "G": l.get("predecessor", ""),
            "H": l.get("responsavel", ""),
            "I": "",                       # % Concluído — vem na execução (Passo 7)
            "J": l.get("ini", ""),         # Linha de base — Início (provisória até o Passo 5)
            "K": l.get("fim", ""),         # Linha de base — Término
        })
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    versao = ("1.0", hoje, "", _carimbo(4, T("Cronograma"), "Cronograma do projeto"))
    saida = os.path.join(_saida_dir(), Tf("Cronograma do Projeto - {0}.xlsx", _slug_arquivo(projeto)))
    caminho, avisos = preencher_xlsx(
        _template(T("Cronograma do Projeto.xlsx")), saida,
        aba=ABAS["cronograma"], primeira_linha=9, linhas=linhas,
        trocas=_trocas_capa(projeto), versao=versao, aba_capa=ABAS["cronograma"])
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


def _resumo_por_lookahead(nos):
    """resumo[i] = True se o próximo nó da EAP tem nível maior (i.e., o nó i tem filhos:
    é fase ou entrega). É a tarefa-resumo do MS Project — as datas dela o Project calcula
    dos filhos. Mais robusto que 'tipo != pacote' (pega uma entrega sem pacotes)."""
    return [bool(i + 1 < len(nos) and nos[i + 1]["nivel"] > n["nivel"])
            for i, n in enumerate(nos)]


def gerar_cronograma_mpp(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Lê a EAP + as datas + as dependências do quadro e gera 'Cronograma do Projeto.xml'
    — o formato XML NATIVO do MS Project (MSPDI). O aluno abre no MS Project (Arquivo >
    Abrir) e salva como .mpp com um clique. É o companheiro OPCIONAL do Gantt do ProjectAdm,
    para quem prefere o MS Project (o .mpp binário não dá para gerar sem o Project instalado).

    A hierarquia da EAP vira o recuo do Project (fase > entrega > pacote); cada pacote
    leva as suas datas, o responsável (como recurso) e as setas de dependência (os links
    FS reais do quadro). A linha de base sai preenchida = plano provisório (igual ao .xlsx).
    Devolve (caminho, avisos), ou (None, []) quando ainda não há EAP com datas."""
    from concurrent.futures import ThreadPoolExecutor

    from documentos import escrever_mspdi
    from pa_client import list_dependencies

    # com_tarefas: o MS Project recebe o cronograma INTEIRO — fases, entregas, pacotes e
    # as atividades de cada pacote (o 4º nível). É o que um cronograma tradicional mostra.
    nos, _tem_fases = quadro.arvore_da_eap(ids, com_tarefas=True)
    # ATIVIDADE SEM DATA NÃO ENTRA — e este descarte é o que protege o cronograma. No MS
    # Project, um pai com filhos vira TAREFA-RESUMO: as datas dele passam a ser calculadas
    # dos filhos e as suas próprias são ignoradas. Uma atividade sem data (a IA não
    # respondeu o "ate:") transformaria um pacote bem datado num resumo sem datas — o
    # pacote perderia a barra no Gantt por causa de uma linha vazia. Tirando-a, o pacote
    # volta a ser folha (o _resumo_por_lookahead olha a lista JÁ filtrada) e mantém as
    # suas datas.
    nos = [n for n in nos if n["tipo"] != "tarefa" or (n.get("ini") or n.get("fim"))]
    if not nos or not any(n.get("ini") or n.get("fim") for n in nos):
        return None, []

    projeto = (projeto or _nome_projeto()).strip()
    uid_por_card = {str(n["id"]): i + 1 for i, n in enumerate(nos)}
    resumo = _resumo_por_lookahead(nos)

    # dependências REAIS do quadro (setas FS/SS/FF/SF) — só nas folhas, lidas em paralelo.
    def _deps(par):
        i, n = par
        preds = []
        try:
            for p in (list_dependencies(n["id"]).get("predecessores") or []):
                puid = uid_por_card.get(str(p.get("cartao_origem_id")))
                if puid and puid != i + 1:
                    preds.append((puid, p.get("tipo") or "finish_to_start"))
        except Exception:
            pass
        return i, preds

    deps_por_i = {}
    folhas = [(i, n) for i, n in enumerate(nos) if not resumo[i]]
    if folhas:
        with ThreadPoolExecutor(max_workers=8) as ex:
            for i, preds in ex.map(_deps, folhas):
                deps_por_i[i] = preds

    tarefas = [{
        "uid": i + 1, "nome": n["titulo"], "nivel": n["nivel"], "wbs": n["codigo"],
        "resumo": resumo[i], "inicio": n.get("ini", ""), "fim": n.get("fim", ""),
        "dur_horas": None, "predecessores": deps_por_i.get(i, []),
        "recurso": (n.get("responsavel") or "").strip(),
    } for i, n in enumerate(nos)]

    saida = os.path.join(_saida_dir(), Tf("Cronograma do Projeto - {0}.xml", _slug_arquivo(projeto)))
    caminho, avisos = escrever_mspdi(saida, projeto, tarefas)
    n_dep = sum(len(v) for v in deps_por_i.values())
    log(Tf("  MS Project: {0} tarefa(s), {1} dependência(s).", len(tarefas), n_dep))
    return caminho, avisos


def gerar_p4_documentos(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Os artefatos do Passo 4: Cronograma (.xlsx + .xml MS Project) + Orçamento (.docx)
    + o Dicionário da EAP REGERADO — porque é o Passo 4 que enche a coluna Custo dele
    (o dinheiro do orçamento pousa no pacote que o consome). Devolve (lista de caminhos
    | None, avisos)."""
    saidas, avisos = [], []
    for fn in (gerar_cronograma, gerar_cronograma_mpp, gerar_orcamento,
               gerar_dicionario_da_eap):
        try:
            s, a = fn(ids, abordagem=abordagem, projeto=projeto, log=log)
        except Exception as e:
            log(Tf("  [aviso] {0}: {1}", fn.__name__, e))
            continue
        if s:
            saidas += [s] if isinstance(s, str) else list(s)
        avisos += a or []
    return (saidas or None), avisos


# ── Passo 5: o Plano de Gerenciamento do Projeto (o capstone que consolida o plano) ──
def _dod_blocos(ids):
    """A Definição de Pronto (checklist do cartão '✅ Definição de Pronto' na lista
    Concluído) como itens. [] se o Passo 5 ainda não gravou a DoD."""
    lid = ids.get("CONCLUIDO")
    if not lid:
        return []
    from pa_client import list_cards, list_checklists
    alvo = next((c for c in list_cards(lid)
                 if (c.get("titulo") or "").startswith("✅ Definição de Pronto")), None)
    if not alvo:
        return []
    out = []
    for chk in list_checklists(alvo["id"]):
        for it in (chk.get("itens") or chk.get("checklist_itens") or []):
            t = (it.get("titulo") or it.get("descricao") or "").strip()
            if t:
                out.append(Bloco.item(t))
    return out


def _cronograma_plano_blocos(ids):
    """Resumo do cronograma para o plano: fases/entregas com início–término, + ponteiro
    para o detalhe (Cronograma.xlsx / MS Project)."""
    wbs = quadro.cronograma_do_quadro(ids)
    if not any(l.get("ini") or l.get("fim") for l in (wbs or [])):
        return [Bloco.paragrafo(T("(cronograma ainda não datado — rode o Passo 4)"))]
    out = [Bloco.sub(T("Cronograma (linha de base)"))]
    for l in wbs:
        if l["nivel"] > 2:                       # o detalhe fica no .xlsx / MS Project
            continue                             # (resumo = os dois primeiros níveis)
        janela = " a ".join(x for x in (l.get("ini"), l.get("fim")) if x)
        out.append(Bloco.item(f"{l['codigo']}  {l['titulo']}" + (f" — {janela}" if janela else "")))
    out.append(Bloco.paragrafo(T("Detalhe completo (pacotes, durações, dependências e "
               "caminho crítico) no Cronograma do Projeto.xlsx e no arquivo do MS Project.")))
    return out


def _orcamento_plano_blocos(ids):
    """Resumo do orçamento para o plano: base + reservas + total (linha de base dos custos)."""
    itens = quadro.orcamento_do_quadro(ids)
    if not itens:
        return [Bloco.paragrafo(T("(orçamento ainda não feito — rode o Passo 4)"))]
    total = sum(it["valor_num"] for it in itens)
    reservas = sum(it["valor_num"] for it in itens
                   if "reserv" in entradas._sem_acento(it.get("tipo", ""))
                   or "conting" in entradas._sem_acento(it.get("nome", "")))
    base = total - reservas
    out = [Bloco.sub(T("Orçamento (linha de base dos custos)")),
           Bloco.item(Tf("Custo base das estimativas (sem reservas): {0}", _reais(base)))]
    if reservas:
        out.append(Bloco.item(Tf("Reserva de contingência (proporcional aos riscos): {0}", _reais(reservas))))
    out.append(Bloco.item(Tf("Linha de base dos custos (base + reservas): {0}", _reais(total))))
    out.append(Bloco.paragrafo(T("Detalhe por item e fluxo de caixa no Orçamento do projeto.docx.")))
    return out


def _riscos_plano_blocos(ids):
    """Riscos e problemas do plano, lidos da lista Riscos (riscos_do_quadro)."""
    pontos = quadro.riscos_do_quadro(ids)
    if not pontos:
        return [Bloco.paragrafo(T("(riscos ainda não analisados — rode o Passo 3)"))]
    out = []
    for p in pontos:
        enun = (p.get("risco") or "").strip()
        if not enun:
            continue
        tipo = p.get("tipo") or "Risco"
        out.append(Bloco.item(f"[{_tipo_ponto(tipo)}] {enun}"))
        det = []
        prob = (p.get("prob_ou_urgencia") or "").strip()
        imp = (p.get("impacto") or "").strip()
        if vocabulario.avaliavel(tipo) and (prob or imp):
            det.append(Tf("probabilidade/urgência: {0} · impacto: {1}",
                          prob or "-", imp or "-"))
        if (p.get("resposta") or "").strip():
            det.append(Tf("resposta: {0}", p["resposta"].strip()))
        if (p.get("dono") or "").strip():
            det.append(Tf("dono: {0}", p["dono"].strip()))
        if det:
            out.append(Bloco.paragrafo("    " + " | ".join(det)))
    return out


def gerar_plano_de_gerenciamento(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Passo 5 — o CAPSTONE: consolida o plano aprovado num só documento, o 'Plano de
    gerenciamento do projeto.docx', a partir das saídas dos passos do curso (termo, EAP,
    cronograma, orçamento, riscos, partes e a Definição de Pronto). É a referência contra
    a qual toda mudança se mede (Passo 9).

    Só faz sentido na abordagem PREDITIVA (a EAP é a espinha; o ágil usa o backlog e as
    Sprints). Devolve (caminho, avisos), ou (None, []) quando ainda não há EAP (o Passo 2
    não rodou) — sem escopo não há plano preditivo a consolidar."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    if abordagem != "PREDITIVA":
        return None, []
    nos, tem_fases = quadro.arvore_da_eap(ids)
    if not nos:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    resp = quadro.respostas_do_termo(ids)

    def g(rot):
        return (resp.get(rot) or "").strip()

    # 1) Objetivos deste documento: propósito + justificativa + OKR (o porquê)
    obj = [Bloco.paragrafo(
        Tf("Este é o Plano de Gerenciamento do Projeto {0}: o plano aprovado que consolida, "
           "num só documento, as saídas dos passos do curso — o termo de abertura, a linha de "
           "base do escopo (EAP), o cronograma e o orçamento, os riscos, as partes "
           "interessadas e a Definição de Pronto. A partir da aprovação, a linha de base está "
           "congelada e toda mudança se mede contra ela (Passo 9).", projeto))]
    if g("1.1"):
        obj += [Bloco.sub(T("Justificativa do projeto")), *blocos_de_texto(g("1.1"))]
    if g("1.2"):
        obj += [Bloco.vazio(), Bloco.sub(T("Objetivos e Key Results (OKR)")), *_okr_blocos(g("1.2"))]

    # 2) Linha de base do Escopo: a EAP (fase > entrega > pacote)
    escopo = [Bloco.paragrafo(T("A linha de base do escopo é a EAP abaixo (detalhada no "
              "Dicionário da EAP). Ela só muda por controle de mudanças (Passo 9)."))]
    for no in nos:
        rot = f"{no['codigo']}  {no['titulo']}"
        if no["tipo"] == "fase":
            escopo.append(Bloco.sub(rot))
        elif no["tipo"] == "entrega":
            escopo.append(Bloco.sub(rot) if tem_fases else Bloco.sub(rot))
        else:
            ac = f" — aceitação: {no['aceitacao']}" if no.get("aceitacao") else ""
            escopo.append(Bloco.item(rot + ac))

    # 3) Organização e responsabilidades: as partes interessadas
    org = [Bloco.paragrafo(T("Quem recebe o valor (clientes), quem faz (equipe) e quem "
           "influencia. A matriz de responsabilidades por entrega está no Dicionário da EAP."))]
    org += _partes_blocos(g("3")) or [Bloco.paragrafo(T("(partes ainda não detalhadas)"))]

    # 4) Cronograma + Orçamento (a linha de base do cronograma e dos custos)
    cron_orc = _cronograma_plano_blocos(ids) + [Bloco.vazio()] + _orcamento_plano_blocos(ids)

    # 5) Como será medido o progresso: KRs + status na EAP + Status Report + DoD
    prog = [Bloco.paragrafo(
        T("O progresso é medido em três níveis: (a) os Key Results do Passo 1 — o valor "
        "prometido; (b) o status das tarefas na EAP → Cronograma (Aberto → Em Progresso → "
        "Concluída), que dá o planejado × realizado; (c) o Status Report do Passo 8, que "
        "compara o realizado com esta linha de base e liga cada desvio ao Key Result que "
        "ele ameaça."))]
    dod = _dod_blocos(ids)
    if dod:
        prog += [Bloco.vazio(),
                 Bloco.sub(T("Definição de Pronto (DoD) — o que torna QUALQUER entrega \"pronta\"")),
                 *dod]

    # 6) Riscos e problemas
    risc = _riscos_plano_blocos(ids)
    risc.append(Bloco.paragrafo(T("Registro completo (prioridade = probabilidade/urgência × "
                "impacto, resposta e dono) no Registro dos pontos de atenção.xlsx.")))

    # 7) Comunicação (a abordagem; o detalhe por parte vem no Passo 6)
    com = [Bloco.paragrafo(
        T("A comunicação segue a cadência do projeto: o Status Report (Passo 8) reporta "
        "planejado × realizado contra esta linha de base para quem decide, ligando cada "
        "alerta ao Key Result ameaçado. O plano de engajamento de cada parte — o que ela "
        "espera, a estratégia e a frequência de contato — é detalhado no Passo 6 (Registro "
        "das partes interessadas)."))]

    # 8) Gestão de mudança de escopo (controlar contra a baseline — Passo 9)
    mud = [Bloco.paragrafo(
        T("Aprovado o plano, a linha de base (escopo, cronograma e custos) está congelada. "
        "Toda solicitação de mudança passa por análise de impacto — escopo, prazo, custo, "
        "qualidade, riscos e partes — no Passo 9, com recomendação e decisão do "
        "patrocinador. Mudança aprovada ATUALIZA a linha de base; é por existir esta "
        "referência que o custo de cada mudança fica visível."))]

    mapa = [
        (T("objetivos deste documento"), obj),
        (T("linha de base do escopo"), escopo),
        (T("organizacao do projeto"), org),
        (T("cronograma de execucao"), cron_orc),
        (T("como sera medido o progresso"), prog),
        (T("gestao de riscos"), risc),
        (T("gestao da comunicacao"), com),
        (T("gestao de mudanca"), mud),
    ]
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    versao = ("1.0", hoje, "",
              _carimbo(5, T("Plano aprovado"), "Plano de gerenciamento do projeto"))
    saida = os.path.join(_saida_dir(),
                         Tf("Plano de gerenciamento do projeto - {0}.docx", _slug_arquivo(projeto)))
    caminho, avisos = preencher_docx(
        _template(T("Plano de gerenciamento do projeto.docx")), mapa, saida,
        cabecalho={T("Nome do Projeto"): projeto}, versao=versao,
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


# ── Passo 6: Registro das partes interessadas (.xlsx) ────────────────────────
# O rótulo EXATO que a coluna Poder/Interesse valida (aba Param do template), por idioma.
_ESCALA_PI = {1: T("1-Muito baixo"), 2: T("2-Baixo"), 3: T("3-Médio"),
              4: T("4-Alto"), 5: T("5-Muito Alto")}


def _rating_pi(v):
    """'alto' (ou 'high') -> '4-Alto' / '4-High': o rótulo que a coluna Poder/Interesse
    valida e que a Importância (=Poder×Interesse) sabe ler pelo primeiro dígito.

    Quem classifica é `quadro.nivel_1a5`, que já entende os DOIS idiomas (é o mesmo
    conversor da Probabilidade/Impacto do Passo 3). A tabela própria que existia aqui só
    tinha as palavras portuguesas: num quadro em inglês o Poder ia para a planilha como
    'high' cru — fora do domínio da coluna e sem o dígito de que a fórmula da Importância
    precisa, então a coluna Importância saía VAZIA (Eduardo, FIRE, 06/08/2026)."""
    n = quadro.nivel_1a5(v)
    return _ESCALA_PI.get(n) or (v or "").strip()


def _num_pi(label):
    m = re.match(r"\s*(\d)", label or "")
    return int(m.group(1)) if m else None


# Postura de engajamento -> o rótulo EXATO que as colunas Atual/Desejado validam no
# template (domínio 'Engajamento'): Resistente · Neutro · Apoiador · Lidera · Desconhecido
# — e, no template inglês, Resistant · Neutral · Supportive · Leading · Unaware. O T()
# faz a ponte: escrever o rótulo português numa planilha inglesa estoura a validação da
# coluna (Eduardo, quadro 324/FIRE, 06/08/2026).
_NIVEL_ENGAJ = {
    "desconhecido": T("Desconhecido"), "desconhece": T("Desconhecido"),
    "alheio": T("Desconhecido"), "nao sabe": T("Desconhecido"),
    "unaware": T("Desconhecido"),
    "resistente": T("Resistente"), "resistant": T("Resistente"),
    "neutro": T("Neutro"), "neutral": T("Neutro"),
    "apoiador": T("Apoiador"), "apoia": T("Apoiador"), "supportive": T("Apoiador"),
    "lider": T("Lidera"), "lidera": T("Lidera"), "leading": T("Lidera"),
}


def _nivel_engaj(v):
    """Mapeia a postura que a IA escreveu ('apoiador', 'líder'...) para o rótulo EXATO
    que a coluna Atual/Desejado valida no template, no idioma do pacote. Vazio -> vazio;
    texto não reconhecido -> capitaliza o que veio (nunca quebra a validação)."""
    s = (v or "").strip()
    if not s:
        return ""
    return _NIVEL_ENGAJ.get(entradas._sem_acento(s.lower()), s.capitalize())


def _campo_linha(txt, rotulo):
    m = re.search(r"(?:" + rotulo + r")\s*:\s*(.+)", txt, re.I)
    return m.group(1).strip() if m else ""


def _rot_parte(chave):
    """A alternativa de regex do rótulo `chave` do cartão de parte, NOS DOIS IDIOMAS."""
    return vocabulario.ROTULOS_PARTE[chave]


def _sem_rotulo_repetido(valor, chave):
    """'desired: leading' -> 'leading' (o rótulo colado no próprio valor).

    A IA devolve o engajamento como um campo só — "atual: neutro → desejado: apoiador" —
    e ao partir na seta o lado direito vem com o rótulo grudado. Sem tirá-lo, o cartão
    ficava com "desejado: desejado: apoiador" e a planilha herdava o texto inteiro numa
    coluna que só valida 'Supportive' (Eduardo, quadro 324/FIRE, 06/08/2026).

    Tira o rótulo QUANTAS VEZES ele aparecer: um cartão que passou duas vezes pelo
    Passo 6 antes desta correção guarda "desired: desired: leading"."""
    return re.sub(rf"^(?:\s*(?:{_rot_parte(chave)})\s*:\s*)+", "", valor or "",
                  flags=re.I).strip()


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


def _email(valor):
    """O endereço dentro do que estiver escrito na linha "e-mail:" do cartão.

    A linha raramente vem limpa: a IA escreve "ana@x.com (trabalho)", o aluno cola
    "<ana@x.com>" ou "mailto:ana@x.com", e o Passo 6 pode ter deixado "(a definir)".
    Extrair o endereço em vez de confiar na linha inteira é o que impede um "To:"
    inválido no rascunho do Passo 7 — e um e-mail que não sai não avisa que não saiu.
    """
    m = _EMAIL_RE.search(valor or "")
    return m.group(0) if m else ""


def _grupo_das_partes(ids):
    """{id_do_cartao: 'cliente'|'equipe'|'demais'} para as partes interessadas.

    O grupo e' a HIERARQUIA: a parte e' filha de um dos tres cartoes de cabecalho. E o
    `GET` de LISTA nao devolve `cartao_pai_id` (lacuna 0f da API, aberta com o Henry) —
    so' o GET de CARTAO devolve. Por isso e' preciso um GET por cartao, em paralelo,
    exatamente como o `team_check.cartoes_da_equipe` ja' fazia para a equipe.

    Falha de rede devolve {} — e ai quem filtra por grupo simplesmente nao filtra, que
    e' o comportamento antigo. Nunca derruba a geracao do documento por causa disto.
    """
    from concurrent.futures import ThreadPoolExecutor

    from pa_client import get_card, list_cards
    lid = ids.get("PARTES")
    if not lid:
        return {}
    try:
        cards = list_cards(lid)
        rotulo = {}
        for c in cards:
            t = (c.get("titulo") or "").strip()
            if quadro.e_grupo_equipe(t):
                rotulo[str(c["id"])] = "equipe"
            elif t.startswith(("CLIENTES", "CLIENTS", "CUSTOMERS")):
                rotulo[str(c["id"])] = "cliente"
            elif t.startswith(("DEMAIS", "OTHERS", "OTHER")):
                rotulo[str(c["id"])] = "demais"
        if not rotulo:
            return {}
        with ThreadPoolExecutor(max_workers=6) as ex:
            detalhes = list(ex.map(lambda c: (c, get_card(c["id"])), cards))
        return {str(c["id"]): rotulo[str(full.get("cartao_pai_id"))]
                for c, full in detalhes
                if str(full.get("cartao_pai_id")) in rotulo}
    except Exception:
        return {}


def _partes_do_quadro(ids):
    """Lê a lista Partes Interessadas e devolve, por parte, o que o Passo 1 gravou
    (papel, o que espera, poder, interesse) MAIS o Plano de engajamento do Passo 6
    (atual→desejado, estratégia, cadência). A fonte da verdade é o quadro.

    OS RÓTULOS SÃO LIDOS NOS DOIS IDIOMAS (vocabulario.ROTULOS_PARTE): quem escreveu
    o cartão foi o próprio curso, no idioma do pacote, então o pacote inglês grava
    "Role:"/"Power:" e procurar só "Papel:"/"Poder:" devolvia toda parte VAZIA — a
    planilha saía com o nome da parte e mais nada."""
    from pa_client import list_cards
    lid = ids.get("PARTES")
    if not lid:
        return []
    grupos = _grupo_das_partes(ids)
    out = []
    for cd in list_cards(lid):
        t = (cd.get("titulo") or "").strip()
        if not t or t.startswith(quadro.PROTEGIDOS_PARTES + ("0.",)):
            continue
        nome = re.sub(r"\s*\[[^\]]*\]\s*$", "", t).strip()
        txt = entradas.texto_do_cartao(cd.get("descricao", "")) or ""
        pod = _campo_linha(txt, _rot_parte("poder"))
        if "·" in pod:                              # "alto · Interesse: baixo" -> "alto"
            pod = pod.split("·")[0].strip()
        m = re.search(rf"(?:{_rot_parte('interesse')})\s*:\s*([^\n·]+)", txt, re.I)
        inter = m.group(1).strip() if m else ""
        atual = _campo_linha(txt, _rot_parte("atual"))
        desejado = _campo_linha(txt, _rot_parte("desejado"))
        if "→" in atual or "->" in atual:
            a = atual.replace("->", "→").split("→")
            atual = a[0].strip()
            desejado = desejado or (a[1].strip() if len(a) > 1 else "")
        out.append({
            "nome": nome,
            # CLIENTES / EQUIPE / DEMAIS — ver _grupo_das_partes(). Sem esta chave, todo
            # consumidor que filtrava por grupo (`_papeis_do_quadro`) descartava TODA
            # parte e caia no fallback generico: os planos de gerenciamento saiam com uma
            # linha "Gerente do Projeto / (sem nome)" em vez da equipe real do projeto.
            "grupo": grupos.get(str(cd.get("id")), ""),
            "papel": _campo_linha(txt, _rot_parte("papel")),
            "espera": _campo_linha(txt, _rot_parte("espera")),
            # O endereço alimenta DUAS coisas: a coluna e-mail desta planilha (que nascia
            # sempre vazia) e os rascunhos que o Passo 7 escreve para cada dono.
            "email": _email(_campo_linha(txt, _rot_parte("email"))),
            "poder": pod, "interesse": inter,
            "atual": _sem_rotulo_repetido(atual, "atual"),
            "desejado": _sem_rotulo_repetido(desejado, "desejado"),
            "estrategia": _campo_linha(txt, _rot_parte("estrategia")),
            "cadencia": _campo_linha(txt, _rot_parte("cadencia")),
        })
    return out


def gerar_registro_partes(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Passo 6: gera 'Registro das partes interessadas.xlsx' — identificação (Passo 1) +
    plano de engajamento (Passo 6). A Importância (Poder × Interesse) sai por FÓRMULA VIVA
    (recalcula se o aluno mudar Poder/Interesse no Excel). Serve às duas abordagens.
    Devolve (caminho, avisos), ou (None, []) quando ainda não há parte com dado."""
    partes = [p for p in _partes_do_quadro(ids) if p["nome"]]
    if not partes:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    linhas = []
    for i, p in enumerate(partes):
        r = 4 + i                                   # 1a linha de dados = 4
        poder, inter = _rating_pi(p["poder"]), _rating_pi(p["interesse"])
        # N/O = Nível de engajamento (Atual → Desejado), colunas dedicadas do template,
        # com os rótulos que o dropdown valida; P = Estratégias; Q = Comentários (cadência).
        linha = {"B": ("=ROW()-3", i + 1), "D": p["nome"], "E": p.get("email", ""),
                 "I": p["papel"],
                 "K": p["espera"], "L": poder, "M": inter,
                 "N": _nivel_engaj(p["atual"]), "O": _nivel_engaj(p["desejado"]),
                 "P": p["estrategia"]}
        # C = Importância = Poder × Interesse (fórmula viva) — só quando há os dois níveis
        pn, mn = _num_pi(poder), _num_pi(inter)
        if pn and mn:
            linha["C"] = (f"=IF(ISTEXT(L{r}),LEFT(L{r},1),L{r})"
                          f"*IF(ISTEXT(M{r}),LEFT(M{r},1),M{r})", pn * mn)
        if p["cadencia"]:
            linha["Q"] = "Cadência: " + p["cadencia"]
        linhas.append(linha)

    versao = ("1.0", hoje, "",
              _carimbo(6, T("Partes interessadas"), "Registro das partes interessadas"))
    saida = os.path.join(_saida_dir(),
                         Tf("Registro das partes interessadas - {0}.xlsx", _slug_arquivo(projeto)))
    cam, avisos = preencher_xlsx(
        _template(T("Registro das partes interessadas.xlsx")), saida,
        aba=ABAS["partes"], primeira_linha=4, linhas=linhas,
        trocas=_trocas_capa(projeto), versao=versao, aba_capa=ABAS["capa"])
    for a in avisos:
        log(Tf("  [aviso xlsx] {0}", a))
    return cam, avisos


# ── Passo 8: Status Report (.docx) ───────────────────────────────────────────
def _atividades_por_status(ids):
    """As atividades (pacotes da EAP + tarefas de execução do Passo 7) separadas por
    status: concluídas / em andamento / próximo período. Usa `concluido` (o status vive
    no cartão) e a data de início para separar andamento de futuro."""
    from datetime import date as _date
    from pa_client import list_cards
    lid = ids.get("ESCOPO")
    if not lid:
        return [], [], []
    cards = list_cards(lid)
    concl = {str(c["id"]): quadro.card_concluido(c) for c in cards}
    ini = {str(c["id"]): (c.get("data_inicio") or "")[:10] for c in cards}
    hoje = _date.today().isoformat()
    feitas, andamento, proximo = [], [], []
    nos, _tf = quadro.arvore_da_eap(ids)                 # exclui as tarefas de execução (P7)
    for n in nos:
        if n["tipo"] != "pacote":                        # atividade = pacote de trabalho
            continue
        cid, rot = str(n["id"]), f"{n['codigo']}  {n['titulo']}"
        if concl.get(cid):
            feitas.append(rot)
        elif ini.get(cid) and ini[cid] > hoje:
            proximo.append(rot)
        else:
            andamento.append(rot)
    for c in cards:                                      # tarefas de execução do Passo 7
        if quadro.MARCA_EXEC_P7 in (c.get("descricao") or ""):
            rot = (c.get("titulo") or "").strip()
            (feitas if quadro.card_concluido(c) else andamento).append("• " + rot)
    return feitas, andamento, proximo


def gerar_status_report(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Passo 8: gera 'Status Report.docx' — o retrato do projeto para quem decide, LIDO do
    quadro: veredito e números (planejado × realizado, OKR), a EAP, as atividades por
    status e os pontos de atenção (riscos abertos). A análise escrita da IA (RESUMO/
    ALERTAS/DECISÕES) fica no cartão 📊 do quadro; aqui está o retrato estruturado.
    Serve às duas abordagens. (None, []) se ainda não há escopo (o Passo 2 não rodou)."""
    import verificar_projeto
    nos, _tf = quadro.arvore_da_eap(ids)
    if not nos:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    diag = verificar_projeto.diagnostico(ids)
    av = diag.get("okr") or {}
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    feitas, andamento, proximo = _atividades_por_status(ids)

    def _lista(rotulo, itens):
        out = [Bloco.h1(rotulo)]
        out += [Bloco.item(x) for x in itens] or [Bloco.paragrafo(T("(nenhuma neste período)"))]
        return out

    blocos = [Bloco.h1(T("Sumário executivo do status do projeto"))]
    total = len(feitas) + len(andamento) + len(proximo)
    pct = round(100 * len(feitas) / total) if total else 0
    blocos.append(Bloco.paragrafo(
        Tf("Situação em {0}. Atividades: {1} · concluídas: {2} · em andamento: {3} · a "
           "iniciar: {4} · progresso: {5}%.", hoje, total, len(feitas), len(andamento), len(proximo), pct)))
    for o in (av.get("objetivos") or []):
        blocos.append(Bloco.item(
            Tf("{0} — {1}/{2} entregas concluídas ({3}%)", (o.get('objetivo') or '').strip(), o.get('entregues', 0), o.get('entregas', 0), o.get('pct', 0))))
    blocos.append(Bloco.paragrafo(T("A análise escrita para quem decide (resumo, alertas e "
                  "decisões) está no cartão 📊 Status Report mais recente do quadro.")))

    blocos.append(Bloco.h1(T("Estrutura Analítica do Projeto (EAP)")))
    for n in nos:
        rot = f"{n['codigo']}  {n['titulo']}"
        blocos.append(Bloco.sub(rot) if n["tipo"] != "pacote" else Bloco.item(rot))

    blocos += _lista(T("Atividades Concluídas no período"), feitas)
    blocos += _lista(T("Atividades em andamento"), andamento)
    blocos += _lista(T("Atividades para o próximo período"), proximo)

    blocos.append(Bloco.h1(T("Pontos de Atenção")))
    pontos = quadro.riscos_do_quadro(ids)
    abertos = [p for p in pontos if vocabulario.avaliavel(p.get("tipo"))
               and (p.get("risco") or "").strip()]
    if abertos:
        for p in abertos:
            det = []
            if (p.get("prob_ou_urgencia") or "").strip() or (p.get("impacto") or "").strip():
                det.append(Tf("prob./urg.: {0} · impacto: {1}",
                              p.get("prob_ou_urgencia") or "-", p.get("impacto") or "-"))
            if (p.get("resposta") or "").strip():
                det.append(Tf("resposta: {0}", p["resposta"].strip()))
            blocos.append(Bloco.item(f"[{_tipo_ponto(p.get('tipo'))}] {p['risco'].strip()}"
                          + (f"  ({' | '.join(det)})" if det else "")))
    else:
        blocos.append(Bloco.paragrafo(T("(sem riscos/problemas abertos registrados)")))

    blocos.append(Bloco.h1(T("Informações adicionais")))
    blocos.append(Bloco.paragrafo(
        Tf("Relatório gerado a partir do quadro no ProjectAdm em {0}. Baseline, cronograma e "
            "orçamento nos artefatos do {1} e do {2}.", hoje,
            _referencia(4, "Cronograma do projeto"),
            _referencia(5, "Plano de gerenciamento do projeto"))))

    versao = ("1.0", hoje, "", _carimbo(8, T("Status Report"), "Status Report"))
    saida = os.path.join(_saida_dir(), Tf("Status Report - {0}.docx", _slug_arquivo(projeto)))
    caminho, avisos = preencher_docx(
        _template(T("Status Report.docx")), [], saida,
        intervalos=[(T("Sumário executivo do status do projeto"), "\x00", blocos)],
        cabecalho={T("Nome do Projeto"): projeto}, versao=versao,
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


# ── Passo 10: Lições Aprendidas (.docx) ──────────────────────────────────────
def _card_texto(ids, prefixo, chave="CONCLUIDO"):
    """O texto (sem HTML) do 1o cartão da lista `chave` cujo título começa com `prefixo`."""
    from pa_client import list_cards
    lid = ids.get(chave)
    if not lid:
        return ""
    c = next((cd for cd in list_cards(lid)
              if (cd.get("titulo") or "").startswith(prefixo)), None)
    return entradas.texto_do_cartao(c.get("descricao", "")) if c else ""


def _texto_para_blocos(txt):
    """Converte o texto de um cartão (linhas • / detalhe) em blocos do documento."""
    out = []
    for ln in (txt or "").splitlines():
        s = ln.strip()
        if not s:
            continue
        if s[0] in "•✅🟡❌":
            out.append(Bloco.item(s.lstrip("•").strip()))
        elif s.upper().startswith(("PLACAR", "VERIFICAÇÃO", "LIÇÕES")):
            out.append(Bloco.sub(s))
        else:
            out.append(Bloco.paragrafo("    " + s))
    return out


def gerar_licoes_aprendidas(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Passo 10: gera 'Licoes Aprendidas.docx' — o encerramento que prova (ou não) o valor:
    objetivos × realizado, o que atendeu escopo, os pontos fortes/fracos e as recomendações
    para o próximo projeto. Lê o OKR (Passo 1) e os cartões 🎯 Resultado do OKR e 📚 Lições
    que o Passo 10 grava. Serve às duas abordagens. (None, []) se o Passo 10 ainda não rodou
    (sem os cartões de resultado/lições) — nada a consolidar."""
    resultado = _card_texto(ids, "🎯 Resultado do OKR")
    licoes = _card_texto(ids, "📚 Lições aprendidas")
    if not resultado and not licoes:
        return None, []
    import verificar_projeto
    projeto = (projeto or _nome_projeto()).strip()
    resp = quadro.respostas_do_termo(ids)
    diag = verificar_projeto.diagnostico(ids)
    hoje = datetime.date.today().strftime("%d/%m/%Y")

    blocos = [Bloco.h1(T("Objetivos"))]
    okr_txt = (resp.get("1.2") or "").strip()
    blocos += _okr_blocos(okr_txt) if okr_txt else [Bloco.paragrafo(T("(OKR não preenchido)"))]

    blocos.append(Bloco.h1(T("Planejado x Realizado")))
    blocos.append(Bloco.h2(T("Os objetivos foram atingidos?")))
    blocos += _texto_para_blocos(resultado) or [Bloco.paragrafo(T("(sem verificação de valor registrada)"))]
    blocos.append(Bloco.h2(T("Projeto foi entregue dentro do prazo?")))
    blocos.append(Bloco.paragrafo(T("Compare as datas reais com a linha de base congelada no "
                  "Passo 5 (o desvio de prazo é medido no Status Report do Passo 8).")))
    blocos.append(Bloco.h2(T("No orçamento?")))
    blocos.append(Bloco.paragrafo(T("Compare o custo real com a linha de base dos custos "
                  "(Passo 4/5). O consumo do orçamento é reportado no Passo 8.")))
    blocos.append(Bloco.h2(T("Atendeu o escopo?")))
    blocos.append(Bloco.paragrafo(
        Tf("Entregas concluídas: {0} de {1} ({2}%). O escopo aprovado é a EAP (linha de base "
           "do Passo 2/5).", diag.get('prontos', 0), diag.get('escopo', 0), diag.get('pct', 0))))

    blocos.append(Bloco.h1(T("O Projeto")))
    blocos.append(Bloco.h2(T("Pontos fortes")))
    blocos.append(Bloco.paragrafo(T("O que repetir no próximo projeto — veja as lições abaixo.")))
    blocos.append(Bloco.h2(T("Pontos fracos")))
    blocos.append(Bloco.paragrafo(T("O que mudar no próximo projeto — veja as lições abaixo.")))

    blocos.append(Bloco.h1(T("Questões do Projeto")))
    pontos = quadro.riscos_do_quadro(ids)
    probs = [p for p in pontos if p.get("tipo") == "Problema" and (p.get("risco") or "").strip()]
    if probs:
        for p in probs:
            blocos.append(Bloco.item(p["risco"].strip()
                          + (Tf("  (resposta: {0})", p['resposta'].strip())
                             if (p.get("resposta") or "").strip() else "")))
    else:
        blocos.append(Bloco.paragrafo(T("(sem problemas registrados no projeto)")))

    blocos.append(Bloco.h1(T("Recomendações a serem adotadas para os próximos projetos")))
    blocos += _texto_para_blocos(licoes) or [Bloco.paragrafo(T("(sem lições registradas)"))]

    blocos.append(Bloco.h1(T("Questões em Aberto")))
    abertos = [p for p in pontos if vocabulario.avaliavel(p.get("tipo"))
               and (p.get("risco") or "").strip()]
    blocos.append(Bloco.paragrafo(
        Tf("{0} risco(s)/problema(s) registrado(s) no projeto — reveja o Registro dos pontos "
           "de atenção para o que ficou pendente.", len(abertos)) if abertos
        else T("(sem pendências registradas em relação às entregas)")))

    blocos.append(Bloco.h1(T("Desempenho dos fornecedores")))
    blocos.append(Bloco.paragrafo(T("(este projeto não manteve um processo de avaliação de "
                  "fornecedores à parte)")))

    versao = ("1.0", hoje, "", _carimbo(10, T("Encerramento"), "Lições Aprendidas"))
    saida = os.path.join(_saida_dir(), Tf("Licoes Aprendidas - {0}.docx", _slug_arquivo(projeto)))
    caminho, avisos = preencher_docx(
        _template(T("Licoes Aprendidas.docx")), [], saida,
        intervalos=[(T("Objetivos"), "\x00", blocos)],
        cabecalho={T("Nome do Projeto"): projeto}, versao=versao,
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


# ── Passo 9: Registro das solicitações de mudanças (.xlsx) ───────────────────
def _mudancas_do_quadro(ids):
    """Lê a lista Mudanças e devolve, por solicitação, o que o Passo 9 gravou:
    solicitação, impacto (itens), recomendação e a data. A fonte da verdade é o quadro."""
    from pa_client import list_cards
    lid = ids.get("MUDANCAS")
    if not lid:
        return []
    out = []
    for c in list_cards(lid):
        t = (c.get("titulo") or "").strip()
        if not t or quadro.e_guia(t):
            continue
        txt = entradas.texto_do_cartao(c.get("descricao", "")) or ""
        # Nos dois idiomas: o cartão foi escrito pelo Passo 9 no idioma do pacote.
        m = re.search(r"(?:Solicitada em|Requested on)\s*([0-9/\-]+)", txt, re.I)
        data = m.group(1) if m else ""
        # Os cabeçalhos em inglês ('REQUEST:', 'IMPACT:') já entram por
        # vocabulario.SECOES — entradas.secoes() devolve sempre a chave canônica.
        secs = entradas.secoes(txt, ["SOLICITACAO", "SOLICITAÇÃO", "IMPACTO",
                                     "RECOMENDACAO", "RECOMENDAÇÃO"])
        solic = (secs.get("solicitacao", "") or secs.get("solicitação", "")).strip()
        impacto = [i.get("_", "") for i in entradas.itens(secs.get("impacto", ""))]
        recom = (secs.get("recomendacao", "") or secs.get("recomendação", "")).strip()
        out.append({
            "solicitacao": solic or t.lstrip("🔄").strip(),
            "impacto": impacto, "recomendacao": recom, "data": data,
        })
    return out


def gerar_registro_mudancas(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Passo 9: gera 'Registro das solicitacoes de mudancas.xlsx' — cada mudança analisada
    (solicitação, impacto e recomendação). Os campos quantitativos (esforço, custo, prazo)
    e a decisão ficam para o aluno preencher com o patrocinador. Serve às duas abordagens.
    (None, []) quando ainda não há mudança registrada (o Passo 9 não rodou)."""
    mudancas = _mudancas_do_quadro(ids)
    if not mudancas:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    linhas = []
    for i, m in enumerate(mudancas):
        just = []
        if m["impacto"]:
            just.append(Tf("Impacto: {0}", "; ".join(m["impacto"])))
        if m["recomendacao"]:
            just.append(Tf("Recomendação: {0}", m["recomendacao"]))
        linhas.append({
            "B": ("=ROW()-2", i + 1),               # Ref. (1a linha de dados = 3)
            "F": m["data"] or hoje,                  # Data da Solicitação
            "G": m["solicitacao"],                   # Descrição da Solicitação
            "H": " | ".join(just),                   # Justificativa (impacto + recomendação)
        })
    versao = ("1.0", hoje, "",
              _carimbo(9, T("Mudanças"), "Solicitação de mudança"))
    saida = os.path.join(_saida_dir(),
                         Tf("Registro das solicitacoes de mudancas - {0}.xlsx", _slug_arquivo(projeto)))
    cam, avisos = preencher_xlsx(
        _template(T("Registro das solicitacoes de mudancas.xlsx")), saida,
        aba=ABAS["mudancas"], primeira_linha=3, linhas=linhas,
        trocas=_trocas_capa(projeto), versao=versao, aba_capa=ABAS["capa"])
    for a in avisos:
        log(Tf("  [aviso xlsx] {0}", a))
    return cam, avisos


# ── Passo 7: Kick-Off do Projeto (.pptx) ─────────────────────────────────────
# O template (Modelos/Kick-Off do Projeto.pptx) é gerado por tools/build_kickoff_template.py
# no design system do curso. Slides ESTÁTICOS (agenda, metodologia, os 10 passos, fecho)
# vêm prontos; os DINÂMICOS têm só o título e um corpo VAZIO que preenchemos aqui — a CHAVE
# de `conteudos` é o TÍTULO do slide (documentos.preencher_pptx casa por substring do título).
def _curto(s, n):
    s = (s or "").strip()
    return s if len(s) <= n else s[:n - 1].rstrip() + "…"


def _kickoff_gerente():
    """O nome do gerente do projeto (o aluno), lido do .env (Passo 1). '' se não informado —
    a API do ProjectAdm não expõe esse nome, então é o aluno que o informa."""
    from env import cfg
    g = (cfg().get("MANAGER") or "").strip()
    return "" if (not g or g.upper().startswith("ESCREVA")) else g


def _kickoff_justificativa(texto):
    txt = (texto or "").strip()
    if not txt:
        return [T("(justificativa a preencher — rode o Passo 1)")]
    linhas = [l.strip() for l in txt.splitlines() if l.strip()]
    if len(linhas) == 1 and len(linhas[0]) > 260:      # um blob longo: agrupa por frase
        frases = [f.strip() for f in re.split(r"(?<=[.!?])\s+", linhas[0]) if f.strip()]
        linhas, buf = [], ""
        for f in frases:
            if buf and len(buf) + len(f) + 1 > 240:
                linhas.append(buf)
                buf = f
            else:
                buf = (buf + " " + f).strip()
        if buf:
            linhas.append(buf)
    return linhas[:6]


def _kickoff_okr(texto):
    objetivos = (entradas.okr(texto) or {}).get("objetivos") or []
    if not objetivos:
        return [T("(objetivos a preencher — rode o Passo 1)")]
    out = []
    for i, o in enumerate(objetivos, 1):
        rot = (Tf("Objetivo {0}: ", i) if len(objetivos) > 1 else T("Objetivo: "))
        out.append((0, rot + (o.get("objetivo") or "").strip()))
        for j, kr in enumerate(o.get("key_results") or [], 1):
            meta = (kr.get("meta") or "").strip()
            selo = T(" (provisória)") if kr.get("meta_provisoria") else ""
            linha = f"KR {i}.{j}: {(kr.get('kr') or '').strip()}"
            if meta:
                linha += Tf(" — meta: {0}", meta) + selo
            out.append((1, linha))
    return out[:14]


def _kickoff_fases_entregas(ids):
    nos, tem_fases = quadro.arvore_da_eap(ids)
    if not nos:
        return [T("(escopo a definir — rode o Passo 2)")]
    out = []
    for n in nos:
        if tem_fases and n["nivel"] == 1:               # fase = cabeçalho
            out.append((0, f"{n['codigo']}  {n['titulo']}"))
        elif n["tipo"] != "pacote":                     # entrega = bullet, em qualquer nível
            # A profundidade da EAP vira o NÍVEL do bullet: numa EAP de 4 níveis (lote >
            # filial > entrega) o kick-off mostrava só o 2º e ENGOLIA os demais — quem
            # ouvia a apresentação não via as entregas de cada unidade (Eduardo,
            # 2026-07-30). Antes o recuo era espaço digitado; agora é nível de verdade,
            # com o bullet e o recuo que o template dá a cada um.
            nivel = max(0, n["nivel"] - (2 if tem_fases else 1)) + (1 if tem_fases else 0)
            out.append((min(nivel, 4), n["titulo"]))
        # pacotes de trabalho (as folhas) ficam de fora — kick-off = PRINCIPAIS entregas
    return out[:16]


def _kickoff_restricoes_premissas(ids, resp):
    pontos = quadro.riscos_do_quadro(ids)
    restr = [p["risco"].strip() for p in pontos
             if p.get("tipo") == "Restrição" and (p.get("risco") or "").strip()]
    prem = [p["risco"].strip() for p in pontos
            if p.get("tipo") == "Premissa" and (p.get("risco") or "").strip()]
    if not restr:                                       # fallback ao termo (Passo 1)
        restr = entradas.simples(resp.get("4.2") or "")
    if not prem:
        prem = entradas.simples(resp.get("4.3") or "")
    # 200 caracteres, nao 120: o corte antigo foi calibrado para o corpo em 28pt, que
    # enchia a lamina. Com o nivel 2 em 14pt (06/08/2026) sobrava meia lamina em branco e
    # a restricao chegava cortada num "…" — agora cabe a frase inteira em duas linhas.
    out = [(0, T("RESTRIÇÕES"))]                        # bloco de restrições primeiro (pedido do Eduardo)
    out += [(1, _curto(r, 200)) for r in restr[:5]] or [(1, T("(nenhuma registrada)"))]
    out.append((0, T("PREMISSAS")))
    out += [(1, _curto(p, 200)) for p in prem[:5]] or [(1, T("(nenhuma registrada)"))]
    return out


def _kickoff_riscos(ids):
    pontos = [p for p in quadro.riscos_do_quadro(ids)
              if vocabulario.avaliavel(p.get("tipo")) and (p.get("risco") or "").strip()]
    if not pontos:
        return [T("(riscos a analisar — rode o Passo 3)")]

    def _score(p):
        s, _n = quadro.prioridade_do_risco(p.get("prob_ou_urgencia"), p.get("impacto"))
        return s or 0

    pontos.sort(key=_score, reverse=True)               # top 5 por prioridade (o pior primeiro)
    out = []
    for p in pontos[:5]:
        # O risco e a sua RESPOSTA sao pai e filho, nao uma linha so' com uma seta no
        # meio: assim a resposta cabe inteira (era cortada em 70 caracteres para caber
        # ao lado) e o slide vira SmartArt com o tratamento embaixo da ameaca.
        out.append((0, _curto(p["risco"].strip(), 200)))
        resp = (p.get("resposta") or "").strip()
        if resp:
            out.append((1, _curto(resp, 200)))
    return out


def _kickoff_marcos_orcamento(ids, resp):
    out = [(0, T("MARCOS"))]
    linhas_m = []
    for m in entradas.marcos(resp.get("5") or "")[:6]:
        marco = (m.get("marco") or "").strip()
        if not marco:
            continue
        quando = (m.get("quando") or "").strip()
        linhas_m.append((1, marco + (f" — {quando}" if quando else "")))
    if not linhas_m:                                    # deriva das datas de fim do cronograma
        for l in (quadro.cronograma_do_quadro(ids) or []):
            if l.get("nivel", 9) <= 1 and l.get("fim"):
                linhas_m.append((1, Tf("{0} concluída — {1}", l["titulo"], l["fim"])))
        linhas_m = linhas_m[:6]
    out += linhas_m or [(1, T("(marcos a definir — rode o Passo 4)"))]

    out.append((0, T("ORÇAMENTO")))
    itens = quadro.orcamento_do_quadro(ids)
    if itens:
        total = sum(it["valor_num"] for it in itens)
        reservas = sum(it["valor_num"] for it in itens
                       if "reserv" in entradas._sem_acento(it.get("tipo", ""))
                       or "conting" in entradas._sem_acento(it.get("nome", "")))
        out.append((1, Tf("Custo base das estimativas: {0}", _reais(total - reservas))))
        if reservas:
            out.append((1, Tf("Reserva de contingência: {0}", _reais(reservas))))
        out.append((1, Tf("Linha de base dos custos (total): {0}", _reais(total))))
    else:
        out.append((1, T("(orçamento a estimar — rode o Passo 4)")))
    return out


def gerar_kickoff(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Passo 7: gera 'Kick-Off do Projeto.pptx' — a apresentação que LANÇA a execução,
    lida do quadro: nome do projeto, patrocinador e gerente na capa; justificativa; OKR;
    fases e principais entregas; restrições e premissas (até 5 de cada); os 5 principais
    riscos por prioridade; e os marcos e o orçamento. Os slides de metodologia (por que
    engajar agora, os 10 passos, o fecho) são estáticos. Serve às duas abordagens.
    (None, []) se o Passo 1 ainda não foi respondido."""
    from documentos import preencher_pptx
    resp = quadro.respostas_do_termo(ids)

    def g(rot):
        return (resp.get(rot) or "").strip()

    if not g("1.1") and not g("1.2"):
        return None, []
    projeto = (projeto or _nome_projeto()).strip()

    partes = entradas.partes(g("3"))
    patroc = next((p.get("nome", "").strip() for p in partes
                   if "patrocin" in entradas._sem_acento(
                       (p.get("papel", "") or "") + " " + (p.get("nome", "") or ""))), "")
    cliente = next((p.get("nome", "").strip() for p in partes
                    if (p.get("grupo") or "").lower() == "cliente"), "")
    alvo = patroc or cliente
    gerente = _kickoff_gerente()

    # AS CHAVES SÃO ÂNCORAS DO TEMPLATE, não texto solto: `trocas` casa o placeholder da
    # capa por prefixo e `conteudos` casa o TÍTULO do slide. No template inglês a capa diz
    # "[Project]" e o slide se chama "Project Justification" — com a âncora em português
    # nada casava e a apresentação saía com os seis slides de conteúdo VAZIOS, sem um
    # aviso sequer (o _pptx_corpo só ignora o slide). Eduardo, quadro 324/FIRE, 06/08/2026.
    trocas = {
        T("[Projeto"): projeto,
        T("[Cliente"): (Tf("Patrocinador: {0}", _curto(alvo, 60)) if alvo
                        else T("Patrocinador: (a definir)")),
        T("[Gerente do Projeto"): (Tf("Gerente do projeto: {0}", gerente) if gerente
                                   else T("Gerente do projeto: (preencha o seu nome no Passo 1)")),
    }
    conteudos = {
        T("Justificativa do Projeto"): _kickoff_justificativa(g("1.1")),
        T("Objetivos e Key Results"): _kickoff_okr(g("1.2")),
        T("Fases e Principais Entregas"): _kickoff_fases_entregas(ids),
        T("Restrições e Premissas"): _kickoff_restricoes_premissas(ids, resp),
        T("Principais Riscos"): _kickoff_riscos(ids),
        T("Marcos e Orçamento"): _kickoff_marcos_orcamento(ids, resp),
    }

    saida = os.path.join(_saida_dir(), Tf("Kick-Off do Projeto - {0}.pptx", _slug_arquivo(projeto)))
    caminho, avisos = preencher_pptx(
        _template(T("Kick-Off do Projeto.pptx")), saida, trocas=trocas, conteudos=conteudos)
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


# ── Registro: passo -> artefato(s). O menu e o fim de cada passo consultam isto. ──
#
# QUEM GERA O QUE VEM DO CATALOGO, NAO DAQUI (13/08/2026)
# ------------------------------------------------------
# `courses/_together_kit/catalogo/catalogo_pmbok8.json` e' a fonte unica que os dois
# curriculos compartilham (Project Together, 10 PASSOS · PMBOK 8 Together, 40 PROCESSOS).
# Manter uma segunda tabela passo->funcao aqui era o mesmo fork que fechamos nos modelos:
# quem consertasse de um lado nao consertaria do outro.
#
# O ROTULO fica aqui de proposito. Ele e' texto de INTERFACE e passa pelo T(): se viesse
# do .json, o `extrair_textos.py` nao o acharia e o pacote EN mostraria os dez rotulos em
# portugues — foi assim que o "COMO RODAR" em portugues chegou aos 11 cartoes do quadro
# em ingles. Dado no catalogo, frase no codigo.
_ROTULOS = {
    1: T("Termo de Abertura do Projeto (.docx)"),
    2: T("Dicionário da EAP (.docx e .xlsx)"),
    3: T("Registro dos pontos de atenção (.xlsx)"),
    4: T("Cronograma (.xlsx + MS Project .xml) e Orçamento (.docx)"),
    5: T("Plano de Gerenciamento do Projeto (.docx)"),
    6: T("Registro das partes interessadas (.xlsx)"),
    7: T("Kick-Off do Projeto (.pptx)"),
    8: T("Status Report (.docx)"),
    9: T("Registro das solicitações de mudanças (.xlsx)"),
    10: T("Lições Aprendidas (.docx)"),
}


def _montar_artefatos():
    """passo -> (rotulo, funcao), com a funcao vinda do catalogo.

    Se o catalogo faltar ou apontar para uma funcao que nao existe, LEVANTA. Um passo que
    silenciosamente deixasse de gerar o documento e' pior do que um erro na cara: o aluno
    roda, nao recebe arquivo e conclui que quebrou (a mesma armadilha dos 6 processos de
    versao nova do PMBOK 8 Together)."""
    import catalogo_pmbok8 as cat
    out = {}
    for passo, rotulo in _ROTULOS.items():
        nome_fn = cat.gerador_do_passo(passo)
        if not nome_fn:
            raise RuntimeError(Tf("catálogo sem gerador para o passo {0}", passo))
        fn = globals().get(nome_fn)
        if not callable(fn):
            raise RuntimeError(Tf(
                "catálogo aponta o passo {0} para {1}(), que não existe em artefatos.py",
                passo, nome_fn))
        out[passo] = (rotulo, fn)
    return out


ARTEFATOS = _montar_artefatos()


def gerar_disponiveis(ids, abordagem=None, log=lambda _m: None):
    """Gera todos os artefatos cujo passo-fonte ja tem dados no quadro.

    Um gerador devolve (saida, avisos): `saida` pode ser um caminho, uma LISTA de
    caminhos (ex.: a EAP gera .docx e .xlsx) ou None (ainda nao ha o que gerar).
    Devolve [(rotulo, caminho), ...].
    """
    gerados = []
    for _passo, (rotulo, fn) in sorted(ARTEFATOS.items()):
        try:
            saida, _av = fn(ids, abordagem=abordagem, log=log)
        except Exception as e:
            log(Tf("  [aviso] não consegui gerar {0}: {1}", rotulo, e))
            continue
        if not saida:
            continue
        for caminho in ([saida] if isinstance(saida, str) else list(saida)):
            gerados.append((rotulo, caminho))
    return gerados


# ═════════════════════════════════════════════════════════════════════════════
# PMBOK 8 TOGETHER — os processos do curriculo de 40 que o de 10 passos nao cobre
#
# O QUE MUDA AQUI EM RELACAO AOS GERADORES DE CIMA
#     Os de cima preenchem os modelos do CURSO, que sao de texto corrido. Estes
#     preenchem os modelos do KIT (`templates/pmbok8/EP/`), que sao TABULARES por
#     desenho — "Aspecto | Descricao", "Papel | Responsabilidades | Nome". Por isso
#     usam `tabelas=` de `preencher_docx`, que nasceu para eles (13/08/2026).
#
# E POR QUE ELES SAO DERIVADOS, E NAO UM FORMULARIO NOVO
#     Eduardo, 13/08: "trabalhar de forma didatica respeitando as melhores praticas do
#     Guia PMBOK 8, comecando no levantamento de alto nivel nos passos 1 e 2".
#     A aba `Process` da planilha confirma: a ENTRADA do processo 3 e' o Termo de Abertura.
#     Entao o processo nao abre cartao em branco — ele PROPOE a partir do que ja' foi
#     respondido no 5W2H e o aluno confirma. E' a regra de ouro do curso.
# ═════════════════════════════════════════════════════════════════════════════
def _modelo_da_saida(saida, padrao_pt):
    """O arquivo de modelo desta SAIDA no idioma corrente (cai no PT se nao houver EN).

    POR QUE ISTO EXISTE (15/08/2026)
        Os geradores nomeiam o modelo em portugues, direto no codigo. Funciona porque o
        modelo PT e o EN sao o MESMO documento em dois idiomas — e' o caso de 32 dos 33.
        Nao e' o caso da Avaliacao de Desempenho da Equipe: no Kit ela e' `.docx` em PT e
        `.xlsx` em EN. Um nome fixo no codigo nao atravessa isso.

        O catalogo ja' sabe a resposta (`artefatos[saida].modelo.{pt,en}`), entao quem
        pergunta e' ele. Sem catalogo em maos, ou sem par EN, vale o nome PT: degrada,
        nunca some.
    """
    try:
        import catalogo_pmbok8 as cat
        idi = "en" if lang() == "EN" else "pt"
        return cat.modelo_do_artefato(saida, idi) or padrao_pt
    except Exception:
        return padrao_pt


def _template_kit(nome):
    """Acha um modelo do KIT (no pacote do aluno ou no repo)."""
    candidatos = [
        os.path.join(HERE, "modelos", nome),
        os.path.join(HERE, "Modelos", nome),
        os.path.abspath(os.path.join(HERE, "..", "Modelos", nome)),
        # repo: templates/pmbok8/EP (PT) — 4 niveis acima de student-scripts/
        os.path.abspath(os.path.join(HERE, "..", "..", "..", "templates", "pmbok8", "EP", nome)),
        # ...e os modelos EN, que NAO ficam em EP/: no pacote do aluno eles chegam em
        # modelos/ (o build copia de Modelos/en/), mas no REPO so' existem nestes dois
        # lugares. Sem eles, testar o caminho EM INGLES a partir do repo e' impossivel —
        # foi o que aconteceu em 15/08/2026 ao gerar o Termo de Aceite EN.
        os.path.abspath(os.path.join(HERE, "..", "Modelos", "en", nome)),
        os.path.abspath(os.path.join(HERE, "..", "..", "..", "templates", "pmbok8", nome)),
    ]
    for c in candidatos:
        if os.path.exists(c):
            return c
    raise FileNotFoundError("modelo do Kit nao encontrado: " + nome)


def _aprovadores(ids):
    """[Papel, Nome, Assinatura, Data] — quem aprova, lido das partes interessadas."""
    linhas = []
    for p in _partes_do_quadro(ids):
        papel = (p.get("papel") or "").lower()
        nome = (p.get("nome") or "").strip()
        if not nome:
            continue
        if any(k in papel for k in ("patrocin", "sponsor", "aprova", "approve")):
            linhas.append([T("Patrocinador do Projeto"), nome, "", ""])
        elif any(k in papel for k in ("gerente", "manager", "decis")):
            linhas.append([T("Gerente do Projeto"), nome, "", ""])
    if not linhas:
        linhas = [[T("Patrocinador do Projeto"), "", "", ""],
                  [T("Gerente do Projeto"), "", "", ""]]
    return linhas


def _papeis_do_quadro(ids, foco):
    """[Papel, Responsabilidades, Nome] a partir das partes interessadas do quadro."""
    linhas = []
    for p in _partes_do_quadro(ids):
        nome = (p.get("nome") or "").strip()
        if not nome:
            continue
        grupo = (p.get("grupo") or "").lower()
        # A frase e' "Executa <o foco>: <o papel da pessoa>". Ate' 14/08/2026 ela nunca
        # apareceu — `_partes_do_quadro` nao devolvia `grupo`, entao TODA parte caia no
        # `continue` e a tabela saia com uma linha generica. Com o grupo no lugar, o
        # "{foco} — {papel}" de antes lia "o escopo — Decisor final", que nao e' frase.
        if grupo == "equipe":
            resp = Tf("Executa {0}: {1}", foco.lower(),
                      p.get("papel") or T("participa da execução"))
        elif grupo == "cliente":
            resp = Tf("Valida {0}: {1}", foco.lower(), p.get("espera") or T("o resultado esperado"))
        else:
            continue                      # 'demais' nao tem papel de execucao no plano
        linhas.append([p.get("papel") or T("Participante"), resp, nome])
    return linhas or [[T("Gerente do Projeto"), Tf("Responsável por {0}", foco.lower()), ""]]


def _metricas_dos_krs(texto_okr):
    """[Métrica, Valor Alvo, Frequência, Responsável] — os KRs do cartão 1.2 viram as
    métricas de controle do plano. O KR JA' E' a métrica: reescrevê-la seria inventar."""
    linhas = []
    d = entradas.okr(texto_okr) or {}
    for i, o in enumerate(d.get("objetivos") or [], 1):
        for j, kr in enumerate(o.get("key_results") or [], 1):
            texto = (kr.get("kr") or "").strip()
            if not texto:
                continue
            linhas.append([f"KR {i}.{j}: {texto}", (kr.get("meta") or "").strip(),
                           T("A cada revisão do plano"), T("Gerente do Projeto")])
    return linhas or [["", "", "", ""]]


def _quem_aprova(ids):
    """Uma frase nomeando quem aprova, a partir das partes interessadas do quadro."""
    sponsor = gp = ""
    for p in _partes_do_quadro(ids):
        papel = (p.get("papel") or "").lower()
        nome = (p.get("nome") or "").strip()
        if not nome:
            continue
        if not sponsor and any(k in papel for k in ("patrocin", "sponsor")):
            sponsor = nome
        if not gp and any(k in papel for k in ("gerente", "manager")):
            gp = nome
    if sponsor and gp:
        return Tf("{0} (patrocinador) é a autoridade de aprovação; {1} (gerente do "
                  "projeto) decide dentro dos limites definidos abaixo.", sponsor, gp)
    if sponsor:
        return Tf("{0} (patrocinador) é a autoridade de aprovação designada.", sponsor)
    return T("► VOCÊ DECIDE: o quadro ainda não tem quem seja patrocinador. Nomeie a "
             "autoridade de aprovação no cartão 3 (QUEM) do Termo de Abertura.")


def _abordagem_por_extenso(abordagem):
    return (T("preditiva (escopo definido no início, entregue de uma vez)")
            if (abordagem or "PREDITIVA").upper() == "PREDITIVA"
            else T("adaptativa (escopo elaborado em ciclos, entregue em incrementos)"))


def _plano_de_dominio(ids, abordagem, projeto, modelo, titulo_doc, foco,
                      aspectos, ferramentas, log, metricas=None):
    """O molde dos 8 'Plano de gerenciamento de X' — todos usam o MESMO modelo do Kit
    (1. Objetivo · 2. Abordagem · 3. Papeis · 4. Ferramentas · 5. Metricas · 6. Aprovacoes
    · 7. Versao). O que muda entre eles e' o FOCO, os ASPECTOS e as FERRAMENTAS, que
    chegam por parametro. Um plano novo custa uma chamada, nao uma funcao.

    `metricas` troca a tabela 5 quando o dominio tem metrica PROPRIA melhor do que os KRs:
    no Plano de Engajamento, a metrica que importa e' a lacuna atual->desejado de cada
    parte interessada, que o quadro ja' guarda. Sem o parametro continua valendo o KR do
    cartao 1.2 — que e' a metrica certa para todos os outros."""
    resp = quadro.respostas_do_termo(ids)
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    mapa = [(T("1. objetivo do documento"), [
        Bloco.paragrafo(Tf(
            "Este documento descreve como {0} será conduzido no projeto {1}. Ele foi "
            "derivado do Termo de Abertura — da abordagem de desenvolvimento escolhida, "
            "do escopo de alto nível e das partes interessadas já identificadas — e deve "
            "ser revisado por quem responde pelo projeto antes de valer como acordo.",
            foco.lower(), projeto)),
    ])]
    tabelas = [
        (T("2. abordagem e metodologia"), aspectos),
        (T("3. papéis e responsabilidades"), _papeis_do_quadro(ids, foco)),
        (T("4. ferramentas e técnicas"), ferramentas),
        (T("5. métricas e controle"), metricas or _metricas_dos_krs(resp.get("1.2", ""))),
        (T("6. aprovações"), _aprovadores(ids)),
    ]
    saida = os.path.join(_saida_dir(), Tf("{0} - {1}.docx", titulo_doc, _slug_arquivo(projeto)))
    caminho, avisos = preencher_docx(
        _template_kit(modelo), mapa, saida,
        capa={"[Nome do Projeto]": projeto},
        cabecalho={T("Nome do Projeto"): projeto},
        tabelas=tabelas,
        versao=("1.0", hoje, "", Tf("Derivado do Termo de Abertura ({0})",
                                    prompts.nome_abordagem(abordagem))),
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


def gerar_plano_do_escopo(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Etapa 3 (Planejar o gerenciamento do escopo) -> Plano de gerenciamento do escopo."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    resp = quadro.respostas_do_termo(ids)
    # AS QUATRO PERGUNTAS sao as do livro PMBOK 8 na Pratica, cap. 4, §4.4.1 — nao uma
    # estrutura minha. O livro e' explicito: o plano "responde a quatro perguntas antes de
    # a coleta de requisitos comecar. Nao uma descricao do que o projeto vai entregar. Um
    # livro de regras de COMO o projeto vai gerenciar o que entrega."
    estrutura = (T("Estrutura Analítica do Projeto (EAP): fases, entregas e pacotes de trabalho")
                 if abordagem.upper() == "PREDITIVA"
                 else T("Product Backlog: itens de valor priorizados"))
    aprovador = _quem_aprova(ids)
    aspectos = [
        [T("1. Quem aprova mudanças de escopo?"),
         Tf("{0} A autoridade precisa nomear pessoas específicas e os limites que "
            "determinam qual nível se aplica (§4.4.1).", aprovador)],
        [T("2. Que ferramenta ou formato captura o escopo?"),
         Tf("O quadro do projeto no ProjectAdm é a fonte única da verdade. O escopo vive "
            "na lista de escopo, decomposto em {0}. Não precisa ser sofisticado: precisa "
            "ser o sistema único que todos concordam em usar (§4.4.1).", estrutura)],
        [T("3. Quais são os limites de mudança?"),
         T("► VOCÊ DECIDE. Defina como NÚMEROS — horas de esforço, valores ou dias de "
           "atraso — não como 'mudanças pequenas' e 'grandes'. O livro traz o limite de "
           "3 níveis do ProjectAdm como referência: abaixo de 8 horas o GP decide; de 8 a "
           "40 horas, aval do patrocinador por e-mail em 48 h; acima de 40 horas, revisão "
           "do CCM com impacto escrito. Esse único limite resolveu 31 das 47 solicitações "
           "sem consumir tempo do comitê (§4.4.1).")],
        [T("4. Como as entregas serão validadas e aceitas?"),
         T("Cada pacote de trabalho carrega um critério de aceitação verificável, e a "
           "entrega só é aceita quando o critério é atendido e registrado. ► VOCÊ DECIDE "
           "quem inspeciona, quem dá o aceite formal, em que prazo, e qual é o processo de "
           "retrabalho se a entrega for rejeitada (§4.4.1).")],
        [T("Abordagem de desenvolvimento"), _abordagem_por_extenso(abordagem)],
        [T("Fora do escopo (do Termo de Abertura)"),
         (resp.get("4.6") or T("(defina os limites no cartão 4.6)"))[:600]],
    ]
    ferramentas = [
        [T("Quadro do projeto no ProjectAdm"),
         T("Fonte única e autoritativa do escopo"), T("Gerente do Projeto")],
        [T("Decomposição"),
         T("Abrir entregas em pacotes de trabalho estimáveis e verificáveis"), T("Equipe")],
        [T("Critério de aceitação por pacote"),
         T("Tornar cada pacote inspecionável — o padrão contra o qual a entrega é validada"),
         T("Cliente")],
        [T("Lista Mudanças do quadro"),
         T("Registrar a solicitação e o impacto antes da decisão"), T("Gerente do Projeto")],
    ]
    return _plano_de_dominio(ids, abordagem, projeto,
                             "Plano-de-Gerenciamento-do-Escopo-template.docx",
                             T("Plano de Gerenciamento do Escopo"),
                             T("o gerenciamento do escopo"), aspectos, ferramentas, log)


def gerar_plano_dos_requisitos(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Etapa 3 (Planejar o gerenciamento do escopo) -> Plano de ger. dos requisitos."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    # AS CINCO PERGUNTAS sao as do livro, cap. 4, §4.4.2. O livro avisa que este plano
    # descreve o PROCESSO, nao o conteudo: "os requisitos em si vem no proximo capitulo".
    # E fecha com um limite: "se o seu plano passa de duas paginas, voce esta' super-elaborando."
    aspectos = [
        [T("1. Como os requisitos serão coletados?"),
         T("A partir das entregas do Termo de Abertura: para cada entrega, o requisito que "
           "ela precisa atender e o critério que prova o atendimento. ► VOCÊ DECIDE se o "
           "método é entrevista, workshop facilitado, pesquisa, análise de documentos ou "
           "prototipagem — e o livro pede em termos operacionais: quem conduz, em que "
           "cronograma, que documentação sai (§4.4.2).")],
        [T("2. Como serão numerados e rastreados?"),
         T("Convenção REQ-001, REQ-002 — é o mecanismo que permite rastrear um requisito "
           "da coleta até a entrega da EAP e referenciá-lo sem ambiguidade numa "
           "solicitação de mudança. Coletar os requisitos atribui esses IDs automaticamente a "
           "partir do cartão 2.2, e Definir o escopo os reusa nos critérios de "
           "aceitação (§4.4.2).")],
        [T("3. Como requisitos conflitantes serão resolvidos?"),
         T("► VOCÊ DECIDE, e escreva como PROCEDIMENTO com prazo, não como princípio. O "
           "livro dá a forma: 'O GP facilita a resolução entre as partes; se não resolvido "
           "em 48 horas, escala para o patrocinador.' Em projeto com mais de duas partes "
           "interessadas, conflito não é exceção — é inevitabilidade (§4.4.2).")],
        [T("4. O que significa 'requisito aprovado'?"),
         T("► VOCÊ DECIDE quem pode aprovar um requisito para entrar na linha de base do "
           "escopo, e em que prazo. Requisito vindo do patrocinador tem autoridade "
           "diferente de requisito levantado por usuário final. Sem prazo realista, a "
           "coleta trava num decisor indisponível (§4.4.2).")],
        [T("5. Como mudanças de requisito serão tratadas depois de aprovados?"),
         T("Pela lista Mudanças do quadro, com impacto avaliado antes da decisão. ► VOCÊ "
           "DECIDE se ela segue os mesmos limites do Plano de Gerenciamento do Escopo ou "
           "tem processo próprio — o livro é explícito: os dois planos precisam ser "
           "coerentes entre si (§4.4.2).")],
    ]
    ferramentas = [
        [T("Cartão de entrega no quadro"),
         T("Guardar requisito e critério de aceitação juntos"), T("Gerente do Projeto")],
        [T("Convenção REQ-NNN"),
         T("Referenciar um requisito sem ambiguidade"), T("Gerente do Projeto")],
        [T("Matriz de rastreabilidade dos requisitos"),
         T("Ligar requisito → origem → entrega; requisito sem entrega é escopo prometido "
           "e não entregue"), T("Equipe")],
    ]
    return _plano_de_dominio(ids, abordagem, projeto,
                             "Plano-de-Gerenciamento-dos-Requisitos-template.docx",
                             T("Plano de Gerenciamento dos Requisitos"),
                             T("o gerenciamento dos requisitos"), aspectos, ferramentas, log)


def gerar_documentacao_requisitos(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 4 (Coletar os requisitos) -> Documentacao dos Requisitos (.docx).

    Le' o cartao 2.2 do 5W2H: cada entrega ja' trouxe o REQUISITO e o CRITERIO DE
    ACEITACAO. O processo 4 nao pergunta de novo — ele consolida o que o processo 1
    levantou em alto nivel e da' um ID a cada requisito, que e' o que torna a
    rastreabilidade possivel nos processos 5, 6 e 31."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    resp = quadro.respostas_do_termo(ids)
    itens = entradas.entregas(resp.get("2.2", ""))
    if not itens:
        return None, []
    hoje = datetime.date.today().strftime("%d/%m/%Y")

    # OS CINCO COMPONENTES sao os do livro, cap. 5, §5.4.1 — nao uma tabela plana de
    # requisitos. O livro: "uma documentacao de requisitos bem estruturada tem cinco
    # componentes", e todo requisito passa antes por um teste de quatro partes: e' CLARO,
    # COMPLETO, CONSISTENTE e VERIFICAVEL. O que o quadro nao guarda (requisitos de
    # transicao) e' declarado como pendencia, nao inventado.
    requisitos, criterios = [], []
    requisitos.append([T("REQUISITOS DE NEGÓCIO"),
                       (resp.get("1.1") or T("(a justificativa está no cartão 1.1)"))[:700]])
    for p in _partes_do_quadro(ids):
        nome, espera = (p.get("nome") or "").strip(), (p.get("espera") or "").strip()
        if nome and espera:
            requisitos.append([Tf("REQUISITO DE PARTE INTERESSADA · {0}", nome), espera])
    requisitos.append([T("REQUISITOS DA SOLUÇÃO"),
                       T("Declarados abaixo, um por entrega. Funcional: 'a entrega deve "
                         "[verbo] [objeto] [condição]', preciso o bastante para escrever "
                         "um teste. Não funcional: número verificável ou padrão "
                         "mensurável, nunca adjetivo (§5.4.1).")])
    for i, e in enumerate(itens, 1):
        nome = (e.get("nome") or "").strip()
        req = (e.get("requisito") or "").strip()
        ace = (e.get("aceitacao") or "").strip()
        if not nome:
            continue
        rid = f"REQ-{i:03d}"
        requisitos.append([f"{rid} · {nome}", req or T("(requisito a detalhar)")])
        if ace:
            criterios.append([rid, ace, T("Inspeção da entrega"), T("Cliente")])
    requisitos.append([T("REQUISITOS DE TRANSIÇÃO"),
                       T("► VOCÊ COMPLETA. O que é preciso para passar do estado atual ao "
                         "estado entregue: migração de dados, treinamento, operação em "
                         "paralelo, protocolo de virada. O livro avisa que estes são 'os "
                         "requisitos mais comumente omitidos na documentação inicial e mais "
                         "comumente descobertos como trabalho-surpresa no fim da "
                         "execução' (§5.4.1).")])
    restr = "; ".join(i.get("_", "").strip()
                      for i in entradas.itens(resp.get("4.2", "")) if i.get("_"))
    prem = "; ".join(i.get("_", "").strip()
                     for i in entradas.itens(resp.get("4.3", "")) if i.get("_"))
    requisitos.append([T("RESTRIÇÕES E PREMISSAS"),
                       Tf("Restrições: {0}\nPremissas: {1}",
                          restr or T("(nenhuma registrada)"),
                          prem or T("(nenhuma registrada)"))])
    if not criterios:
        criterios = [["", "", "", ""]]

    mapa = [(T("1. objetivo do documento"), [
        Bloco.paragrafo(Tf(
            "Este documento é o registro estruturado dos requisitos do projeto {0}, num "
            "formato que sustenta revisão, aprovação e rastreabilidade. Não é transcrição "
            "de reunião: é documento verificado, com o aval das partes interessadas, que "
            "define o que o projeto deve entregar.", projeto)),
        Bloco.vazio(),
        Bloco.paragrafo(T(
            "Antes de aprovar, submeta cada requisito ao teste de quatro partes: ele é "
            "CLARO (tem uma única interpretação)? COMPLETO (especifica todas as condições "
            "para construir contra ele)? CONSISTENTE (não contradiz outro requisito)? "
            "VERIFICÁVEL (pode ser testado e aceito ou rejeitado)? Requisito que falha em "
            "qualquer um deles não está pronto para a linha de base — devolva-o à parte "
            "interessada antes de aprovar o documento.")),
        Bloco.vazio(),
        Bloco.paragrafo(Tf("{0} requisito(s) com identificador, na abordagem {1}.",
                           len(itens), prompts.nome_abordagem(abordagem))),
    ])]
    saida = os.path.join(_saida_dir(),
                         Tf("Documentação dos Requisitos - {0}.docx", _slug_arquivo(projeto)))
    caminho, avisos = preencher_docx(
        _template_kit("Documentacao-dos-Requisitos-template.docx"), mapa, saida,
        capa={"[Nome do Projeto]": projeto},
        cabecalho={T("Nome do Projeto"): projeto},
        tabelas=[(T("2. documentação dos requisitos"), requisitos),
                 (T("3. critérios de aceitação"), criterios)],
        versao=("1.0", hoje, "", T("Consolidado do Termo de Abertura (cartão 2.2)")),
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


def gerar_declaracao_do_escopo(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Etapa 5 (Definir o escopo) -> Declaracao do Escopo do Projeto (.docx).

    A declaracao e' onde o escopo deixa de ser lista e vira FRONTEIRA: o que esta'
    dentro, o que esta' fora, sob que premissas e restricoes. Tudo isso ja' foi
    respondido no 5W2H — 2.1 (entregas), 4.5 (escopo inicial), 4.6 (o ONDE, que e' o
    limite), 4.2 (restricoes) e 4.3 (premissas). O processo 5 junta e formaliza."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    resp = quadro.respostas_do_termo(ids)
    hoje = datetime.date.today().strftime("%d/%m/%Y")

    def _lista(rot):
        return "; ".join(i.get("_", "").strip()
                         for i in entradas.itens(resp.get(rot, "")) if i.get("_"))

    # OS SEIS ELEMENTOS sao os do livro, cap. 6, §6.4, e nesta ordem. O livro fecha com um
    # veredito que vale imprimir: "uma declaracao de escopo que omite qualquer um desses
    # elementos e' incompleta. Uma que e' vaga em qualquer um deles e' um passivo."
    entregas_txt = "\n".join(
        "• " + e["nome"] for e in entradas.entregas(resp.get("2.1", "")) if e.get("nome"))
    campos = [
        [T("Descrição do escopo do produto"),
         Tf("{0}\n\n{1}", (resp.get("4.5") or T("(defina o escopo inicial no cartão 4.5)"))[:800],
            T("► Mais específica que o Termo: inclua as principais características "
              "funcionais, as especificações técnicas e os requisitos de integração (§6.4)."))],
        [T("Entregas"),
         entregas_txt or T("(defina as entregas no cartão 2.1)")],
        [T("Critérios de aceitação"),
         T("Um por entrega, na tabela da seção 3 deste documento. São o padrão contra o "
           "qual Validar o Escopo vai testar a entrega. Critério vago — 'o "
           "sistema tem desempenho adequado' — não é critério: é disputa esperando para "
           "acontecer (§6.4).")],
        [T("Exclusões"),
         Tf("{0}\n\n{1}", (resp.get("4.6") or T("(defina os limites no cartão 4.6)"))[:800],
            T("► O elemento mais subutilizado e mais valioso da declaração: exclusão "
              "converte premissa implícita em acordo explícito. Funcionalidade excluída "
              "não pode ser scope creep — só pode ser solicitação de mudança formal (§6.4)."))],
        [T("Restrições"), _lista("4.2") or T("(nenhuma restrição registrada)")],
        [T("Premissas"),
         Tf("{0}\n\n{1}", _lista("4.3") or T("(nenhuma premissa registrada)"),
            T("► Se uma premissa se mostrar falsa, o escopo pode precisar mudar. Por isso "
              "elas ficam sinalizadas aqui: para o risco ficar visível (§6.4)."))],
        [T("Abordagem de desenvolvimento"), _abordagem_por_extenso(abordagem)],
    ]
    criterios = []
    for i, e in enumerate(entradas.entregas(resp.get("2.2", "")), 1):
        ace = (e.get("aceitacao") or "").strip()
        if ace:
            criterios.append([f"REQ-{i:03d}", ace, T("Inspeção da entrega"), T("Cliente")])
    if not criterios:
        criterios = [["", "", "", ""]]

    mapa = [(T("1. objetivo do documento"), [
        Bloco.paragrafo(Tf(
            "Esta declaração define a fronteira do projeto {0}: o que ele entrega, o que "
            "ele deliberadamente não entrega, e sob que premissas e restrições isso vale. "
            "Ela é a base da Estrutura Analítica do Projeto e a referência contra a qual "
            "toda mudança de escopo é avaliada.", projeto)),
    ])]
    saida = os.path.join(_saida_dir(),
                         Tf("Declaração do Escopo - {0}.docx", _slug_arquivo(projeto)))
    caminho, avisos = preencher_docx(
        _template_kit("Declaracao-do-Escopo-template.docx"), mapa, saida,
        capa={"[Nome do Projeto]": projeto},
        cabecalho={T("Nome do Projeto"): projeto},
        tabelas=[(T("2. declaração do escopo"), campos),
                 (T("3. critérios de aceitação"), criterios)],
        versao=("1.0", hoje, "", T("Consolidado do Termo de Abertura (cartões 2.1, 4.2, 4.3, 4.5 e 4.6)")),
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


# ═════════════════════════════════════════════════════════════════════════════
# OS 17 DIRETOS — as saidas que ja' estao INTEIRAS no quadro (14/08/2026)
#
# "Direto" (`_admin/PMBOK8_TOGETHER_LACUNAS.md`) = o dado existe no quadro e nao ha'
# decisao nova a tomar: e' mapear e renderizar. O oposto sao os DERIVADOS, onde o
# processo propoe e o aluno confirma.
#
# A REGRA QUE VALE PARA TODOS ELES — nao inventar numero.
#     Onde o Guia pede um dado que o quadro nao tem (a estimativa otimista de um custo,
#     a folga de uma atividade, a receita prevista), a celula sai VAZIA. Preencher com
#     um chute daria ao aluno um documento que parece pronto e mente — e' o contrario
#     do curso, e e' o tipo de numero que depois vira decisao.
#
# Todos os modelos do Kit tem a mesma forma: aba de dados com nome proprio, cabecalho
# na linha 6, primeira linha de dados na 7, e "[Nome do Projeto]" na capa.
# ═════════════════════════════════════════════════════════════════════════════
_L1_KIT = 7                      # 1a linha de dados nos modelos PT (o EN e' outra — ver abaixo)

# Abas de APOIO: existem em todos os modelos do Kit e nunca recebem dados.
_ABAS_DE_APOIO = ("instruções", "instrucoes", "instructions", "param", "recursos",
                  "resources", "📚 resources", "📚 recursos")


def _aba_e_linha(caminho_modelo):
    """(nome da aba de dados, 1a linha de dados) LIDOS DO PROPRIO MODELO.

    POR QUE NAO E' UMA TABELA FIXA (achado em 14/08/2026)
        Os modelos PT e EN do Kit foram construidos em ondas diferentes e nao tem a
        mesma forma: no PT a aba se chama "Dados do Cronograma" e o cabecalho esta' na
        linha 6; no EN ela e' "Schedule Data" e o cabecalho esta' na linha 2. Uma tabela
        com 17 pares por idioma daria 34 linhas para manter a mao — e a primeira que
        saisse do lugar geraria, em silencio, uma planilha com os dados por cima do
        cabecalho. Ler do modelo e' a unica versao que nao pode ficar desatualizada.

    O cabecalho e' a primeira linha com 5+ celulas de texto, na primeira aba que nao
    seja de apoio. Sem achar, cai no padrao PT — degrada, nao quebra.
    """
    import re as _re
    import zipfile
    try:
        with zipfile.ZipFile(caminho_modelo) as z:
            wb = z.read("xl/workbook.xml").decode("utf-8", "replace")
            nomes = _re.findall(r'<sheet[^>]*name="([^"]+)"', wb)
            dados = next((n for n in nomes if n.strip().lower() not in _ABAS_DE_APOIO), None)
            if not dados:
                return None, _L1_KIT
            for parte in sorted(x for x in z.namelist() if "worksheets/sheet" in x):
                xml = z.read(parte).decode("utf-8", "replace")
                if dados.split()[0] not in xml and len(nomes) > 1:
                    pass                  # a aba certa e' achada pelo cabecalho, abaixo
                for m in _re.finditer(r'<row[^>]*r="(\d+)"[^>]*>(.*?)</row>', xml, _re.S):
                    linha, corpo = m.groups()
                    if len(_re.findall(r"<is><t>([^<]+)</t></is>", corpo)) >= 5:
                        return dados, int(linha) + 1
    except Exception:
        pass
    return None, _L1_KIT


def _listas_de_validacao(caminho_modelo):
    """{coluna: (1a linha de dados, {valores permitidos})} LIDOS DO PROPRIO MODELO.

    OS MODELOS TABULARES DO KIT TEM DROPDOWN DE VERDADE (achado em 15/08/2026)
        Colunas como Tipo, Categoria, Status e Conformidade tem
        `<dataValidation type="list">` apontando para a aba Param. Gravar nelas um texto
        de fora da lista NAO da erro na geracao: o arquivo abre, a celula mostra o texto,
        e o defeito so' aparece quando o ALUNO clica na seta e o Excel recusa o valor.
        Foi assim que 78 celulas sairam com o vocabulario do quadro ("Pessoa", "servico",
        "Designado") em colunas que so' aceitam o vocabulario do modelo ("Humano",
        "Contratação", "Alocado").

    Ler do modelo, e nao de uma tabela nossa, e' o que faz isto valer nos DOIS idiomas:
    a aba Param do modelo ingles esta' em ingles. Falhou a leitura? Devolve {} — a
    guarda some, nada quebra."""
    import re as _re
    import xml.etree.ElementTree as _ET
    import zipfile
    ns = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
    nsr = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
    try:
        with zipfile.ZipFile(caminho_modelo) as z:
            try:
                ss = ["".join(t.text or "" for t in si.iter(ns + "t"))
                      for si in _ET.fromstring(z.read("xl/sharedStrings.xml"))]
            except KeyError:
                ss = []
            rels = {r.get("Id"): r.get("Target")
                    for r in _ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))}
            partes = {}
            for s in _ET.fromstring(z.read("xl/workbook.xml")).iter(ns + "sheet"):
                alvo = rels[s.get(nsr + "id")].lstrip("/")
                partes[s.get("name")] = z.read(
                    alvo if alvo.startswith("xl/") else "xl/" + alvo)

            def _valor(c):
                if c.get("t") == "inlineStr":
                    return "".join(t.text or "" for t in c.iter(ns + "t"))
                v = c.find(ns + "v")
                if v is None or v.text is None:
                    return ""
                if c.get("t") == "s" and v.text.isdigit() and int(v.text) < len(ss):
                    return ss[int(v.text)]
                return v.text

            def _celulas(dados):
                sd = _ET.fromstring(dados).find(ns + "sheetData")
                return {c.get("r"): _valor(c)
                        for row in (sd if sd is not None else []) for c in row
                        if c.get("r")}

            out = {}
            for dados in partes.values():
                xml = dados.decode("utf-8", "replace")
                for m in _re.finditer(
                        r'<dataValidation[^>]*sqref="([^"]+)"[^>]*type="list"[^>]*>\s*'
                        r'<formula1>([^<]+)</formula1>', xml):
                    alvo = _re.match(r"([A-Z]+)(\d+)", m.group(1))
                    fm = _re.match(r"([^!]+)!\$([A-Z]+)\$(\d+):\$([A-Z]+)\$(\d+)",
                                   m.group(2).replace("'", ""))
                    if not alvo or not fm or fm.group(1) not in partes:
                        continue
                    cel = _celulas(partes[fm.group(1)])
                    valores = {cel.get(f"{fm.group(2)}{n}", "").strip()
                               for n in range(int(fm.group(3)), int(fm.group(5)) + 1)}
                    valores.discard("")
                    if valores:
                        out[alvo.group(1)] = (int(alvo.group(2)), valores)
            return out
    except Exception:
        return {}


def _respeitar_listas(modelo, linhas, log):
    """Apaga o que nao cabe no dropdown da coluna — e DIZ que apagou.

    Apagar e' melhor do que escrever fora da lista: a celula vazia o aluno preenche pelo
    dropdown; a celula com valor invalido ele so' descobre que e' invalida quando o Excel
    recusa. Silencio nao entra: cada coluna podada sai no log, com o valor que caiu."""
    listas = _listas_de_validacao(modelo)
    if not listas:
        return linhas
    podados = {}
    for linha in linhas:
        for col, (_l1, permitidos) in listas.items():
            val = (linha.get(col) or "")
            if isinstance(val, str) and val.strip() and val.strip() not in permitidos:
                podados.setdefault(col, set()).add(val.strip())
                linha[col] = ""
    for col, valores in sorted(podados.items()):
        log(Tf("  [aviso] coluna {0}: {1} não está na lista do modelo e ficou em "
               "branco — escolha pelo menu da célula.", col,
               " · ".join(sorted(valores)[:4])))
    return linhas


def _xlsx_kit(nome_modelo, aba, linhas, projeto, titulo_arquivo, log, formatos=None):
    """O esqueleto comum dos diretos tabulares: preenche um modelo do Kit e devolve
    (caminho, avisos). Sem linha nenhuma devolve (None, []) — "ainda nao ha' o que
    gerar" nao e' erro, e' o estado de quem ainda nao rodou o passo que alimenta.

    SEM `versao=`, ao contrario dos geradores do curso. Os modelos tabulares do Kit nao
    tem aba "Capa" nem bloco de Controle de Versoes — pedir um enche a tela do aluno com
    um aviso sobre uma tabela que nao existe. A procedencia nao se perde: a linha 4 de
    cada um ja' diz "Saida do Processo: <nome> | Dominio: <dominio>", escrita no proprio
    modelo, e ela e' mais precisa do que um carimbo nosso.
    """
    if not linhas:
        return None, []
    modelo = _template_kit(nome_modelo)
    # a aba e a linha vem do MODELO, nao do codigo: PT e EN nao tem a mesma forma.
    # O `aba` do parametro fica como plano B, para o caso de a leitura falhar.
    aba_lida, primeira = _aba_e_linha(modelo)
    linhas = _respeitar_listas(modelo, linhas, log)
    saida = os.path.join(_saida_dir(), Tf("{0} - {1}.xlsx", titulo_arquivo,
                                          _slug_arquivo(projeto)))
    caminho, avisos = preencher_xlsx(
        modelo, saida, aba=aba_lida or aba, primeira_linha=primeira,
        linhas=linhas, trocas={"[Nome do Projeto]": projeto}, formatos=formatos)
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


def _so_atividades(wbs):
    """As folhas do cronograma — pacotes e tarefas, nunca fase/entrega.

    Uma fase nao e' atividade: a data dela e' rollup dos filhos. Listar a fase junto
    com os pacotes dela numa planilha de atividades conta o mesmo trabalho duas vezes.
    """
    niveis = [l["nivel"] for l in wbs]
    return [l for i, l in enumerate(wbs)
            if not (i + 1 < len(wbs) and niveis[i + 1] > l["nivel"])]


def _dias_entre(d1, d2):
    """Diferenca em dias entre duas datas ("" quando nao da' para calcular)."""
    def _p(s):
        for f in ("%d/%m/%Y", "%Y-%m-%d"):
            try:
                return datetime.datetime.strptime((s or "").strip(), f).date()
            except (ValueError, TypeError):
                continue
        return None
    a, b = _p(d1), _p(d2)
    return str((b - a).days) if a and b else ""


def _nomes_da_equipe(ids):
    """Os nomes dos membros da EQUIPE — a lista fechada de quem faz o trabalho."""
    try:
        import team_check
        return {(c.get("titulo") or "").strip()
                for c, _full in team_check.cartoes_da_equipe(ids)
                if (c.get("titulo") or "").strip()}
    except Exception:
        return set()


def _baseline_ou_nada(ids):
    """A ultima linha de base capturada, ou None.

    Varios documentos do Guia sao COMPARACAO contra o plano aprovado. Sem baseline nao
    ha' o que comparar — e devolver uma planilha de zeros seria pior do que nao gerar:
    o aluno acharia que mediu."""
    try:
        import baseline as _b
        for nome in ("ultima", "carregar", "ler"):
            fn = getattr(_b, nome, None)
            if callable(fn):
                foto = fn(ids)
                if foto:
                    return foto
    except Exception:
        pass
    return None


def gerar_dados_do_cronograma(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 9 (Desenvolver o cronograma) -> Dados do Cronograma (.xlsx).

    O Guia separa o CRONOGRAMA (o modelo com as datas) dos DADOS DO CRONOGRAMA (o que
    sustenta o modelo: a rede, as folgas, os marcos). Aqui saem a atividade, as datas,
    a predecessora, o responsavel e o marco. FOLGA fica VAZIA de proposito: calcula-la
    exige o caminho critico, e o quadro guarda uma predecessora por cartao — inventar
    folga seria dar numero de gestao a quem nao pediu."""
    wbs = quadro.cronograma_do_quadro(ids)
    atividades = [l for l in _so_atividades(wbs) if l.get("ini") or l.get("fim")]
    projeto = (projeto or _nome_projeto()).strip()
    linhas = [{
        "A": l["codigo"],
        "B": l["titulo"],
        "C": l.get("ini", ""),
        "D": l.get("fim", ""),
        "E": "",                          # Folga total — precisa do caminho critico
        "F": "",                          # Folga livre — idem
        "G": l.get("predecessor", ""),
        "H": l.get("responsavel", ""),
        "I": T("Sim") if (l.get("ini") and l.get("ini") == l.get("fim")) else T("Não"),
    } for l in atividades]
    return _xlsx_kit("Dados-do-Cronograma-template.xlsx", "Dados do Cronograma", linhas,
                     projeto, T("Dados do Cronograma"), log)


def gerar_linha_de_base_do_cronograma(ids, abordagem=None, projeto=None,
                                      log=lambda _m: None):
    """Processo 9 -> Linha de Base do Cronograma (.xlsx).

    So' existe DEPOIS de aprovar o plano: linha de base e' a FOTO do cronograma no dia
    da aprovacao, e e' contra ela que o Processo 39 mede toda mudanca. Sem a baseline
    capturada devolve None — chamar o cronograma de hoje de "linha de base" destruiria
    a unica coisa que ela serve para fazer."""
    if not _baseline_ou_nada(ids):
        return None, []
    wbs = quadro.cronograma_do_quadro(ids)
    atividades = [l for l in _so_atividades(wbs) if l.get("ini") or l.get("fim")]
    projeto = (projeto or _nome_projeto()).strip()
    linhas = [{
        "A": str(i),
        "B": l["codigo"],
        "C": l["titulo"],
        "D": l.get("predecessor", ""),
        "E": l.get("ini", ""),
        "F": l.get("fim", ""),
        "G": l.get("duracao", ""),
        "H": l.get("responsavel", ""),
        "I": T("S") if (l.get("ini") and l.get("ini") == l.get("fim")) else T("N"),
    } for i, l in enumerate(atividades, 1)]
    return _xlsx_kit("Linha-de-Base-do-Cronograma-template.xlsx",
                     "Linha de Base Cronograma", linhas, projeto,
                     T("Linha de Base do Cronograma"), log)


def gerar_previsoes_do_cronograma(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 33 (Monitorar o cronograma) -> Previsoes do Cronograma (.xlsx).

    Previsao e' comparacao: termino ORIGINAL (a linha de base) contra termino PREVISTO
    (o cronograma de hoje). Sem linha de base nao ha' o que comparar — e' o mesmo
    veredito do painel. SPI e ETC ficam vazios: exigem valor agregado, que o quadro nao
    guarda, e um SPI inventado e' o numero mais perigoso de um relatorio."""
    foto = _baseline_ou_nada(ids)
    if not foto:
        return None, []
    wbs = quadro.cronograma_do_quadro(ids)
    atividades = [l for l in _so_atividades(wbs) if l.get("fim")]
    projeto = (projeto or _nome_projeto()).strip()
    antes = {}
    for item in (foto.get("itens") or foto.get("linhas") or []):
        if isinstance(item, dict) and item.get("codigo"):
            antes[str(item["codigo"])] = item.get("fim") or ""
    linhas = []
    for i, l in enumerate(atividades, 1):
        original = antes.get(str(l["codigo"]), "")
        linhas.append({
            "A": str(i),
            "B": l["titulo"],
            "C": original,
            "D": l.get("fim", ""),
            "E": _dias_entre(original, l.get("fim", "")),
            "F": "",                      # SPI — precisa de valor agregado
            "G": "",                      # ETC — idem
            "H": l.get("responsavel", ""),
            "I": "",                      # a acao e' decisao do GP, nao do script
        })
    return _xlsx_kit("Previsoes-do-Cronograma-template.xlsx", "Previsões do Cronograma",
                     linhas, projeto, T("Previsões do Cronograma"), log)


def gerar_requisitos_de_recursos(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 8 (Estimar Recursos) -> Requisitos de Recursos (.xlsx).

    O que cada pacote de trabalho PRECISA para acontecer. O quadro ja' diz de quem e'
    cada pacote e quando ele corre — e' o requisito de recurso no nivel do pacote, que
    e' onde o Guia o coloca. Quantidade e habilidade ficam vazias: sao decisao de quem
    planeja, nao dado do quadro."""
    wbs = quadro.cronograma_do_quadro(ids, com_tarefas=False)
    projeto = (projeto or _nome_projeto()).strip()
    equipe = _nomes_da_equipe(ids)
    linhas = []
    for i, l in enumerate(_so_atividades(wbs), 1):
        resp = (l.get("responsavel") or "").strip()
        linhas.append({
            "A": f"RR-{i:03d}",
            "B": f"{l['codigo']} {l['titulo']}",
            "C": resp,
            # "Humano" e "Identificado" sao os termos da LISTA do modelo (ver
            # `_respeitar_listas`); "Pessoa" e "A definir" eram os nossos e o dropdown
            # da celula os recusava. Sem responsavel, as duas ficam em branco: o tipo do
            # recurso que ainda nao existe nao se adivinha.
            "D": T("Humano") if resp else "",
            "E": "",                      # quantidade — decisao de quem planeja
            "F": " - ".join(x for x in (l.get("ini"), l.get("fim")) if x),
            "G": T("Da equipe do projeto") if resp in equipe else "",
            "H": (T("Pacote de trabalho da EAP") if resp else
                  T("Pacote de trabalho da EAP — ainda sem responsável no quadro")),
            "I": T("Identificado") if resp else "",
        })
    return _xlsx_kit("Requisitos-de-Recursos-template.xlsx", "Requisitos de Recursos",
                     linhas, projeto, T("Requisitos de Recursos"), log)


def gerar_designacoes_de_recursos(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 22 (Obter recursos) -> Designacoes de Recursos (.xlsx).

    O par do documento acima: la' o que se PRECISA, aqui o que foi de fato DESIGNADO.
    Por isso so' entra pacote que ja' tem responsavel — pacote sem dono nao e' uma
    designacao pendente, e' uma linha que ainda nao existe. O custo vem do orcamento
    quando o item do Passo 4 disse a que pacote pertence."""
    wbs = quadro.cronograma_do_quadro(ids, com_tarefas=False)
    projeto = (projeto or _nome_projeto()).strip()
    custo_por_pacote = {}
    for item in (quadro.orcamento_do_quadro(ids) or []):
        chave = entradas._sem_acento((item.get("pacote") or "").strip())
        if chave:
            custo_por_pacote[chave] = custo_por_pacote.get(chave, 0) + (item.get("valor_num") or 0)
    linhas = []
    for i, l in enumerate(_so_atividades(wbs), 1):
        resp = (l.get("responsavel") or "").strip()
        if not resp:
            continue
        custo = custo_por_pacote.get(entradas._sem_acento(l["titulo"]), 0)
        linhas.append({
            "A": f"DR-{i:03d}",
            "B": resp,
            "C": T("Humano"),             # termo da lista do modelo (era "Pessoa")
            "D": f"{l['codigo']} {l['titulo']}",
            "E": " - ".join(x for x in (l.get("ini"), l.get("fim")) if x),
            "F": "",                      # quantidade — o quadro designa a PESSOA
            "G": "",                      # custo unitario — o orcamento e' por item
            "H": custo or "",
            "I": T("Alocado"),            # termo da lista do modelo (era "Designado")
        })
    return _xlsx_kit("Designacoes-de-Recursos-template.xlsx", "Designações de Recursos",
                     linhas, projeto, T("Designações de Recursos"), log, formatos={"H": '"R$" #,##0.00'})


# O vocabulario do QUADRO nao e' o do MODELO. O aluno escreve o tipo do custo com as
# palavras dele ("servico", "licenca", "pessoas"); a coluna Categoria do modelo do Kit
# so' aceita a lista dela. Este mapa liga um ao outro por RADICAL — e o alvo tambem e'
# um radical, porque o termo exato muda entre modelos ("Reserva" num, "Reserva de
# Contingência" noutro) e entre idiomas. Quem nao casa vai para "Outro", que existe na
# lista justamente para isso: e' classificacao honesta, nao chute.
_CATEGORIA_DE_CUSTO = (
    (("pessoa", "equipe", "pessoal", "salario", "people", "staff", "team", "labor"),
     ("pessoal", "personnel", "labor", "people")),
    (("reserv", "conting"), ("reserv", "conting")),
    (("licen", "software", "assinatura", "saas", "subscription"), ("software",)),
    (("hardware", "equipamento", "maquina", "equipment"), ("hardware", "equipment")),
    (("infra", "servidor", "hospedagem", "server", "hosting", "cloud", "nuvem"),
     ("infra",)),
    (("servic", "service", "contrat", "terceir", "consultor", "fornecedor", "vendor"),
     ("contrat", "outsourc", "service", "procure")),
)


def _categoria_de_custo(tipo, permitidos):
    """O termo da lista do modelo que corresponde ao tipo escrito no quadro.

    `permitidos` e' o conjunto lido do PROPRIO modelo (`_listas_de_validacao`), entao
    isto funciona igual no modelo ingles. Sem casar, devolve o "Outro" da lista — e,
    se nem ele existir, string vazia (a guarda do `_xlsx_kit` faria isso de todo jeito;
    aqui e' so' para o log nao acusar o que ja' foi tratado)."""
    t = entradas._sem_acento((tipo or "").strip())
    if not t:
        return ""
    for radicais, alvos in _CATEGORIA_DE_CUSTO:
        if not any(r in t for r in radicais):
            continue
        for p in sorted(permitidos):
            if any(a in entradas._sem_acento(p) for a in alvos):
                return p
    for p in sorted(permitidos):
        if entradas._sem_acento(p) in ("outro", "outros", "other"):
            return p
    return ""


def gerar_estimativas_de_custos(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 11 (Estimar os custos) -> Estimativas de Custos (.xlsx).

    O orcamento do quadro E' a estimativa: cada item que o aluno levantou no Passo 4 tem
    valor, tipo e o pacote a que pertence. O modelo do Kit pede TRES pontos (otimista,
    realista, pessimista) para o calculo PERT — e o quadro guarda UM. Entao o valor do
    quadro entra como REALISTA e as outras duas colunas ficam VAZIAS, com a coluna
    Justificativa dizendo de onde veio o numero. Distribuir o unico valor em tres
    (±20%, por exemplo) inventaria uma faixa de incerteza que ninguem estimou — e o
    PERT calculado em cima dela pareceria analise quando seria aritmetica do script."""
    itens = quadro.orcamento_do_quadro(ids) or []
    projeto = (projeto or _nome_projeto()).strip()
    # a coluna Categoria tem dropdown: o tipo escrito no quadro passa pelo mapa
    categorias = _listas_de_validacao(
        _template_kit("Estimativas-de-Custos-template.xlsx")).get("C", (0, set()))[1]
    linhas = []
    for i, item in enumerate(itens, 1):
        pacote = (item.get("pacote") or "").strip()
        linhas.append({
            "A": f"EC-{i:03d}",
            "B": pacote or (item.get("nome") or "").strip(),
            "C": _categoria_de_custo(item.get("tipo"), categorias),
            "D": "",                      # otimista  — o quadro guarda UM valor
            "E": item.get("valor_num") or "",
            "F": "",                      # pessimista — idem
            "G": "",                      # PERT — sem os tres pontos nao ha' PERT
            "H": "",                      # desvio padrao — idem
            "I": Tf("Estimativa do orçamento do projeto: {0}",
                    (item.get("nome") or "").strip() or T("item sem descrição")),
        })
    return _xlsx_kit("Estimativas-de-Custos-template.xlsx", "Estimativas de Custos",
                     linhas, projeto, T("Estimativas de Custos"), log,
                     formatos={"E": '"R$" #,##0.00'})


def gerar_requisitos_de_recursos_financeiros(ids, abordagem=None, projeto=None,
                                             log=lambda _m: None):
    """Processo 12 (Determinar o orcamento) -> Requisitos de Recursos Financeiros (.xlsx).

    Nao e' o orcamento outra vez: e' o orcamento POR PERIODO — quanto dinheiro precisa
    estar disponivel, e quando. O Passo 4 ja' pergunta o "Quando:" de cada item de
    custo, e e' exatamente esse campo que o Guia chama de requisito de financiamento.
    Itens sem periodo entram agrupados em "(sem período definido)", visiveis: um custo
    sem data e' um risco de caixa, nao uma linha para esconder."""
    itens = quadro.orcamento_do_quadro(ids) or []
    projeto = (projeto or _nome_projeto()).strip()
    # a coluna Categoria tem dropdown, e a lista deste modelo NAO e' igual a' do
    # Estimativas de Custos (aqui e' "Reserva de Contingência", la' e' "Reserva") —
    # mais uma razao para o alvo do mapa ser radical, e para ler do proprio modelo.
    categorias = _listas_de_validacao(
        _template_kit("Requisitos-de-Recursos-Financeiros-template.xlsx")
    ).get("B", (0, set()))[1]
    por_periodo = {}
    for item in itens:
        quando = (item.get("quando") or "").strip() or T("(sem período definido)")
        por_periodo.setdefault(quando, []).append(item)
    linhas = []
    for periodo in sorted(por_periodo):
        for item in por_periodo[periodo]:
            linhas.append({
                "A": periodo,
                "B": _categoria_de_custo(item.get("tipo"), categorias),
                "C": (item.get("nome") or "").strip(),
                "D": item.get("valor_num") or "",
                "E": "",                  # fonte de financiamento — decisao do Processo 10
                "F": "",                  # data de liberacao — idem
                "G": T("Previsto"),
                "H": Tf("Pacote: {0}", item.get("pacote") or T("projeto (não alocado)")),
            })
    return _xlsx_kit("Requisitos-de-Recursos-Financeiros-template.xlsx",
                     "Recursos Financeiros", linhas, projeto,
                     T("Requisitos de Recursos Financeiros"), log,
                     formatos={"D": '"R$" #,##0.00'})


def gerar_previsoes_de_custos(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 34 (Monitorar o orcamento) -> Previsoes de Custos e Receitas (.xlsx).

    Custo PREVISTO por periodo o quadro tem (o "Quando:" de cada item). Custo
    REALIZADO, receita e EAC, nao — o quadro nao registra desembolso. Entao as colunas
    de realizado saem VAZIAS e o documento vale como o que ele e': a curva planejada,
    pronta para o aluno lancar o realizado ao lado. Sem linha de base o documento nao
    sai: previsao sem plano aprovado nao e' previsao, e' o proprio plano."""
    if not _baseline_ou_nada(ids):
        return None, []
    itens = quadro.orcamento_do_quadro(ids) or []
    projeto = (projeto or _nome_projeto()).strip()
    por_periodo = {}
    for item in itens:
        quando = (item.get("quando") or "").strip() or T("(sem período definido)")
        por_periodo[quando] = por_periodo.get(quando, 0) + (item.get("valor_num") or 0)
    linhas = [{
        "A": periodo,
        "B": valor,
        "C": "",                          # realizado — o quadro nao registra desembolso
        "D": "",                          # variacao — depende do realizado
        "E": "",                          # receitas previstas — fora do quadro
        "F": "",                          # receitas realizadas — idem
        "G": "",                          # saldo liquido — idem
        "H": "",                          # EAC — precisa do realizado
        "I": T("Custo planejado lido do orçamento do projeto"),
    } for periodo, valor in sorted(por_periodo.items())]
    return _xlsx_kit("Previsoes-de-Custos-e-Receitas-template.xlsx",
                     "Previsões Financeiras", linhas, projeto,
                     T("Previsões de Custos e Receitas"), log,
                     formatos={"B": '"R$" #,##0.00'})


def gerar_designacoes_da_equipe(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 22 (Obter recursos) -> Designacoes da Equipe do Projeto (.xlsx).

    Quem esta' no time, com o papel que o cartao da pessoa declara e o e-mail que o
    Passo 1 ja' ligou a' conta do ProjectAdm. Alocacao (%) e datas ficam vazias — o
    quadro nao guarda dedicacao, e um "100%" automatico viraria compromisso de alguem
    que nunca disse isso."""
    projeto = (projeto or _nome_projeto()).strip()
    linhas = []
    # numera DEPOIS de filtrar: numerar antes deixava buracos (EQ-001, EQ-004...) que o
    # aluno le' como linha faltando.
    equipe = [p for p in _partes_do_quadro(ids)
              if (p.get("grupo") or "").lower() == "equipe"]
    for i, p in enumerate(equipe, 1):
        linhas.append({
            "A": f"EQ-{i:03d}",
            "B": (p.get("nome") or "").strip(),
            "C": "",                      # organizacao — o quadro nao pergunta
            "D": (p.get("papel") or "").strip(),
            "E": "",                      # habilidades — decisao de quem planeja
            "F": "",                      # alocacao % — o quadro nao guarda dedicacao
            "G": "",                      # inicio  — idem
            "H": "",                      # termino — idem
            "I": (p.get("email") or "").strip(),
        })
    return _xlsx_kit("Designacoes-da-Equipe-do-Projeto-template.xlsx",
                     "Designações da Equipe", linhas, projeto,
                     T("Designações da Equipe do Projeto"), log)


def gerar_estrutura_analitica_dos_recursos(ids, abordagem=None, projeto=None,
                                           log=lambda _m: None):
    """Processo 8 (Estimar Recursos) -> Estrutura Analitica dos Recursos (.xlsx).

    A EAR e' a EAP dos RECURSOS: categoria -> recurso. O quadro ja' agrupa as partes
    interessadas em CLIENTES / EQUIPE / DEMAIS, e o grupo EQUIPE e' exatamente a
    categoria "Pessoas" da EAR. As categorias de material e servico saem do ORCAMENTO,
    onde cada item ja' vem tipado (o "Tipo:" do Passo 4) — e' o mesmo dado, olhado pelo
    lado do recurso em vez do lado do dinheiro."""
    projeto = (projeto or _nome_projeto()).strip()
    linhas, n = [], 0
    pessoas = [p for p in _partes_do_quadro(ids)
               if (p.get("grupo") or "").lower() == "equipe" and (p.get("nome") or "").strip()]
    if pessoas:
        n += 1
        linhas.append({"A": "1", "B": "1", "C": T("Pessoas"), "D": "", "E": "", "F": "",
                       "G": T("Equipe do projeto (cartão EQUIPE — quem faz)")})
        for i, p in enumerate(pessoas, 1):
            # "Humano" e' o termo da LISTA da coluna Tipo; "Interno" era o nosso e o
            # dropdown o recusava. Onde ela veio (equipe interna) e' observacao, e a
            # coluna Observacoes e' texto livre.
            linhas.append({"A": "2", "B": f"1.{i}", "C": T("Pessoas"),
                           "D": (p.get("papel") or "").strip(), "E": (p.get("nome") or "").strip(),
                           "F": T("Humano"), "G": T("Equipe interna do projeto")})
    por_tipo = {}
    for item in (quadro.orcamento_do_quadro(ids) or []):
        tipo = (item.get("tipo") or "").strip()
        nome = (item.get("nome") or "").strip()
        if tipo and nome and T("pessoa") not in tipo.lower():
            por_tipo.setdefault(tipo, []).append(nome)
    for tipo in sorted(por_tipo):
        n += 1
        linhas.append({"A": "1", "B": str(n), "C": tipo, "D": "", "E": "", "F": "",
                       "G": T("Categoria de custo do orçamento do projeto")})
        for i, nome in enumerate(por_tipo[tipo], 1):
            # o Tipo fica VAZIO: a lista da coluna e' de natureza do recurso (material,
            # equipamento, instalacao, tecnologia) e o quadro so' sabe a categoria de
            # CUSTO. Escolher por ele seria classificar o recurso do aluno no lugar dele.
            linhas.append({"A": "2", "B": f"{n}.{i}", "C": tipo, "D": "", "E": nome,
                           "F": "", "G": T("A contratar — escolha o Tipo pelo menu da "
                                           "célula")})
    return _xlsx_kit("Estrutura-Analitica-dos-Recursos-template.xlsx", "EAR", linhas,
                     projeto, T("Estrutura Analítica dos Recursos"), log)


def gerar_informacoes_de_desempenho(ids, abordagem=None, projeto=None,
                                    log=lambda _m: None):
    """Processo 30 (Monitorar e Controlar) -> Informacoes sobre o Desempenho (.xlsx).

    O Guia distingue DADOS (o cru), INFORMACOES (o cru comparado com o plano) e
    RELATORIOS (a informacao comunicada). Este e' o do meio, e por isso ele so' traz
    indicador com os DOIS lados: planejado e realizado. Hoje o quadro sustenta tres —
    entregas prontas, pacotes com responsavel e itens de custo alocados. Um indicador
    sem realizado nao entra: a coluna vazia num documento chamado "informacoes de
    desempenho" e' pior do que a ausencia da linha."""
    wbs = quadro.cronograma_do_quadro(ids, com_tarefas=False)
    if not wbs:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    pacotes = _so_atividades(wbs)
    total = len(pacotes)
    com_resp = sum(1 for l in pacotes if (l.get("responsavel") or "").strip())
    com_data = sum(1 for l in pacotes if l.get("fim"))
    itens = quadro.orcamento_do_quadro(ids) or []
    alocados = sum(1 for i in itens if (i.get("pacote") or "").strip())

    def _linha(n, area, indicador, plan, real, acao):
        var = (real - plan) if isinstance(plan, int) and isinstance(real, int) else ""
        idx = f"{real / plan:.2f}" if isinstance(plan, int) and plan and isinstance(real, int) else ""
        # A coluna TENDENCIA (lista: melhorando · estavel · piorando) fica VAZIA, e nao
        # e' descuido: tendencia exige DUAS medicoes, e o quadro guarda o estado de
        # agora. O sentido do desvio ja' esta' na coluna Variacao, com sinal — dizer
        # "estável" na primeira leitura seria afirmar uma serie historica que nao existe.
        return {"A": f"ID-{n:03d}", "B": area, "C": indicador, "D": plan, "E": real,
                "F": var, "G": idx, "H": "", "I": acao}

    linhas = [
        _linha(1, T("Escopo"), T("Pacotes de trabalho com responsável definido"),
               total, com_resp,
               T("Atribuir responsável aos pacotes sem dono") if com_resp < total else ""),
        _linha(2, T("Cronograma"), T("Pacotes de trabalho com data de término"),
               total, com_data,
               T("Datar os pacotes que faltam") if com_data < total else ""),
    ]
    if itens:
        # "Custo" e' o termo da lista da coluna Área/Processo; "Finanças" e' o nome do
        # dominio do Guia, e o dropdown do modelo nao o tem.
        linhas.append(_linha(3, T("Custo"), T("Itens de custo alocados a um pacote"),
                             len(itens), alocados,
                             T("Alocar os custos ao pacote que os consome")
                             if alocados < len(itens) else ""))
    return _xlsx_kit("Informacoes-de-Desempenho-do-Trabalho-template.xlsx",
                     "Informações de Desempenho", linhas, projeto,
                     T("Informações de Desempenho do Trabalho"), log)


def _docx_kit(nome_modelo, mapa, tabelas, projeto, titulo_arquivo, nota_versao, log):
    """O esqueleto comum dos diretos em .docx do Kit (1. Objetivo · tabelas · Aprovações).

    Ao contrario dos tabulares, ESTES modelos tem "5. Controle de Versão" — entao aqui o
    `versao=` entra, e com ele a procedencia."""
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    saida = os.path.join(_saida_dir(), Tf("{0} - {1}.docx", titulo_arquivo,
                                          _slug_arquivo(projeto)))
    # O placeholder da capa muda de idioma: o modelo PT traz "[Nome do Projeto]" e o EN,
    # "[Enter project name]". Mandar os DOIS sempre custaria um aviso falso em todo
    # documento (preencher_docx reclama de cada chave que nao acha), entao vai o do
    # idioma corrente — mesmo problema que `_trocas_capa` resolve para a celula da capa.
    capa_chave = "[Enter project name]" if lang() == "EN" else "[Nome do Projeto]"
    caminho, avisos = preencher_docx(
        _template_kit(nome_modelo), mapa, saida,
        capa={capa_chave: projeto},
        cabecalho={T("Nome do Projeto"): projeto},
        tabelas=tabelas,
        versao=("1.0", hoje, "", nota_versao),
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


def gerar_relatorio_de_riscos(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 17 (Identificar os riscos) -> Relatorio de Riscos (.docx).

    O REGISTRO de riscos e' a lista, um risco por linha; o RELATORIO e' a visao do
    conjunto — quantos riscos ha', como se distribuem, quais sao os criticos. Sao saidas
    diferentes do Guia e por isso ele pede as duas. Tudo aqui e' contagem do que ja' esta'
    no quadro: nao ha' um numero novo a inventar."""
    pontos = quadro.riscos_do_quadro(ids) or []
    riscos = [p for p in pontos if T("risco") in (p.get("tipo") or "").lower()]
    if not pontos:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()

    def _nivel(p):
        return (p.get("prob_ou_urgencia") or "").strip().lower()

    altos = [p for p in riscos if "alt" in _nivel(p) or "high" in _nivel(p)]
    sem_dono = [p for p in riscos if not (p.get("dono") or "").strip()]
    sem_resposta = [p for p in riscos if not (p.get("resposta") or "").strip()]
    por_tipo = {}
    for p in pontos:
        t = (p.get("tipo") or T("não classificado")).strip()
        por_tipo[t] = por_tipo.get(t, 0) + 1

    resumo = [
        [T("Riscos identificados"), T("Registrado"), "", str(len(riscos)), ""],
        [T("Riscos de alta probabilidade/urgência"), T("Atenção") if altos else T("OK"),
         "", str(len(altos)), ""],
        [T("Riscos com resposta definida"),
         T("OK") if not sem_resposta else T("Incompleto"),
         str(len(riscos)), str(len(riscos) - len(sem_resposta)), str(-len(sem_resposta))],
        [T("Riscos com dono definido"), T("OK") if not sem_dono else T("Incompleto"),
         str(len(riscos)), str(len(riscos) - len(sem_dono)), str(-len(sem_dono))],
    ]
    detalhe = [[tipo, str(n), T("Pontos de atenção registrados no quadro do projeto")]
               for tipo, n in sorted(por_tipo.items())]
    acoes = [[(p.get("resposta") or T("(resposta a definir)")).strip()[:180],
              (p.get("dono") or T("(sem dono)")).strip(), "",
              T("Planejada")] for p in riscos[:20]]
    if not acoes:
        acoes = [["", "", "", ""]]
    mapa = [(T("1. objetivo do documento"), [
        Bloco.paragrafo(Tf(
            "Este relatório apresenta a visão consolidada dos riscos do projeto {0} na "
            "data de emissão: quantos foram identificados, como se distribuem e quais "
            "ainda não têm resposta ou dono. Ele complementa o Registro dos Riscos, que "
            "traz cada risco individualmente.", projeto)),
    ])]
    return _docx_kit("Relatorio-de-Riscos-template.docx", mapa,
                     [(T("2. resumo executivo"), resumo),
                      (T("3. análise detalhada"), detalhe),
                      (T("4. próximas ações"), acoes)],
                     projeto, T("Relatório de Riscos"),
                     T("Consolidado da lista Riscos e Premissas do quadro"), log)


def gerar_linha_de_base_do_escopo(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 6 (Desenvolver a Estrutura do Escopo) -> Linha de Base do Escopo (.docx).

    O Guia e' explicito: a linha de base do escopo sao TRES documentos juntos — a
    Declaracao do Escopo, a EAP e o Dicionario da EAP. Este documento e' a capa que os
    amarra e declara a versao aprovada. Sem EAP no quadro nao ha' linha de base."""
    nos, _tem_fases = quadro.arvore_da_eap(ids)
    if not nos:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    resp = quadro.respostas_do_termo(ids)
    fases = sum(1 for n in nos if n["tipo"] == "fase")
    entregas = sum(1 for n in nos if n["tipo"] == "entrega")
    pacotes = sum(1 for n in nos if n["tipo"] == "pacote")
    campos = [
        [T("Declaração do escopo do projeto"),
         (resp.get("4.5") or T("(escopo inicial no cartão 4.5 do quadro)"))[:600]],
        [T("Estrutura Analítica do Projeto (EAP)"),
         Tf("{0} elemento(s): {1} fase(s), {2} entrega(s) e {3} pacote(s) de trabalho. "
            "A árvore completa está na EAP gerada por este mesmo projeto.",
            len(nos), fases, entregas, pacotes)],
        [T("Dicionário da EAP"),
         T("Um verbete por pacote de trabalho, com descrição, responsável, datas e "
           "custo — gerado a partir do mesmo quadro.")],
        [T("Versão aprovada"),
         T("Esta linha de base só muda por solicitação de mudança aprovada. É contra "
           "ela que toda variação de escopo passa a ser medida.")],
    ]
    criterios = []
    for i, n in enumerate([x for x in nos if x["tipo"] == "pacote"], 1):
        ace = (n.get("aceitacao") or "").strip()
        criterios.append([f"{n['codigo']}", ace or T("(critério a definir no pacote)"),
                          T("Inspeção da entrega"), ""])
    if not criterios:
        criterios = [["", "", "", ""]]
    mapa = [(T("1. objetivo do documento"), [
        Bloco.paragrafo(Tf(
            "Este documento declara a linha de base do escopo do projeto {0} — a versão "
            "aprovada da Declaração do Escopo, da EAP e do Dicionário da EAP. A partir "
            "dela, toda alteração de escopo é uma solicitação de mudança, não um ajuste.",
            projeto)),
    ])]
    return _docx_kit("Linha-de-Base-do-Escopo-template.docx", mapa,
                     [(T("2. linha de base do escopo"), campos),
                      (T("3. critérios de aceitação"), criterios[:40])],
                     projeto, T("Linha de Base do Escopo"),
                     T("Consolidado da lista de escopo do quadro"), log)


def gerar_base_das_estimativas(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processos 8 e 11 (Estimar Recursos / Custos) -> Base das Estimativas (.docx).

    O documento que responde "de onde saiu esse numero?". O Guia o exige junto das
    estimativas justamente porque uma estimativa sem premissa declarada nao pode ser
    revista — so' contestada. Aqui as premissas sao as do quadro (cartao 4.3) e o metodo
    e' o que o curso de fato usou: estimativa analoga do proprio aluno, item a item."""
    itens = quadro.orcamento_do_quadro(ids) or []
    wbs = quadro.cronograma_do_quadro(ids, com_tarefas=False)
    if not itens and not wbs:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    resp = quadro.respostas_do_termo(ids)
    total = sum((i.get("valor_num") or 0) for i in itens)
    premissas = [i.get("_", "").strip()
                 for i in entradas.itens(resp.get("4.3", "")) if i.get("_")]
    restricoes = [i.get("_", "").strip()
                  for i in entradas.itens(resp.get("4.2", "")) if i.get("_")]
    campos = [
        [T("Método de estimativa"),
         T("Estimativa análoga por item, feita por quem conhece o trabalho: cada item de "
           "custo e cada pacote de trabalho foi estimado individualmente, a partir da "
           "decomposição da EAP. Não houve modelo paramétrico nem estimativa de três "
           "pontos — quando houver, a Base das Estimativas registra qual foi.")],
        [T("Base do custo"),
         Tf("{0} item(ns) de custo, somando {1}. Cada item declara tipo, período e, "
            "quando aplicável, o pacote de trabalho que o consome.",
            len(itens), _reais(total))],
        [T("Base do prazo"),
         Tf("{0} elemento(s) na decomposição do escopo, com datas atribuídas pacote a "
            "pacote no cronograma do projeto.", len(wbs))],
        [T("Premissas declaradas"),
         "; ".join(premissas) if premissas
         else T("(nenhuma premissa registrada no cartão 4.3 — toda estimativa tem "
                "premissa; declarar as suas é o que permite revisá-las depois)")],
        [T("Restrições consideradas"),
         "; ".join(restricoes) if restricoes else T("(nenhuma restrição registrada)")],
        [T("Faixa de confiança"),
         T("Não calculada: o quadro guarda um valor por item, não três pontos. Para "
           "declarar uma faixa, registre a estimativa otimista e a pessimista de cada "
           "item na planilha Estimativas de Custos.")],
    ]
    mapa = [(T("1. objetivo do documento"), [
        Bloco.paragrafo(Tf(
            "Este documento registra COMO as estimativas de prazo e custo do projeto {0} "
            "foram construídas: o método, as premissas em que se apoiam e o que ainda "
            "não foi quantificado. É o que permite revisar uma estimativa quando a "
            "realidade mudar, em vez de simplesmente contestá-la.", projeto)),
    ])]
    return _docx_kit("Base-das-Estimativas-template.docx", mapa,
                     [(T("2. base das estimativas"), campos)],
                     projeto, T("Base das Estimativas"),
                     T("Consolidado do orçamento e do cronograma do quadro"), log)


def gerar_transicao_do_produto(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 40 (Encerrar o projeto ou fase) -> Transicao do Produto (.docx).

    O que o projeto ENTREGA e para quem, no dia em que ele acaba. As entregas vem da
    EAP; o destinatario vem das partes interessadas do grupo CLIENTES — que e',
    literalmente, "quem recebe o valor" no cartao do curso."""
    nos, _t = quadro.arvore_da_eap(ids)
    if not nos:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    clientes = [p["nome"] for p in _partes_do_quadro(ids)
                if (p.get("grupo") or "").lower() == "cliente" and p.get("nome")]
    destino = ", ".join(clientes) if clientes else T("(defina os CLIENTES no quadro)")
    entregas = [n for n in nos if n["tipo"] in ("entrega", "pacote")]
    campos = [
        [T("Produto entregue"),
         Tf("{0} entrega(s) e pacote(s) de trabalho concluídos, conforme a linha de base "
            "do escopo deste projeto.", len(entregas))],
        [T("Quem recebe"), destino],
        [T("Critério de aceitação"),
         T("O critério de cada entrega está no verbete dela no Dicionário da EAP. A "
           "transição só se completa quando quem recebe confirma cada um deles.")],
        [T("O que passa a ser responsabilidade de quem recebe"),
         T("A operação e a sustentação do produto entregue. O projeto encerra; o "
           "resultado continua — e é lá, depois da entrega, que o valor aparece.")],
    ]
    # O modelo do Kit e' o de RELATORIO (2. Resumo Executivo / 3. Analise Detalhada),
    # nao um de secoes proprias — os titulos abaixo sao os que existem nele. Pedir
    # "2. transição do produto" gerava aviso e a tabela nao era escrita.
    resumo = [[T("Entregas do projeto"), T("Concluído"), str(len(entregas)),
               str(len(entregas)), destino]]
    itens = [[n["codigo"], n["titulo"][:120], T("Inspeção da entrega")]
             for n in entregas[:40]] or [["", "", ""]]
    mapa = [(T("1. objetivo do documento"), [
        Bloco.paragrafo(Tf(
            "Este documento formaliza a transição do produto do projeto {0} para quem "
            "vai operá-lo. Ele lista o que está sendo entregue, quem recebe e sob que "
            "critério a entrega é aceita.", projeto)),
    ])]
    return _docx_kit("Transicao-do-Produto-template.docx", mapa,
                     [(T("2. resumo executivo"), resumo),
                      (T("3. análise detalhada"), itens),
                      (T("4. próximas ações"), [[x[0], x[1], "", T("Pendente")]
                                                for x in campos])],
                     projeto, T("Transição do Produto"),
                     T("Consolidado do escopo e das partes interessadas do quadro"), log)


def gerar_relatorio_final(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 40 (Encerrar o projeto ou fase) -> Relatorio Final do Projeto (.docx).

    O fecho: o que foi prometido (o Termo), o que foi entregue (a EAP concluida) e o
    que se aprendeu. O VALOR de cada KR nao entra calculado — quem o mede e' o aluno, no
    mundo, e isso e' a regra do curso, nao uma limitacao do script."""
    resp = quadro.respostas_do_termo(ids)
    nos, _t = quadro.arvore_da_eap(ids)
    if not resp.get("1.1") and not nos:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    pacotes = [n for n in nos if n["tipo"] == "pacote"]
    okr = entradas.okr(resp.get("1.2", "")) or {}
    objetivos = okr.get("objetivos") or []
    n_krs = sum(len(o.get("krs") or []) for o in objetivos)
    resumo = [
        [T("Justificativa do projeto"), T("Registrado"), "", "",
         (resp.get("1.1") or "")[:200]],
        [T("Objetivos e Key Results"), T("Registrado"), str(len(objetivos)),
         str(n_krs), T("O valor realizado de cada KR é medido por você, no mundo")],
        [T("Escopo entregue"), T("Registrado"), str(len(pacotes)), "",
         T("Pacotes de trabalho da linha de base do escopo")],
    ]
    detalhe = [[T("Escopo"), Tf("{0} pacote(s) de trabalho", len(pacotes)),
                T("Conforme a EAP aprovada")],
               [T("Partes interessadas"),
                Tf("{0} parte(s) registrada(s)", len(_partes_do_quadro(ids))),
                T("Clientes, equipe e demais influenciadores")],
               [T("Riscos"), Tf("{0} ponto(s) de atenção",
                                len(quadro.riscos_do_quadro(ids) or [])),
                T("Riscos, premissas e restrições do projeto")]]
    acoes = [[T("Medir o valor real de cada Key Result"), "",
              T("Após o encerramento"), T("Pendente")],
             [T("Registrar as lições aprendidas na organização"), "", "",
              T("Pendente")]]
    mapa = [(T("1. objetivo do documento"), [
        Bloco.paragrafo(Tf(
            "Este é o relatório final do projeto {0}: o que ele se propôs a fazer, o que "
            "entregou e o que fica para depois. Muitos benefícios de um projeto só "
            "aparecem meses após o encerramento — por isso o valor de cada Key Result é "
            "medido por você, no mundo, e não por este documento.", projeto)),
    ])]
    return _docx_kit("Relatorio-Final-template.docx", mapa,
                     [(T("2. resumo executivo"), resumo),
                      (T("3. análise detalhada"), detalhe),
                      (T("4. próximas ações"), acoes)],
                     projeto, T("Relatório Final do Projeto"),
                     T("Consolidado do termo de abertura, do escopo e do progresso"), log)


def gerar_documentos_do_projeto(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 5 (Definir o escopo) -> Documentos do Projeto (.docx).

    O indice do que o projeto ja' produziu. A fonte e' a pasta `_saida/documentos/` do
    proprio aluno — os arquivos que os processos geraram ate' agora. Nao ha' o que
    inventar: se o documento existe, ele entra; se nao existe, nao entra."""
    projeto = (projeto or _nome_projeto()).strip()
    pasta = _saida_dir()
    try:
        arquivos = sorted(f for f in os.listdir(pasta)
                          if os.path.splitext(f)[1].lower() in (".docx", ".xlsx", ".pptx",
                                                                ".xml", ".mpp")
                          and not f.startswith("~$"))
    except OSError:
        arquivos = []
    # o proprio indice nao se lista
    arquivos = [f for f in arquivos if not f.startswith(T("Documentos do Projeto"))]
    if not arquivos:
        return None, []
    # As colunas sao as do modelo: ID · Nome · Tipo · Versao · Data · Responsavel ·
    # Localizacao. E os titulos das secoes sao "1. Objetivo e Uso" e "2. Índice de
    # Documentos" — este modelo nao segue a forma dos outros.
    campos = []
    for i, f in enumerate(arquivos, 1):
        caminho = os.path.join(pasta, f)
        try:
            quando = datetime.datetime.fromtimestamp(
                os.path.getmtime(caminho)).strftime("%d/%m/%Y")
        except OSError:
            quando = ""
        campos.append([f"DOC-{i:03d}", os.path.splitext(f)[0],
                       os.path.splitext(f)[1].lstrip(".").upper(), "1.0", quando, "",
                       "_saida/documentos/"])
    mapa = [(T("1. objetivo e uso"), [
        Bloco.paragrafo(Tf(
            "Este é o índice dos documentos do projeto {0} — o que já foi gerado a "
            "partir do seu quadro, com a data de cada versão. Ele se atualiza sozinho: "
            "rode-o de novo depois de gerar documentos novos.", projeto)),
    ])]
    return _docx_kit("Documentos-do-Projeto-template.docx", mapa,
                     [(T("2. índice de documentos"), campos)],
                     projeto, T("Documentos do Projeto"),
                     T("Índice da pasta _saida/documentos do seu projeto"), log)


# ═════════════════════════════════════════════════════════════════════════════
# OS 14 DERIVADOS DO TERMO (15/08/2026)
#
# O QUE E' UM "DERIVADO" (`_admin/PMBOK8_TOGETHER_LACUNAS.md`)
#     O Guia declara o Termo de Abertura, o Plano de Gerenciamento do Projeto ou os
#     Documentos do Projeto como ENTRADA desta saida. Entao o processo NAO abre um
#     formulario em branco: ele PROPOE a partir do que ja' foi respondido e o aluno
#     CONFIRMA ou ajusta. E' a regra de ouro do curso ("a IA propoe em cima do que voce
#     escreveu; voce decide") aplicada aos planos de gerenciamento.
#
# A MARCA "► VOCE DECIDE" E' O CONTRATO DESSA PROPOSTA
#     Onde a decisao e' do aluno — o limite que dispara replanejamento, o apetite a
#     risco, ate' quanto o GP aprova sem subir — o documento sai com a PERGUNTA
#     operacional do livro, nunca com um numero inventado para parecer pronto. Numero
#     chutado vira decisao seis meses depois, e ninguem lembra que quem o escreveu foi
#     um script. E' a mesma regra dos 17 diretos (celula vazia > chute).
#
# DE ONDE VEM O CONTEUDO
#     Do LIVRO, capitulo por capitulo (`books/pmbok-8-in-action/pt/chapters/`), secao
#     X.4 "Saidas" — o capitulo de cada processo esta' no catalogo (`capitulo_livro`).
#     Nunca da memoria do modelo: e' a regra da casa para tudo que cita o PMBOK 8.
# ═════════════════════════════════════════════════════════════════════════════
def _referencia_do_script(n_passo, script):
    """"Passo 9" ou "Processo 39" — citar o passo/processo pelo SCRIPT que o roda.

    Existe ao lado do `_referencia()` porque nem toda citacao tem uma saida util para
    apontar. "Onde a mudanca e' avaliada?" e' o `09_mudancas.py`; pela SAIDA, a
    "Solicitacao de mudanca" nasce em 18 processos e o catalogo devolve o PRIMEIRO deles
    (o 2) — que nao e' onde a mudanca e' decidida. Quem identifica aqui e' o script."""
    import catalogo_pmbok8 as cat
    return cat.rotulo_do_script(n_passo, script)


def _equipe_do_quadro(ids):
    """As partes interessadas que sao EQUIPE — quem executa o trabalho.

    O grupo e' a HIERARQUIA do quadro (filho do cartao CLIENTES/EQUIPE/DEMAIS), nao uma
    palavra no texto: e' por isso que ele vem do `_grupo_das_partes()`, com um GET por
    cartao (o GET de LISTA nao devolve `cartao_pai_id` — lacuna 0f da API)."""
    return [p for p in _partes_do_quadro(ids)
            if (p.get("grupo") or "").strip().lower() == "equipe"]


def _entregue_por_responsavel(ids):
    """{responsavel: (pacotes, concluidos)} — o que a MAQUINA sabe de quem entregou.

    O julgamento continua sendo do GP (e' o `confirmar` da Avaliacao de Desempenho da
    Equipe no catalogo): daqui sai o CONTADO, nunca a nota."""
    from pa_client import list_cards
    lid = ids.get("ESCOPO")
    if not lid:
        return {}
    concl = {str(c["id"]): quadro.card_concluido(c) for c in list_cards(lid)}
    nos, _tem_fases = quadro.arvore_da_eap(ids)
    out = {}
    for n in nos:
        if n["tipo"] != "pacote":
            continue
        nome = (n.get("responsavel") or "").strip() or T("(sem responsável)")
        pacotes, feitos = out.get(nome, (0, 0))
        out[nome] = (pacotes + 1, feitos + (1 if concl.get(str(n["id"])) else 0))
    return out


def _dinheiro_do_quadro(ids):
    """(itens, total, reservas, base, por_periodo) — a leitura financeira que os tres
    documentos de dinheiro (Plano Financeiro, Estrategia e Propostas) compartilham.

    A regra de reserva e' a mesma do `gerar_orcamento`: o que o aluno marcou como tipo
    'reserva' ou batizou de 'contingencia'. Repetir a regra em tres lugares e' como ela
    passa a divergir."""
    itens = quadro.orcamento_do_quadro(ids) or []
    total = sum(i["valor_num"] for i in itens)
    reservas = sum(i["valor_num"] for i in itens
                   if "reserv" in entradas._sem_acento(i.get("tipo", ""))
                   or "conting" in entradas._sem_acento(i.get("nome", "")))
    por_periodo = {}
    for i in itens:
        k = (i.get("quando") or "").strip()
        por_periodo[k] = por_periodo.get(k, 0.0) + i["valor_num"]
    return itens, total, reservas, total - reservas, por_periodo


def _janela_do_cronograma(ids):
    """(primeira data, ultima data, quantas atividades) — ('', '', 0) sem cronograma."""
    wbs = quadro.cronograma_do_quadro(ids)
    atividades = [l for l in _so_atividades(wbs) if l.get("ini") or l.get("fim")]
    if not atividades:
        return "", "", 0
    inicios = sorted(l["ini"] for l in atividades if l.get("ini"))
    fins = sorted(l["fim"] for l in atividades if l.get("fim"))
    return (inicios[0] if inicios else ""), (fins[-1] if fins else ""), len(atividades)


def gerar_plano_do_cronograma(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 7 (Planejar o gerenciamento do cronograma) -> Plano de Ger. do Cronograma.

    AS SETE PERGUNTAS sao as do livro, cap. 8, §8.4 — e a primeira frase do capitulo e' a
    que impede este documento de virar copia do Processo 9: "O Plano de Gerenciamento do
    Cronograma nao contem o cronograma. Contem o arcabouco de governanca do cronograma"
    (§8.1). Nada aqui repete atividade, data ou duracao: o que entra sao as REGRAS."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    ini, fim, n_atividades = _janela_do_cronograma(ids)
    metodo = (T("Método do Caminho Crítico sobre a EAP: a rede de atividades com "
                "predecessora, duração e responsável por pacote de trabalho")
              if abordagem.upper() == "PREDITIVA"
              else T("Planejamento por sprints: escopo comprometido por ciclo, "
                     "com base na capacidade real da equipe"))
    aspectos = [
        [T("1. Qual metodologia e qual ferramenta de cronograma?"),
         Tf("{0}. A ferramenta é o quadro do projeto no ProjectAdm — as datas, as "
            "predecessoras e os responsáveis vivem no cartão, e é de lá que saem o "
            "Cronograma (.xlsx) e o arquivo do MS Project (.xml). O livro pede que o "
            "porquê da escolha fique escrito: ele vai importar quando alguém questionar "
            "a estrutura do cronograma depois (§8.3).", metodo)],
        [T("2. Quem desenvolve o cronograma e quem o revisa?"),
         Tf("{0} As durações e as dependências são propostas pela equipe no cartão de "
            "cada pacote e revisadas antes de o cronograma entrar na linha de base "
            "(§8.4).", _quem_aprova(ids))],
        [T("3. Com que frequência o cronograma é atualizado?"),
         T("► VOCÊ DECIDE. Escreva a cadência (semanal, quinzenal, por sprint), o que "
           "dispara uma atualização fora do ciclo e quem é responsável por mantê-la. O "
           "livro é explícito sobre o erro a evitar: uma cadência frequente demais para "
           "a capacidade da equipe é abandonada — e, com ela, a utilidade do cronograma "
           "como ferramenta de gestão (§8.5).")],
        [T("4. Qual é o limite de variação que exige ação?"),
         T("► VOCÊ DECIDE, e defina como NÚMERO. O livro converte \"estamos um pouco "
           "atrasados\" em um limite operacional — o exemplo dele é \"IDP abaixo de 0,90 "
           "dispara um plano de ação corretiva em até 5 dias úteis\". Defina em DOIS "
           "níveis: o que dispara a ação do GP e o que dispara a notificação ao "
           "patrocinador. Limite frouxo não é governança, é autorização para atrasar "
           "(§8.4, §8.5).")],
        [T("5. Qual é a política de reserva de cronograma?"),
         T("► VOCÊ DECIDE. Diga se o tempo de folga é reserva de CONTINGÊNCIA (dentro da "
           "linha de base, à mão do GP, para riscos já identificados) ou reserva "
           "GERENCIAL (retida pelo patrocinador), e qual é o processo para acessar cada "
           "uma. Um plano silencioso sobre isso produz a conversa desagradável de quando "
           "o GP usa um buffer com que o patrocinador ainda contava (§8.5).")],
        [T("6. Como as mudanças na linha de base do cronograma são processadas?"),
         Tf("Toda mudança entra pela lista Mudanças do quadro, com o impacto escrito "
            "antes da decisão, e é avaliada em {0}. A linha de base — a foto contra a "
            "qual a mudança é medida — é capturada quando o plano é aprovado, em {1}.",
            _referencia_do_script(9, "09_mudancas.py"),
            _referencia(5, "Linha de base do cronograma"))],
        [T("7. Como o desempenho do cronograma é medido?"),
         Tf("A base já está no quadro: {0} atividade(s) com data, cada uma com "
            "responsável e situação de conclusão — é dela que saem as Informações sobre "
            "o Desempenho do Trabalho e as Previsões do Cronograma. ► VOCÊ DECIDE se "
            "acrescenta índice de valor agregado (IDP): ele exige registrar o valor "
            "agregado por período, que o quadro ainda não guarda (§8.4).", n_atividades)],
        [T("Abordagem de desenvolvimento"), _abordagem_por_extenso(abordagem)],
        [T("Horizonte do cronograma (lido do seu quadro)"),
         (Tf("{0} atividade(s) entre {1} e {2}.", n_atividades, ini, fim) if ini and fim
          else T("(o cronograma ainda não tem datas — rode o processo que desenvolve o "
                 "cronograma antes de fechar este plano)"))],
    ]
    ferramentas = [
        [T("Quadro do projeto no ProjectAdm"),
         T("Fonte única das datas, das predecessoras e dos responsáveis"),
         T("Gerente do Projeto")],
        [T("Cronograma (.xlsx) e MS Project (.xml)"),
         T("Comunicar o cronograma e simular cenários fora do quadro"),
         T("Gerente do Projeto")],
        [T("Linha de base do cronograma"),
         T("O padrão aprovado contra o qual toda variação é medida"), T("Patrocinador")],
        [T("Marcos"), T("Pontos de verificação que dividem o projeto em trechos "
                        "conferíveis"), T("Equipe")],
        [T("Lista Mudanças do quadro"),
         T("Registrar a solicitação e o impacto no prazo antes da decisão"),
         T("Gerente do Projeto")],
    ]
    return _plano_de_dominio(ids, abordagem, projeto,
                             "Plano-de-Gerenciamento-do-Cronograma-template.docx",
                             T("Plano de Gerenciamento do Cronograma"),
                             T("o gerenciamento do cronograma"), aspectos, ferramentas, log)


def gerar_plano_financeiro(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 10 (Planejar o Gerenciamento Financeiro) -> Plano de Ger. Financeiro.

    AS SEIS DIMENSOES sao as do livro, cap. 11, §11.4. A que o livro chama de "elemento
    operacionalmente mais importante do plano" e' a matriz de autorizacao de despesa — e
    ela e' `► VOCE DECIDE` por definicao: os valores tem de ser calibrados ao tamanho do
    projeto, e quem sabe o tamanho e' o aluno."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    itens, total, reservas, base, por_periodo = _dinheiro_do_quadro(ids)
    aspectos = [
        [T("1. Qual é a estrutura de financiamento?"),
         T("► VOCÊ DECIDE: o projeto é financiado por uma alocação única em montante "
           "fechado, por parcelas (trimestrais, mensais) ou por verba com restrição de "
           "uso? Cada estrutura cria uma restrição diferente — parcelas obrigam a "
           "gerenciar FLUXO DE CAIXA, e não só o total; verba com restrição limita em "
           "que o dinheiro pode ser gasto. Documentar isso evita a surpresa (§11.4). O "
           "detalhamento é a Estratégia de Financiamento, deste mesmo processo.")],
        [T("2. Quem autoriza despesa, até que valor?"),
         Tf("► VOCÊ DECIDE — e o livro chama esta de a decisão mais operacional do "
            "plano. Defina três faixas: até quanto o GP autoriza sem aprovação prévia; a "
            "faixa que exige o patrocinador; o valor acima do qual decide o comitê. Os "
            "valores têm de ser calibrados ao tamanho do projeto — a referência do livro "
            "é um limite de GP de US$ 15.000 num projeto de US$ 350.000. O seu projeto "
            "hoje soma {0} (§11.4). {1}", _reais(total), _quem_aprova(ids))],
        [T("3. Qual é a cadência de reporte financeiro?"),
         T("► VOCÊ DECIDE: que relatório, com que frequência, para quem e em que "
           "formato. O livro trata o desenho do reporte como tão importante quanto o "
           "conteúdo — um painel mensal de uma página monitora a saúde financeira com "
           "tempo mínimo do patrocinador; um relatório narrativo trimestral pode chegar "
           "meses depois de o projeto já estar em apuros (§11.4).")],
        [T("4. Quais são os limites de variação e o escalonamento?"),
         T("► VOCÊ DECIDE, em IDC ou em percentual de variação de custo: em que "
           "variação o GP age sozinho, em que variação ele escala ao patrocinador e em "
           "que variação o projeto exige nova linha de base. Específicos, mensuráveis e "
           "acordados com o patrocinador ANTES de serem necessários (§11.4).")],
        [T("5. Como a reserva é acessada?"),
         (Tf("A reserva já registrada no seu orçamento é {0} sobre um custo base de {1}. "
             "► VOCÊ DECIDE quem autoriza o saque, que documentação ele exige e como ele "
             "é reportado. A reserva GERENCIAL, se existir, é retida pelo patrocinador e "
             "tem política própria, mais restritiva (§11.4).", _reais(reservas),
             _reais(base)) if reservas else
          T("► VOCÊ DECIDE. O seu orçamento ainda não tem nenhum item marcado como "
            "reserva. Se houver contingência, registre-a como item do tipo 'reserva' na "
            "lista Orçamento — é o que separa o custo base da linha de base dos custos — "
            "e escreva aqui quem autoriza o saque e sob que documentação (§11.4)."))],
        [T("6. Que mudança financeira exige solicitação formal?"),
         Tf("Mudança na linha de base de custos sempre exige. ► VOCÊ DECIDE o resto: "
            "remanejamento entre contas de custo dentro do mesmo total exige? E despesa "
            "antecipada, que acelera um custo já planejado? A decisão é avaliada em {0} "
            "(§11.4).", _referencia_do_script(9, "09_mudancas.py"))],
        [T("Orçamento de referência (lido do seu quadro)"),
         (Tf("{0} item(ns) de custo, {1} no total ({2} de custo base e {3} de reserva), "
             "distribuídos em {4} período(s).", len(itens), _reais(total), _reais(base),
             _reais(reservas), len([k for k in por_periodo if k])) if itens else
          T("(o orçamento ainda está vazio — os valores entram na lista Orçamento do "
            "quadro, no processo que estima os custos)"))],
    ]
    ferramentas = [
        [T("Lista Orçamento do quadro"),
         T("Fonte única dos itens de custo, com tipo, valor e período"),
         T("Gerente do Projeto")],
        [T("Linha de base dos custos"),
         T("Custo base mais reservas — o padrão contra o qual a variação é medida"),
         T("Patrocinador")],
        [T("Matriz de autorização de despesa"),
         T("Dizer quem pode gastar quanto, sem consultar a cada compra"),
         T("Patrocinador")],
        [T("Previsões de Custos e Receitas"),
         T("Projetar o custo final a partir do desempenho até aqui"),
         T("Gerente do Projeto")],
    ]
    return _plano_de_dominio(ids, abordagem, projeto,
                             "Plano-de-Gerenciamento-Financeiro-template.docx",
                             T("Plano de Gerenciamento Financeiro"),
                             T("o gerenciamento financeiro"), aspectos, ferramentas, log)


def gerar_estrategia_de_financiamento(ids, abordagem=None, projeto=None,
                                      log=lambda _m: None):
    """Processo 10 -> Estrategia de Financiamento (.docx).

    O Plano Financeiro diz COMO o dinheiro e' governado; a Estrategia diz DE ONDE ele
    vem e EM QUE PARCELAS (o `confirmar` do catalogo). O que a maquina sabe e' a
    NECESSIDADE por periodo — ela ja' esta' na lista Orcamento, no campo 'quando' de
    cada item. A origem do dinheiro nao esta' no quadro e nao se deduz: e' decisao."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    itens, total, reservas, base, por_periodo = _dinheiro_do_quadro(ids)
    if not itens:
        return None, []
    aspectos = [
        [T("1. De onde vem o dinheiro?"),
         T("► VOCÊ DECIDE: orçamento próprio da área, verba de capital, recurso de "
           "cliente, financiamento externo, receita do próprio produto. Nomeie a fonte e "
           "quem responde por ela — sem isso, o restante desta estratégia é aritmética "
           "sem dono (cap. 11).")],
        [T("2. Em que parcelas ele entra?"),
         T("► VOCÊ DECIDE: montante único no início, parcelas por período ou liberação "
           "por marco atingido. Se for por marco, diga qual marco libera qual parcela — "
           "é o que liga o dinheiro ao cronograma e transforma atraso de entrega em "
           "risco de caixa visível.")],
        [T("3. Qual é a necessidade total?"),
         Tf("{0}, sendo {1} de custo base e {2} de reserva. É este o valor que a fonte "
            "de financiamento precisa cobrir.", _reais(total), _reais(base),
            _reais(reservas))],
    ]
    for periodo in sorted(k for k in por_periodo if k):
        n = sum(1 for i in itens if (i.get("quando") or "").strip() == periodo)
        aspectos.append([Tf("Necessidade em {0}", periodo),
                         Tf("{0} — {1} item(ns) de custo previsto(s) para o período.",
                            _reais(por_periodo[periodo]), n)])
    sem_periodo = por_periodo.get("", 0.0)
    if sem_periodo:
        aspectos.append([T("Sem período definido"),
                         Tf("{0} — itens de custo ainda sem o campo 'Quando' no cartão. "
                            "Enquanto ficarem assim, não entram no fluxo de caixa.",
                            _reais(sem_periodo))])
    aspectos.append(
        [T("4. Que restrição a fonte impõe?"),
         T("► VOCÊ DECIDE: há despesa que a fonte não cobre? Há prazo para usar o "
           "recurso? O saldo não usado volta? Restrição de uso descoberta no meio do "
           "projeto é a que mais custa (§11.4).")])
    ferramentas = [
        [T("Lista Orçamento do quadro"),
         T("A necessidade por período, item a item"), T("Gerente do Projeto")],
        [T("Requisitos de Recursos Financeiros"),
         T("A necessidade acumulada por período, já no formato do Guia"),
         T("Gerente do Projeto")],
        [T("Propostas de Financiamento"),
         T("O pedido formal de cada liberação, com justificativa e retorno"),
         T("Patrocinador")],
        [T("Marcos do cronograma"),
         T("Amarrar cada liberação a uma entrega verificável"), T("Patrocinador")],
    ]
    return _plano_de_dominio(ids, abordagem, projeto,
                             "Estrategia-de-Financiamento-template.docx",
                             T("Estratégia de Financiamento"),
                             T("o financiamento do projeto"), aspectos, ferramentas, log)


def gerar_plano_dos_recursos(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 13 (Planejar o ger. dos recursos) -> Plano de Ger. dos Recursos.

    AS CINCO DIMENSOES sao as do livro, cap. 14, §14.4: aquisicao, liberacao,
    treinamento, gestao de desempenho e histograma. A liberacao e' a que o livro faz
    questao de puxar para o planejamento — "cedo no planejamento, nao so' no
    encerramento" — e e' a que quase todo plano esquece."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    equipe = _equipe_do_quadro(ids)
    carga = _entregue_por_responsavel(ids)
    aspectos = [
        [T("1. Como os recursos são adquiridos?"),
         Tf("► VOCÊ DECIDE por origem: equipe interna vem por negociação com o gerente "
            "funcional; terceiro vem por contratação; fornecedor vem pelo processo "
            "definido no Plano de Estratégia de Aquisições. Escreva a SEQUÊNCIA e o "
            "PRAZO de cada um — é o que faz o recurso estar disponível quando o "
            "cronograma o exige (§14.4). Hoje o seu quadro tem {0} pessoa(s) no grupo "
            "EQUIPE.", len(equipe))],
        [T("2. Quando e como cada pessoa é liberada?"),
         T("► VOCÊ DECIDE, e o livro pede que isso seja decidido AGORA, não no "
           "encerramento: quem sai antes do fim precisa de plano de transição — que "
           "trabalho repassa, que documentação entrega e qual é a próxima alocação "
           "dela. Liberação sem plano é conhecimento que sai pela porta (§14.4).")],
        [T("3. De que treinamento a equipe precisa?"),
         T("► VOCÊ DECIDE: que habilidade o trabalho exige e a equipe ainda não tem, "
           "quem ensina e quando. Treinamento é CUSTO do projeto — se ele existir, entra "
           "na lista Orçamento como item, senão ele acontece de qualquer jeito e sem "
           "orçamento (§14.4).")],
        [T("4. Como o desempenho da equipe é avaliado?"),
         Tf("A base já está no quadro: {0} pacote(s) de trabalho com responsável "
            "nomeado, e a Avaliação de Desempenho da Equipe conta o entregue. ► VOCÊ "
            "DECIDE o resto — qual é o papel do GP na avaliação em relação ao do gerente "
            "funcional, e que reconhecimento existe para desempenho excepcional (§14.4).",
            sum(t for t, _f in carga.values()))],
        [T("5. Como a carga da equipe é enxergada?"),
         T("O histograma de recursos põe a DEMANDA (horas por papel por período) contra "
           "a DISPONIBILIDADE. Ele existe para tornar a sobrecarga visível antes de "
           "virar problema — o exemplo do livro é o membro a 130% de capacidade nas "
           "semanas 5 a 7. ► VOCÊ DECIDE a capacidade de cada pessoa por período: o "
           "quadro guarda quem responde por cada pacote, não quantas horas cada um tem "
           "(§14.4).")],
        [T("Abordagem de desenvolvimento"), _abordagem_por_extenso(abordagem)],
    ]
    for nome, (pacotes, _feitos) in sorted(carga.items()):
        aspectos.append([Tf("Carga de {0}", nome),
                         Tf("{0} pacote(s) de trabalho sob responsabilidade dela(e) "
                            "hoje.", pacotes)])
    ferramentas = [
        [T("Lista Partes Interessadas do quadro (grupo EQUIPE)"),
         T("Quem é a equipe, com papel e contato"), T("Gerente do Projeto")],
        [T("Responsável por pacote de trabalho"),
         T("Ligar cada pacote a uma pessoa — a base da carga e da avaliação"),
         T("Gerente do Projeto")],
        [T("Estrutura Analítica dos Recursos"),
         T("Organizar os recursos por tipo, do time às categorias de custo"),
         T("Gerente do Projeto")],
        [T("Termo de Nomeação da Equipe"),
         T("Os acordos de convivência que a equipe assina"), T("Equipe")],
    ]
    return _plano_de_dominio(ids, abordagem, projeto,
                             "Plano-de-Gerenciamento-dos-Recursos-template.docx",
                             T("Plano de Gerenciamento dos Recursos"),
                             T("o gerenciamento dos recursos"), aspectos, ferramentas, log)


def gerar_termo_de_nomeacao_da_equipe(ids, abordagem=None, projeto=None,
                                      log=lambda _m: None):
    """Processo 13 -> Termo de Nomeacao da Equipe (.docx).

    E' o CONTRATO SOCIAL da equipe (livro, cap. 14, §14.4): horario e disponibilidade,
    normas de comunicacao, regras de decisao, resolucao de conflito, normas de reuniao e
    definicao de pronto. Quem a maquina traz sao as PESSOAS e a Definicao de Pronto que
    ja' esta' no quadro; os acordos sao da equipe — e um acordo escrito por script nao e'
    acordo, e' texto.

    Este modelo NAO segue o esqueleto dos planos: sao 10 secoes proprias, e as tabelas
    tem larguras diferentes. Por isso ele monta as tabelas na mao, em vez de chamar o
    `_plano_de_dominio`."""
    projeto = (projeto or _nome_projeto()).strip()
    equipe = _equipe_do_quadro(ids)
    if not equipe:
        return None, []
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    resp = quadro.respostas_do_termo(ids)
    hoje = datetime.date.today().strftime("%d/%m/%Y")

    gerente = next((p["nome"] for p in _partes_do_quadro(ids)
                    if any(k in (p.get("papel") or "").lower()
                           for k in ("gerente", "manager"))), "")
    info = [
        [T("Nome do Projeto"), projeto],
        [T("Nome da Equipe"), T("► VOCÊ DECIDE (a equipe se batiza)")],
        [T("Gerente do Projeto"), gerente or T("► VOCÊ DECIDE")],
        [T("Abordagem de desenvolvimento"), _abordagem_por_extenso(abordagem)],
        [T("Período de vigência"), Tf("A partir de {0}, enquanto durar o projeto.", hoje)],
        [T("Tamanho da equipe"), Tf("{0} pessoa(s) no grupo EQUIPE do quadro.",
                                    len(equipe))],
    ]
    membros = [[p["nome"], p.get("papel") or T("Participante"),
                p.get("espera") or T("► VOCÊ DECIDE: o que esta pessoa entrega"),
                p.get("email") or ""] for p in equipe]
    valores = [
        [T("► VOCÊ DECIDE"),
         T("Escreva de 3 a 5 valores e, ao lado de cada um, COMO a equipe o pratica no "
           "dia a dia. Valor sem prática é cartaz de parede — o que vale é a frase que "
           "descreve o comportamento observável.")],
    ]
    comunicacao = [
        [T("► VOCÊ DECIDE: reunião periódica"), T("► frequência"), T("► participantes"),
         T("Alinhar o andamento e destravar impedimento")],
        [T("► VOCÊ DECIDE: canal do dia a dia"), T("► tempo de resposta esperado"),
         T("Equipe"),
         T("O livro pede em números: e-mail em até 24 h, mensagem em até 2 h no horário "
           "de trabalho (§14.4)")],
        [T("► VOCÊ DECIDE: canal do urgente"), T("Quando houver"), T("► quem atende"),
         T("Um canal separado para o que não pode esperar a próxima reunião")],
        [T("Quadro do projeto no ProjectAdm"), T("Contínuo"), T("Equipe"),
         T("A situação de cada pacote — a fonte única, para ninguém precisar perguntar")],
    ]
    decisao = [
        [T("Decisão técnica"), T("► VOCÊ DECIDE o processo"), T("► VOCÊ DECIDE quem")],
        [T("Decisão de escopo"),
         Tf("Entra pela lista Mudanças e é avaliada em {0}",
            _referencia_do_script(9, "09_mudancas.py")), _quem_aprova(ids)[:180]],
        [T("Decisão em impasse"),
         T("O livro é direto: alguém decide. Sem essa regra, nada é decidido porque "
           "todos esperam que outro assuma (§14.4)"),
         gerente or T("Gerente do Projeto")],
    ]
    conflitos = [
        ["1ª etapa", T("As pessoas envolvidas conversam diretamente"),
         T("► VOCÊ DECIDE o prazo")],
        ["2ª etapa", T("O Gerente do Projeto facilita a conversa"),
         T("► VOCÊ DECIDE o prazo")],
        ["3ª etapa", T("Escalada ao patrocinador, com o impasse escrito"),
         T("► VOCÊ DECIDE o prazo")],
    ]
    acordos = [
        [T("Horário de trabalho e disponibilidade"),
         T("► VOCÊ DECIDE: qual é o horário padrão, o que é flexível e qual é a "
           "expectativa entre remoto e presencial (§14.4)")],
        [T("Normas de reunião"),
         T("► VOCÊ DECIDE: que preparação se espera, se dá para faltar e como a decisão "
           "da reunião é registrada")],
    ]
    # A Definicao de Pronto NAO se inventa aqui: ela ja' e' um checklist do quadro (o
    # cartao "✅ Definição de Pronto" que o processo de aprovacao do plano grava). Ela
    # entra como acordo porque e' exatamente isso: o acordo do que conta como concluido.
    dod = [(b.texto or "").strip() for b in _dod_blocos(ids) if (b.texto or "").strip()]
    for texto in dod:
        acordos.append([T("Definição de Pronto"), texto])
    if not dod:
        acordos.append(
            [T("Definição de Pronto"),
             T("► VOCÊ DECIDE: o que faz um pacote estar concluído — não só o trabalho, "
               "mas a documentação, a revisão e a atualização do quadro. É o acordo que "
               "evita a conversa 'eu terminei' contra 'ainda não está pronto' (§14.4). "
               "O processo que aprova o plano grava a sua no quadro.")])

    mapa = [(T("1. objetivo do documento"), [
        Bloco.paragrafo(Tf(
            "Este Termo de Nomeação registra os acordos de trabalho da equipe do projeto "
            "{0}: quem é a equipe, como ela se comunica, como decide e como resolve "
            "conflito. Ele é derivado do Termo de Abertura — das pessoas já identificadas "
            "no quadro — e só vale como acordo depois de a equipe o discutir e assinar.",
            projeto)),
        Bloco.paragrafo(T("Ter um processo escrito não previne o conflito: fornece um "
                          "caminho de resolução que não exige que o Gerente do Projeto "
                          "arbitre cada desentendimento do zero (§14.4).")),
    ])]
    tabelas = [
        (T("2. informações da equipe"), info),
        (T("3. membros da equipe e papéis"), membros),
        (T("4. valores da equipe"), valores),
        (T("5. diretrizes de comunicação"), comunicacao),
        (T("6. processo de tomada de decisão"), decisao),
        (T("7. resolução de conflitos"), conflitos),
        (T("8. acordos de trabalho"), acordos),
        (T("9. aprovações"), _aprovadores(ids)),
    ]
    saida = os.path.join(_saida_dir(), Tf("{0} - {1}.docx",
                                          T("Termo de Nomeação da Equipe"),
                                          _slug_arquivo(projeto)))
    caminho, avisos = preencher_docx(
        _template_kit("Termo-de-Nomeacao-da-Equipe-template.docx"), mapa, saida,
        capa={"[Nome do Projeto]": projeto},
        cabecalho={T("Nome do Projeto"): projeto},
        tabelas=tabelas,
        versao=("1.0", hoje, "", T("Derivado das partes interessadas do quadro "
                                   "(grupo EQUIPE)")),
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


def gerar_plano_de_engajamento(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 14 (Planejar o engajamento das partes) -> Plano de Engajamento.

    O livro (cap. 15, §15.4) lista o que o plano documenta POR PARTE: nivel atual e
    desejado, a lacuna entre os dois, a estrategia para fecha-la, as mensagens-chave, as
    atividades, o responsavel e o indicador de sucesso. Quatro desses sete o quadro ja'
    guarda no cartao de cada parte — entao aqui a maquina traz muito, e o que sobra para
    o aluno e' a estrategia de quem ainda nao tem uma.

    A LACUNA E' A METRICA deste plano, e nao o KR do cartao 1.2: e' por isso que ele
    passa `metricas=` ao molde. Medir engajamento por KR de projeto seria medir outra
    coisa."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    partes = _partes_do_quadro(ids)
    if not partes:
        return None, []
    aspectos = [
        [T("Como o engajamento é avaliado"),
         T("Cada parte interessada tem, no cartão dela, o nível ATUAL e o nível "
           "DESEJADO de engajamento (desconhece · resiste · neutro · apoia · lidera). A "
           "distância entre os dois é a LACUNA — e é ela, não o poder ou o interesse "
           "isolados, que diz onde a sua energia rende mais (§15.4).")],
        [T("Como a estratégia muda quando o engajamento muda"),
         T("O plano é atualizado à medida que os níveis mudam: quando uma parte "
           "resistente fica neutra, a estratégia migra da persuasão para o reforço; "
           "quando uma parte neutra vira campeã, ela migra do fornecimento de informação "
           "para o envolvimento e o reconhecimento (§15.4).")],
        [T("Abordagem de desenvolvimento"), _abordagem_por_extenso(abordagem)],
    ]
    for p in partes:
        atual = p.get("atual") or T("(não avaliado)")
        desejado = p.get("desejado") or T("(não definido)")
        estrategia = (p.get("estrategia") or "").strip()
        cadencia = (p.get("cadencia") or "").strip()
        detalhe = Tf("Hoje {0} → precisa estar {1}.", atual, desejado)
        if p.get("poder") or p.get("interesse"):
            detalhe += Tf(" Poder {0}, interesse {1}.", p.get("poder") or "—",
                          p.get("interesse") or "—")
        detalhe += " " + (estrategia if estrategia else
                          T("► VOCÊ DECIDE a estratégia: que ação específica e agendada "
                            "fecha esta lacuna, qual é a mensagem-chave e quem a "
                            "executa."))
        if cadencia:
            detalhe += Tf(" Cadência: {0}.", cadencia)
        aspectos.append([p["nome"], detalhe[:1800]])
    metricas = [[Tf("Engajamento: {0}", p["nome"]),
                 (p.get("desejado") or T("► VOCÊ DECIDE o nível desejado")),
                 T("A cada revisão do plano"), T("Gerente do Projeto")]
                for p in partes]
    ferramentas = [
        [T("Lista Partes Interessadas do quadro"),
         T("Fonte única de quem é quem, com poder, interesse e engajamento"),
         T("Gerente do Projeto")],
        [T("Registro das Partes Interessadas"),
         T("O documento com a matriz de engajamento atual → desejado"),
         T("Gerente do Projeto")],
        [T("Comunicações do projeto"),
         T("O que foi de fato comunicado a cada parte, e quando"),
         T("Gerente do Projeto")],
        [T("Reuniões e demonstrações"),
         T("Mostrar entrega funcionando é o que move um cético mais do que relatório"),
         T("Equipe")],
    ]
    return _plano_de_dominio(ids, abordagem, projeto,
                             "Plano-de-Engajamento-das-Partes-Interessadas-template.docx",
                             T("Plano de Engajamento das Partes Interessadas"),
                             T("o engajamento das partes interessadas"),
                             aspectos, ferramentas, log, metricas=metricas)


def gerar_plano_dos_riscos(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 16 (Planejar o ger. dos riscos) -> Plano de Ger. dos Riscos.

    OS SETE ELEMENTOS sao os do livro, cap. 17, §17.4: metodologia, categorias (EAR),
    escalas de probabilidade e impacto, matriz, papeis, cadencia de revisao e politica
    de reserva.

    AS ESCALAS NAO SAO INVENTADAS AQUI NEM COPIADAS DO LIVRO: sao as que o curso ja'
    APLICA no quadro do aluno (`quadro.nivel_1a5`, `escala_probabilidade`,
    `escala_impacto`, `prioridade_do_risco`). Escrever no plano uma escala diferente da
    que o registro usa seria publicar uma regra que o proprio projeto desmente na linha
    de baixo. O livro pede que os LIMITES sejam calibrados ao tamanho do projeto — e essa
    calibragem e' `► VOCE DECIDE`."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    pontos = quadro.riscos_do_quadro(ids) or []
    riscos = [p for p in pontos if T("risco") in (p.get("tipo") or "").lower()]
    _itens, _total, reservas, base, _pp = _dinheiro_do_quadro(ids)
    por_tipo = {}
    for p in pontos:
        t = (p.get("tipo") or T("não classificado")).strip()
        por_tipo[t] = por_tipo.get(t, 0) + 1
    aspectos = [
        [T("1. Qual é a metodologia de risco?"),
         Tf("Identificação a partir das causas do Termo de Abertura e das premissas, com "
            "a IA propondo e você confirmando; análise QUALITATIVA de todos os riscos "
            "(probabilidade × impacto). ► VOCÊ DECIDE se algum risco recebe também "
            "análise quantitativa — e quais. Hoje o quadro tem {0} ponto(s) de atenção, "
            "sendo {1} classificado(s) como risco (§17.4).", len(pontos), len(riscos))],
        [T("2. Quais são as categorias de risco (EAR)?"),
         (Tf("As categorias já usadas no seu quadro: {0}. As categorias existem para a "
             "identificação não se fixar no tipo de risco familiar e perder os outros — "
             "a equipe percorre cada uma perguntando \"o que pode dar errado aqui?\" "
             "(§17.4).",
             " · ".join(f"{k} ({v})" for k, v in sorted(por_tipo.items())))
          if por_tipo else
          T("► VOCÊ DECIDE. O livro sugere quatro categorias de primeiro nível para "
            "projetos de tecnologia: técnicos, externos, organizacionais e de "
            "gerenciamento do projeto — cada uma desdobrada em subcategorias do seu tipo "
            "de projeto (§17.4)."))],
        [T("3. Qual é a escala de probabilidade?"),
         T("Cinco níveis, como o curso já os aplica no seu quadro: 1-Muito baixa · "
           "2-Baixa · 3-Média · 4-Alta · 5-Muito alta. ► VOCÊ DECIDE a faixa de "
           "percentual de cada nível para ESTE projeto: o livro adverte que a mesma "
           "palavra significa coisas diferentes em projetos de tamanhos diferentes "
           "(§17.4).")],
        [T("4. Qual é a escala de impacto?"),
         T("Os mesmos cinco níveis: 1-Muito baixo · 2-Baixo · 3-Médio · 4-Alto · "
           "5-Muito alto. ► VOCÊ DECIDE o que cada nível significa em DINHEIRO e em DIAS "
           "no seu projeto — sem isso, dois membros da equipe pontuam o mesmo risco de "
           "formas diferentes e a priorização deixa de significar alguma coisa (§17.4).")],
        [T("5. Como a prioridade é calculada?"),
         T("Probabilidade × Impacto, de 1 a 25 — é o cálculo que o próprio quadro faz e "
           "grava no cartão do risco. As faixas que o curso aplica: 15 ou mais = "
           "urgente · 9 a 14 = alta · 5 a 8 = média · abaixo de 5 = baixa. ► VOCÊ DECIDE "
           "a partir de que faixa o risco é escalado para fora do projeto.")],
        [T("6. Quem faz o quê nos riscos?"),
         Tf("Cada risco tem um DONO nomeado no cartão — é ele quem executa a resposta e "
            "avisa quando o risco muda. {0} ► VOCÊ DECIDE quem tem autoridade para "
            "fechar um risco e quem escala o que passa do limite do projeto (§17.4).",
            _quem_aprova(ids))],
        [T("7. Com que frequência os riscos são revisados?"),
         T("► VOCÊ DECIDE a cadência — e o livro pede que ela seja específica: "
           "quinzenal na retrospectiva, mensal na revisão com o patrocinador, em cada "
           "gate de fase. É a cadência que determina com que rapidez o projeto responde "
           "quando a condição de risco muda (§17.4).")],
        [T("8. Qual é a política de reserva?"),
         (Tf("A reserva de contingência hoje registrada no orçamento é {0}, sobre um "
             "custo base de {1}. O livro pede que o plano declare a ligação entre o "
             "registro dos riscos (que dimensiona a reserva) e a linha de base dos "
             "custos (que a contém). ► VOCÊ DECIDE quem autoriza o saque e como ele é "
             "reabastecido (§17.4).", _reais(reservas), _reais(base)) if reservas else
          T("► VOCÊ DECIDE. Não há item de reserva no orçamento ainda. A reserva de "
            "contingência é dimensionada a partir dos riscos identificados e vive DENTRO "
            "da linha de base dos custos; a reserva gerencial fica fora dela, com o "
            "patrocinador (§17.4)."))],
        [T("Abordagem de desenvolvimento"), _abordagem_por_extenso(abordagem)],
    ]
    ferramentas = [
        [T("Lista Riscos e Premissas do quadro"),
         T("Fonte única dos riscos, com probabilidade, impacto, resposta e dono"),
         T("Gerente do Projeto")],
        [T("Registro dos Riscos"),
         T("A lista completa, um risco por linha, com a prioridade calculada"),
         T("Gerente do Projeto")],
        [T("Relatório de Riscos"),
         T("A visão do conjunto: quantos são, como se distribuem, quais não têm resposta"),
         T("Patrocinador")],
        [T("Causa → evento → impacto"),
         T("A forma de enunciado que separa risco de medo — sem ela, a resposta não "
           "tem em que agir"), T("Equipe")],
    ]
    return _plano_de_dominio(ids, abordagem, projeto,
                             "Plano-de-Gerenciamento-dos-Riscos-template.docx",
                             T("Plano de Gerenciamento dos Riscos"),
                             T("o gerenciamento dos riscos"), aspectos, ferramentas, log)


def gerar_plano_de_aquisicoes(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 20 (Planejar Estrategia de Aquisicoes) -> Plano de Estrategia de Aquisicoes.

    O livro (cap. 21, §21.4) e' preciso sobre o que a estrategia documenta POR PACOTE DE
    AQUISICAO: fazer ou comprar, justificativa, tipo de contrato, justificativa do tipo e
    os termos inegociaveis. Ela e' "o compromisso do GP com uma abordagem ANTES de as
    conversas com fornecedores comecarem".

    O QUE A MAQUINA TRAZ: os itens de custo que nao sao a propria equipe — todo item que
    o aluno classificou como compra, servico, licenca ou terceiro E' um candidato a
    pacote de aquisicao. O que ela NAO decide: fazer ou comprar, e sob que contrato."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    projeto = (projeto or _nome_projeto()).strip()
    itens, total, reservas, _base, _pp = _dinheiro_do_quadro(ids)
    # "pessoa" e "reserva" nao sao aquisicao: a primeira e' a equipe (Processo 22), a
    # segunda e' dinheiro guardado, nao coisa comprada.
    candidatos = [i for i in itens
                  if not any(k in entradas._sem_acento(i.get("tipo", ""))
                             for k in ("pessoa", "equipe", "reserv"))]
    aspectos = [
        [T("1. O que este projeto compra?"),
         (Tf("{0} item(ns) do seu orçamento não são trabalho da própria equipe e, por "
             "isso, são candidatos a pacote de aquisição — eles estão listados abaixo, "
             "um a um. ► VOCÊ DECIDE, para cada um, se é FAZER ou COMPRAR, e por quê "
             "(§21.4).", len(candidatos)) if candidatos else
          T("► VOCÊ DECIDE. Nenhum item do orçamento está classificado como compra, "
            "serviço ou licença hoje — se o projeto vai contratar alguma coisa, "
            "registre-a como item de custo na lista Orçamento antes de fechar esta "
            "estratégia."))],
        [T("2. Como o fornecedor é escolhido?"),
         T("► VOCÊ DECIDE o mecanismo: cotação aberta, lista de fornecedores "
           "pré-qualificados ou fonte única — e, em qualquer um deles, o critério de "
           "avaliação escrito ANTES de as propostas chegarem. Critério escrito depois "
           "da proposta é justificativa, não critério (§21.4).")],
        [T("3. Que tipo de contrato se aplica?"),
         T("► VOCÊ DECIDE por pacote, e o livro pede a JUSTIFICATIVA junto: preço fixo "
           "transfere o risco de custo ao fornecedor e exige escopo bem definido; "
           "tempo e material aceita escopo em evolução e mantém o risco com você "
           "(§21.4).")],
        [T("4. Quais são os termos inegociáveis?"),
         T("► VOCÊ DECIDE: prazo, propriedade do que for produzido, confidencialidade, "
           "nível de serviço, condição de rescisão. São os termos que você não abre — "
           "listá-los antes da negociação é o que impede que eles virem concessão no "
           "meio dela (§21.4).")],
        [T("5. Como o desempenho do fornecedor é medido?"),
         T("► VOCÊ DECIDE o indicador e a frequência do reporte, e como uma mudança de "
           "contrato é controlada. Fornecedor sem indicador acordado só é avaliado "
           "quando já é tarde (§21.4).")],
        [T("Abordagem de desenvolvimento"), _abordagem_por_extenso(abordagem)],
    ]
    for i in candidatos:
        valor = _reais(i["valor_num"]) if i["valor_num"] else (i.get("valor") or "")
        aspectos.append(
            [Tf("Pacote de aquisição: {0}", i["nome"]),
             Tf("Tipo: {0} · Valor previsto: {1} · Período: {2}. ► VOCÊ DECIDE: fazer ou "
                "comprar? Sob que tipo de contrato? Com que termo inegociável?",
                i.get("tipo") or "—", valor or "—", i.get("quando") or "—")])
    ferramentas = [
        [T("Lista Orçamento do quadro"),
         T("Os itens que são candidatos a compra, com valor e período"),
         T("Gerente do Projeto")],
        [T("Análise fazer-ou-comprar"),
         T("Decidir cada pacote pelo custo total e pela capacidade interna, não pelo "
           "preço isolado"), T("Gerente do Projeto")],
        [T("Critério de seleção escrito"),
         T("Avaliar propostas contra um padrão definido antes de recebê-las"),
         T("Patrocinador")],
        [T("Lista Mudanças do quadro"),
         T("Registrar toda alteração de contrato como mudança do projeto"),
         T("Gerente do Projeto")],
    ]
    return _plano_de_dominio(ids, abordagem, projeto,
                             "Plano-de-Estrategia-de-Aquisicoes-template.docx",
                             T("Plano de Estratégia de Aquisições"),
                             T("as aquisições do projeto"), aspectos, ferramentas, log)


# Os QUATRO INDICADORES sao os do Guia, na letra (PMBOK 8, "Avaliações de desempenho da
# equipe", p.122): "A avaliação da eficácia de uma equipe pode incluir indicadores como:
# melhorias nas habilidades que permitem que os indivíduos realizem tarefas com mais
# eficiência; melhorias nas competências que ajudam os membros da equipe a ter um melhor
# desempenho em equipe; reduzida a taxa de rotatividade de funcionários; e maior coesão da
# equipe, em que os membros compartilham informações e experiências abertamente e ajudam
# uns aos outros a melhorar o desempenho geral do projeto."
#
# NENHUM DELES ESTA NO QUADRO — e nao se deduz de contagem de pacote. Por isso eles saem
# como as PERGUNTAS da avaliacao, com a resposta em branco: e' o `confirmar` do catalogo
# ("a maquina traz o entregue, quem julga e' o GP") escrito no vocabulario do Guia.
def _indicadores_do_guia():
    return [
        T("Melhoria nas habilidades que permitem realizar tarefas com mais eficiência"),
        T("Melhoria nas competências que ajudam a ter um melhor desempenho EM EQUIPE"),
        T("Taxa de rotatividade da equipe"),
        T("Coesão da equipe: as pessoas compartilham informação e ajudam umas às outras"),
    ]


def gerar_avaliacao_de_desempenho_da_equipe(ids, abordagem=None, projeto=None,
                                            log=lambda _m: None):
    """Processo 23 (Liderar a Equipe) -> Avaliacao de Desempenho da Equipe.

    DUAS FORMAS, POR DECISAO (Eduardo, 15/08/2026) — e e' a unica saida assim: no Kit
    este documento e' `.docx` em portugues e `.xlsx` em ingles. Em vez de esperar o
    alinhamento dos modelos (D7), o gerador atravessa: pergunta ao catalogo qual e' o
    modelo do idioma corrente e escolhe o esqueleto pela EXTENSAO dele. O conteudo e' o
    mesmo dos dois lados — o que muda e' o continente.

    O QUE A MAQUINA TRAZ e o que ela NAO traz: daqui sai o CONTADO (quantos pacotes cada
    pessoa tem e quantos concluiu, lido do quadro). A nota nao sai. O Guia lista quatro
    indicadores de eficacia da equipe e nenhum deles e' contagem de pacote — eles entram
    como as perguntas da avaliacao, para o GP responder."""
    projeto = (projeto or _nome_projeto()).strip()
    equipe = _equipe_do_quadro(ids)
    carga = _entregue_por_responsavel(ids)
    if not equipe and not carga:
        return None, []
    modelo = _modelo_da_saida("Avaliação de desempenho da equipe",
                              "Avaliacao-de-Desempenho-da-Equipe-template.docx")
    if modelo.lower().endswith(".xlsx"):
        return _avaliacao_da_equipe_xlsx(ids, modelo, projeto, equipe, carga, log)
    total_pacotes = sum(t for t, _f in carga.values())
    total_feitos = sum(f for _t, f in carga.values())
    com_dono = sum(t for nome, (t, _f) in carga.items()
                   if nome != T("(sem responsável)"))
    resumo = [
        [T("Pessoas na equipe do projeto"), T("Registrado"), "", str(len(equipe)), ""],
        [T("Pacotes de trabalho com responsável nomeado"),
         T("OK") if com_dono == total_pacotes else T("Incompleto"),
         str(total_pacotes), str(com_dono), str(com_dono - total_pacotes)],
        [T("Pacotes de trabalho concluídos"),
         T("Em andamento") if total_feitos < total_pacotes else T("Concluído"),
         str(total_pacotes), str(total_feitos), str(total_feitos - total_pacotes)],
    ]
    # os quatro indicadores do Guia entram no resumo COM A COLUNA DE RESULTADO EM BRANCO:
    # o Guia os chama de "avaliações formais ou informais da eficácia da equipe", e
    # nenhum deles se mede contando cartão concluído.
    for indicador in _indicadores_do_guia():
        resumo.append([indicador, T("► avaliar"), "", "", ""])
    detalhe = []
    for p in equipe:
        pacotes, feitos = carga.get(p["nome"], (0, 0))
        detalhe.append([
            p["nome"],
            Tf("{0} de {1} pacote(s) concluído(s)", feitos, pacotes) if pacotes
            else T("sem pacote de trabalho sob responsabilidade dela(e)"),
            Tf("Papel: {0}. ► VOCÊ DECIDE a avaliação — o número acima é o entregue "
               "medido no quadro, não um julgamento de desempenho.",
               p.get("papel") or T("(sem papel definido)"))])
    for nome, (pacotes, feitos) in sorted(carga.items()):
        if any(d[0] == nome for d in detalhe):
            continue
        # quem responde por pacote mas nao esta' no grupo EQUIPE: e' informacao, nao erro
        detalhe.append([nome, Tf("{0} de {1} pacote(s) concluído(s)", feitos, pacotes),
                        T("Responsável por pacote sem estar no grupo EQUIPE do quadro — "
                          "confira se falta cadastrá-la(o) como parte interessada.")])
    if not detalhe:
        detalhe = [["", "", ""]]
    acoes = [
        [T("Dar retorno individual a cada pessoa da equipe"),
         T("Gerente do Projeto"), T("► VOCÊ DECIDE o prazo"), T("Planejada")],
        [T("Registrar no quadro o que a equipe aprendeu neste período"),
         T("Equipe"), T("► VOCÊ DECIDE o prazo"), T("Planejada")],
    ]
    if com_dono < total_pacotes:
        acoes.insert(0, [T("Nomear responsável para os pacotes que ainda não têm"),
                         T("Gerente do Projeto"), T("► VOCÊ DECIDE o prazo"),
                         T("Planejada")])
    mapa = [(T("1. objetivo do documento"), [
        Bloco.paragrafo(Tf(
            "Esta avaliação apresenta o que a equipe do projeto {0} entregou até a data "
            "de emissão, lido do quadro: quantos pacotes de trabalho cada pessoa tem sob "
            "responsabilidade e quantos já estão concluídos.", projeto)),
        Bloco.paragrafo(T("O número é o ponto de partida da conversa, não a conversa. O "
                          "Guia define esta saída como as avaliações, formais ou "
                          "informais, da EFICÁCIA da equipe — e lista quatro indicadores "
                          "para ela: melhoria de habilidades individuais, melhoria de "
                          "competências de trabalho em equipe, taxa de rotatividade e "
                          "coesão. Eles estão no resumo, com a avaliação em branco: quem "
                          "responde é o Gerente do Projeto.")),
        Bloco.paragrafo(T("O Guia também diz para que serve a avaliação: identificar o "
                          "treinamento, o coaching, a mentoria, a assistência ou as "
                          "mudanças necessárias para melhorar o desempenho da equipe — e "
                          "os recursos para implementá-las. É o que vai em Próximas "
                          "Ações.")),
    ])]
    return _docx_kit(modelo, mapa,
                     [(T("2. resumo executivo"), resumo),
                      (T("3. análise detalhada"), detalhe),
                      (T("4. próximas ações"), acoes)],
                     projeto, T("Avaliação de Desempenho da Equipe"),
                     T("Entregue por responsável, lido da lista de escopo do quadro"),
                     log)


# ═════════════════════════════════════════════════════════════════════════════
# A ABORDAGEM AGIL — as duas saidas de FORMA agil do processo 6 (15/08/2026)
#
# A FRASE DO GUIA QUE TORNA ISTO SIMPLES
#     "Em projetos ágeis, a EAP CORRESPONDE ao backlog de produto, onde os itens de
#     trabalho podem ser divididos em épicos e histórias de usuário" (PMBOK 8, §2.2.2.4).
#     Entao nao ha' lista nova no quadro, pergunta nova ao aluno nem estrutura paralela:
#     e' a MESMA arvore que vira EAP na preditiva, lida na forma que o Guia nomeia. Foi
#     essa leitura que transformou "artefato agil" de segunda onda em duas funcoes.
#
# A DECOMPOSICAO TAMBEM E' DO GUIA, na mesma secao: "épicos, funcionalidades e histórias
# de usuário". O nivel da arvore ja' carrega isso:
#     fase (nivel 1)   -> Épico
#     entrega (nivel 2) -> Feature
#     pacote (folha)    -> História de Usuário
#
# O QUE NAO SAI DAQUI: prioridade, story points e sprint. O quadro nao os guarda, e
# invente-los seria dar ao aluno um backlog priorizado que ninguem priorizou. A ORDEM das
# linhas, essa sim, e' informacao real — e' a ordem do quadro.
# ═════════════════════════════════════════════════════════════════════════════
def _tipo_agil(tipo):
    """fase -> Épico · entrega -> Feature · folha -> História de Usuário.

    É FUNÇÃO, e não constante de módulo, por causa do T(): o `extrair_textos.py` só
    enxerga literal dentro de uma chamada de saída, então texto que nasce numa constante
    e só passa por T() lá na frente fica INVISÍVEL para o extrator — e sai em português
    no pacote inglês. É a armadilha (a) de `_admin/DEV_STANDARDS.md`, a mesma que já fez
    o prompt inteiro da tecla C viajar sem tradução. Aqui os três termos são também os
    três valores do dropdown da coluna Tipo do modelo, então errar um deles esvazia a
    célula (ver `_respeitar_listas`)."""
    return {"fase": T("Épico"), "entrega": T("Feature"),
            "pacote": T("História de Usuário")}.get(tipo, T("História de Usuário"))


def _so_na_abordagem_agil(abordagem, saida, log):
    """True quando NAO e' para gerar — e diz por que, em vez de sumir em silencio."""
    if (abordagem or "").upper() == "AGIL":
        return False
    log(Tf("  [--] {0} é saída de FORMA ágil: só sai quando a abordagem do projeto é "
           "adaptativa. A sua é preditiva — a mesma árvore do quadro já saiu como EAP e "
           "Dicionário da EAP.", saida))
    return True


def _arvore_com_epico(ids):
    """A árvore do quadro com o ÉPICO (o ancestral de nível 1) em cada linha.

    O item de NÍVEL 1 sai com o épico em BRANCO — ele não tem ninguém acima dele; ele É o
    épico dos filhos. A primeira versão marcava o próprio título como épico, e num backlog
    plano (todo item no nível 1, que é o caso comum em ágil) isso produzia a coluna Épico
    repetindo a coluna Título, linha por linha. Ruído com cara de dado."""
    nos, _tem_fases = quadro.arvore_da_eap(ids)
    epico_acima = ""
    out = []
    for n in nos:
        if n["nivel"] == 1:
            epico_acima = n["titulo"]
            out.append((n, ""))
        else:
            out.append((n, epico_acima))
    return out


def _concluidos_do_escopo(ids):
    """{id do cartao: True/False} — a situação de cada item da lista de escopo."""
    from pa_client import list_cards
    lid = ids.get("ESCOPO")
    if not lid:
        return {}
    return {str(c["id"]): quadro.card_concluido(c) for c in list_cards(lid)}


def gerar_product_backlog(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 6 (Desenvolver a Estrutura do Escopo) -> Product Backlog (.xlsx).

    O Guia: "o backlog de produto é uma lista DINÂMICA e PRIORIZADA de itens de trabalho e
    funcionalidades"; no glossário, ele "inclui requisitos, funcionalidades, épicos e
    histórias de usuário, representando o escopo geral e a visão do produto".

    Entra a árvore inteira — épico, feature e história —, porque e' isso que o backlog de
    produto guarda. As histórias sozinhas saem no documento ao lado."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    if _so_na_abordagem_agil(abordagem, T("Product Backlog"), log):
        return None, []
    linhas_arvore = _arvore_com_epico(ids)
    if not linhas_arvore:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    concl = _concluidos_do_escopo(ids)
    linhas = [{
        "A": n["codigo"],
        "B": epico,
        "C": _tipo_agil(n["tipo"]),
        "D": n["titulo"],
        "E": "",                       # Descrição — o título já é a descrição do item
        "F": "",                       # Prioridade MoSCoW — o quadro não prioriza
        "G": "",                       # Story Points — idem
        "H": "",                       # Sprint — idem
        "I": T("Done") if concl.get(str(n["id"])) else T("Backlog"),
        "J": (n.get("aceitacao") or "").strip(),
    } for n, epico in linhas_arvore]
    return _xlsx_kit("Product-Backlog-template.xlsx", "Product Backlog", linhas,
                     projeto, T("Product Backlog"), log)


def gerar_historias_de_usuario(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 6 -> Historias de Usuario (.xlsx).

    A FORMA E' DO GUIA, na letra: "uma história de usuário típica deve descrever o papel
    do usuário ou da parte interessada, quem se beneficia do recurso (PAPEL), o que a
    parte interessada precisa realizar (OBJETIVO) e o benefício para a parte interessada
    (MOTIVAÇÃO). Os critérios de aceitação devem se concentrar nas funcionalidades
    específicas e nas expectativas do usuário final."

    O quadro sabe DUAS das três: o objetivo (o título do item) e o critério de aceitação.
    O PAPEL e a MOTIVAÇÃO ficam em branco de propósito — e o próprio Guia diz por quê: a
    história "é uma PROMESSA DE UMA CONVERSA para esclarecer detalhes". Ela nasce da
    conversa com a parte interessada, não de uma árvore. Preenchê-las por dedução seria
    inventar de quem é a necessidade."""
    abordagem = abordagem or quadro.abordagem_do_env() or "PREDITIVA"
    if _so_na_abordagem_agil(abordagem, T("Histórias de usuário"), log):
        return None, []
    folhas = [(n, e) for n, e in _arvore_com_epico(ids) if n["tipo"] == "pacote"]
    if not folhas:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    concl = _concluidos_do_escopo(ids)
    linhas = [{
        "A": n["codigo"],
        "B": epico,
        "C": "",                       # Como... (Persona) — sai da conversa, não da árvore
        "D": n["titulo"],              # Quero... (o objetivo do item)
        "E": "",                       # Para... (Benefício) — idem
        "F": (n.get("aceitacao") or "").strip(),
        "G": "",                       # Prioridade — o quadro não prioriza
        "H": "",                       # Story Points
        "I": "",                       # Sprint
        "J": T("Concluída") if concl.get(str(n["id"])) else T("Backlog"),
    } for n, epico in folhas]
    return _xlsx_kit("Historias-de-Usuario-template.xlsx", "Histórias de Usuário",
                     linhas, projeto, T("Histórias de Usuário"), log)


def _avaliacao_da_equipe_xlsx(ids, modelo, projeto, equipe, carga, log):
    """A MESMA avaliacao, na forma do modelo INGLES do Kit (duas abas, nao secoes).

    O modelo EN tem `Individual` (uma linha por pessoa, com cinco notas de 1 a 5) e
    `Team` (uma linha por data, com a produtividade e a coesao). As NOTAS ficam em
    branco — sao o julgamento do GP. O que a maquina preenche e' a identificacao de cada
    pessoa e, na aba do time, a PRODUTIVIDADE: "X de Y pacotes concluidos" e' literalmente
    o que a coluna Team Velocity/Productivity pede, e e' contagem, nao opiniao.

    Duas abas exigem duas passadas: `preencher_xlsx` escreve UMA aba por chamada, entao a
    segunda usa a saida da primeira como modelo. E' o mesmo arquivo, aberto duas vezes."""
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    caminho_modelo = _template_kit(modelo)
    saida = os.path.join(_saida_dir(), Tf("{0} - {1}.xlsx",
                                          T("Avaliação de Desempenho da Equipe"),
                                          _slug_arquivo(projeto)))
    # aba 1 — uma linha por pessoa: identificacao preenchida, notas em branco
    pessoas = [{
        "A": p["nome"],
        "B": p.get("papel") or "",
        "C": hoje,
        # D..I sao as notas (1-5) e o total: julgamento do GP, nao contagem
        "J": "", "K": "", "L": "",
    } for p in equipe]
    for nome, (pacotes, feitos) in sorted(carga.items()):
        if any(x["A"] == nome for x in pessoas):
            continue
        pessoas.append({"A": nome, "B": "", "C": hoje, "J": "", "K": "",
                        "L": Tf("{0} de {1} pacote(s) concluído(s) no quadro",
                                feitos, pacotes)})
    if not pessoas:
        return None, []
    caminho, avisos = preencher_xlsx(
        caminho_modelo, saida, aba="Individual", primeira_linha=3, linhas=pessoas,
        trocas={"[Nome do Projeto]": projeto, "[Project Name]": projeto})

    # aba 2 — o time: a produtividade E' contagem; o resto e' avaliacao
    total_pacotes = sum(t for t, _f in carga.values())
    total_feitos = sum(f for _t, f in carga.values())
    time = [{
        "A": hoje,
        "B": Tf("{0} de {1} pacote(s) de trabalho concluído(s)", total_feitos,
                total_pacotes),
        "C": "", "D": "", "E": "", "F": "", "G": "",
        "H": Tf("{0} pessoa(s) na equipe. ► Avalie os quatro indicadores do Guia: "
                "habilidades, competências de equipe, rotatividade e coesão.",
                len(equipe)),
    }]
    caminho, av2 = preencher_xlsx(caminho, saida, aba="Team", primeira_linha=3,
                                  linhas=time)
    for a in list(avisos) + list(av2):
        log("  [aviso] " + a)
    return caminho, list(avisos) + list(av2)


def gerar_comunicacoes_do_projeto(ids, abordagem=None, projeto=None,
                                  log=lambda _m: None):
    """Processo 25 (Gerenciar as comunicacoes) -> Comunicacoes do Projeto (.xlsx).

    O livro (cap. 26, §26.4) define esta saida como "as comunicacoes REAIS produzidas e
    entregues" — nao o plano de comunicacao, que e' outra coisa. Entao esta planilha
    nasce como o REGISTRO a preencher, ja' com uma linha por parte interessada e a
    estrategia de engajamento que o quadro guarda para cada uma.

    CANAL E DATA SAEM VAZIOS de proposito: o quadro nao sabe por onde a mensagem saiu
    nem quando (e' o `confirmar` do catalogo). O Status sai "Planejada", que e' a
    verdade — a estrategia foi planejada, nao enviada. Uma planilha que ja' viesse com
    "Enviada" seria o registro de uma comunicacao que nunca aconteceu.

    AS COLUNAS E, F e I TEM LISTA DE VALIDACAO no modelo (a aba Param alimenta um
    dropdown do Excel). Escrever nelas um texto de fora da lista quebra o dropdown para
    o aluno — por isso a CADENCIA do cartao, que e' texto livre, vai para a mensagem, e
    nao para a coluna Frequencia."""
    partes = _partes_do_quadro(ids)
    if not partes:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    linhas = []
    for i, p in enumerate(partes, 1):
        assunto = (p.get("estrategia") or "").strip()
        if not assunto:
            assunto = Tf("► O que esta parte precisa saber para ir de {0} a {1}",
                         p.get("atual") or T("(não avaliado)"),
                         p.get("desejado") or T("(não definido)"))
        cadencia = (p.get("cadencia") or "").strip()
        if cadencia:
            assunto += Tf(" · Cadência combinada: {0}", cadencia)
        linhas.append({
            "A": f"COM-{i:03d}",
            "B": p["nome"],
            "C": "",                       # Tipo — o quadro nao classifica a comunicacao
            "D": assunto[:900],
            "E": "",                       # Canal — lista Param; o quadro nao sabe qual
            "F": "",                       # Frequencia — lista Param; idem
            "G": T("Gerente do Projeto"),
            "H": "",                       # Data/Prazo — so' existe depois do envio
            "I": T("Planejada"),           # valor da lista Param: planejada, nao enviada
        })
    return _xlsx_kit("Comunicacoes-do-Projeto-template.xlsx", "Comunicações do Projeto",
                     linhas, projeto, T("Comunicações do Projeto"), log)


def gerar_relatorio_de_qualidade(ids, abordagem=None, projeto=None,
                                 log=lambda _m: None):
    """Processos 26 e 31 -> Relatorio de Qualidade (.xlsx).

    O criterio de aceitacao de cada pacote de trabalho JA' ESTA' no quadro — ele foi
    escrito quando a EAP foi detalhada, e e' ele que a auditoria de qualidade compara
    com o que foi feito. O que falta e' o VEREDITO (o `confirmar` do catalogo), e o
    veredito nao se deduz: sai da auditoria.

    Livro, cap. 27, §27.3 — a auditoria examina conformidade de processo, eficacia do
    processo, oportunidade de melhoria e causa-raiz dos achados. As colunas Resultado,
    Conformidade e Nao Conformidades saem VAZIAS: preenche-las seria dar por auditado o
    que ninguem auditou."""
    nos, _tem_fases = quadro.arvore_da_eap(ids)
    pacotes = [n for n in nos if n["tipo"] == "pacote"]
    if not pacotes:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    linhas = []
    for i, n in enumerate(pacotes, 1):
        criterio = (n.get("aceitacao") or "").strip()
        linhas.append({
            "A": f"QA-{i:03d}",
            "B": f"{n['codigo']} {n['titulo']}",
            "C": criterio or T("► sem critério de aceitação no cartão do pacote"),
            "D": "",                       # Resultado da auditoria — sai da auditoria
            "E": "",                       # Conformidade — idem
            "F": "",                       # Não conformidades — idem
            "G": "",                       # Ação corretiva — depende do achado
            "H": (n.get("responsavel") or "").strip(),
            "I": "",
        })
    return _xlsx_kit("Relatorio-de-Qualidade-template.xlsx", "Relatório de Qualidade",
                     linhas, projeto, T("Relatório de Qualidade"), log)


def gerar_dados_de_desempenho(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 27 (Gerenciar Execucao do Projeto) -> Dados de Desempenho do Trabalho.

    O livro (cap. 28, §28.4) chama esta saida de "os dados BRUTOS da execucao" — e o
    Guia separa DADOS (o cru) de INFORMACOES (o cru comparado com o plano, que sai no
    Processo 30). Entao aqui nao ha' indice, nao ha' variacao calculada: ha' o previsto
    que o quadro guarda e a situacao real de cada pacote.

    HORA TRABALHADA SAI VAZIA (o `confirmar` do catalogo): o quadro guarda a duracao
    PLANEJADA, nao a gasta. Distribuir a duracao planejada pelos dias e chamar de real
    seria transformar o plano em medicao.

    A COLUNA "Metrica" TEM LISTA DE VALIDACAO no modelo (Custo · Prazo · Escopo ·
    Qualidade · Risco · Outro). Por isso ela recebe "Prazo" — que e' o que a data de
    termino mede — e nao uma frase minha: texto de fora da lista quebra o dropdown."""
    nos, _tem_fases = quadro.arvore_da_eap(ids)
    pacotes = [n for n in nos if n["tipo"] == "pacote"]
    if not pacotes:
        return None, []
    from pa_client import list_cards
    concl = {str(c["id"]): quadro.card_concluido(c)
             for c in list_cards(ids["ESCOPO"])} if ids.get("ESCOPO") else {}
    projeto = (projeto or _nome_projeto()).strip()
    linhas = []
    for i, n in enumerate(pacotes, 1):
        fim = (n.get("fim") or "").strip()
        feito = concl.get(str(n["id"]))
        linhas.append({
            "A": f"DAD-{i:03d}",
            "B": f"{n['codigo']} {n['titulo']}",
            "C": T("Prazo"),               # valor da lista Param do modelo
            "D": fim or T("(sem data planejada)"),
            "E": "",                       # data REAL de termino — o quadro nao guarda
            "F": "",                       # Variacao — precisa da data real
            "G": fim[:7],                  # Periodo (AAAA-MM) do termino planejado
            "I": (T("Concluído no quadro. ► Registre a data real de término e as horas "
                    "gastas: o quadro guarda o previsto.") if feito else
                  T("Em aberto no quadro. ► Registre a data real de término e as horas "
                    "gastas quando concluir.")),
            "H": (n.get("responsavel") or "").strip(),
        })
    return _xlsx_kit("Dados-de-Desempenho-do-Trabalho-template.xlsx",
                     "Dados de Desempenho", linhas, projeto,
                     T("Dados de Desempenho do Trabalho"), log)


def gerar_medicoes_da_qualidade(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 31 (Controlar o escopo) -> Medicoes do Controle da Qualidade (.xlsx).

    A diferenca para o Relatorio de Qualidade e' a altitude, e o Guia trata as duas como
    saidas distintas: o RELATORIO e' a auditoria do processo; a MEDICAO e' o resultado
    de conferir UMA entrega contra o criterio de aceitacao dela. Por isso as duas leem o
    mesmo criterio do quadro e param em lugares diferentes.

    Valor Medido sai VAZIO: quem mede e' voce, contra o criterio que ja' esta' escrito
    ao lado. A Conformidade sai "Pendente de Verificacao" — que e' um dos tres valores
    da lista de validacao do modelo e e' a verdade do estado: nao medido ainda. Deixa-la
    vazia seria igualmente honesto, mas menos util; marca-la "Conforme" seria mentira."""
    nos, _tem_fases = quadro.arvore_da_eap(ids)
    pacotes = [n for n in nos if n["tipo"] == "pacote" and (n.get("aceitacao") or "").strip()]
    if not pacotes:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    linhas = [{
        "A": f"MED-{i:03d}",
        "B": f"{n['codigo']} {n['titulo']}",
        "C": T("Atendimento ao critério de aceitação do pacote"),
        "D": (n.get("aceitacao") or "").strip()[:900],
        "E": "",                           # Valor medido — sai da inspecao
        "F": T("Pendente de Verificação"),  # valor da lista Param do modelo
        "G": "",                           # Data da medicao — sai da inspecao
        "H": (n.get("responsavel") or "").strip(),
        "I": "",                           # Acao corretiva — so' se houver desvio
    } for i, n in enumerate(pacotes, 1)]
    return _xlsx_kit("Medicoes-do-Controle-de-Qualidade-template.xlsx",
                     "Medições de Qualidade", linhas, projeto,
                     T("Medições do Controle da Qualidade"), log)


def gerar_propostas_de_financiamento(ids, abordagem=None, projeto=None,
                                     log=lambda _m: None):
    """Processo 34 (Monitorar e Controlar as Financas) -> Propostas de Financiamento.

    A proposta e' o PEDIDO: o que precisa ser financiado, quanto, de onde vem e o que o
    projeto devolve em troca. O quadro sabe as duas primeiras (a lista Orcamento, item a
    item) e sabe o retorno prometido (os KRs do cartao 1.2 — a promessa de valor do
    projeto). O status de cada pedido e' o `confirmar` do catalogo, e sai vazio.

    Este modelo tem estrutura propria (Necessidade · Detalhamento · Justificativa e
    Retorno · Aprovacoes), diferente do esqueleto dos planos e do dos relatorios."""
    projeto = (projeto or _nome_projeto()).strip()
    itens, total, reservas, base, por_periodo = _dinheiro_do_quadro(ids)
    if not itens:
        return None, []
    resp = quadro.respostas_do_termo(ids)
    hoje = datetime.date.today().strftime("%d/%m/%Y")

    detalhamento = [[i["nome"][:200], i.get("tipo") or "",
                     _reais(i["valor_num"]) if i["valor_num"] else (i.get("valor") or ""),
                     T("► VOCÊ DECIDE a fonte"), i.get("quando") or ""]
                    for i in itens]
    retorno = []
    for linha in _metricas_dos_krs(resp.get("1.2", "")):
        kr, meta = linha[0], linha[1]
        if not (kr or "").strip():
            continue
        retorno.append([_reais(total), kr[:300],
                        meta or T("► VOCÊ DECIDE o prazo de retorno"),
                        T("► VOCÊ DECIDE o risco de não realizar este retorno")])
    if not retorno:
        retorno = [[_reais(total),
                    T("► VOCÊ DECIDE: que resultado este dinheiro compra — os KRs do "
                      "cartão 1.2 do seu quadro são a resposta natural"),
                    T("► VOCÊ DECIDE o prazo"), T("► VOCÊ DECIDE o risco")]]

    periodos = ", ".join(f"{k} ({_reais(v)})" for k, v in sorted(por_periodo.items()) if k)
    mapa = [(T("1. descrição da necessidade de financiamento"), [
        Bloco.paragrafo(Tf(
            "O projeto {0} precisa de {1} para ser executado: {2} de custo base e {3} de "
            "reserva, distribuídos em {4} item(ns) de custo.",
            projeto, _reais(total), _reais(base), _reais(reservas), len(itens))),
        Bloco.paragrafo(Tf("Distribuição por período: {0}.", periodos) if periodos else
                        T("Os itens de custo ainda não têm período definido no cartão — "
                          "sem ele, não há como pedir a liberação por parcela.")),
        Bloco.paragrafo(T("► VOCÊ DECIDE de onde vem cada parcela e qual é o status de "
                          "cada pedido. A Estratégia de Financiamento é onde essa "
                          "decisão fica registrada.")),
    ])]
    saida = os.path.join(_saida_dir(), Tf("{0} - {1}.docx",
                                          T("Propostas de Financiamento"),
                                          _slug_arquivo(projeto)))
    caminho, avisos = preencher_docx(
        _template_kit("Propostas-de-Financiamento-template.docx"), mapa, saida,
        capa={"[Nome do Projeto]": projeto},
        cabecalho={T("Nome do Projeto"): projeto},
        tabelas=[(T("2. detalhamento do financiamento"), detalhamento),
                 (T("3. justificativa e retorno esperado"), retorno),
                 (T("4. aprovações"), _aprovadores(ids))],
        versao=("1.0", hoje, "", T("Derivado da lista Orçamento e dos KRs do quadro")),
        propriedades={"subject": projeto})
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


# ═════════════════════════════════════════════════════════════════════════════
# AS TRES SAIDAS QUE FALTAVAM (15/08/2026) — de 51 para 54 geradores
#
# Ate' hoje o catalogo dizia que estas tres nao davam para gerar. O Eduardo derrubou as
# duas justificativas, e ele estava certo nas duas:
#
#   1. "Entregas nao e' documento, e' o produto."  A planilha de referencia (aba Saidas)
#      mapeia Entregas -> `Termo-de-Aceite-da-Entrega-template.docx` (WPDM 9403). O que
#      MATERIALIZA a entrega e' o aceite formal dela — e esse e' um documento.
#   2. "Feriado nao se deduz do quadro."  Feriado NACIONAL e' lei, e os moveis saem todos
#      da Pascoa por calculo (`public_holidays.py`, BR e US). O que nao se deduz e' o
#      do aluno e o ponto facultativo — esses ficam com ele, e o documento DIZ isso em vez
#      de fingir que sabe.
#
# A REGRA DA CASA CONTINUA VALENDO: gerador nao pergunta. A jornada e as entregas
# escolhidas chegam como PARAMETRO — quem conversa com o aluno e' o script de passo. Sem
# parametro, cada um tem um padrao honesto e o documento registra a origem.
# ═════════════════════════════════════════════════════════════════════════════

# Jornada padrao: 8h/dia, segunda a sexta, 100% alocado. E' a resposta da pergunta unica
# do passo ("uma pergunta para todos", decisao do Eduardo em 15/08) e o padrao de quem
# nao respondeu nada.
JORNADA_PADRAO = {"horas_dia": 8, "dias_semana": (0, 1, 2, 3, 4), "disponibilidade": 100}


def _pais_do_curso():
    """(modulo_de_feriados, codigo_do_pais) — ou (None, cod) se o modulo nao veio no pacote.

    O PAIS vem da chave `COUNTRY` do .env; sem ela vale o padrao do IDIOMA (PT -> BR,
    EN -> US). Decisao do Eduardo, 15/08/2026: escrever feriado brasileiro no calendario de
    um aluno de lingua inglesa estava simplesmente errado, e o certo e' o aluno escolher.
    """
    try:
        import public_holidays
    except ImportError:                      # pacote antigo: degrada, nao quebra
        return None, ""
    try:
        escolhido = (cfg().get("COUNTRY") or "").strip()
    except Exception:
        escolhido = ""
    return public_holidays, public_holidays.pais_padrao(lang(), escolhido)


def _data(s):
    """'2026-08-15' ou '15/08/2026' -> date. None quando nao da' para ler."""
    for f in ("%Y-%m-%d", "%d/%m/%Y"):
        try:
            return datetime.datetime.strptime((s or "").strip(), f).date()
        except (ValueError, TypeError):
            continue
    return None


def _periodo_do_projeto(ids):
    """(inicio, fim) do projeto, lidos das datas da EAP.

    Sem data nenhuma devolve (None, None) e quem chama decide. NAO inventa periodo: um
    calendario com ano chutado e' pior do que nenhum calendario.
    """
    datas = []
    for l in (quadro.cronograma_do_quadro(ids) or []):
        datas += [d for d in (_data(l.get("ini")), _data(l.get("fim"))) if d]
    return (min(datas), max(datas)) if datas else (None, None)


def _br(d):
    return d.strftime("%d/%m/%Y") if d else ""


def gerar_calendarios_do_projeto(ids, abordagem=None, projeto=None, log=lambda _m: None):
    """Processo 8 (Desenvolver o cronograma) -> Calendarios do Projeto (.xlsx).

    O modelo do Kit e' uma lista de eventos datados (ID · Evento/Marco · Tipo · Data ·
    Descricao · Responsavel · Status), e o quadro ja' tem duas das tres fontes:

      - INICIO e TERMINO do projeto -> menor e maior data da EAP;
      - MARCOS                      -> os nos da EAP em que inicio == termino (marco e'
                                       exatamente isso: duracao zero);
      - DIAS NAO UTEIS              -> os feriados NACIONAIS do periodo, calculados.

    O que fica de fora, dito no proprio documento: feriado municipal/estadual e ponto
    facultativo. Sao ~5.500 conjuntos diferentes e nenhum se deduz do quadro.
    """
    ini, fim = _periodo_do_projeto(ids)
    if not ini:
        return None, []                    # sem datas na EAP nao ha' calendario a fazer
    projeto = (projeto or _nome_projeto()).strip()
    feriados_mod, pais = _pais_do_curso()
    if not feriados_mod:
        log("  [aviso] " + T("sem o módulo de feriados — o calendário sai só com os marcos."))

    linhas = []

    def _linha(evento, tipo, data, desc, resp, status):
        linhas.append({"A": str(len(linhas) + 1), "B": evento, "C": tipo, "D": data,
                       "E": desc, "F": resp, "G": status})

    _linha(T("Início do projeto"), T("Marco"), _br(ini),
           T("Menor data registrada na EAP."), "", T("Previsto"))
    for no in (quadro.cronograma_do_quadro(ids) or []):
        i, f = (no.get("ini") or "").strip(), (no.get("fim") or "").strip()
        if not i or i != f:                # marco = duracao zero
            continue
        _linha(no.get("titulo", ""), T("Marco"), _br(_data(i)),
               Tf("Marco da EAP {0}.", no.get("codigo", "")),
               no.get("responsavel", ""), T("Previsto"))
    _linha(T("Término do projeto"), T("Marco"), _br(fim),
           T("Maior data registrada na EAP."), "", T("Previsto"))

    if feriados_mod:
        for d, nome, tipo in feriados_mod.feriados_no_periodo(ini, fim, pais, lang()):
            _linha(nome, T("Feriado"), _br(d),
                   Tf("Feriado NACIONAL de data {0} — dia não útil.", tipo.lower()),
                   "", T("Confirmado"))
    _linha(T("► Feriados municipais/estaduais e pontos facultativos"), T("Outro"), "",
           Tf("VOCÊ DECIDE: acrescente aqui os dias não úteis da sua cidade e da sua "
              "empresa. Acima estão os feriados NACIONAIS de {0} — os regionais mudam de "
              "lugar para lugar e o quadro não tem como saber os seus. País errado? "
              "Troque a chave COUNTRY no .env e rode de novo.",
              feriados_mod.nome_do_pais(pais, lang()) if feriados_mod else "—"),
           "", T("Previsto"))
    if lang() == "EN":
        return _calendario_do_projeto_en(ids, projeto, ini, fim, feriados_mod, pais, log)
    return _xlsx_kit("Calendarios-do-Projeto-template.xlsx", T("Calendários do Projeto"),
                     linhas, projeto, T("Calendários do Projeto"), log)


def _calendario_do_projeto_en(ids, projeto, ini, fim, feriados_mod, pais, log):
    """O mesmo calendario na FORMA do modelo ingles do Kit — que nao e' o PT traduzido.

    O modelo PT e' UMA lista de eventos (ID · Evento · Tipo · Data · ...). O EN separa o
    assunto em duas abas proprias, com colunas diferentes:
        Holidays  -> Date · Holiday Name · Type · Impact on Project
        Key Dates -> Date · Event · Type · Owner · Status
    Traduzir rotulo nenhum resolveria isso: a informacao vai para lugares diferentes.

    Duas abas exigem DUAS passadas (preencher_xlsx escreve uma aba por chamada), entao a
    segunda usa a saida da primeira como modelo — mesmo desenho da Avaliacao de Desempenho.
    """
    modelo = _template_kit("Project-Calendars-PMBOK-8.xlsx")
    saida = os.path.join(_saida_dir(), Tf("{0} - {1}.xlsx", T("Calendários do Projeto"),
                                          _slug_arquivo(projeto)))
    feriados = [{"A": d.isoformat(), "B": nome, "C": "National",
                 "D": "No work - adjust schedule"}
                for d, nome, _t in (feriados_mod.feriados_no_periodo(ini, fim, pais, "EN")
                                    if feriados_mod else [])]
    chaves = [{"A": ini.isoformat(), "B": T("Início do projeto"), "C": "Milestone",
               "D": "", "E": "Planned"}]
    for no in (quadro.cronograma_do_quadro(ids) or []):
        i, f = (no.get("ini") or "").strip(), (no.get("fim") or "").strip()
        if i and i == f:
            chaves.append({"A": (_data(i) or ini).isoformat(), "B": no.get("titulo", ""),
                           "C": "Milestone", "D": no.get("responsavel", ""),
                           "E": "Planned"})
    chaves.append({"A": fim.isoformat(), "B": T("Término do projeto"), "C": "Deadline",
                   "D": "", "E": "Planned"})
    avisos = []
    caminho, av = preencher_xlsx(modelo, saida, aba="Holidays", primeira_linha=3,
                                 linhas=feriados, trocas={"[Nome do Projeto]": projeto})
    avisos += av or []
    if caminho:
        caminho, av = preencher_xlsx(caminho, saida, aba="Key Dates", primeira_linha=3,
                                     linhas=chaves, trocas={"[Nome do Projeto]": projeto})
        avisos += av or []
    for a in avisos:
        log("  [aviso] " + a)
    return caminho, avisos


def gerar_calendarios_de_recursos(ids, abordagem=None, projeto=None, log=lambda _m: None,
                                  jornada=None):
    """Processo 21 (Planejar o gerenciamento dos recursos) -> Calendarios de Recursos.

    Uma linha por pessoa da EQUIPE do quadro (nome e papel ja' estao la'), com a
    disponibilidade repetida nos meses em que o projeto corre. `jornada` e' a resposta da
    pergunta unica do passo; sem ela vale o JORNADA_PADRAO, e a coluna Observacoes DIZ que
    foi padrao — para o aluno saber o que conferir.

    O que a maquina NAO inventa: ferias, afastamento e alocacao parcial de cada pessoa.
    Isso e' edicao do GP, e esta' escrito na linha de cada um.
    """
    equipe = _equipe_do_quadro(ids)
    if not equipe:
        return None, []                    # sem equipe no quadro nao ha' calendario
    projeto = (projeto or _nome_projeto()).strip()
    j = dict(JORNADA_PADRAO)
    j.update(jornada or {})
    padrao = not jornada
    ini, fim = _periodo_do_projeto(ids)
    if ini and fim:                        # meses em que o projeto corre
        meses, a, m = set(), ini.year, ini.month
        while (a, m) <= (fim.year, fim.month):
            meses.add(m)
            a, m = (a + 1, 1) if m == 12 else (a, m + 1)
    else:
        meses = set(range(1, 13))
    disp = "{0}%".format(j["disponibilidade"])
    cols_mes = ["F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q"]
    obs = Tf("Jornada de {0}h/dia, {1} dias por semana. {2} Férias, afastamentos e "
             "alocação parcial são edição sua — o quadro não guarda esses dados.",
             j["horas_dia"], len(j["dias_semana"]),
             T("Padrão do curso (ninguém respondeu diferente).") if padrao
             else T("Informado por você no passo."))
    linhas = []
    for i, p in enumerate(equipe, 1):
        linha = {"A": str(i), "B": p.get("nome", ""),
                 "C": p.get("papel") or T("(sem papel definido)"),
                 "D": T("Humano"), "E": disp, "R": obs}
        for mes, col in enumerate(cols_mes, 1):
            linha[col] = disp if mes in meses else ""
        linhas.append(linha)
    if lang() == "EN":
        return _calendario_de_recursos_en(equipe, projeto, ini, fim, j, padrao, log)
    return _xlsx_kit("Calendarios-de-Recursos-template.xlsx", T("Calendários de Recursos"),
                     linhas, projeto, T("Calendários de Recursos"), log)


def _calendario_de_recursos_en(equipe, projeto, ini, fim, j, padrao, log):
    """O modelo EN pede DIA UTIL por mes, nao porcentagem.

    Colunas: Resource Name · Role · Jan..Dec · Total Days. Escrever "100%" ali seria texto
    numa coluna de numero — o total nao somaria. Entao aqui o numero e' contado de verdade:
    dias uteis do mes (jornada da semana menos feriado nacional) x disponibilidade. E' o
    mesmo dado do PT, na unidade que o modelo pede.
    """
    import calendar as _cal
    feriados_mod, pais = _pais_do_curso()
    anos = range(ini.year, fim.year + 1) if (ini and fim) else [datetime.date.today().year]
    fer = set()
    if feriados_mod:
        for a in anos:
            fer |= {d for d, _n, _t in feriados_mod.feriados_nacionais(a, pais)}
    def _uteis(mes):
        """Dias uteis DAQUELE MES dentro do periodo — somando TODOS os anos do projeto.

        O modelo tem 12 colunas e nenhuma de ano. Num projeto que atravessa o ano (aqui:
        jul/2026 a mai/2027) contar so' o ano de inicio zerava Janeiro a Maio, que sao
        justamente os meses de 2027 em que o projeto corre. Somar os anos e' a leitura
        que cabe nas 12 colunas sem mentir: a coluna Jan traz os dias uteis de janeiro
        que pertencem ao projeto.
        """
        n = 0
        for a in anos:
            for dia in range(1, _cal.monthrange(a, mes)[1] + 1):
                d = datetime.date(a, mes, dia)
                if ini and fim and not (ini <= d <= fim):
                    continue
                if d.weekday() in j["dias_semana"] and d not in fer:
                    n += 1
        return n

    cols = ["C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N"]
    linhas = []
    for p in equipe:
        linha = {"A": p.get("nome", ""), "B": p.get("papel") or T("(sem papel definido)")}
        total = 0
        for mes, col in enumerate(cols, 1):
            dias = int(round(_uteis(mes) * j["disponibilidade"] / 100.0))
            linha[col] = str(dias) if dias else ""
            total += dias
        linha["O"] = str(total)
        linhas.append(linha)
    modelo = _template_kit("Resource-Calendars-PMBOK-8.xlsx")
    saida = os.path.join(_saida_dir(), Tf("{0} - {1}.xlsx", T("Calendários de Recursos"),
                                          _slug_arquivo(projeto)))
    aba, primeira = _aba_e_linha(modelo)
    caminho, avisos = preencher_xlsx(modelo, saida, aba=aba or "Resource Calendars",
                                     primeira_linha=primeira or 3, linhas=linhas,
                                     trocas={"[Nome do Projeto]": projeto})
    for a in (avisos or []):
        log("  [aviso] " + a)
    if padrao:
        log("  " + T("dias úteis calculados com a jornada padrão do curso "
                     "(seg-sex, feriados nacionais descontados)."))
    return caminho, avisos


def entregas_da_eap(ids):
    """[(codigo, titulo)] das ENTREGAS da EAP — o cardapio que o passo mostra ao aluno.

    Mora aqui, e nao no script, porque quem sabe ler a EAP e' este modulo. Quem PERGUNTA
    continua sendo o passo (regra da casa: gerador nao pergunta).
    """
    nos, _tem_fases = quadro.arvore_da_eap(ids)
    return [(n["codigo"], n["titulo"]) for n in (nos or []) if n.get("tipo") == "entrega"]


def gerar_entregas(ids, abordagem=None, projeto=None, log=lambda _m: None, escolhidas=None):
    """Saida ENTREGAS -> Termo de Aceite da Entrega (.docx), um por entrega escolhida.

    A entrega e' o produto; o DOCUMENTO que a materializa e' o aceite formal dela — e a
    planilha de referencia da casa ja' mapeava esse modelo para esta saida (WPDM 9403).

    `escolhidas` sao os codigos da EAP que o aluno marcou no passo (decisao do Eduardo,
    15/08: "ele escolhe entre todas as entregas da EAP e monta de acordo com a necessidade
    dele"). Sem escolha, sai um termo para CADA entrega.

    Os criterios nao sao inventados: vem do "Criterio de aceitacao:" que o Passo 2 gravou
    nos pacotes daquela entrega. Pacote sem criterio vira a pergunta ao lado, nunca um
    criterio plausivel.
    """
    nos, _tem_fases = quadro.arvore_da_eap(ids)
    if not nos:
        return None, []
    entregas = [n for n in nos if n.get("tipo") == "entrega"]
    if escolhidas:
        alvo = {str(c).strip() for c in escolhidas}
        entregas = [n for n in entregas if str(n.get("codigo") or "").strip() in alvo]
    if not entregas:
        return None, []
    projeto = (projeto or _nome_projeto()).strip()
    modelo = _modelo_da_saida("Entregas", "Termo-de-Aceite-da-Entrega-template.docx")
    hoje = datetime.date.today().strftime("%d/%m/%Y")
    caminhos, todos_avisos = [], []

    for e in entregas:
        cod = str(e.get("codigo") or "").strip()
        # os pacotes DESTA entrega: os nos cujo codigo comeca por "<cod>."
        pacotes = [n for n in nos if n.get("tipo") == "pacote"
                   and str(n.get("codigo") or "").startswith(cod + ".")]
        # (numero, criterio, pacote) — a FORMA da linha muda com o idioma, ver abaixo.
        crus = []
        for i, p in enumerate(pacotes, 1):
            criterio = (p.get("aceitacao") or "").strip()
            crus.append((str(i),
                         criterio or Tf("► VOCÊ DECIDE: qual é o critério de aceitação "
                                        "de \"{0}\"?", p.get("titulo", "")),
                         p.get("titulo", "")))
        if not crus:
            crus = [("1", T("► VOCÊ DECIDE: escreva o critério que prova que esta "
                            "entrega está pronta."), "")]
        identificacao = [
            [T("Entrega"), e.get("titulo", "")],
            [T("Código da EAP"), cod],
            [T("Projeto"), projeto],
            [T("Data da avaliação"), hoje],
            [T("Pacotes de trabalho que a compõem"), str(len(pacotes))],
        ]
        # O modelo EN do Kit NAO e' o PT traduzido: ele tem outras secoes (Purpose ·
        # Deliverable Information · Acceptance Criteria Checklist · Approvals) e a tabela
        # de criterios tem QUATRO colunas, nao cinco (# · Criteria · Met? · Comments).
        # Traduzir so' os rotulos deixaria a coluna sobrando cair fora da tabela — e' a
        # mesma armadilha que a Avaliacao de Desempenho da Equipe ja' tinha mostrado.
        en = lang() == "EN"
        if en:
            criterios = [[n, c, "", pac] for n, c, pac in crus]
            secoes = [("deliverable information", identificacao),
                      ("acceptance criteria checklist", criterios),
                      # a secao chama-se "7. Approvals" no modelo: o casamento inclui o
                      # NUMERO (foi assim que "1. identificação da entrega" casou no PT)
                      ("7. approvals", _aprovadores(ids))]
            secao_texto = "1. purpose"
        else:
            criterios = [[n, c, "", "", pac] for n, c, pac in crus]
            secoes = [(T("1. identificação da entrega"), identificacao),
                      (T("2. critérios de aceitação avaliados"), criterios),
                      (T("3. pendências e não conformidades"), [["", "", "", ""]]),
                      (T("4. aceite formal"), _aprovadores(ids))]
            secao_texto = T("1. identificação da entrega")
        mapa = [(secao_texto, [
            Bloco.paragrafo(Tf(
                "Este termo registra o aceite formal da entrega \"{0}\" (EAP {1}) do "
                "projeto {2}. Ele é o documento que materializa a saída ENTREGAS do Guia "
                "PMBOK 8: a entrega é o produto, e o aceite é a prova de que ela foi "
                "avaliada contra os critérios combinados e recebida.",
                e.get("titulo", ""), cod, projeto)),
            Bloco.paragrafo(T("Os critérios abaixo vieram dos pacotes de trabalho desta "
                              "entrega, como você os escreveu no Passo 2. As colunas "
                              "Resultado e Conforme ficam em branco de propósito: quem "
                              "avalia é você, com o cliente — a máquina não dá aceite.")),
        ])]
        caminho, avisos = _docx_kit(
            modelo, mapa, secoes,
            projeto, Tf("Termo de Aceite da Entrega - {0}", cod),
            Tf("Entrega {0} da EAP, critérios lidos dos pacotes do Passo 2", cod), log)
        if caminho:
            caminhos.append(caminho)
            log("  " + Tf("termo de aceite gerado: {0}", os.path.basename(caminho)))
        todos_avisos += avisos or []
    return (caminhos[0] if caminhos else None), todos_avisos
