"""
Together Kit — documentos: escrever Word/Excel SEM DEPENDENCIA (biblioteca padrao).

POR QUE ISTO EXISTE (o padrao Together, agora tambem para os documentos)
  O aluno roda o Python PORTATIL do pacote: biblioteca padrao, nada de pip. Entao
  nao da para usar python-docx nem openpyxl. Mas nao precisa: um .docx (e um .xlsx)
  E um .zip de arquivos XML. Aqui a gente PREENCHE o template do curso — o mesmo
  visual, o mesmo cabecalho, a mesma marca — com o que o aluno respondeu no QUADRO.

  A FONTE DA VERDADE E O QUADRO (nao o contexto.json): quem monta o conteudo le o
  quadro pela API e passa as secoes prontas para ca. Este modulo e o MOTOR generico
  (preenche um template ancorado por titulos); o mapeamento "cartao do 5W2H -> secao
  do documento" mora no curso (artefatos.py), porque e ele que conhece o 5W2H.

COMO FUNCIONA O PREENCHIMENTO (docx ancorado por titulo)
  O template do curso e um documento com titulos (Heading 1/2) e, sob cada um, um
  texto-guia entre colchetes. A gente acha cada titulo pelo seu texto, APAGA o
  texto-guia daquela secao (ate o proximo titulo) e escreve ali o conteudo do aluno.
  Tabelas (Controle de Versoes, Aprovacoes), cabecalho, rodape, fontes e cores do
  template ficam intactos — o documento gerado e o template, preenchido.

  Idempotente e seguro: nunca altera nada fora das secoes mapeadas.

USO (a partir do curso)
  from documentos import preencher_docx, Bloco
  mapa = [("justificativa do projeto", [Bloco.paragrafo("...")]),
          ("objetivos e key results", [Bloco.sub("Objetivo 1"), Bloco.item("KR 1.1 ...")])]
  preencher_docx(template_path, mapa, saida_path,
                 cabecalho={"Nome do Projeto": "Meu Projeto"},
                 versao=("1.0", "18/07/2026", "", "Gerado do quadro (Passo 1)"))
"""
import copy
import os
import re
import xml.etree.ElementTree as ET

import entradas   # _sem_acento (kit sibling)
from idioma import T, Tf

# Quem consta como autor do documento gerado. NAO e' uma pessoa de proposito: o
# documento e' do aluno, escrito pelo curso a partir do quadro dele. Ver
# _props_do_documento() para o que isto substitui.
AUTORIA = "Project Together"

# ── Namespace do WordprocessingML ────────────────────────────────────────────
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
XML = "http://www.w3.org/XML/1998/namespace"
_HEADINGS = {"Heading1", "Heading2", "Heading3", "Heading4",
             "Title", "TOCHeading"}
_DOC_DECL = b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n'


def _w(tag):
    return f"{{{W}}}{tag}"


def _norm(s):
    """Titulo -> forma comparavel (sem acento, minusculo, sem espacos repetidos)."""
    return re.sub(r"\s+", " ", entradas._sem_acento(s or "")).strip()


# ── Blocos de conteudo (o que se escreve dentro de uma secao) ────────────────
class Bloco:
    """Um pedaco de conteudo de uma secao. `tipo`: paragrafo | item | sub | vazio."""

    __slots__ = ("tipo", "texto")

    def __init__(self, tipo, texto=""):
        self.tipo = tipo
        self.texto = texto

    @staticmethod
    def paragrafo(t):
        return Bloco("paragrafo", t)

    @staticmethod
    def item(t):
        return Bloco("item", t)          # vira "• t"

    @staticmethod
    def sub(t):
        return Bloco("sub", t)           # subtitulo em negrito (ex.: grupo de partes)

    @staticmethod
    def h1(t):
        return Bloco("h1", t)            # Heading 1 (ex.: FASE da EAP)

    @staticmethod
    def h2(t):
        return Bloco("h2", t)            # Heading 2 (ex.: ENTREGA da EAP)

    @staticmethod
    def h3(t):
        return Bloco("h3", t)            # Heading 3 (ex.: PACOTE de trabalho)

    @staticmethod
    def vazio():
        return Bloco("vazio", "")


def blocos_de_texto(texto, como_itens=False):
    """Texto multi-linha -> lista de Blocos (uma linha por bloco).

    Linhas que ja comecam com '•', '-' ou '*' viram itens; as demais, paragrafos
    (ou itens, se `como_itens`). Linhas em branco viram espacos."""
    blocos = []
    for linha in (texto or "").splitlines():
        s = linha.strip()
        if not s:
            blocos.append(Bloco.vazio())
            continue
        m = re.match(r"^[•\-\*]\s+(.*)$", s)
        if m:
            blocos.append(Bloco.item(m.group(1)))
        elif como_itens:
            blocos.append(Bloco.item(s))
        else:
            blocos.append(Bloco.paragrafo(s))
    return blocos


# ── Construcao de elementos <w:p> ─────────────────────────────────────────────
def _paragrafo(texto="", estilo="Normal", negrito=False, italico=False,
               sem_numeracao=False):
    p = ET.Element(_w("p"))
    ppr = ET.SubElement(p, _w("pPr"))
    ET.SubElement(ppr, _w("pStyle")).set(_w("val"), estilo)
    if sem_numeracao:
        # O estilo Titulo/Heading do template pode trazer numeracao automatica de
        # lista (1, 1.1, ...). Quando a gente ja escreve o numero da EAP no texto
        # ('1  Preparacao'), a numeracao do Word DUPLICA ('1  1  Preparacao'). numId=0
        # desliga a lista SO neste paragrafo (as secoes fixas do template ficam iguais).
        numpr = ET.SubElement(ppr, _w("numPr"))
        ET.SubElement(numpr, _w("ilvl")).set(_w("val"), "0")
        ET.SubElement(numpr, _w("numId")).set(_w("val"), "0")
    if texto:
        r = ET.SubElement(p, _w("r"))
        if negrito or italico:
            rpr = ET.SubElement(r, _w("rPr"))
            if negrito:
                ET.SubElement(rpr, _w("b"))
            if italico:
                ET.SubElement(rpr, _w("i"))
        t = ET.SubElement(r, _w("t"))
        t.set(f"{{{XML}}}space", "preserve")
        t.text = texto
    return p


_HEADING_ESTILO = {"h1": "Heading1", "h2": "Heading2", "h3": "Heading3"}


def _bloco_para_p(b):
    if b.tipo == "vazio":
        return _paragrafo("")
    if b.tipo in _HEADING_ESTILO:
        return _paragrafo(b.texto, estilo=_HEADING_ESTILO[b.tipo], sem_numeracao=True)
    if b.tipo == "sub":
        return _paragrafo(b.texto, estilo="Normal", negrito=True)
    if b.tipo == "item":
        return _paragrafo("•  " + b.texto, estilo="Normal")
    return _paragrafo(b.texto, estilo="Normal")


def _estilo_do_p(p):
    ppr = p.find(_w("pPr"))
    if ppr is None:
        return ""
    st = ppr.find(_w("pStyle"))
    return st.get(_w("val")) if st is not None else ""


def _texto_do_p(p):
    return "".join(t.text or "" for t in p.iter(_w("t")))


def _e_titulo(el):
    return el.tag == _w("p") and _estilo_do_p(el) in _HEADINGS


# ── Preenchimento das secoes ancoradas por titulo ────────────────────────────
def _preencher_secoes(body, mapa, avisos):
    """Para cada (prefixo_de_titulo, [Bloco]) do mapa: acha o titulo, apaga o
    texto-guia da secao (ate o proximo titulo/tabela) e escreve o conteudo."""
    filhos = list(body)
    # prefixo normalizado -> blocos
    alvos = [(_norm(pref), blocos) for pref, blocos in mapa]
    usados = set()
    acoes = []   # (titulo_el, [remover], [inserir])
    for i, el in enumerate(filhos):
        if not _e_titulo(el):
            continue
        titulo = _norm(_texto_do_p(el))
        chave = next((k for k, _b in alvos
                      if k not in usados and (titulo == k or titulo.startswith(k))), None)
        if chave is None:
            continue
        usados.add(chave)
        blocos = dict(alvos)[chave]
        # a secao vai do titulo ate o proximo titulo OU o proximo nao-paragrafo
        remover = []
        for el2 in filhos[i + 1:]:
            if el2.tag != _w("p") or _e_titulo(el2):
                break
            remover.append(el2)
        inserir = [_bloco_para_p(b) for b in blocos] + [_paragrafo("")]
        acoes.append((el, remover, inserir))

    for chave, _b in alvos:
        if chave not in usados:
            avisos.append(Tf("titulo nao encontrado no template: '{0}'", chave))

    # aplica (de tras para a frente nao e' preciso: mexemos por elemento, nao por indice)
    for titulo_el, remover, inserir in acoes:
        for r in remover:
            body.remove(r)
        pos = list(body).index(titulo_el) + 1
        for k, novo in enumerate(inserir):
            body.insert(pos + k, novo)


def _substituir_intervalo(body, de, ate, blocos, avisos):
    """Substitui TUDO entre dois titulos por uma sequencia de blocos GERADA.

    Ao contrario de `_preencher_secoes` (secoes fixas, uma por titulo), aqui a
    estrutura e' dinamica: um Dicionario da EAP tem N fases x N entregas x N pacotes,
    que nao existem no template. O template traz um titulo-ancora inicial (`de`, ex.:
    'Fase 1') e um titulo-ancora final (`ate`, ex.: 'Informacoes Adicionais'). Tudo do
    `de` (inclusive) ate o `ate` (exclusive) e' removido e substituido pelos `blocos`
    (que podem incluir Heading1/2/3, itens, paragrafos). O restante do documento —
    tabelas, cabecalho, secoes fixas — fica intacto.
    """
    filhos = list(body)
    de_n, ate_n = _norm(de), _norm(ate)
    i_de = i_ate = None
    for i, el in enumerate(filhos):
        if not _e_titulo(el):
            continue
        t = _norm(_texto_do_p(el))
        if i_de is None and (t == de_n or t.startswith(de_n)):
            i_de = i
        elif i_de is not None and (t == ate_n or t.startswith(ate_n)):
            i_ate = i
            break
    if i_de is None:
        avisos.append(Tf("intervalo: titulo inicial nao encontrado no template: '{0}'", de))
        return
    fim = i_ate if i_ate is not None else len(filhos)
    ancora_fim = filhos[i_ate] if i_ate is not None else None
    # O <w:sectPr> NAO E' CONTEUDO: e' o ultimo filho do body e guarda o tamanho da
    # pagina, as margens e as REFERENCIAS de cabecalho/rodape. Quando nao ha ancora
    # final (o Status Report e as Licoes Aprendidas passam "\x00" de proposito, para
    # dizer "ate o fim"), o intervalo ia ate len(filhos) e levava o sectPr junto: as
    # partes header1.xml/footer1.xml continuavam no .docx, mas sem ninguem a apontar
    # para elas — o documento abria SEM cabecalho e SEM rodape (Eduardo, Status Report
    # do FIRE, 06/08/2026). Removemos conteudo; a estrutura da secao fica.
    for el in filhos[i_de:fim]:
        if el.tag != _w("sectPr"):
            body.remove(el)
    if ancora_fim is not None:
        pos = list(body).index(ancora_fim)
    else:
        restantes = list(body)
        sect = next((i for i, el in enumerate(restantes)
                     if el.tag == _w("sectPr")), None)
        pos = sect if sect is not None else len(restantes)   # ...e escreve ANTES dele
    for k, b in enumerate(blocos):
        body.insert(pos + k, _bloco_para_p(b))


