"""
Together Kit — INPUTS: ler as entradas do aluno DIRETO DO QUADRO (padrao Together).

POR QUE ISTO EXISTE (a mudanca que tira o codigo do caminho do aluno)
  Ate aqui, o aluno no modo CHAT tinha de colar a resposta da IA dentro do script
  (a "ZONA DE EDICAO"). Isso obriga um profissional de projetos a editar Python —
  friccao que nao ensina nada sobre projetos.

  Agora: a IA devolve TEXTO. O aluno cola o texto no CARTAO correspondente da lista
  **INPUTS** do seu quadro. O script LE o quadro. Ninguem edita codigo.

  A FONTE DA VERDADE E' O QUADRO — so' ele, e nao ha o que configurar.
  Ate 01/08/2026 existiu um segundo caminho (`AI_TYPE=IDE`: a IA editava o arquivo e o
  aluno aceitava o diff). Esse modo foi REMOVIDO junto com a promessa de que o aluno
  nunca abre um arquivo do curso. A chave `AI_TYPE` nao existe mais: nao esta' no .env,
  nao sai do course.json e nenhum script a le. Se alguem a escrever no .env, e' ignorada.

O FORMATO
  E o mesmo texto que o cartao ja mostra: linhas com "• item" e, abaixo, campos
  "chave: valor". Legivel por gente, parseavel por maquina. Os prompts (prompts.py)
  pedem a IA para responder EXATAMENTE neste formato.

  Tudo aqui e TOLERANTE: aceita "-" ou "*" no lugar de "•", aceita acento ou nao,
  ignora linhas vazias e cabecalhos. Se o aluno editar o texto a mao (e ele vai),
  continua funcionando.
"""
import html as _html
import re
import unicodedata

import vocabulario


# ─────────────────────────────────────────────────────────────────────────────
# O QUE VOLTA DE UM CARTAO E HTML — e nao texto.
#
# O editor de descricao do ProjectAdm e rico: quando o aluno escreve no navegador,
# a API devolve "<p>linha 1</p><p>linha 2</p>", tudo numa linha so. Quem le um
# cartao achando que e texto puro quebra de duas maneiras, e as duas aconteceram:
#   1. o HTML entra no prompt que vai para a IA (feio, e polui o contexto e os
#      documentos que os passos geram);
#   2. a Caixa de entrada NUNCA e distribuida: os rotulos [1.2] que o script procura
#      no INICIO DA LINHA vem embrulhados em <p>, entao o split nao acha nada, o
#      cartao 1.2 fica com o texto-guia e o passo pede de novo a mesma resposta —
#      o aluno entra num LOOP (Eduardo, 2026-07-14).
# Tudo o que se le de um cartao passa por aqui ANTES de ser parseado.
# ─────────────────────────────────────────────────────────────────────────────
_QUEBRA = re.compile(r"(?i)</(p|div|li|h[1-6]|tr)\s*>|<br\s*/?>|</?(p|div|ul|ol|tr)[^>]*>")
_TAG = re.compile(r"<[^>]+>")


def texto_do_cartao(desc):
    """A descricao de um cartao (HTML do editor OU texto puro) -> TEXTO limpo.

    Idempotente: um cartao que o script escreveu (texto puro, sem tag) sai igual —
    e' o que mantem a comparacao com o texto-de-fabrica funcionando.
    """
    s = desc or ""
    if "<" not in s:
        return s.strip()
    s = _QUEBRA.sub("\n", s)
    s = _TAG.sub("", s)                       # o que sobrou de tag (b, i, span, a...)
    s = _html.unescape(s)                     # &amp; &nbsp; &#39; ...
    s = s.replace("\xa0", " ")
    s = re.sub(r"[ \t]+\n", "\n", s)
    s = re.sub(r"\n{3,}", "\n\n", s)
    return s.strip()


def _sem_acento(s):
    return "".join(c for c in unicodedata.normalize("NFD", (s or ""))
                   if unicodedata.category(c) != "Mn").lower().strip()