# ── Tabela "Controle de Versoes" ─────────────────────────────────────────────
def _set_celula(tc, texto):
    """Escreve `texto` na 1a linha (paragrafo) de uma celula, preservando o estilo."""
    p = tc.find(_w("p"))
    if p is None:
        p = ET.SubElement(tc, _w("p"))
    for r in p.findall(_w("r")):
        p.remove(r)
    r = ET.SubElement(p, _w("r"))
    t = ET.SubElement(r, _w("t"))
    t.set(f"{{{XML}}}space", "preserve")
    t.text = texto


# O TEMPLATE TEM IDIOMA. Os modelos do curso existem em portugues e em ingles (os 12 do
# pacote EN foram traduzidos em 04/08/2026), e a tabela de versao muda de nome junto:
# "Controle de Versoes" / "Versao | Data" vira "Version Control" / "Version | Date".
# Procurar so' o portugues fazia o preenchimento sair em silencio — o documento saia com
# a tabela de versao EM BRANCO, que foi o que o Eduardo viu no Dicionario da EAP em
# ingles (05/08/2026). Como o preenchimento nao levanta erro quando nao acha, ninguem
# percebe ate' abrir o arquivo.
_TITULO_VERSAO = ("controle de versoes", "version control")
_CAB_VERSAO = (("versao", "data"), ("version", "date"))


def _e_cabecalho_versao(textos):
    """Esta linha/celula e' o cabecalho da tabela de versoes, em qualquer idioma?"""
    return any(all(p in textos for p in par) for par in _CAB_VERSAO)


def _linha_de_cabecalho_versao(linhas):
    """O indice da linha de CABECALHO da tabela de versoes (None se nao for ela).

    OS DOIS MODELOS NAO POEM O CABECALHO NO MESMO LUGAR (achado em 15/08/2026)
        Modelos do CURSO: linha 0 = titulo mesclado ("Controle de Versões"),
                          linha 1 = cabecalho, linha 2+ = dados.
        Modelos do KIT:   o titulo e' um Heading FORA da tabela ("7. Controle de
                          Versão", no singular), a linha 0 JA' E' o cabecalho.
    Procurar o titulo dentro da tabela — como era ate' aqui — nunca achava a tabela do
    Kit: os 19 documentos que usam esses modelos saiam com a tabela de versao em branco,
    sem erro nenhum na tela. Achar pelo CABECALHO cobre os dois, porque o cabecalho e'
    o que os dois tem igual."""
    for i, tr in enumerate(linhas[:2]):
        textos = {_norm(_texto_do_p(p)) for p in tr.iter(_w("p"))}
        if _e_cabecalho_versao(textos):
            return i
    return None


def _preencher_versao(body, versao):
    """Preenche a 1a linha de dados vazia da tabela de Controle de Versoes.
    versao = (numero, data, autor, notas)."""
    for tbl in body.findall(_w("tbl")):
        linhas = tbl.findall(_w("tr"))
        cab = _linha_de_cabecalho_versao(linhas)
        if cab is None:
            # plano B: a tabela que TRAZ o titulo dentro dela e nao tem cabecalho
            # reconhecivel (modelo antigo). Mantido para nao mudar o que ja' funciona.
            txt = _norm(" ".join(_texto_do_p(p) for p in tbl.iter(_w("p"))))
            if not any(t in txt for t in _TITULO_VERSAO):
                continue
            cab = 1
        for tr in linhas[cab + 1:]:
            celulas = tr.findall(_w("tc"))
            if not celulas:
                continue
            if any(_texto_do_p(c).strip() for c in celulas):
                continue                      # ja preenchida — nao mexe
            for c, val in zip(celulas, versao):
                _set_celula(c, val)
            return True
    return False


# ── Tabelas de DADOS do .docx (as do Kit) ────────────────────────────────────
# POR QUE ISTO EXISTE (13/08/2026)
#     Os 85 modelos de `templates/pmbok8/` sao TABULARES por desenho: "Aspecto | Descricao",
#     "Papel | Responsabilidades | Nome", "Metrica | Valor Alvo | Frequencia | Responsavel".
#     E' uma boa estrutura — transformar isso em prosa seria piorar o modelo.
#     Mas `_preencher_secoes` so' escreve PARAGRAFO, e para no primeiro nao-paragrafo. Num
#     modelo do Kit a tabela vem colada no titulo, entao o conteudo do aluno era escrito
#     ACIMA dela e a tabela ficava logo abaixo com o exemplo ficticio intacto.
#     Sem isto, 18 dos 40 .docx do Kit nao tem como ser preenchidos.
#
# COMO: a 1a linha e' o cabecalho e fica. As demais sao EXEMPLO: a primeira delas vira o
# molde (herda bordas, sombreado, fonte) e e' clonada por linha de dado; o resto sai.
def _clonar_linha(modelo, valores):
    tr = copy.deepcopy(modelo)
    celulas = tr.findall(_w("tc"))
    for i, tc in enumerate(celulas):
        _set_celula(tc, valores[i] if i < len(valores) else "")
    return tr


def _preencher_tabela(body, titulo, linhas, avisos):
    """Escreve `linhas` (lista de listas) na tabela logo abaixo do titulo `titulo`.

    Preserva o cabecalho e o estilo; descarta as linhas de exemplo do modelo.
    Devolve True se achou a tabela."""
    filhos = list(body)
    alvo = _norm(titulo)
    for i, el in enumerate(filhos):
        if not _e_titulo(el):
            continue
        t = _norm(_texto_do_p(el))
        if not (t == alvo or t.startswith(alvo)):
            continue
        tbl = next((e for e in filhos[i + 1:]
                    if e.tag == _w("tbl") or _e_titulo(e)), None)
        if tbl is None or tbl.tag != _w("tbl"):
            avisos.append(Tf("tabela nao encontrada sob o titulo: '{0}'", titulo))
            return False
        trs = tbl.findall(_w("tr"))
        if len(trs) < 2:
            avisos.append(Tf("tabela sem linha de exemplo para usar de molde: '{0}'", titulo))
            return False
        molde = trs[1]
        for tr in trs[1:]:
            tbl.remove(tr)
        if not linhas:
            # nada a escrever: devolve UMA linha vazia, para o documento nao sair sem grade
            tbl.append(_clonar_linha(molde, []))
            return True
        for valores in linhas:
            tbl.append(_clonar_linha(molde, list(valores)))
        return True
    avisos.append(Tf("titulo nao encontrado no template: '{0}'", titulo))
    return False


# ── Cabecalho / rodape (partes separadas do docx) ────────────────────────────
def _substituir_texto(xml_bytes, trocas):
    """Substitui textos literais nos <w:t> de uma parte (header/footer). Simples:
    troca dentro de cada run de texto (o placeholder do template e' um run so')."""
    _registrar_ns(xml_bytes)
    root = ET.fromstring(xml_bytes)
    mudou = False
    for t in root.iter(_w("t")):
        if not t.text:
            continue
        novo = t.text
        for de, para in trocas.items():
            if de in novo:
                novo = novo.replace(de, para)
        if novo != t.text:
            t.text = novo
            mudou = True
    if not mudou:
        return None
    return _serializar(root, xml_bytes)


# ── Propriedades do documento (docProps/core.xml) ────────────────────────────
# O cabecalho do template mostra o TITLE e o SUBJECT via CAMPO ({ SUBJECT }). Trocar
# so' o texto do run nao basta: quando o Word atualiza os campos, ele recalcula o
# SUBJECT a partir daqui. Entao a fonte certa do "Nome do Projeto" no cabecalho e' a
# propriedade dc:subject. Ver header2.xml do template.
_PROP_TAG = {"subject": "dc:subject", "title": "dc:title", "creator": "dc:creator",
             "description": "dc:description", "keywords": "cp:keywords",
             "modificado_por": "cp:lastModifiedBy"}

# O namespace de cada prefixo que a gente pode ter de ACRESCENTAR ao core.xml. Ver
# `_set_core_props`: acrescentar `<dc:title>` num arquivo que nao declara `xmlns:dc`
# no elemento raiz produz XML malformado ("unbound prefix"), e ai nada abre.
_NS_DO_PREFIXO = {
    "dc": "http://purl.org/dc/elements/1.1/",
    "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
    "dcterms": "http://purl.org/dc/terms/",
}


def _raiz_declara(s, prefixo):
    """O elemento RAIZ do core.xml declara este prefixo? (o prolog `<?xml ...?>` nao conta)"""
    i = s.find("<cp:coreProperties")
    if i < 0:
        return True          # formato inesperado: nao mexer
    return f'xmlns:{prefixo}=' in s[i:s.find(">", i) + 1]