# MARKDOWN: a IA responde em **negrito** com muito mais frequencia do que se imagina — e
# um negrito no COMECO da linha destroi todos os parsers deste modulo, porque a linha
# deixa de comecar pelo rotulo que eles procuram.
#
# O caso que revelou isto (Eduardo, quadro 324, 05/08/2026): a resposta CONSOLIDADA (a
# tecla C, onde uma terceira IA reescreve o texto — e reescrever quase sempre vira
# markdown) veio com "**OBJECTIVE 1:** ..." e "**KR 1.1:** ...". O resultado no cartao 1.2:
#   · "OBJECTIVE:" VAZIO — `^\s*OBJECTIVE` nunca casou, porque a linha comeca por '*';
#   · os 13 KRs de TRES objetivos empilhados num objetivo so', renumerados KR 1.1..1.13,
#     porque sem objetivo aberto todo KR cai num bloco anonimo;
#   · e o proprio lixo "*KR 1.1:**" dentro do texto de cada KR — o '**' casou como
#     MARCADOR DE ITEM ([•\-\*]) e o resto da linha virou o nome do Key Result.
#
# A enfase do markdown nunca e' DADO neste curso (o formato pedido e' 'campo: valor'),
# entao ela cai aqui, no unico lugar por onde toda linha de toda resposta passa —
# okr(), itens(), secoes() e backlog() se consertam de uma vez. Só o PAR duplo (** ou __)
# e' removido: o asterisco SOZINHO e' marcador de item ('* entrega') e nao se toca nele.
_MD_FORTE = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")


def _sem_markdown(linha):
    """A linha sem os marcadores de negrito do markdown."""
    s = linha or ""
    for _ in range(4):                     # pares repetidos na mesma linha
        novo = _MD_FORTE.sub(lambda m: m.group(1) or m.group(2) or "", s)
        if novo == s:
            break
        s = novo
    # Par aberto e nunca fechado ("**KR 1.1:" sozinho): o marcador solto tambem sai, senao
    # ele continua na frente do rotulo e o parser continua cego.
    return s.replace("**", "").replace("__", "")


def _linhas(texto):
    return [_sem_markdown(l.rstrip()) for l in (texto or "").splitlines()]


def _e_item(linha):
    return bool(re.match(r"^\s*[•\-\*•]\s+\S", linha or ""))


def _texto_item(linha):
    return re.sub(r"^\s*[•\-\*•]\s+", "", linha).strip()


def _campo(linha):
    """'  requisito: X' -> ('requisito', 'X'). Senao, None.

    O nome do campo passa pelo vocabulario: 'probability:' entra aqui e sai
    'probabilidade', que e' como o resto do curso o chama. E' o que deixa o mesmo
    parser ler a resposta de uma IA em portugues e de uma IA em ingles.
    """
    m = re.match(r"^\s+([A-Za-zÀ-ÿ ]{2,40}):\s*(.+)$", linha or "")
    if not m:
        return None
    return vocabulario.campo(_sem_acento(m.group(1))), m.group(2).strip()