def _props_do_documento(saida_path, propriedades=None):
    """As propriedades finais de docProps/core.xml. O que o gerador passou VENCE.

    POR QUE EXISTE (16/08/2026) — dois vazamentos que nao apareciam no conteudo
    do arquivo, so' nas Propriedades dele (Explorer, painel Informacoes do Word,
    indexacao de busca), e por isso atravessaram todas as revisoes ate' aqui:

    1. O TITULO CONTINUAVA EM PORTUGUES NO PACOTE EM INGLES. O modelo do Kit chega
       com `<dc:title>Registro das Partes Interessadas</dc:title>`, e nada por cima
       o reescrevia — o aluno em ingles gerava "Stakeholder Register - Meu Projeto.xlsx"
       com titulo em portugues dentro. Um caso pior: o Kick-Off saia com o titulo
       "Aula 2 Modelagem de Processos", sobra de um deck de BPM.
    2. O AUTOR ERA O EDUARDO. `dc:creator` e `cp:lastModifiedBy` vinham do modelo
       ("eduardo@escritoriodeprojetos.com.br", "Eduardo Montes") e iam junto em TODO
       documento que TODO aluno gera e compartilha. Isso e' o e-mail dele viajando
       dentro do trabalho de outra pessoa.

    O titulo sai do NOME DO ARQUIVO — que os geradores ja' montam com o rotulo
    traduzido ("Stakeholder Register - <projeto>.docx") — entao ele acerta nos dois
    idiomas sem que nenhum dos 30+ pontos de chamada precise passar nada. O nome do
    projeto, que vem depois do " - ", vira o SUBJECT: e' o que os geradores de .docx
    ja' passavam a mao (`propriedades={"subject": projeto}`) e o que os de .xlsx nunca
    passaram — por isso a planilha em ingles saia com
    "Template de Plano de gerenciamento das partes interessadas" no assunto.

    As PALAVRAS-CHAVE seguem o titulo pelo mesmo motivo: o modelo chega com
    "Template Gerenciamento de Projetos", que num documento ja' preenchido esta'
    errado em duas frentes — nao e' mais um template, e nao e' ingles.
    `dc:description` fica como esta': e' a URL do curso, igual nos dois idiomas.
    """
    nome = os.path.splitext(os.path.basename(saida_path))[0]
    titulo, _, projeto = nome.partition(" - ")
    titulo = titulo.strip() or nome
    props = {"title": titulo, "keywords": titulo, "creator": AUTORIA,
             "modificado_por": AUTORIA}
    if projeto.strip():
        props["subject"] = projeto.strip()
    props.update(propriedades or {})
    return props


def _escape_xml(v):
    return (str(v).replace("&", "&amp;").replace("<", "&lt;")
            .replace(">", "&gt;"))


def _set_core_props(xml_bytes, props):
    """Escreve as propriedades em docProps/core.xml, no LUGAR onde a tag ja' esta'.

    A TAG VAZIA VEM AUTO-FECHADA, E ISSO CORROMPIA O ARQUIVO (13/08/2026)
        Um modelo com a propriedade em branco guarda `<dc:subject/>`, nao
        `<dc:subject></dc:subject>` — e' assim que o python-docx grava, e e' assim que
        estao os 85 modelos de templates/pmbok8/. O padrao antigo so' casava o par de
        tags, entao ele nao achava nada e caia no ramo de acrescentar: o arquivo ficava
        com DUAS `<dc:subject>` — a vazia no lugar certo e a preenchida no fim, fora da
        sequencia que o schema exige. O Word abre isso com "The file appears to be
        corrupted", sem dizer onde.

        Nao aparecia ate' agora porque o modelo do curso tem a propriedade PREENCHIDA
        ("Nome do Projeto"), entao o par de tags existia e o caminho ruim nunca rodava.
    """
    s = xml_bytes.decode("utf-8")
    for chave, valor in props.items():
        tag = _PROP_TAG.get(chave)
        if not tag:
            continue
        v = _escape_xml(valor)
        t = re.escape(tag)
        par = re.compile(r"(<%s(?:\s[^>]*)?>).*?(</%s>)" % (t, t), re.S)
        vazia = re.compile(r"<%s(\s[^>]*?)?\s*/>" % t)
        if par.search(s):
            s = par.sub(lambda m, v=v: m.group(1) + v + m.group(2), s, count=1)
        elif vazia.search(s):
            s = vazia.sub(lambda m, v=v, tag=tag: f"<{tag}{m.group(1) or ''}>{v}</{tag}>",
                          s, count=1)
        else:
            # Ultimo recurso: a propriedade nao existe no modelo. Fica no fim mesmo —
            # o Word aceita.
            #
            # E O PREFIXO PRECISA DE NAMESPACE (17/08/2026, board 270)
            #     "nunca vimos um modelo nosso sem as tags de core.xml" deixou de ser
            #     verdade: `Cronograma do Projeto.xlsx` foi gerado pelo OPENPYXL, e o
            #     core.xml dele nao tem `<dc:title>` nem `<dc:subject>` — e declara o
            #     `xmlns:dc` DENTRO do `<dc:creator>`, nao no elemento raiz. Acrescentar
            #     um `<dc:title>` cru ali produzia "unbound prefix": XML malformado, e o
            #     Cronograma do Passo 4 nao abria em lugar nenhum. Os outros modelos
            #     nasceram no Word/Excel de verdade, ja' trazem as tags e caem nos ramos
            #     de cima — por isso o defeito ficou escondido num artefato so'.
            #
            #     Declaramos o namespace NO PROPRIO elemento (e' o que o arquivo ja' faz
            #     no `dc:creator`), e so' quando a raiz nao o declara.
            pref = tag.split(":")[0] if ":" in tag else ""
            ns = ""
            if pref and pref in _NS_DO_PREFIXO and not _raiz_declara(s, pref):
                ns = f' xmlns:{pref}="{_NS_DO_PREFIXO[pref]}"'
            s = s.replace("</cp:coreProperties>",
                          f"<{tag}{ns}>{v}</{tag}></cp:coreProperties>")
    return s.encode("utf-8")


# ── Namespaces: registrar os do proprio arquivo (round-trip limpo) ───────────
_NS_RE = re.compile(rb'xmlns:([A-Za-z0-9]+)="([^"]+)"')
_ns_registrados = set()


def _registrar_ns(xml_bytes):
    for pref, uri in _NS_RE.findall(xml_bytes[:2000]):
        pref, uri = pref.decode(), uri.decode()
        if (pref, uri) in _ns_registrados:
            continue
        try:
            ET.register_namespace(pref, uri)
            _ns_registrados.add((pref, uri))
        except ValueError:
            pass


def _serializar(root, original):
    """ET -> bytes, RE-DECLARANDO no root os namespaces do arquivo original.

    O ElementTree so' declara os namespaces que ele VE em uso na arvore. Um docx
    declara ~16 namespaces no root e referencia alguns deles em mc:Ignorable
    ('w14 w15 wp14') sem usa-los em elemento nenhum. Se a gente nao os redeclara,
    o Word acusa "arquivo corrompido" (o prefixo do Ignorable ficou sem declaracao).
    Aqui: pega TODAS as declaracoes do root original e insere as que faltarem.
    """
    corpo = ET.tostring(root, encoding="utf-8")
    fim_tag = corpo.index(b">")                       # fim do <w:...> raiz
    ja = set(re.findall(rb'xmlns:(\w+)=', corpo[:fim_tag]))
    faltam = b"".join(
        b' xmlns:%s="%s"' % (pref, uri)
        for pref, uri in _NS_RE.findall(original[:2000]) if pref not in ja)
    if faltam:
        corpo = corpo[:fim_tag] + faltam + corpo[fim_tag:]
    return _DOC_DECL + corpo


# ── API principal ─────────────────────────────────────────────────────────────
def preencher_docx(template_path, mapa, saida_path, cabecalho=None, versao=None,
                   propriedades=None, intervalos=None, capa=None, tabelas=None):
    """Preenche `template_path` (docx) com as secoes de `mapa` e grava em `saida_path`.

    mapa         = [(prefixo_do_titulo, [Bloco]), ...] — a ordem nao importa.
    tabelas      = [(prefixo_do_titulo, [[celula, celula, ...], ...]), ...] — escreve
                   as LINHAS na tabela logo abaixo daquele titulo, preservando o
                   cabecalho e o estilo e descartando as linhas de exemplo. E' o que
                   permite preencher os modelos do Kit, que sao tabulares por desenho.
    intervalos   = [(titulo_inicial, titulo_final, [Bloco]), ...] — substitui TUDO
                   entre dois titulos por uma sequencia gerada (para estruturas
                   dinamicas, ex.: as N fases/entregas/pacotes do Dicionario da EAP).
    cabecalho    = {texto_no_cabecalho: valor} (troca o run em cache do campo).
    capa         = {texto_no_corpo: valor} — troca literal nos <w:t> do PROPRIO corpo,
                   FORA das secoes ancoradas. E' para o titulo da capa dos modelos que
                   NAO tem header: os do Kit (templates/pmbok8/) foram gerados pelo
                   python-docx e nao trazem nenhuma parte header*.xml, entao o
                   "[Nome do Projeto]" da capa deles e' texto de corpo e o `cabecalho`
                   nao o alcancava — o termo do aluno saia com o placeholder na capa.
    versao       = (numero, data, autor, notas) para a tabela Controle de Versoes.
    propriedades = {"subject": ..., "title": ...} -> docProps/core.xml (a fonte
                   real do cabecalho, que e' um campo { SUBJECT }).

    Devolve (saida_path, avisos). Zero dependencia: so' zipfile + xml.etree.
    """
    import zipfile
    avisos = []
    with zipfile.ZipFile(template_path) as zin:
        partes = {n: zin.read(n) for n in zin.namelist()}

    doc_xml = partes.get("word/document.xml")
    if not doc_xml:
        raise ValueError(Tf("template sem word/document.xml: {0}", template_path))
    _registrar_ns(doc_xml)
    root = ET.fromstring(doc_xml)
    body = root.find(_w("body"))
    # a capa ANTES das secoes: assim a troca so' pode alcancar texto do TEMPLATE, nunca
    # o que o aluno escreveu (se a resposta dele contivesse o placeholder, seria trocada).
    if capa:
        achou = set()
        for t in body.iter(_w("t")):
            if not t.text:
                continue
            novo = t.text
            for de, para in capa.items():
                if de in novo:
                    novo = novo.replace(de, para)
                    achou.add(de)
            t.text = novo
        for de in capa:
            if de not in achou:
                avisos.append(Tf("capa: texto nao encontrado no corpo: '{0}'", de))
    _preencher_secoes(body, mapa, avisos)
    for titulo, linhas in (tabelas or []):
        _preencher_tabela(body, titulo, linhas, avisos)
    for de, ate, blocos in (intervalos or []):
        _substituir_intervalo(body, de, ate, blocos, avisos)
    if versao:
        _preencher_versao(body, versao)
    partes["word/document.xml"] = _serializar(root, doc_xml)

    if cabecalho:
        for nome in list(partes):
            if re.match(r"word/(header|footer)\d*\.xml$", nome):
                novo = _substituir_texto(partes[nome], cabecalho)
                if novo is not None:
                    partes[nome] = novo

    if "docProps/core.xml" in partes:
        partes["docProps/core.xml"] = _set_core_props(
            partes["docProps/core.xml"], _props_do_documento(saida_path, propriedades))

    os.makedirs(os.path.dirname(os.path.abspath(saida_path)), exist_ok=True)
    with zipfile.ZipFile(saida_path, "w", zipfile.ZIP_DEFLATED) as zout:
        for nome, dados in partes.items():
            zout.writestr(nome, dados)
    return saida_path, avisos


# ═════════════════════════════════════════════════════════════════════════════
# PLANILHA (.xlsx) — mesmo principio do docx: preencher o TEMPLATE do curso, zero
# dependencia. Um .xlsx tambem e' um .zip de XML. A gente mantem a capa, o cabecalho,
# a marca, as cores, as validacoes e a formatacao condicional do template — e so'
# escreve as LINHAS de dados (lidas do quadro) na aba certa.
# ═════════════════════════════════════════════════════════════════════════════
SS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"