def _campos_da_linha(linha):
    """Uma linha de campos (indentada OU à esquerda) -> lista de (campo, valor).

    Aceita VARIOS campos numa linha so', separados por ' · ' ou ' | '
    ('  poder: alto · interesse: baixo' -> [('poder','alto'), ('interesse','baixo')]).
    So' separa quando o proximo segmento tambem e' 'palavra: ...', para nao quebrar
    um valor que por acaso tenha um ' · ' no meio.

    NAO exige indentacao. Exigir era um bug silencioso: os campos de continuacao de um
    item ('  probabilidade: alta · impacto: alto', '  resposta: ...') so' eram lidos se
    viessem recuados — mas o editor rico do ProjectAdm ENGOLE o recuo das linhas (cada
    linha vira <p>...</p> sem espaco a esquerda) e as IAs quase nunca indentam. Resultado:
    o cartao 4.4 (riscos) voltava com 'probabilidade:  · impacto: / resposta:' vazios,
    porque riscos() nao achava os campos (Eduardo, board 270, 2026-07-18). Quem distingue
    um campo de continuacao de uma linha de prosa nao e o recuo — e o proprio 'palavra:'
    (2 a 40 letras + dois-pontos), que _campo() ja valida. Uma linha de prosa sem esse
    padrao continua sendo ignorada."""
    m = re.match(r"^(\s*)(\S.*)$", linha or "")
    if not m:
        return []
    corpo = m.group(2)
    segmentos = re.split(r"\s+[·|]\s+(?=[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ ]{1,39}:\s)", corpo)
    out = []
    for seg in segmentos:
        c = _campo("  " + seg)
        if c:
            out.append(c)
    return out


def itens(texto):
    """Texto -> lista de dicts. Cada '• ...' vira {'_': titulo, campo: valor, ...}.

    Campos podem vir em linhas indentadas ('  requisito: X') OU na mesma linha,
    separados por '·' ou '|' ('• Fulano | poder: alto · interesse: baixo').
    """
    saida = []
    for linha in _linhas(texto):
        if _e_item(linha):
            bruto = _texto_item(linha)
            item = {}
            # campos inline apos '|' ou '·'
            partes = re.split(r"\s+[|·]\s+", bruto)
            item["_"] = partes[0].strip()
            for p in partes[1:]:
                if ":" in p:
                    k, v = p.split(":", 1)
                    item[_sem_acento(k)] = v.strip()
            saida.append(item)
        elif saida:
            for k, v in _campos_da_linha(linha):
                saida[-1][k] = v
    return saida


def secoes(texto, nomes):
    """Divide um texto em secoes por cabecalhos ('ONDE:', 'MARCOS:', ...).

    nomes = lista de rotulos aceitos. Devolve {rotulo_sem_acento: texto_da_secao}.
    Texto antes do 1o cabecalho vai para '_'.
    """
    alvo = {_sem_acento(n): n for n in nomes}
    # O mesmo cabecalho em ingles abre a mesma secao: 'RISKS:' -> a secao 'RISCOS'
    # que o script pediu. So' entram os aliases cujo canonico esta em `nomes` — um
    # cabecalho que este passo nao espera continua sendo texto comum.
    for ali, canon in vocabulario.SECOES.items():
        k = _sem_acento(canon)
        if k in alvo:
            alvo.setdefault(_sem_acento(ali), alvo[k])
    atual, buf, out = "_", [], {}
    for linha in _linhas(texto):
        chave = _sem_acento(re.sub(r"[:：].*$", "", linha)) if ":" in linha or linha.strip() else ""
        cabecalho = chave in alvo and len(linha.strip()) < 60
        if cabecalho:
            out[atual] = "\n".join(buf).strip()
            # A chave de saida e' sempre o rotulo CANONICO que o script pediu: um
            # cabecalho 'RISKS:' devolve a secao em out['riscos'], que e' onde quem
            # chamou vai procurar. Sem isto o alias abre a secao e some com ela.
            atual, buf = _sem_acento(alvo[chave]), []
            resto = linha.split(":", 1)[1].strip() if ":" in linha else ""
            if resto:
                buf.append(resto)
        else:
            buf.append(linha)
    out[atual] = "\n".join(buf).strip()
    return out


# ── Parsers por cartao ───────────────────────────────────────────────────────

# Um Key Result traz sempre um CAMPO 'meta:' (ou 'meta provisória:'/'meta preliminar:')
# ou 'prazo:' — com os dois-pontos. É o que distingue um KR de uma linha de raciocínio
# que só CITA a palavra "meta" ("60% como meta realista"). Ver okr().
_CAMPO_META_KR = re.compile(
    r"\b(meta(\s+provis[oó]ria|\s+preliminar)?|prazo|"
    r"target(\s+provisional|\s+preliminary)?|deadline)\s*:", re.I)