def _sx(tag):
    return f"{{{SS}}}{tag}"


def _col_letras(idx):
    """1 -> 'A', 2 -> 'B', 27 -> 'AA'."""
    s = ""
    while idx > 0:
        idx, r = divmod(idx - 1, 26)
        s = chr(65 + r) + s
    return s


def _col_idx(letras):
    """'A' -> 1, 'B' -> 2, 'AA' -> 27."""
    n = 0
    for ch in letras:
        n = n * 26 + (ord(ch) - 64)
    return n


def _celula_ref(ref):
    """'B12' -> ('B', 12)."""
    m = re.match(r"([A-Z]+)(\d+)", ref or "")
    return (m.group(1), int(m.group(2))) if m else ("A", 1)


def _ler_shared_strings(partes):
    """A tabela de textos compartilhados -> lista, para RESOLVER o texto das celulas."""
    xml = partes.get("xl/sharedStrings.xml")
    if not xml:
        return []
    root = ET.fromstring(xml)
    return ["".join(t.text or "" for t in si.iter(_sx("t")))
            for si in root.findall(_sx("si"))]


def _texto_celula(c, ss):
    """O texto que uma celula MOSTRA (resolve compartilhado/inline)."""
    t = c.get("t")
    v = c.find(_sx("v"))
    if t == "s" and v is not None and v.text is not None:
        try:
            return ss[int(v.text)]
        except (ValueError, IndexError):
            return ""
    if t in ("inlineStr", "str") or c.find(_sx("is")) is not None:
        return "".join(x.text or "" for x in c.iter(_sx("t")))
    return v.text if v is not None else ""


def _set_inline(c, valor):
    """Transforma a celula `c` num texto literal (inline string), preservando o estilo."""
    for filho in list(c):
        c.remove(filho)                      # tira <v> e/ou <is> antigos
    if valor is None or valor == "":
        if c.get("t"):
            del c.attrib["t"]
        return
    c.set("t", "inlineStr")
    is_el = ET.SubElement(c, _sx("is"))
    t_el = ET.SubElement(is_el, _sx("t"))
    t_el.set(f"{{{XML}}}space", "preserve")
    t_el.text = str(valor)


def _set_valor(c, valor):
    """Escreve o valor de uma celula preservando o estilo, escolhendo o tipo certo:

    - `('=FORMULA', cache)`  -> formula VIVA (o Excel recalcula) com valor em cache;
    - `'=FORMULA'`           -> formula viva sem cache;
    - int/float              -> numero;
    - texto (ou vazio)       -> inline string (delega a _set_inline).

    A formula viva e' o que faz a coluna Prioridade (= Probabilidade x Impacto) se
    recalcular sozinha quando o aluno muda a probabilidade/impacto no Excel — igual ao
    template do curso. A ordem dos filhos (<f> antes de <v>) segue o schema do OOXML."""
    formula = cache = None
    if isinstance(valor, tuple) and len(valor) == 2 and \
            isinstance(valor[0], str) and valor[0].startswith("="):
        formula, cache = valor[0][1:], valor[1]
    elif isinstance(valor, str) and valor.startswith("="):
        formula = valor[1:]
    if formula is not None:
        for filho in list(c):
            c.remove(filho)
        if c.get("t"):
            del c.attrib["t"]
        ET.SubElement(c, _sx("f")).text = formula
        if cache not in (None, ""):
            if isinstance(cache, str):
                c.set("t", "str")
            ET.SubElement(c, _sx("v")).text = str(cache)
        return
    if isinstance(valor, (int, float)) and not isinstance(valor, bool):
        for filho in list(c):
            c.remove(filho)
        if c.get("t"):
            del c.attrib["t"]
        ET.SubElement(c, _sx("v")).text = (
            repr(valor) if isinstance(valor, float) else str(valor))
        return
    _set_inline(c, valor)


# ── O FILTRO DA PLANILHA (as setinhas no cabecalho) ─────────────────────────────
#
# POR QUE (Eduardo, 18/08/2026): "precisa ja deixar ordenado por prioridade e FILTRADO
# com todas as colunas". A ordem por prioridade o gerador ja' fazia; o filtro nao — e sem
# ele quem abre um registro com 30 riscos nao consegue ver "so' as ameacas", "so' o que e'
# do Fulano", "so' o que esta' Pendente" sem antes montar o filtro a mao. Uma planilha que
# se usa e' uma planilha que se filtra.
#
# A ORDEM DOS ELEMENTOS NO XML NAO E' NEGOCIAVEL: no esquema do Excel o <autoFilter> vem
# DEPOIS de <sheetData> e ANTES de <mergeCells>/<dataValidations>/<pageMargins>. Fora de
# ordem, o Excel abre com "conteudo ilegivel" e oferece reparo — que e' pior do que nao
# ter filtro. Por isso a insercao procura o primeiro elemento que TEM de vir depois dele,
# em vez de simplesmente colar apos </sheetData>.
_DEPOIS_DO_AUTOFILTER = ("<sortState", "<dataConsolidate", "<customSheetViews",
                         "<mergeCell", "<phoneticPr", "<conditionalFormatting",
                         "<dataValidation", "<hyperlink", "<printOptions",
                         "<pageMargins", "<pageSetup", "<headerFooter", "<rowBreaks",
                         "<colBreaks", "<customProperties", "<cellWatches",
                         "<ignoredErrors", "<drawing", "<legacyDrawing", "<extLst")


def ler_xlsx(caminho, aba, primeira_linha=1):
    """LE uma aba de um .xlsx: [{'linha': n, 'B': 'texto', 'C': 'texto', ...}, ...].

    O CAMINHO DE VOLTA (Eduardo, 18/08/2026): "e' muito mais facil ajustar os riscos no
    Excel — permitir gerar a engenharia reversa no ProjectAdm". Ate' aqui este modulo so'
    ESCREVIA planilhas; ler uma de volta era o que faltava para a planilha deixar de ser
    o fim da linha e virar mais uma superficie de edicao do projeto.

    SEM openpyxl, e isso nao e' preferencia: o Python que vai no pacote do aluno e' o
    EMBUTIDO (python312.zip, sem site-packages). Uma dependencia aqui nao "pesaria um
    pouco" — ela simplesmente nao existiria na maquina dele. Mesma regra do resto do
    modulo: zipfile + ElementTree.

    Devolve o TEXTO MOSTRADO de cada celula (compartilhado, inline ou o valor em cache de
    uma formula) — que e' o que o aluno ve e edita. Linhas totalmente vazias saem fora.
    """
    import zipfile
    with zipfile.ZipFile(caminho) as z:
        partes = {n: z.read(n) for n in z.namelist()}
    abas = _mapa_abas(partes)
    parte = abas.get(aba) or next(
        (p for nome, p in abas.items() if _norm(nome) == _norm(aba)), None)
    if not parte or parte not in partes:
        raise ValueError(Tf("aba '{0}' nao encontrada em {1}", aba, caminho))
    ss = _ler_shared_strings(partes)
    _prep_ns_xlsx(partes[parte])
    saida = []
    for row in ET.fromstring(partes[parte]).iter(_sx("row")):
        n = int(row.get("r") or 0)
        if n < primeira_linha:
            continue
        linha = {"linha": n}
        for c in row.findall(_sx("c")):
            col, _ = _celula_ref(c.get("r") or "")
            texto = (_texto_celula(c, ss) or "").strip()
            if texto:
                linha[col] = texto
        if len(linha) > 1:
            saida.append(linha)
    return saida


def _escrever_celulas(sheet_bytes, celulas):
    """Grava valores em celulas AVULSAS de uma aba: {'R2': 'texto', ...}.

    Serve ao cabecalho de uma coluna que o template nao tem (a do id do cartao): ele
    mora na linha do cabecalho, que `_escrever_tabela` nao toca de proposito. Cria a
    celula quando ela nao existe; a linha tem de existir (e existe: e' o cabecalho)."""
    _prep_ns_xlsx(sheet_bytes)
    root = ET.fromstring(sheet_bytes)
    sd = root.find(_sx("sheetData"))
    if sd is None:
        return sheet_bytes
    por_linha = {}
    for ref, valor in celulas.items():
        col, lin = _celula_ref(ref)
        por_linha.setdefault(lin, []).append((col, ref, valor))
    for row in sd.findall(_sx("row")):
        alvos = por_linha.get(int(row.get("r") or 0))
        if not alvos:
            continue
        for col, ref, valor in alvos:
            c = next((x for x in row.findall(_sx("c")) if x.get("r") == ref), None)
            if c is None:
                c = ET.SubElement(row, _sx("c"))
                c.set("r", ref)
                # o `spans` da linha ("2:16") passou a mentir: ele e' uma dica de
                # otimizacao e e' opcional — tirar e' mais seguro do que recalcular.
                row.attrib.pop("spans", None)
            _set_valor(c, valor)
    return _serializar(root, sheet_bytes)


def _com_autofiltro(sheet_bytes, ref):
    """Liga o filtro do Excel no intervalo `ref` (ex.: 'B2:P42') da aba."""
    xml = sheet_bytes.decode("utf-8")
    if "<autoFilter" in xml:                       # o template ja' trazia um: so' o alcance
        return re.sub(r'<autoFilter ref="[^"]*"', '<autoFilter ref="%s"' % ref,
                      xml, count=1).encode("utf-8")
    fim = xml.find("</sheetData>")
    if fim < 0:
        return sheet_bytes                         # aba sem dados: nada a filtrar
    fim += len("</sheetData>")
    corte = min([p for p in (xml.find(t, fim) for t in _DEPOIS_DO_AUTOFILTER)
                 if p >= 0] or [fim])
    return (xml[:corte] + '<autoFilter ref="%s"/>' % ref + xml[corte:]).encode("utf-8")


def _sem_calcchain(partes):
    """Descarta o xl/calcChain.xml e liga o recalculo no carregamento.

    A cadeia de calculo lista as celulas-formula na ordem de dependencia; ela fica
    OBSOLETA assim que reescrevemos/adicionamos linhas de formula, e uma cadeia obsoleta
    faz o Excel abrir com aviso de reparo. Remover o calcChain (e' opcional no formato) +
    fullCalcOnLoad faz o Excel reconstrui-la em silencio. Mesmo caminho do openpyxl."""
    partes.pop("xl/calcChain.xml", None)
    ct = partes.get("[Content_Types].xml")
    if ct:
        partes["[Content_Types].xml"] = re.sub(
            r'<Override PartName="/xl/calcChain\.xml"[^>]*/>', "",
            ct.decode("utf-8")).encode("utf-8")
    rel = partes.get("xl/_rels/workbook.xml.rels")
    if rel:
        partes["xl/_rels/workbook.xml.rels"] = re.sub(
            r'<Relationship\b[^>]*\bTarget="[^"]*calcChain\.xml"[^>]*/>', "",
            rel.decode("utf-8")).encode("utf-8")
    wb = partes.get("xl/workbook.xml")
    if wb:
        s = wb.decode("utf-8")
        if "<calcPr" in s and "fullCalcOnLoad" not in s:
            s = s.replace("<calcPr ", '<calcPr fullCalcOnLoad="1" ', 1)
        partes["xl/workbook.xml"] = s.encode("utf-8")