def okr(texto):
    """OKR com N objetivos, cada um com os seus Key Results.

        OBJETIVO 1: ...
        KR 1.1: ... — meta: ... · prazo: ... · responsável: ...
        KR 1.2: ...
        OBJETIVO 2: ...
        KR 2.1: ...

    Um projeto raramente tem um objetivo só. Cada 'OBJETIVO' abre um bloco; os KRs
    que vierem abaixo dele são DELE. KR que apareça antes de qualquer objetivo cai
    num objetivo sem nome (tolerância: o aluno colou só os KRs).

    Devolve {"objetivos": [{"objetivo": str, "key_results": [ {kr, meta,
    meta_provisoria, prazo, responsavel} ]}, ...]} — ou None se não houver nada.
    'meta provisória: 50' vale como meta e marca meta_provisoria=True (ondas sucessivas).
    """
    objetivos = []

    def _abrir(nome):
        objetivos.append({"objetivo": nome, "key_results": []})
        return objetivos[-1]

    atual = None
    for linha in _linhas(texto):
        # O prompt (prompts.py) separa o OKR do bloco de raciocínio "COMO CHEGUEI ÀS
        # METAS PROVISÓRIAS" com uma linha-régua '---'. Dali para baixo é explicação,
        # não Key Result — paramos de ler. Sem isto, cada '• para o KR X: ... meta ...'
        # do raciocínio virava um KR fantasma no cartão 1.2 (Eduardo, board 270,
        # 2026-07-18: "criou mais dois KRs 2.4 e 2.5 de forma errada").
        if re.match(r"^\s*[-—–]{3,}\s*$", linha):
            break
        m = re.match(rf"^\s*{vocabulario.OBJETIVO_RE}\s*\d*\s*[:\-—]\s*(.+)$", linha, re.I)
        if m:
            atual = _abrir(m.group(1).strip())
            continue
        # DUAS formas de linha, com exigências diferentes:
        #
        # (a) marcador EXPLÍCITO — "KR 1.2: ..." — é um Key Result SEMPRE, traga ou não o
        #     campo 'meta:'. Até 2026-08-08 exigíamos o campo aqui também, e o resultado
        #     era o pior possível: a IA seguia o conselho do PRÓPRIO prompt ("escreva a meta
        #     como 'de X para Y'"), devolvia "KR 2.1: aumentar a abertura de 15% para 25%"
        #     — sem o campo — e a linha era descartada EM SILÊNCIO. Eduardo colou 11 KRs no
        #     quadro 326 e só 4 chegaram ao cartão: o Objetivo 2 ficou sem nenhum, e o
        #     prompt do elo seguinte foi planejar entregas para um OKR mutilado. Agora o KR
        #     entra com meta vazia e a conferência o acusa ("falta: sem meta") — o curso
        #     AVISA, não descarta.
        #
        # (b) marcador solto — "• ..." / "- ..." — continua exigindo o CAMPO 'meta:'/'prazo:'
        #     (com dois-pontos). É aqui que mora a ambiguidade que a regra antiga protegia:
        #     uma linha de raciocínio como "60% como meta realista..." menciona "meta" mas
        #     não é um Key Result. Um bullet sem campo é prosa; "KR 1.2:" nunca é.
        m = re.match(r"^\s*(?:KRs?|key\s*results?)\s*[\d.]*\s*[:\-—]\s*(.+)$", linha, re.I)
        if not m:
            m = re.match(r"^\s*(?:(?:KRs?|key\s*results?)\s*[\d.]*|[•\-\*])\s*[:\-—]?\s*(.+)$",
                         linha, re.I)
            if m and not _CAMPO_META_KR.search(linha):
                m = None
        if m:
            partes = re.split(r"\s+[—·|]\s+", m.group(1))
            campos = {}
            for p in partes[1:]:
                if ":" in p:
                    k, v = p.split(":", 1)
                    # Pelo vocabulario, como todo campo do curso: 'target:' e' 'meta:',
                    # 'acceptance:' e' 'aceitacao:'. Sem isto o campo escrito em ingles
                    # chegava ao cartao VAZIO — e a conferencia acusava falta de um
                    # dado que a IA tinha respondido.
                    campos[vocabulario.campo(_sem_acento(k).strip())] = v.strip()
            if atual is None:
                atual = _abrir("")
            # "meta provisoria: 50 usuarios" E uma meta — a das ondas seguintes. Em
            # planejamento por ondas sucessivas o numero certo so existe depois que a
            # onda anterior acontece, e ate la o KR vale com a meta provisoria. Sem
            # isto ela se perdia e o KR aparecia "sem meta" (Eduardo, board 270).
            provisoria = campos.get("meta provisoria") or campos.get("meta preliminar")
            atual["key_results"].append({
                "kr": partes[0].strip(),
                "meta": campos.get("meta") or provisoria or "",
                "meta_provisoria": bool(provisoria and not campos.get("meta")),
                "prazo": campos.get("prazo", ""),
                "responsavel": campos.get("responsavel", "")})
    objetivos = [o for o in objetivos if o["objetivo"] or o["key_results"]]
    return {"objetivos": objetivos} if objetivos else None


def objetivos_do_okr(d):
    """Os objetivos de um OKR, aceitando a forma nova e a antiga (1 objetivo só).

    Existe porque um contexto.json gravado por uma versão anterior do curso tem
    {"objetivo": ..., "key_results": [...]} — e o aluno não pode perder o passo 1
    que ele já fez por causa de uma mudança nossa.
    """
    if not d:
        return []
    if d.get("objetivos"):
        return d["objetivos"]
    if d.get("objetivo") or d.get("key_results"):
        return [{"objetivo": d.get("objetivo", ""),
                 "key_results": d.get("key_results") or []}]
    return []


# "(serve ao ...)" e formato ANTIGO/errado: o KR devia vir como um CAMPO 'serve ao KR:'
# (com dois-pontos). Quando ele vinha entre parenteses dentro do nome, o parser nao o
# reconhecia como KR — ele sobrava no nome e um '(serve ao )' vazio se ACUMULAVA a cada
# re-execucao do Passo 1 (o cartao 2.1 era reescrito a partir do proprio texto ja
# formatado). Aqui a entrega se AUTO-CURA na leitura: recupera o 1o KR e limpa o nome.
_PAREN_SERVE_AO = re.compile(r"\((?:serve ao|serves)[^)]*\)", re.I)

# Os CAMPOS de uma entrega (serve ao KR, requisito, aceitacao). Uma entrega e' um NOME
# com estes campos abaixo, como linhas de continuacao ('  serve ao KR: ...'). O bug do
# board 266 (Eduardo, 2026-07-24): o aluno corrigiu o 2.1 mas cada campo veio com um '•'
# na frente ('• serve ao KR: KR 1.1'), entao itens() leu CADA campo como uma ENTREGA nova
# — 9 entregas viraram 36 entregas-lixo, todas sem KR. Aqui a leitura se AUTO-CURA: uma
# linha-item cujo texto e', na verdade, um CAMPO conhecido nao abre uma entrega nova —
# ela e' rebaixada a linha de continuacao da entrega de cima. Assim re-rodar o Passo 1
# regrava o 2.1 limpo (a promessa de auto-cura que o 01_iniciar ja anunciava).
_ENTREGA_CAMPO = re.compile(
    r"^(serve ao kr|kr|requisito|criterio de aceitacao|aceitacao|"
    r"serves kr|requirement|acceptance(\s+criteri(on|a))?)\s*:", re.I)
_MD_BOLD = re.compile(r"\*\*|__")