def _mapa_abas(partes):
    """{nome_da_aba: caminho_da_parte_da_planilha}."""
    wb = partes.get("xl/workbook.xml")
    rels = partes.get("xl/_rels/workbook.xml.rels")
    if not wb or not rels:
        return {}
    R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
    rid_para_alvo = {}
    for rel in ET.fromstring(rels):
        rid_para_alvo[rel.get("Id")] = rel.get("Target")
    out = {}
    for sh in ET.fromstring(wb).iter(_sx("sheet")):
        rid = sh.get(f"{{{R}}}id")
        alvo = rid_para_alvo.get(rid, "")
        if alvo:
            # Excel escreve 'worksheets/sheet1.xml' (relativo); openpyxl escreve
            # '/xl/worksheets/sheet1.xml' (absoluto). Normaliza os dois para 'xl/...'.
            alvo = alvo.lstrip("/")
            if not alvo.startswith("xl/"):
                alvo = "xl/" + alvo
            out[sh.get("name")] = alvo
    return out


def _prep_ns_xlsx(xml_bytes):
    """Registra os namespaces da planilha (inclusive o DEFAULT, sem prefixo) para o
    round-trip do ElementTree sair identico ao template."""
    ET.register_namespace("", SS)
    _registrar_ns(xml_bytes)


def _estilos_da_linha(sheet_bytes, primeira_linha):
    """{letra_da_coluna: indice_de_estilo} da linha-prototipo (a 1a linha de dados do
    template). E' a partir dela que cada linha nova herda bordas, cores e formato."""
    _prep_ns_xlsx(sheet_bytes)
    root = ET.fromstring(sheet_bytes)
    sd = root.find(_sx("sheetData"))
    if sd is None:
        return {}
    linhas_el = sd.findall(_sx("row"))
    proto = next((r for r in linhas_el if int(r.get("r") or 0) == primeira_linha), None)
    if proto is None:
        proto = next((r for r in linhas_el if int(r.get("r") or 0) >= primeira_linha), None)
    if proto is None:
        return {}
    return {_celula_ref(c.get("r"))[0]: c.get("s") for c in proto.findall(_sx("c"))}


def _registrar_formato(styles_bytes, estilo_base, formato):
    """Cria em xl/styles.xml um estilo IGUAL a `estilo_base` mais um FORMATO DE NUMERO
    (`formato` = um formatCode do Excel, ex.: '"R$" #,##0.00'). Devolve (styles_bytes,
    indice_do_estilo) — o indice vai no atributo `s` das celulas daquela coluna.

    Existe porque um numero cru numa coluna de dinheiro ('12500') nao e' o documento que
    o curso promete: a coluna Custo do Dicionario da EAP tem de sair como R$ 12.500,00 —
    e ainda assim NUMERO (o aluno soma, filtra e edita no Excel; texto nao soma). O
    template so' traz os formatos que ele usa, entao registramos o nosso: um numFmt novo
    (id >= 176, faixa dos personalizados) e um cellXf clonado do estilo da coluna, para
    borda, fonte e alinhamento continuarem os do template.

    Falha SEGURA: qualquer imprevisto devolve (styles_bytes, estilo_base) — a planilha
    sai com o numero sem formato, nunca corrompida.
    """
    try:
        _prep_ns_xlsx(styles_bytes)
        root = ET.fromstring(styles_bytes)
        # 1) o numFmt: reusa se o formatCode ja existir, senao cria com um id livre
        numfmts = root.find(_sx("numFmts"))
        usados, fmt_id = set(), None
        if numfmts is not None:
            for nf in numfmts.findall(_sx("numFmt")):
                usados.add(int(nf.get("numFmtId") or 0))
                if (nf.get("formatCode") or "") == formato:
                    fmt_id = int(nf.get("numFmtId"))
        if fmt_id is None:
            fmt_id = next(i for i in range(176, 400) if i not in usados)
            if numfmts is None:
                numfmts = ET.Element(_sx("numFmts"))
                root.insert(0, numfmts)        # numFmts e' o 1o filho de <styleSheet>
            nf = ET.SubElement(numfmts, _sx("numFmt"))
            nf.set("numFmtId", str(fmt_id))
            nf.set("formatCode", formato)
            numfmts.set("count", str(len(numfmts.findall(_sx("numFmt")))))
        # 2) o cellXf: clone do estilo da coluna + o formato de numero
        cellxfs = root.find(_sx("cellXfs"))
        if cellxfs is None:
            return styles_bytes, estilo_base
        xfs = cellxfs.findall(_sx("xf"))
        try:
            base = xfs[int(estilo_base)] if estilo_base not in (None, "") else xfs[0]
        except (ValueError, IndexError):
            base = xfs[0]
        for i, xf in enumerate(xfs):            # ja registrado numa chamada anterior?
            if xf.get("numFmtId") == str(fmt_id) and xf.get("borderId") == base.get("borderId") \
                    and xf.get("fillId") == base.get("fillId") and xf.get("fontId") == base.get("fontId"):
                return _serializar(root, styles_bytes), i
        novo = ET.fromstring(ET.tostring(base))
        novo.set("numFmtId", str(fmt_id))
        novo.set("applyNumberFormat", "1")
        cellxfs.append(novo)
        cellxfs.set("count", str(len(xfs) + 1))
        return _serializar(root, styles_bytes), len(xfs)
    except Exception:
        return styles_bytes, estilo_base


def _escrever_tabela(sheet_bytes, primeira_linha, linhas, avisos, estilos_extra=None):
    """Reescreve as linhas de DADOS de uma aba (>= primeira_linha) a partir de `linhas`.

    Mantem intacto tudo antes de `primeira_linha` (titulo + cabecalho da tabela) e
    clona o ESTILO das celulas da 1a linha de dados do template para cada linha nova —
    entao bordas, cores e larguras continuam as do template. `linhas` = lista de dicts
    {letra_da_coluna: valor} (as colunas ausentes ficam vazias, mas com o estilo certo).
    `estilos_extra` = {coluna: indice} sobrepoe o estilo herdado (ver `formatos`).
    """
    _prep_ns_xlsx(sheet_bytes)
    root = ET.fromstring(sheet_bytes)
    sd = root.find(_sx("sheetData"))
    if sd is None:
        avisos.append(T("planilha sem sheetData"))
        return sheet_bytes

    linhas_el = sd.findall(_sx("row"))
    proto = next((r for r in linhas_el if int(r.get("r") or 0) == primeira_linha), None)
    if proto is None:
        proto = next((r for r in linhas_el if int(r.get("r") or 0) >= primeira_linha), None)
    # estilo por coluna, a partir da linha-prototipo (col -> style index)
    estilo_col = {}
    if proto is not None:
        for c in proto.findall(_sx("c")):
            col, _ = _celula_ref(c.get("r"))
            estilo_col[col] = c.get("s")
    estilo_col.update({k: str(v) for k, v in (estilos_extra or {}).items() if v is not None})
    # COLUNAS QUE O TEMPLATE NAO TEM entram tambem (18/08/2026). Ate' aqui so' saia o que
    # existia na linha-prototipo, e uma coluna pedida em `linhas` que o modelo nao previa
    # era descartada em silencio. E' o que a coluna do ID do cartao precisa: ela nasce
    # FORA da tabela do template (depois da ultima coluna), para a volta planilha ->
    # quadro saber, sem adivinhar, qual cartao e' cada linha. Sem estilo proprio — o
    # template nao tem um para ela, e inventar um seria pior do que a celula simples.
    extras = {c for l in linhas for c in l if c not in estilo_col}
    cols_ordenadas = sorted(set(estilo_col) | extras, key=_col_idx) or ["B", "C", "D", "E"]

    for r in linhas_el:                      # remove as linhas de dados antigas
        if int(r.get("r") or 0) >= primeira_linha:
            sd.remove(r)

    n = 0
    for i, valores in enumerate(linhas):
        rn = primeira_linha + i
        row = ET.SubElement(sd, _sx("row"))
        row.set("r", str(rn))
        row.set("spans", f"{_col_idx(cols_ordenadas[0])}:{_col_idx(cols_ordenadas[-1])}")
        for col in cols_ordenadas:
            c = ET.SubElement(row, _sx("c"))
            c.set("r", f"{col}{rn}")
            if estilo_col.get(col) is not None:
                c.set("s", estilo_col[col])
            val = valores.get(col)
            if val not in (None, ""):
                _set_valor(c, val)
        n += 1

    ultima = primeira_linha + n - 1 if n else primeira_linha - 1
    # dimensao: mantem a coluna inicial/final do template, atualiza a ultima linha
    dim = root.find(_sx("dimension"))
    if dim is not None and n:
        ref = dim.get("ref") or "A1"
        ini, _, fim = ref.partition(":")
        col_fim, _ = _celula_ref(fim or ini)
        # ...e alarga se as linhas foram alem da ultima coluna do template (a coluna do
        # id do cartao). Uma <dimension> que nao cobre as celulas escritas faz o Excel
        # abrir com "conteudo ilegivel" e oferecer reparo — o arquivo "funciona" no
        # openpyxl e assusta o aluno no Excel.
        if _col_idx(cols_ordenadas[-1]) > _col_idx(col_fim):
            col_fim = cols_ordenadas[-1]
        dim.set("ref", f"{ini}:{col_fim}{ultima}")
    # estende validacoes de dados e formatacao condicional que comecavam na 1a linha
    if n:
        _ampliar_sqref(root, primeira_linha, ultima)

    return _serializar(root, sheet_bytes)


def _ampliar_sqref(root, primeira_linha, ultima):
    """Faz as faixas 'Q4:Q24' do template cobrirem ate a ultima linha escrita."""
    for el in root.iter():
        sq = el.get("sqref")
        if not sq:
            continue
        m = re.fullmatch(r"([A-Z]+)(\d+):([A-Z]+)(\d+)", sq.strip())
        if m and int(m.group(2)) == primeira_linha and int(m.group(4)) < ultima:
            el.set("sqref", f"{m.group(1)}{primeira_linha}:{m.group(3)}{ultima}")


def _trocar_celulas(sheet_bytes, trocas, ss):
    """Troca o texto de qualquer celula cujo valor MOSTRADO seja uma chave de `trocas`
    (ex.: o rotulo 'Nome do Projeto' -> o nome do projeto). Preserva o estilo."""
    _prep_ns_xlsx(sheet_bytes)
    root = ET.fromstring(sheet_bytes)
    mudou = False
    for c in root.iter(_sx("c")):
        txt = _texto_celula(c, ss)
        if txt in trocas:
            _set_inline(c, trocas[txt])
            mudou = True
    return _serializar(root, sheet_bytes) if mudou else sheet_bytes


def _preencher_versao_xlsx(capa_bytes, versao, ss):
    """Preenche a 1a linha de dados da tabela 'Controle de Versoes' (colunas B,C,D,E)."""
    _prep_ns_xlsx(capa_bytes)
    root = ET.fromstring(capa_bytes)
    sd = root.find(_sx("sheetData"))
    if sd is None:
        return capa_bytes
    linha_cab = None
    for row in sd.findall(_sx("row")):
        textos = {_norm(_texto_celula(c, ss)) for c in row.findall(_sx("c"))}
        if _e_cabecalho_versao(textos):
            linha_cab = int(row.get("r"))
            break
    if not linha_cab:
        return capa_bytes
    alvo = linha_cab + 1
    row_alvo = next((r for r in sd.findall(_sx("row"))
                     if int(r.get("r") or 0) == alvo), None)
    if row_alvo is None:
        return capa_bytes
    celulas = {(_celula_ref(c.get("r"))[0]): c for c in row_alvo.findall(_sx("c"))}
    for col, val in zip(("B", "C", "D", "E"), versao):
        c = celulas.get(col)
        if c is None:
            c = ET.SubElement(row_alvo, _sx("c"))
            c.set("r", f"{col}{alvo}")
        if val not in (None, ""):
            _set_inline(c, val)
    return _serializar(root, capa_bytes)


def preencher_xlsx(template_path, saida_path, aba, primeira_linha, linhas,
                   trocas=None, versao=None, aba_capa="Capa", formatos=None,
                   propriedades=None, autofiltro=None, celulas=None):
    """Preenche `template_path` (.xlsx) e grava em `saida_path`. Zero dependencia.

    aba            = nome da aba de dados (ex.: 'EAP').
    primeira_linha = 1a linha de DADOS dessa aba (as de cima = cabecalho, ficam intactas).
    linhas         = [ {letra_da_coluna: valor, ...}, ... ] — uma por linha de dados.
    trocas         = {texto_literal: valor} — troca celulas em TODAS as abas
                     (ex.: {'Nome do Projeto': 'Meu Projeto'}).
    versao         = (numero, data, autor, notas) -> tabela 'Controle de Versoes' da capa.
    formatos       = {coluna: formatCode} — formato de numero de uma coluna inteira
                     (ex.: {'O': '"R$" #,##0.00'} na coluna Custo). O valor continua
                     NUMERO (soma e filtra no Excel); so' a exibicao muda.
    autofiltro     = ('B', 'P', linha_do_cabecalho) — liga o filtro do Excel (as
                     setinhas) da coluna B ate' a P, com o cabecalho naquela linha e o
                     alcance terminando na ultima linha ESCRITA. None = sem filtro.
    celulas        = {'R2': 'texto'} — celulas AVULSAS da aba de dados, gravadas depois
                     da tabela (ex.: o cabecalho de uma coluna que o template nao tem).

    Devolve (saida_path, avisos).
    """
    import zipfile
    avisos = []
    with zipfile.ZipFile(template_path) as zin:
        partes = {n: zin.read(n) for n in zin.namelist()}

    abas = _mapa_abas(partes)
    ss = _ler_shared_strings(partes)

    parte_aba = abas.get(aba) or next(
        (p for nome, p in abas.items() if _norm(nome) == _norm(aba)), None)
    if not parte_aba or parte_aba not in partes:
        raise ValueError(Tf("aba '{0}' nao encontrada no template: {1}", aba, template_path))

    # formato de numero por coluna (ex.: Custo em R$): registra um estilo novo em
    # xl/styles.xml, clonado do estilo que a coluna ja tem no template.
    estilos_extra = {}
    if formatos and "xl/styles.xml" in partes:
        base_col = _estilos_da_linha(partes[parte_aba], primeira_linha)
        for col, formato in formatos.items():
            partes["xl/styles.xml"], idx = _registrar_formato(
                partes["xl/styles.xml"], base_col.get(col), formato)
            estilos_extra[col] = idx

    partes[parte_aba] = _escrever_tabela(partes[parte_aba], primeira_linha, linhas,
                                         avisos, estilos_extra=estilos_extra)

    if celulas:
        partes[parte_aba] = _escrever_celulas(partes[parte_aba], celulas)

    if autofiltro and linhas:
        col_ini, col_fim, cabecalho = autofiltro
        ultima = primeira_linha + len(linhas) - 1
        partes[parte_aba] = _com_autofiltro(
            partes[parte_aba], "%s%d:%s%d" % (col_ini, cabecalho, col_fim, ultima))

    if trocas:
        for parte in set(abas.values()):
            if parte in partes:
                partes[parte] = _trocar_celulas(partes[parte], trocas, ss)

    if versao:
        # A aba de dados (`aba`) levanta quando nao existe; a CAPA, nao — e' opcional
        # (o Cronograma nem tem capa separada). Mas "opcional" nao pode virar "sumiu em
        # silencio": quando o template TEM capa e o nome pedido nao bate, a tabela de
        # Controle de Versoes simplesmente nao era escrita e ninguem ficava sabendo.
        # Depois de renomear as abas dos modelos ingleses (06/08/2026) isso passou a ser
        # uma possibilidade real — entao agora avisa.
        parte_capa = abas.get(aba_capa) or next(
            (p for nome, p in abas.items() if _norm(nome) == _norm(aba_capa)), None)
        if parte_capa and parte_capa in partes:
            partes[parte_capa] = _preencher_versao_xlsx(partes[parte_capa], versao, ss)
        else:
            avisos.append(Tf("aba de capa '{0}' nao encontrada ({1}): a tabela de "
                             "Controle de Versoes nao foi preenchida. Abas do template: {2}",
                             aba_capa, os.path.basename(template_path),
                             ", ".join(sorted(abas)) or "(nenhuma)"))

    _sem_calcchain(partes)      # cadeia de calculo obsoleta -> Excel reconstroi sozinho

    if "docProps/core.xml" in partes:
        partes["docProps/core.xml"] = _set_core_props(
            partes["docProps/core.xml"], _props_do_documento(saida_path, propriedades))

    os.makedirs(os.path.dirname(os.path.abspath(saida_path)), exist_ok=True)
    with zipfile.ZipFile(saida_path, "w", zipfile.ZIP_DEFLATED) as zout:
        for nome, dados in partes.items():
            zout.writestr(nome, dados)
    return saida_path, avisos


# ── MS Project (MSPDI XML) — o cronograma que o MS Project abre nativamente ──────
#
# O .mpp e' um binario proprietario que so' o proprio MS Project escreve. Mas o
# MS Project ABRE e SALVA o formato XML nativo dele — o MSPDI ("Microsoft Project
# Data Interchange"): Arquivo > Abrir > o .xml vira um projeto de verdade, que o
# aluno salva como .mpp com um clique. E' texto XML puro -> o mesmo motor
# zero-dependencia gera (o .mpp binario nao da para escrever sem o Project instalado).
#
# As tarefas vem NA ORDEM da EAP; a hierarquia e' definida pelo OutlineLevel
# (1 fase, 2 entrega, 3 pacote). As folhas (pacotes/historias) sao MANUAIS
# (Manual=1) para o Project mostrar EXATAMENTE as datas do quadro, sem recalcular
# pelo calendario. As setas de dependencia vem de PredecessorLink (FS/SS/FF/SF).
# A linha de base (Baseline0) sai preenchida = plano provisorio (igual ao .xlsx).

_MSPDI_NS = "http://schemas.microsoft.com/project"

# tipo de dependencia do ProjectAdm -> Type do MSPDI (0=FF 1=FS 2=SF 3=SS)
_MSPDI_LINK = {"finish_to_start": 1, "start_to_start": 3,
               "finish_to_finish": 0, "start_to_finish": 2}


def _mspdi_dt(iso, hora):
    """'2026-08-03' + '08:00:00' -> '2026-08-03T08:00:00' (datetime do MSPDI); '' se sem data."""
    iso = (iso or "")[:10]
    return f"{iso}T{hora}" if iso else ""


def _mspdi_dur(horas):
    """horas -> 'PT40H0M0S' (formato de duracao do MSPDI)."""
    try:
        h = int(round(float(horas)))
    except (TypeError, ValueError):
        h = 0
    return f"PT{max(h, 0)}H0M0S"


def _dias_uteis(ini, fim):
    """Dias uteis (seg-sex) entre duas datas ISO, inclusive. 0 se faltar data."""
    import datetime
    ini, fim = (ini or "")[:10], (fim or "")[:10]
    if not ini or not fim:
        return 0
    try:
        d0, d1 = datetime.date.fromisoformat(ini), datetime.date.fromisoformat(fim)
    except ValueError:
        return 0
    if d1 < d0:
        d0, d1 = d1, d0
    n, d = 0, d0
    while d <= d1:
        if d.weekday() < 5:
            n += 1
        d += datetime.timedelta(days=1)
    return n