def _normalizar_entregas(texto):
    """Rebaixa uma linha-item ('• ...') a linha de continuacao quando o seu texto e', na
    verdade, um CAMPO da entrega ('serve ao KR:', 'requisito:', 'aceitacao:'). Sem isto,
    um 2.1 com '•' em toda linha vira uma entrega por campo (o lixo do board 266)."""
    linhas = []
    for l in (texto or "").splitlines():
        if _e_item(l) and _ENTREGA_CAMPO.match(_sem_acento(_texto_item(l))):
            l = "    " + _texto_item(l)      # tira o '•': agora e' campo da entrega acima
        linhas.append(l)
    return "\n".join(linhas)


def entregas(texto):
    out = []
    for i in itens(_normalizar_entregas(texto)):
        nome = i.get("_", "")
        kr = i.get("serve ao kr", i.get("kr", ""))
        if not kr:
            m = re.search(r"\((?:serve ao|serves)\s+([^)]*[A-Za-z0-9][^)]*)\)", nome, re.I)
            if m:
                kr = m.group(1).strip()
        nome = _MD_BOLD.sub("", nome)                       # '**Entrega**' -> 'Entrega'
        nome = re.sub(r"\s{2,}", " ", _PAREN_SERVE_AO.sub("", nome)).strip()
        if not nome:
            continue                                        # linha de campo solta: ignora
        out.append({"nome": nome, "kr": kr,
                    "requisito": i.get("requisito", ""),
                    "aceitacao": i.get("criterio de aceitacao", i.get("aceitacao", ""))})
    return out


_GRUPO = r"cliente|equipe|demais|client|customer|team|others?"
_GRUPO_FIM = re.compile(rf"\[({_GRUPO})\]\s*$", re.I)


def _normalizar_partes(texto):
    """Recupera uma parte cujo nome veio SEM o '• ' (o aluno adicionou uma linha à mão
    e esqueceu o marcador). Uma linha que TERMINA com o grupo entre colchetes
    ([equipe]/[cliente]/[demais]) e não é uma linha de campo ('papel: ...') é, sem
    dúvida, o cabeçalho de uma parte — não a continuação da parte de cima. Sem isto a
    linha era engolida e os campos dela (papel/espera/poder) grudavam na parte anterior,
    que ainda por cima ficava corrompida — e a parte nova nunca era criada (Eduardo,
    board 270, 2026-07-20: 'a equipe de suporte do ProjectAdm não era reconhecida')."""
    linhas = []
    for l in (texto or "").splitlines():
        if (l.strip() and not _e_item(l) and _GRUPO_FIM.search(l)
                and not _campos_da_linha(l)):
            l = "• " + l.strip()
        linhas.append(l)
    return "\n".join(linhas)


def partes(texto):
    out = []
    for i in itens(_normalizar_partes(texto)):
        titulo = i.get("_", "")
        grupo = ""
        m = re.search(rf"\[({_GRUPO})\]", titulo, re.I)
        if m:
            grupo = vocabulario.valor(m.group(1), vocabulario.GRUPO_PARTE).lower()
            titulo = re.sub(r"\s*\[[^\]]+\]", "", titulo).strip()
        out.append({"nome": titulo,
                    "grupo": grupo or vocabulario.valor(i.get("grupo", "demais"),
                                                        vocabulario.GRUPO_PARTE),
                    "papel": i.get("papel", ""), "espera": i.get("espera", ""),
                    "poder": i.get("poder", ""), "interesse": i.get("interesse", "")})
    return out


def marcos(texto):
    out = []
    for i in itens(texto):
        bruto = i.get("_", "")
        partes_ = re.split(r"\s+[—\-–]\s+", bruto, maxsplit=1)
        out.append({"marco": partes_[0].strip(),
                    "quando": (partes_[1].strip() if len(partes_) > 1
                               else i.get("quando", ""))})
    return out


def simples(texto):
    """Lista de strings (restricoes, premissas, definicao de pronto...)."""
    return [i.get("_", "") for i in itens(texto) if i.get("_")]