def _mspdi_task(t, hora_ini, hora_fim, baseline):
    """Uma <Task> do MSPDI a partir do dict de tarefa (ver escrever_mspdi)."""
    uid = int(t["uid"])
    nome = _escape_xml(t.get("nome") or "(sem nome)")
    nivel = int(t.get("nivel") or 1)
    wbs = _escape_xml(t.get("wbs") or str(uid))
    resumo = bool(t.get("resumo"))
    marco = bool(t.get("marco"))
    ini, fim = (t.get("inicio") or "")[:10], (t.get("fim") or "")[:10]
    tem_data = bool(ini and fim)
    dur_h = t.get("dur_horas")
    if dur_h is None and tem_data:
        dur_h = _dias_uteis(ini, fim) * 8
    manual = 0 if resumo else 1     # resumo = auto (Project calcula dos filhos)

    # A ORDEM dos elementos e o DurationFormat sao os que o PROPRIO MS Project grava
    # (verificado gerando um .xml pelo Project). Se ManualStart/ManualFinish/
    # ManualDuration nao vierem logo depois de Duration, e antes de DurationFormat, o
    # Project IGNORA o fim/duracao da tarefa manual e a duracao "zera". DurationFormat
    # = 21 na tarefa manual, 7 (dias) na de resumo — tambem como o Project grava.
    p = [f"<Task><UID>{uid}</UID><ID>{uid}</ID><Name>{nome}</Name>",
         f"<Active>1</Active><Manual>{manual}</Manual><Type>0</Type><IsNull>0</IsNull>",
         f"<WBS>{wbs}</WBS><OutlineNumber>{wbs}</OutlineNumber>",
         f"<OutlineLevel>{nivel}</OutlineLevel><Priority>500</Priority>"]
    if tem_data:
        s_ini, s_fim = _mspdi_dt(ini, hora_ini), _mspdi_dt(fim, hora_fim)
        s_dur = _mspdi_dur(dur_h)
        p.append(f"<Start>{s_ini}</Start><Finish>{s_fim}</Finish>")
        p.append(f"<Duration>{s_dur}</Duration>")
        if manual:
            p.append(f"<ManualStart>{s_ini}</ManualStart>"
                     f"<ManualFinish>{s_fim}</ManualFinish>"
                     f"<ManualDuration>{s_dur}</ManualDuration>")
        p.append(f"<DurationFormat>{21 if manual else 7}</DurationFormat>")
    p.append(f"<Milestone>{1 if marco else 0}</Milestone>")
    p.append(f"<Summary>{1 if resumo else 0}</Summary>")
    p.append("<Critical>0</Critical><IsSubproject>0</IsSubproject>")
    p.append("<ConstraintType>0</ConstraintType><CalendarUID>-1</CalendarUID>")
    # predecessores (so nas folhas) -> setas no Gantt
    for pred_uid, tipo in (t.get("predecessores") or []):
        link = _MSPDI_LINK.get(tipo, 1)
        p.append(f"<PredecessorLink><PredecessorUID>{int(pred_uid)}</PredecessorUID>"
                 f"<Type>{link}</Type><CrossProject>0</CrossProject>"
                 f"<LinkLag>0</LinkLag><LagFormat>7</LagFormat></PredecessorLink>")
    # linha de base (plano provisorio ate o Passo 5) — barra de baseline no Gantt
    if baseline and tem_data:
        p.append(f"<Baseline><Number>0</Number>"
                 f"<Start>{_mspdi_dt(ini, hora_ini)}</Start>"
                 f"<Finish>{_mspdi_dt(fim, hora_fim)}</Finish>"
                 f"<Duration>{_mspdi_dur(dur_h)}</Duration>"
                 f"<DurationFormat>7</DurationFormat><Work>PT0H0M0S</Work></Baseline>")
    p.append("</Task>")
    return "".join(p)


# ── QUEM VIRA <Resource> NO MS PROJECT (e quem NAO vira) ─────────────────────
# Eduardo, 05/08/2026: o Passo 4 gerou o .xml e o MS Project recusou a importacao com
#   "The element <Name> in the <Resource> element with <UID> = 2 has invalid data."
# O UID=2 daquele arquivo era <Name>[to define]</Name>.
#
# O prompt do Passo 4 manda a IA escrever "[definir]" / "[to define]" quando nenhum membro
# da equipe servir ao pacote — e isso esta certo no QUADRO. Errado era levar o placeholder
# ao MS Project como se fosse gente: ele nao e' um recurso, e o jeito certo de dizer
# "ainda sem dono" num cronograma e' NAO ter recurso atribuido (a coluna Nomes dos
# Recursos fica em branco, que e' a verdade). De quebra some do arquivo o <Name> que o
# Project recusou.
#
# (Por que o placeholder apareceu tanto naquele arquivo: ate 05/08 o pacote EN nunca
# achava o cartao "TEAM — who does the work", entao o [INPUT EQUIPE] ia vazio e a IA nao
# tinha lista de nomes para escolher — ver quadro.e_grupo_equipe. Corrigido; mas o
# placeholder continua sendo resposta VALIDA do prompt, entao o gerador tem de trata-lo.)
_RECURSO_PLACEHOLDER = re.compile(
    r"^[\[\(<]?\s*(a\s+)?(definir|def\.?|to\s+define|to\s+be\s+defined|tbd|n/?a|"
    r"nao\s+definido|indefinido|sem\s+dono|unassigned|none|nobody|-{1,}|\?+)\s*[\]\)>]?$")

# Caracteres que o MS Project reserva na coluna "Nomes dos Recursos": a virgula separa
# recursos e os colchetes carregam as unidades ("Ana[50%]"). Um nome que os traga volta do
# importador como dado invalido — e a IA produz nomes assim sem querer ("Ana, Silva",
# "Ana [PMO]"). Tiramos os caracteres, mantemos a pessoa.
_RECURSO_RESERVADOS = str.maketrans({"[": "", "]": "", ",": " ", ";": " "})


def _nome_de_recurso(bruto):
    """O responsavel como NOME DE RECURSO do MS Project — ou '' quando nao ha dono."""
    s = re.sub(r"\s+", " ", (bruto or "").strip())
    if not s or _RECURSO_PLACEHOLDER.match(entradas._sem_acento(s)):
        return ""
    s = re.sub(r"\s+", " ", s.translate(_RECURSO_RESERVADOS)).strip()
    return s[:255]                      # o Project corta o nome do recurso em 255


def escrever_mspdi(saida_path, projeto, tarefas, moeda="R$",
                   hora_inicio="08:00:00", hora_fim="17:00:00", baseline=True):
    """Gera um arquivo XML nativo do MS Project (MSPDI) em `saida_path`. Zero dependencia.

    `tarefas` = lista de dicts, JA na ordem da EAP (fase, suas entregas, os pacotes...):
      uid            int >= 1 (tambem usado como ID/linha; predecessores referenciam ele)
      nome           str
      nivel          int  (OutlineLevel: a PROFUNDIDADE real, sem teto — 1 fase,
                          2 entrega, 3 pacote no caso comum; e 4, 5... quando a EAP e'
                          mais decomposta ou quando entram as ATIVIDADES de cada pacote)
      wbs            str  (codigo da EAP: '1', '1.1', '1.1.1', '1.1.1.1'...)
      resumo         bool (tarefa-resumo — o Project calcula as datas dela dos filhos)
      inicio, fim    'YYYY-MM-DD' ou '' (datas do cartao)
      dur_horas      float|None (se None e ha datas, calcula por dias uteis x 8h)
      predecessores  [(uid_predecessor, tipo_projectadm), ...] (so nas folhas)
      recurso        str  (responsavel -> vira Resource + Assignment) ou ''
      marco          bool (opcional)

    Devolve (saida_path, avisos)."""
    avisos = []
    tarefas = [t for t in (tarefas or []) if (t.get("nome") or "").strip()]
    if not tarefas:
        raise ValueError(T("sem tarefas para o MS Project."))

    datas = [d[:10] for t in tarefas for d in (t.get("inicio"), t.get("fim")) if d]
    proj_ini = _mspdi_dt(min(datas), hora_inicio) if datas else ""
    proj_fim = _mspdi_dt(max(datas), hora_fim) if datas else ""

    # recursos unicos (responsaveis) -> Resource + Assignment.
    # Passa por _nome_de_recurso: placeholder vira '' (nao e' gente), caractere reservado
    # do Project sai, e o mesmo nome escrito de dois jeitos vira UM recurso so'.
    recursos, ordem_rec, por_chave = {}, [], {}
    for t in tarefas:
        nome = _nome_de_recurso(t.get("recurso"))
        t["_recurso_mspdi"] = nome            # o Assignment usa ESTE, nao o texto cru
        if not nome:
            continue
        chave = _norm(nome)
        if chave in por_chave:                # "responsavel" e "responsável" = uma pessoa
            t["_recurso_mspdi"] = por_chave[chave]
            continue
        por_chave[chave] = nome
        recursos[nome] = len(recursos) + 1
        ordem_rec.append(nome)

    import datetime
    hoje = datetime.date.today().isoformat()
    agora = f"{hoje}T08:00:00"
    ini = proj_ini or agora
    fim = proj_fim or f"{hoje}T17:00:00"
    moeda_code = {"R$": "BRL", "$": "USD", "US$": "USD",
                  "€": "EUR", "£": "GBP"}.get((moeda or "").strip(), "")

    # O HEADER = o conjunto e a ORDEM de campos que o PROPRIO MS Project grava (extraido
    # de um .xml salvo pelo Project). Faltar campos OU troca-los de ordem faz o Project
    # RECALCULAR o cronograma ao importar e ZERAR a duracao das tarefas manuais (era o
    # bug de um header "enxuto"). <Calendars/> vazio = usa o calendario Standard interno
    # do Project (seg-sex 08-17) — que e' exatamente o que queremos.
    cab = [
        ("SaveVersion", "14"),
        ("Name", _escape_xml(os.path.basename(saida_path))),
        ("Title", _escape_xml(projeto)),
        ("CreationDate", agora), ("LastSaved", agora),
        ("ScheduleFromStart", "1"), ("StartDate", ini), ("FinishDate", fim),
        ("FYStartDate", "1"), ("CriticalSlackLimit", "0"),
        ("CurrencyDigits", "2"), ("CurrencySymbol", _escape_xml(moeda)),
        ("CurrencyCode", moeda_code), ("CurrencySymbolPosition", "2"),
        ("CalendarUID", "1"),
        ("DefaultStartTime", hora_inicio), ("DefaultFinishTime", hora_fim),
        ("MinutesPerDay", "480"), ("MinutesPerWeek", "2400"),
        ("DaysPerMonth", "20"), ("DefaultTaskType", "0"),
        ("DefaultFixedCostAccrual", "3"),
        ("DefaultStandardRate", "0"), ("DefaultOvertimeRate", "0"),
        ("DurationFormat", "7"), ("WorkFormat", "2"),
        ("EditableActualCosts", "0"), ("HonorConstraints", "0"),
        ("InsertedProjectsLikeSummary", "1"), ("MultipleCriticalPaths", "0"),
        ("NewTasksEffortDriven", "0"), ("NewTasksEstimated", "1"),
        ("SplitsInProgressTasks", "1"), ("SpreadActualCost", "0"),
        ("SpreadPercentComplete", "0"), ("TaskUpdatesResource", "1"),
        ("FiscalYearStart", "0"), ("WeekStartDay", "0"),
        ("MoveCompletedEndsBack", "0"), ("MoveRemainingStartsBack", "0"),
        ("MoveRemainingStartsForward", "0"), ("MoveCompletedEndsForward", "0"),
        ("BaselineForEarnedValue", "0"), ("AutoAddNewResourcesAndTasks", "1"),
        ("CurrentDate", agora), ("MicrosoftProjectServerURL", "1"),
        ("Autolink", "0"), ("NewTaskStartDate", "0"),
        ("NewTasksAreManual", "1"), ("DefaultTaskEVMethod", "0"),
        ("ProjectExternallyEdited", "0"),
        ("ExtendedCreationDate", "1984-01-01T00:00:00"),
        ("ActualsInSync", "0"), ("RemoveFileProperties", "0"), ("AdminProject", "0"),
        ("UpdateManuallyScheduledTasksWhenEditingLinks", "1"),
        ("KeepTaskOnNearestWorkingTimeWhenMadeAutoScheduled", "0"),
    ]
    partes = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
              f'<Project xmlns="{_MSPDI_NS}">']
    partes += [f"<{k}>{v}</{k}>" for k, v in cab]
    partes.append("<Calendars/>")      # usa o calendario Standard interno do Project

    partes.append("<Tasks>")
    for t in tarefas:
        partes.append(_mspdi_task(t, hora_inicio, hora_fim, baseline))
    partes.append("</Tasks>")

    if recursos:
        partes.append("<Resources>")
        for nome in ordem_rec:
            uid = recursos[nome]
            partes.append(f"<Resource><UID>{uid}</UID><ID>{uid}</ID>"
                          f"<Name>{_escape_xml(nome)}</Name><Type>1</Type>"
                          f"<IsNull>0</IsNull><MaxUnits>1</MaxUnits></Resource>")
        partes.append("</Resources>")
        partes.append("<Assignments>")
        aid = 0
        for t in tarefas:
            nome = t.get("_recurso_mspdi") or ""
            if nome and not t.get("resumo"):
                aid += 1
                partes.append(f"<Assignment><UID>{aid}</UID>"
                              f"<TaskUID>{int(t['uid'])}</TaskUID>"
                              f"<ResourceUID>{recursos[nome]}</ResourceUID>"
                              f"<PercentWorkComplete>0</PercentWorkComplete>"
                              f"<Units>1</Units></Assignment>")
        partes.append("</Assignments>")

    partes.append("</Project>")
    os.makedirs(os.path.dirname(os.path.abspath(saida_path)), exist_ok=True)
    with open(saida_path, "wb") as f:
        f.write("".join(partes).encode("utf-8"))
    return saida_path, avisos


# ═════════════════════════════════════════════════════════════════════════════
# APRESENTACAO (.pptx) — mesmo principio do docx/xlsx: preencher o TEMPLATE do curso,
# zero dependencia (um .pptx tambem e' um .zip de XML). Um slide guarda o texto em
# <p:txBody> -> <a:p> (paragrafo) -> <a:r> (run) -> <a:t>. Duas operacoes bastam para
# o Kick-Off: (1) trocar os placeholders "[...]" pelo dado do projeto, preservando as
# quebras de linha <a:br>; (2) escrever bullets no placeholder de CORPO vazio de um
# slide (achado pelo titulo).
_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
_P = "http://schemas.openxmlformats.org/presentationml/2006/main"


def _a(tag):
    return f"{{{_A}}}{tag}"


def _pns(tag):
    return f"{{{_P}}}{tag}"


def _run_txt(r):
    return "".join(t.text or "" for t in r.iter(_a("t")))


def _set_run_txt(r, texto):
    ts = r.findall(_a("t"))
    if not ts:
        ts = [ET.SubElement(r, _a("t"))]
    ts[0].text = texto
    ts[0].set(f"{{{XML}}}space", "preserve")
    for extra in ts[1:]:
        r.remove(extra)


def _set_par_txt(p, texto):
    """Escreve `texto` num run UNICO do paragrafo (preserva o pPr = estilo/bullet).

    ⚠️ Ordem e estilo importam: num <a:p> vazio de placeholder, o unico filho e o
    <a:endParaRPr> — que DEVE ser o ultimo. Um run acrescentado DEPOIS dele (ou sem
    <a:rPr>) e' descartado no render (o corpo sai em branco). Entao: removemos runs/breaks,
    criamos UM run com <a:rPr> clonado do endParaRPr (herda tamanho/cor do placeholder) e
    o inserimos ANTES do endParaRPr."""
    for r in p.findall(_a("r")):
        p.remove(r)
    for br in p.findall(_a("br")):
        p.remove(br)
    run = ET.Element(_a("r"))
    epr = p.find(_a("endParaRPr"))
    if epr is not None and epr.attrib:
        rpr = ET.SubElement(run, _a("rPr"))
        for k, v in epr.attrib.items():
            rpr.set(k, v)
    t = ET.SubElement(run, _a("t"))
    t.text = texto
    t.set(f"{{{XML}}}space", "preserve")
    if epr is not None:
        p.insert(list(p).index(epr), run)
    else:
        p.append(run)


def _pptx_trocar(root, trocas):
    """Troca placeholders por SEGMENTO de linha (entre <a:br>): se um segmento COMECA com
    uma chave de `trocas` (ex.: '[Projeto', '[Passado'), o segmento inteiro vira o valor —
    preservando as quebras <a:br> do paragrafo (o titulo tem 3 linhas)."""
    if not trocas:
        return
    for p in root.iter(_a("p")):
        seg = []

        def _flush(runs):
            if not runs:
                return
            full = "".join(_run_txt(r) for r in runs).strip()
            # A CHAVE MAIS LONGA GANHA. Com o primeiro-que-casar, '[Project' engolia
            # '[Project Manager]' e o gerente do projeto virava o nome do projeto na capa
            # (template ingles, 06/08/2026). Em portugues a colisao nao existia
            # ('[Projeto' x '[Gerente do Projeto'), entao o bug so' aparecia traduzido.
            achado = max((k for k in trocas if full.startswith(k)), key=len, default=None)
            if achado is not None:
                _set_run_txt(runs[0], trocas[achado])
                for r in runs[1:]:
                    p.remove(r)
                return

        for ch in list(p):
            if ch.tag == _a("r"):
                seg.append(ch)
            elif ch.tag == _a("br"):
                _flush(seg)
                seg = []
        _flush(seg)


def _set_par_nivel(p, nivel):
    """Poe o paragrafo no NIVEL `nivel` da lista (<a:pPr lvl="N">; 0 = primeiro nivel).

    E' isto que faz o slide ter uma HIERARQUIA de verdade, em vez de um '•' digitado
    dentro do texto e uns espacos na frente: o bullet e o recuo passam a vir do
    template (cada nivel tem o seu), e o 'Converter em SmartArt' do PowerPoint enxerga
    pai e filho — com texto fake ele so' via uma lista rasa (Eduardo, 06/08/2026)."""
    ppr = p.find(_a("pPr"))
    if ppr is None:
        ppr = ET.Element(_a("pPr"))
        p.insert(0, ppr)            # <a:pPr> e' obrigatoriamente o 1o filho do <a:p>
    if nivel:
        ppr.set("lvl", str(int(nivel)))
    else:
        ppr.attrib.pop("lvl", None)


def _linha_nivel(item):
    """Aceita 'texto' (nivel 0) ou (nivel, 'texto'). Devolve (nivel, texto)."""
    if isinstance(item, (tuple, list)) and len(item) == 2:
        return int(item[0] or 0), str(item[1])
    return 0, str(item)


def _pptx_corpo(root, conteudos, usados=None):
    """Para o slide cujo TITULO casa uma chave de `conteudos`, escreve as `linhas` (bullets)
    no placeholder de CORPO (ph type='body'), clonando o 1o paragrafo como modelo de estilo.

    Cada linha e' `'texto'` (nivel 0) ou `(nivel, 'texto')` — a tupla poe o paragrafo no
    nivel correspondente da lista do template.

    Anota em `usados` a chave que casou — quem chama compara com o que pediu e AVISA sobre
    a que nao casou. Sem isso, uma ancora errada (a do template ingles, 06/08/2026)
    devolvia a apresentacao inteira com os slides de conteudo vazios, em silencio."""
    if not conteudos:
        return
    import copy
    titulo, body = "", None
    for sp in root.iter(_pns("sp")):
        ph = sp.find(f"./{_pns('nvSpPr')}/{_pns('nvPr')}/{_pns('ph')}")
        ptype = ph.get("type") if ph is not None else None
        txb = sp.find(f".//{_pns('txBody')}")
        if txb is None:
            continue
        if ptype in ("title", "ctrTitle"):
            titulo = "".join(t.text or "" for t in txb.iter(_a("t")))
        elif ptype == "body":
            body = txb
    if body is None:
        return
    chave = next((k for k in conteudos if _norm(k) in _norm(titulo)), None)
    if not chave:
        return
    if usados is not None:
        usados.add(chave)
    linhas = [_linha_nivel(l) for l in conteudos[chave] if l]
    linhas = [(n, t) for n, t in linhas if t.strip()]
    if not linhas:
        return
    ps = body.findall(_a("p"))
    modelo = ps[0] if ps else ET.SubElement(body, _a("p"))
    for extra in ps[1:]:
        body.remove(extra)
    _set_par_txt(modelo, linhas[0][1])
    _set_par_nivel(modelo, linhas[0][0])
    idx = list(body).index(modelo)
    for j, (nivel, texto) in enumerate(linhas[1:], 1):
        novo = copy.deepcopy(modelo)
        _set_par_txt(novo, texto)
        _set_par_nivel(novo, nivel)
        body.insert(idx + j, novo)


def preencher_pptx(template_path, saida_path, trocas=None, conteudos=None):
    """Preenche um .pptx do curso: troca placeholders '[...]' (`trocas`, casadas por
    prefixo de segmento) e escreve bullets nos corpos vazios (`conteudos`, {titulo_do_slide:
    [linhas]}). Mantem tema, layouts, imagens e slides estaticos. Zero dependencia.
    Devolve (saida_path, avisos)."""
    import zipfile
    avisos = []
    with zipfile.ZipFile(template_path) as zin:
        partes = {n: zin.read(n) for n in zin.namelist()}
    slides = [n for n in partes if re.match(r"ppt/slides/slide\d+\.xml$", n)]
    if not slides:
        raise ValueError(Tf("template sem slides: {0}", template_path))
    usados = set()
    for nome in slides:
        xml = partes[nome]
        _registrar_ns(xml)
        root = ET.fromstring(xml)
        _pptx_trocar(root, trocas or {})
        _pptx_corpo(root, conteudos or {}, usados)
        partes[nome] = _serializar(root, xml)
    for chave in (conteudos or {}):
        if chave not in usados:
            avisos.append(Tf("slide nao encontrado no template: '{0}'", chave))
    if "docProps/core.xml" in partes:
        partes["docProps/core.xml"] = _set_core_props(
            partes["docProps/core.xml"], _props_do_documento(saida_path))
    os.makedirs(os.path.dirname(os.path.abspath(saida_path)), exist_ok=True)
    with zipfile.ZipFile(saida_path, "w", zipfile.ZIP_DEFLATED) as zout:
        for nome, dados in partes.items():
            zout.writestr(nome, dados)
    return saida_path, avisos