def riscos(texto):
    out = []
    for i in itens(texto):
        out.append({"risco": i.get("_", ""),
                    "tipo": i.get("tipo", ""),
                    "probabilidade": i.get("probabilidade", ""),
                    "impacto": i.get("impacto", i.get("gravidade", "")),
                    "gravidade": i.get("gravidade", i.get("impacto", "")),
                    "resposta": i.get("resposta", ""),
                    "dono": i.get("dono", i.get("item_backlog", ""))})
    return out


_FASE_LINE = re.compile(r"(?i)^\s*(?:fase|phase)\b\s*\d*\s*[—\-–:.)]+\s*(.+?)\s*$")
_ENTREGA_LINE = re.compile(
    r"(?i)^\s*(?:entrega|deliverable)\b\s*\d*\s*[—\-–:.)]+\s*(.+?)\s*$")
_ACEIT_INLINE = re.compile(
    r"(?i)\s*[—\-–|·]\s*(?:(?:crit[eé]rio\s+de\s+)?aceita[cç][aã]o|"
    r"acceptance(?:\s+criteri(?:on|a))?)\s*:\s*(.+)$")


def eap(texto):
    """EAP em ate 3 niveis: FASE (opcional) > ENTREGA > pacote de trabalho.

    Formato tolerante (o que o prompt do Passo 2 pede):

        FASE: Pre-Lancamento            <- OPCIONAL: some se o projeto nao tem fases
        ENTREGA: Pagina de Inscricao
        • Formulario configurado — aceitacao: ...
        • Texto de convocacao — aceitacao: ...
        ENTREGA: Lista de Inscritos
        • ...

    CARRY-FORWARD: cada pacote HERDA a FASE e a ENTREGA correntes — a IA nao precisa
    repeti-las em cada pacote. Isso conserta o bug em que os pacotes 2+ de uma mesma
    entrega 'soltavam' do pai porque a IA rotulava so o primeiro (board 270, 19/07).
    Tambem aceita o formato antigo, com 'entrega:' como campo de cada pacote: um rotulo
    que aparece DEPOIS de um pacote ainda sem entrega o preenche (backfill), e um que
    aparece antes de novos pacotes vira o pai deles.

    Devolve [{'fase','entrega','pacote','aceitacao'}, ...] na ordem lida.
    """
    out = []
    fase = ""
    entrega = ""
    atual = None
    for linha in _linhas(texto):
        s = linha.strip()
        if not s:
            continue
        if _e_item(linha):
            corpo = _texto_item(linha)
            partes = re.split(r"\s+[|·]\s+", corpo)
            pacote = partes[0]
            campos = {}
            for p in partes[1:]:
                if ":" in p:
                    k, v = p.split(":", 1)
                    # Pelo vocabulario, como todo campo do curso: 'target:' e' 'meta:',
                    # 'acceptance:' e' 'aceitacao:'. Sem isto o campo escrito em ingles
                    # chegava ao cartao VAZIO — e a conferencia acusava falta de um
                    # dado que a IA tinha respondido.
                    campos[vocabulario.campo(_sem_acento(k).strip())] = v.strip()
            ma = _ACEIT_INLINE.search(pacote)
            if ma:
                campos.setdefault("aceitacao", ma.group(1).strip())
                pacote = pacote[:ma.start()].strip()
            if campos.get("fase"):
                fase = campos["fase"]
            if campos.get("entrega"):
                entrega = campos["entrega"]
            atual = {"pacote": pacote.strip(), "fase": fase, "entrega": entrega,
                     "aceitacao": campos.get("aceitacao",
                                             campos.get("criterio de aceitacao", ""))}
            out.append(atual)
            continue
        mf = _FASE_LINE.match(s)
        if mf:
            fase = mf.group(1).strip()
            if atual is not None and not atual["fase"]:
                atual["fase"] = fase
            entrega = ""            # uma fase nova abre novas entregas
            continue
        me = _ENTREGA_LINE.match(s)
        if me:
            entrega = me.group(1).strip()
            if atual is not None and not atual["entrega"]:
                atual["entrega"] = entrega
            continue
        for k, v in _campos_da_linha(s):
            if k == "fase":
                fase = v
                if atual is not None and not atual["fase"]:
                    atual["fase"] = v
            elif k == "entrega":
                entrega = v
                if atual is not None and not atual["entrega"]:
                    atual["entrega"] = v
            elif k in ("aceitacao", "criterio de aceitacao"):
                if atual is not None and not atual["aceitacao"]:
                    atual["aceitacao"] = v
    return out


# A META DO PRODUTO chega como UMA linha rotulada no topo do bloco [P2] agil. Nao
# usamos secoes() aqui de proposito: ela so' reconhece um cabecalho com menos de 60
# caracteres, e esta linha carrega a frase inteira ("META DO PRODUTO: <frase>"), que
# quase sempre passa disso — a secao nunca abriria.
_META_PRODUTO = re.compile(
    r"^\s*[•\-\*]?\s*(?:\*\*)?\s*(?:meta\s+do\s+produto|product\s+goal)"
    r"\s*(?:\*\*)?\s*[:\-—–]\s*(.+?)\s*$", re.I)


def meta_produto(texto):
    """A META DO PRODUTO do bloco [P2] agil — o compromisso do Product Backlog.

    Pelo Guia Scrum cada artefato tem um compromisso: Product Backlog -> Meta do
    Produto, Sprint Backlog -> Meta da Sprint, Incremento -> Definicao de Pronto. O
    curso tinha os DOIS ULTIMOS e nao tinha o primeiro (Eduardo, 2026-08-09) — sem
    ela o backlog e uma lista de desejos, porque nao ha contra o que ordenar.

    Devolve '' quando a IA nao a escreveu: quem chama AVISA, nunca inventa uma meta.
    """
    for linha in _linhas(texto):
        m = _META_PRODUTO.match(linha)
        if not m:
            continue
        valor = m.group(1).strip().strip('"“”').strip()
        # '{o estado do produto...}' e' o placeholder do FORMATO, devolvido cru por uma
        # IA que nao preencheu. Vale o mesmo que nao ter respondido.
        if valor and not valor.startswith("{"):
            return valor
    return ""


def backlog(texto):
    """'• B1 | <item de valor> ' + campos aceitacao/pontos.

    O CÓDIGO (B1, B2…) é a alça ESTÁVEL do item — é por ele que os próximos passos
    (riscos, Sprints, execução) se referem ao item, sem repetir o título inteiro. Nós
    o atribuímos pela ORDEM de leitura (que é a ordem de prioridade que a IA entrega:
    B1 = o mais prioritário) e IGNORAMOS qualquer prefixo que a IA tenha posto
    (B1:, P1 |, 1 -, B1 ·) — assim o código nunca sai com buraco nem repetido.

    O prefixo é retirado de CADA linha-item ANTES de tokenizar: se um ' | ' ou ' · '
    do prefixo chegasse ao itens(), seria lido como separador de CAMPO e comeria o nome
    do item (o nome viraria só 'B1'). Só retira quando há um separador de verdade depois
    do código, então um nome que por acaso começa com número ('3 refeições') fica intacto.
    """
    _pref = re.compile(r"^(\s*[•\-\*]\s+)[BP]?\d+\s*[|·\-—:)]\s+", re.I)
    limpo = "\n".join(_pref.sub(r"\1", l) if _e_item(l) else l for l in _linhas(texto))
    out = []
    for n, i in enumerate(itens(limpo), 1):
        nome = (i.get("_") or "").strip()
        pts = re.sub(r"[^\d]", "", i.get("pontos", "") or "") or "0"
        out.append({"codigo": f"B{n}", "historia": nome, "prioridade": n,
                    "pontos": int(pts),
                    "aceitacao": i.get("aceitacao", i.get("criterio de aceitacao", ""))})
    return out
