"""
Agile Together — Modulo auxiliar para todos os scripts do curso.

Carrega configuracao de .env, expoe cliente da API do ProjectAdm e funcoes
prontas para os scripts dos capitulos. Nenhum script precisa repetir token,
board id ou IDs de listas.

Uso:
    from agile_together import cfg, save_cfg, get_cards, create_card, \
                                move_card, complete_card, register_event, \
                                compute_krs

Toda funcao espera que .env esteja preenchido com TOKEN e BOARD_ID.
"""
import datetime
import json
import os
import re
import sys

import env as _env
import pa_client as _pa
from idioma import T, Tf

# ── Configuracao de output (Windows console aceita UTF-8) ────────────────────
try:
    sys.stdout.reconfigure(encoding='utf-8', errors='replace')
except Exception:
    pass

# O .env e o HTTP sao do KIT (env.py / pa_client.py). Mantemos os nomes antigos
# apontando para la' para nao quebrar quem os importava.
ENV_PATH = _env.ENV_PATH


# ─────────────────────────────────────────────────────────────────────────────
# Configuracao (.env)
# ─────────────────────────────────────────────────────────────────────────────
def cfg():
    """O .env lido pelo KIT (env.py) + os casts numericos que o Agile Together usa.

    A LEITURA e a GRAVACAO do .env sao do kit desde 30/08/2026 — antes este modulo
    tinha a sua propria copia das duas, e era ela que fazia do curso um fork. O que
    fica aqui e o que e' DESTE curso: os campos que precisam ser inteiros e os dois
    padroes de cadencia (TOTAL_SPRINTS, DAILIES_POR_SPRINT).
    """
    data = dict(_env.cfg())
    # Casts numericos ROBUSTOS: pegam o primeiro inteiro do valor, tolerando
    # comentario inline ou texto apos o numero (ex.: "DAILIES_POR_SPRINT=9 #
    # 2 semanas"). Sem isso, um '#' depois do numero fazia int() lancar ValueError
    # e a chave caia no default SILENCIOSAMENTE.
    for key in ['BOARD_ID', 'LIST_PRODUCT_BACKLOG', 'LIST_SPRINT_BACKLOG',
                'LIST_DAILY_SCRUM', 'LIST_CONCLUIDO', 'LIST_EVENTOS_SCRUM',
                'LIST_REFERENCIA', 'SPRINT_ATUAL',
                'TOTAL_SPRINTS', 'DAILIES_POR_SPRINT']:
        if key in data:
            m = re.match(r'\s*(-?\d+)', str(data[key]))
            data[key] = int(m.group(1)) if m else 0
    # TOTAL_SPRINTS: padrao 3 (curso classico de 3 Sprints). Aceita qualquer N >= 1.
    if not data.get('TOTAL_SPRINTS') or data['TOTAL_SPRINTS'] < 1:
        data['TOTAL_SPRINTS'] = 3
    # DAILIES_POR_SPRINT: cadencia FIXA da Sprint. Padrao 4 = Sprint de 1 semana
    # (Planning + 4 Dailies + Review + Retro = 7 eventos). Eventos/Sprint = K + 3.
    if not data.get('DAILIES_POR_SPRINT') or data['DAILIES_POR_SPRINT'] < 1:
        data['DAILIES_POR_SPRINT'] = 4
    return data


def save_cfg(updates):
    """Grava chaves no .env — implementacao do kit (env.save_cfg)."""
    return _env.save_cfg(updates)


def _require(c, *keys):
    missing = [k for k in keys if not c.get(k) or c[k] in ('SEU_TOKEN_AQUI', 0, '0')]
    if missing:
        raise SystemExit(
            Tf("ERRO: preencha {0} no arquivo .env antes de rodar.", ', '.join(missing))
        )


# ─────────────────────────────────────────────────────────────────────────────
# Cliente HTTP da API
# ─────────────────────────────────────────────────────────────────────────────
def _headers(token):
    return {"X-API-Key": token, "Content-Type": "application/json"}


def api(method, path, data=None):
    """Chama a API do ProjectAdm — implementacao do kit (pa_client.api).

    O kit ja' traz o que este modulo nao tinha: repeticao com espera, mensagem de
    erro legivel para o aluno (em vez do traceback do urllib) e cache das listas.
    """
    return _pa.api(method, path, data)


# ─────────────────────────────────────────────────────────────────────────────
# Operacoes de alto nivel (usadas pelos scripts de cada capitulo)
# ─────────────────────────────────────────────────────────────────────────────
LIST_KEYS = {
    'product_backlog': 'LIST_PRODUCT_BACKLOG',
    'sprint_backlog':  'LIST_SPRINT_BACKLOG',
    'daily_scrum':     'LIST_DAILY_SCRUM',
    'concluido':       'LIST_CONCLUIDO',
    'eventos':         'LIST_EVENTOS_SCRUM',
    'referencia':      'LIST_REFERENCIA',
}


def list_id(name):
    """Devolve o ID da lista pelo apelido ('product_backlog', 'sprint_backlog', ...)."""
    c = cfg()
    key = LIST_KEYS.get(name)
    if not key:
        raise ValueError(Tf("Lista desconhecida: {0}. Use uma de {1}", name, list(LIST_KEYS)))
    _require(c, key)
    return c[key]


def list_cards(lista_id):
    """TODOS os cartoes de uma lista (por ID), seguindo a paginacao.

    A API do ProjectAdm pagina (por_pagina padrao 20, max 100). Sem isso,
    listas com >20 cards eram truncadas — causava KR errado e o
    configurar_board recriar cards (duplicatas) que nao "via".
    """
    todos = []
    pagina = 1
    while True:
        r = api("GET",
                f"/listas/{lista_id}/cartoes?pagina={pagina}&por_pagina=100")
        lote = r.get("dados", []) or []
        todos.extend(lote)
        if len(lote) < 100:           # ultima pagina
            break
        pagina += 1
        if pagina > 100:              # salvaguarda anti-loop
            break
    return todos


def get_cards(name):
    """Lista TODOS os cartoes de uma lista pelo apelido (paginado)."""
    return list_cards(list_id(name))


def create_card(list_name, titulo, descricao=""):
    """Cria cartao na lista e devolve o id.

    O titulo e a descricao passam pelos capadores do KIT (`pa_client`), como em todos
    os outros cursos da casa. Este modulo monta o POST na mao — heranca de antes do kit
    — e por isso escrevia CRU: a API do ProjectAdm recusa titulo acima de 250 caracteres
    com um 422 seco ("Invalid data"), sem dizer qual campo nem qual limite.

    Foi assim que o passo 2 morreu no quadro 358 (Eduardo, 31/08/2026): a Meta do Produto
    consolidada pela IA tinha 286 caracteres, o cartao ficaria com 303, e o traceback caia
    no urllib — longe do texto que o causou. Capar aqui protege TODOS os passos de uma vez
    (a Meta da Sprint, os itens do backlog, as tarefas da Daily)."""
    r = api("POST", "/cartoes", {
        "lista_id": list_id(list_name),
        "titulo": _pa.capar_titulo(titulo),
        "descricao": _pa.capar_descricao(descricao),
    })
    return r["dados"]["id"]


def update_card(card_id, titulo=None, descricao=None, prioridade=None,
                data_inicio=None, data_vencimento=None, concluido=None,
                data_conclusao=None):
    """Atualiza campos de um cartao. Argumentos None sao ignorados.

    data_inicio / data_vencimento: string 'YYYY-MM-DD'.
    data_conclusao: string date-time ISO ('YYYY-MM-DDTHH:MM:SS').
    concluido: bool (status concluido do card).
    """
    payload = {}
    if titulo is not None:
        payload["titulo"] = _pa.capar_titulo(titulo)          # ver create_card, acima
    if descricao is not None:
        payload["descricao"] = _pa.capar_descricao(descricao)
    if prioridade is not None:
        payload["prioridade"] = prioridade
    if data_inicio is not None:
        payload["data_inicio"] = data_inicio
    if data_vencimento is not None:
        payload["data_vencimento"] = data_vencimento
    if data_conclusao is not None:
        payload["data_conclusao"] = data_conclusao
    if concluido is not None:
        payload["concluido"] = concluido
    if not payload:
        return None
    return api("PUT", f"/cartoes/{card_id}", payload)


def _stamp_conclusao(card_id):
    """Marca o card como concluido e grava a data de conclusao — UMA vez.

    IDEMPOTENTE: se o card ja tem data de conclusao (ex.: concluido antes,
    apenas sendo realocado para a lista Concluido), PRESERVA a data
    original — nunca reescreve com 'hoje' (isso jogaria o card para a
    Sprint errada). So carimba quando ainda nao ha data de conclusao.
    Grava data_conclusao (date-time) + data_vencimento (date, fallback).
    Nao mexe na descricao (editavel por qualquer um).
    """
    try:
        card = api("GET", f"/cartoes/{card_id}").get("dados", {}) or {}
    except Exception:
        card = {}
    if _completion_date(card) is not None:
        # Ja tem data real — apenas garante o status, sem tocar nas datas.
        if not _is_concluido(card):
            update_card(card_id, concluido=True)
        return
    agora = datetime.datetime.now()
    update_card(card_id,
                data_conclusao=agora.isoformat(timespec='seconds'),
                data_vencimento=agora.date().isoformat(),
                concluido=True)


def delete_card(card_id):
    """Apaga um cartao do board (DELETE /cartoes/{id}).

    Requer que o TOKEN do .env tenha permissao de exclusao — alunos tem
    permissao total no proprio board. Usado por diagnostico_referencia.py
    e configurar_board.py para limpar cards orfaos/duplicados na Referencia.
    Devolve True se apagou, False se falhou.
    """
    try:
        api("DELETE", f"/cartoes/{card_id}")
        return True
    except Exception as e:
        print(Tf("  [AVISO] nao consegui apagar card {0}: {1}", card_id, e))
        return False


def move_card(card_id, dest_name):
    """Move um cartao para outra lista pelo apelido.

    Ao mover para 'concluido', tambem marca status concluido e grava a
    data de conclusao (centralizado aqui — cobre TODOS os scripts que
    concluem cards: complete_card, register_event, daily/review/etc.).
    """
    # ProjectAdm: mover usa endpoint dedicado PUT /cartoes/{id}/mover
    r = api("PUT", f"/cartoes/{card_id}/mover", {"lista_id": list_id(dest_name)})
    if dest_name == 'concluido':
        _stamp_conclusao(card_id)
    return r


def complete_card(card_id):
    """Move o cartao para a lista Concluido (status + data de conclusao)."""
    return move_card(card_id, 'concluido')


def find_card(ref, where=None):
    """Procura um cartao por ID (int ou string numerica) ou trecho do titulo (str).

    Tres formas equivalentes para o mesmo cartao 776:
        find_card(776)             # ID direto (mais preciso)
        find_card("776")           # tambem trata como ID
        find_card("configurar X")  # trecho do titulo nas listas ativas

    Retorna (card_dict, nome_da_lista) se achar, ou (None, None).
    Quando achado por ID, nome_da_lista vem como None (busca direta).
    """
    # Caso 1: ref e numero (ou string numerica) -> tenta GET direto por ID
    try:
        card_id = int(ref)
        try:
            card = api("GET", f"/cartoes/{card_id}").get("dados")
            if card:
                return card, None
        except Exception:
            # ID nao existe ou API erro — se ref era int puro, encerra
            if isinstance(ref, int):
                return None, None
            # se era string numerica, ainda pode ser trecho — cai para busca
    except (ValueError, TypeError):
        pass

    # Caso 2: trecho do titulo nas listas ativas
    where = where or ('product_backlog', 'sprint_backlog', 'daily_scrum')
    trecho_lower = str(ref).lower()
    for nome_lista in where:
        try:
            for card in get_cards(nome_lista):
                if trecho_lower in card.get('titulo', '').lower():
                    return card, nome_lista
        except Exception:
            # Lista nao configurada (ex.: daily_scrum em .env antigo) — pula
            continue
    return None, None


def get_checklists(card_id):
    """Lista checklists de um cartao."""
    return api("GET", f"/cartoes/{card_id}/checklists").get("dados", [])


def create_checklist(card_id, titulo):
    """Cria uma checklist no cartao e devolve o id."""
    r = api("POST", "/checklists", {"cartao_id": card_id, "titulo": titulo})
    return r["dados"]["id"]


def add_checklist_item(checklist_id, item_titulo):
    """Adiciona um item a uma checklist."""
    return api("POST", f"/checklists/{checklist_id}/itens", {"titulo": item_titulo})


def register_event(evento_keyword, sprint=None):
    """
    Marca um evento Scrum como concluido.

    evento_keyword: trecho do titulo do card (ex.: 'Sprint Review'). Para
      Dailies use register_next_daily (slots numerados, qualquer dia).
    sprint: numero da sprint (1..TOTAL_SPRINTS). Se None, usa SPRINT_ATUAL.

    Retorna o card movido, ou None se nao encontrar.
    """
    c = cfg()
    sprint = sprint or c.get('SPRINT_ATUAL', 1)
    cards = get_cards('eventos')
    alvo = f"Sprint {sprint} - {evento_keyword}"
    for card in cards:
        if alvo in card.get('titulo', ''):
            complete_card(card['id'])
            print(Tf("  [OK] Evento registrado: {0}", alvo))
            return card
    # Tambem aceita match parcial mais flexivel
    for card in cards:
        titulo = card.get('titulo', '')
        if f"Sprint {sprint}" in titulo and evento_keyword.split('(')[0].strip() in titulo:
            complete_card(card['id'])
            print(Tf("  [OK] Evento registrado: {0}", titulo))
            return card
    print(Tf("  [AVISO] Evento '{0}' nao encontrado em Eventos Scrum.", alvo))
    return None


def register_next_daily(sprint=None, dia_label=None):
    """Conclui o PROXIMO Daily Scrum em aberto da Sprint, em QUALQUER dia.

    Os Dailies sao slots numerados ("Sprint N - Daily Scrum 1..K") e NAO
    presos a um dia da semana — o aluno roda no dia em que realmente fez a
    Daily (inclusive segunda). Este helper acha o slot de menor numero ainda
    nao concluido, carimba a data real na descricao e o conclui.

    dia_label: rotulo opcional do dia (ex.: 'Segunda', '08/06') — so registro.
    Retorna o card concluido, ou None se todos os Dailies da Sprint ja foram
    feitos (o aluno tentou rodar uma Daily a mais do que a cadencia permite).
    """
    c = cfg()
    sprint = sprint or c.get('SPRINT_ATUAL', 1)
    prefixo = f"Sprint {sprint} - Daily Scrum"

    # Junta os slots de Daily desta Sprint (em qualquer lista), sem duplicar
    # por id, e mantem so os que ainda NAO estao concluidos.
    candidatos, vistos = [], set()
    for nome in ('eventos', 'concluido', 'sprint_backlog', 'daily_scrum'):
        for card in _safe_get(nome):
            cid = card.get('id')
            t = card.get('titulo', '') or ''
            if cid in vistos or not t.startswith(prefixo):
                continue
            vistos.add(cid)
            if not _is_concluido(card):
                candidatos.append(card)
    if not candidatos:
        return None

    def _slot(card):
        m = re.search(r'Daily Scrum\s+(\d+)', card.get('titulo', '') or '')
        return int(m.group(1)) if m else 999
    candidatos.sort(key=_slot)
    alvo = candidatos[0]

    # Carimba o dia real na descricao (preserva qualquer descricao existente).
    hoje = datetime.date.today().strftime('%d/%m/%Y')
    nota = f"Realizada em {hoje}" + (f" ({dia_label})" if dia_label else "")
    desc = (alvo.get('descricao') or '').strip()
    update_card(alvo['id'], descricao=(f"{desc}\n{nota}".strip() if desc else nota))

    complete_card(alvo['id'])   # move para Concluido + status + data_conclusao
    return alvo


# ─────────────────────────────────────────────────────────────────────────────
# Calculo de KRs
# ─────────────────────────────────────────────────────────────────────────────
def _safe_count(name):
    try:
        return len(get_cards(name))
    except Exception:
        return 0


def _safe_get(name):
    try:
        return get_cards(name)
    except Exception:
        return []


def _parse_date(s):
    """Devolve datetime.date ou None.

    O ProjectAdm devolve datas no formato Carbon/Laravel:
        {'date': '2026-05-19 09:16:50.000000', 'timezone_type': 3,
         'timezone': 'America/Sao_Paulo'}
    Tambem aceita string 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS' ou ISO com 'T'.
    """
    if not s:
        return None
    if isinstance(s, dict):                 # objeto Carbon -> usa a chave 'date'
        s = s.get('date')
        if not s:
            return None
    s = str(s).strip()
    if len(s) < 10:
        return None
    try:
        return datetime.date.fromisoformat(s[:10])   # 'YYYY-MM-DD'
    except ValueError:
        return None


def _completion_date(card):
    """Data de conclusao do card.

    Usa o campo nativo `data_conclusao` (preenchido automaticamente pelo
    servidor quando o status vira 'concluido', e tambem por move_card).
    Se nao existir, cai para `data_vencimento`. Devolve date ou None.
    """
    return (_parse_date(card.get('data_conclusao'))
            or _parse_date(card.get('data_vencimento')))


def _is_concluido(card):
    """True se o card esta com status concluido.

    A API e inconsistente: GET de 1 card devolve bool True/False; a
    listagem de uma lista devolve string '1'/'0'. Em Python bool('0') e
    True, entao normalizamos explicitamente aqui.
    """
    v = card.get('concluido')
    if isinstance(v, bool):
        return v
    if isinstance(v, (int, float)):
        return int(v) == 1
    return str(v).strip().lower() in ('1', 'true', 't', 'sim')


def _planning_date(sprint, pools):
    """data_inicio do card 'Sprint N - Sprint Planning'.

    pools = listas de cards onde procurar (Concluido e Eventos Scrum — o
    card pode estar em qualquer uma das duas). Devolve datetime.date ou None.
    """
    pref = f"Sprint {sprint} -"
    for cards in pools:
        for c_ in cards:
            t = c_.get('titulo', '') or ''
            if pref in t and 'Sprint Planning' in t:
                d = _parse_date(c_.get('data_inicio'))
                if d:
                    return d
    return None


def get_planning_card(sprint):
    """Acha o card 'Sprint N - Sprint Planning' nas MESMAS listas que o
    calculo de KR usa (Concluido e Eventos Scrum) e devolve a tupla
    (card, data_inicio). data_inicio e datetime.date ou None; (None, None)
    se o card nem existe. Como get_planning_card e compute_krs olham as
    mesmas listas, gravar a data aqui faz o calculo passar a funcionar."""
    pools = (_safe_get('concluido'), _safe_get('eventos'))
    pref = f"Sprint {sprint} -"
    for cards in pools:
        for c_ in cards:
            t = c_.get('titulo', '') or ''
            if pref in t and 'Sprint Planning' in t:
                return c_, _parse_date(c_.get('data_inicio'))
    return None, None


def ensure_sprint_start_date(sprint):
    """Garante que a Sprint tenha data de inicio (gravada no card de Planning).

    A Sprint N vai de (data_inicio do Planning N) ate (data_inicio do
    Planning N+1). Sem essa data, compute_krs nao monta a janela e KR1..KR4
    saem errados — ou a Sprint e tratada como 'nao iniciada' e some dos
    totais. Isto acontece quando o aluno conclui/arrasta o card de Planning
    pela UI em vez de rodar sprint_planning.py (que grava a data). Se o card
    existe mas esta SEM data, PEDE a data ao aluno e grava no card.

    Retorna:
        True  -> a Sprint tem data (ja tinha ou acabou de ser gravada)
        False -> a Sprint nao foi iniciada (nao existe card de Sprint Planning)
    """
    card, data = get_planning_card(sprint)
    if data is not None:
        return True
    if card is None:
        return False
    print(Tf("\n [ATENCAO] A Sprint {0} tem o card 'Sprint Planning', mas SEM data de "
             "inicio.\n   Sem ela os KRs sao calculados errados (a janela da Sprint fica "
             "indefinida).", sprint))
    while True:
        resp = input(Tf("  Qual foi a data de INICIO da Sprint {0}? [AAAA-MM-DD, Enter = "
                        "hoje]:", sprint)).strip()
        if not resp:
            nova = datetime.date.today().isoformat()
            break
        try:
            nova = datetime.date.fromisoformat(resp).isoformat()
            break
        except ValueError:
            print(T("  Formato invalido. Use AAAA-MM-DD (ex.: 2026-05-25)."))
    update_card(card['id'], data_inicio=nova)
    print(Tf("  [OK] Data de inicio da Sprint {0} gravada no card: {1}", sprint, nova))
    return True


def compute_krs(sprint_atual=None):
    """
    Calcula os 4 KRs do Agile Together. Retorna dict com valores e status.
    """
    c = cfg()
    sprint_atual = sprint_atual or c.get('SPRINT_ATUAL', 1)

    concluido_cards = _safe_get('concluido')

    # Cards que NAO sao entregas de produto (eventos Scrum, META, impedimentos).
    # Estes nao contam para KR1 nem KR2.
    _EVENT_KW = ("Sprint Planning", "Daily", "Sprint Review", "Retrospectiva",
                 "RESUMO Sprint", "RETROSPECTIVE Sprint", "CHECK-IN Sprint",
                 "RESUMO DO PROJETO")

    def _eh_ruido(titulo):
        t = titulo or ''
        if t.startswith('META DA SPRINT') or 'META DO PRODUTO' in t:
            return True
        if '[IMPEDIMENTO]' in t:
            return True
        return any(kw in t for kw in _EVENT_KW)

    # ── Janela temporal da Sprint ────────────────────────────────────────
    # A Sprint N vai de (data_inicio do Planning N) ate (data_inicio do
    # Planning N+1). Sem Planning N+1, a Sprint N e a vigente (janela aberta).
    # O card do Planning pode estar em Concluido OU em Eventos Scrum.
    pools = (concluido_cards, _safe_get('eventos'))
    win_ini = _planning_date(sprint_atual, pools)
    if win_ini is None:
        raise SystemExit(
            Tf("ERRO: a Sprint {0} ainda nao teve Sprint Planning (nenhuma data de inicio no "
               "card 'Sprint {1} - Sprint Planning'). Rode sprint_planning.py para a Sprint "
               "{2} antes do OKR Check-in.", sprint_atual, sprint_atual, sprint_atual))
    win_fim = _planning_date(sprint_atual + 1, pools)  # None => Sprint vigente

    # ENTREGAS = cards com status concluido (em qualquer lista) atribuidos
    # a esta Sprint pela data de conclusao. O aluno pode marcar concluido
    # sem mover para a lista Concluido; verificar_krs.py oferece mover os
    # deslocados. Usamos status como verdade.
    _pool_entregas = {}
    for _c in (_safe_get('sprint_backlog') + _safe_get('daily_scrum')
               + concluido_cards):
        if not _eh_ruido(_c.get('titulo', '')) and _is_concluido(_c):
            _pool_entregas[_c.get('id')] = _c

    def _situacao(card):
        """True = entrega DESTA Sprint; False = outra Sprint; None = sem data."""
        d = _completion_date(card)
        if d is None:
            return None
        if d < win_ini:
            return False
        if win_fim is not None and d >= win_fim:
            return False
        return True

    entregas = [c_ for c_ in _pool_entregas.values()
                if _situacao(c_) is True]
    entregas_sem_data = [c_ for c_ in _pool_entregas.values()
                         if _situacao(c_) is None]

    # PENDENTES = cards atualmente em Sprint Backlog OU Daily Scrum, sem
    # ruido (sem "META DA SPRINT", sem eventos) e SEM status concluido.
    # Nada da lista Concluido vira pendente (mesmo se houver quirk de
    # status). Modelo simples e visivel: o que estiver "em aberto" na
    # parte ativa do board, e so isso, conta como pendente.
    pendentes = []
    _vistos = set()
    for _c in (_safe_get('sprint_backlog') + _safe_get('daily_scrum')):
        cid = _c.get('id')
        if cid in _vistos:
            continue
        if _eh_ruido(_c.get('titulo', '')) or _is_concluido(_c):
            continue
        _vistos.add(cid)
        pendentes.append(_c)

    # KR1 — Taxa de Conclusao = entregas / (entregas + pendentes).
    total_planejado = len(entregas) + len(pendentes)
    kr1 = round(len(entregas) / total_planejado * 100) if total_planejado else 0

    # KR2 — Velocidade: entregas reais ATRIBUIDAS a esta Sprint pela data.
    kr2 = len(entregas)
    _aviso_sem_data = (
        f' · {len(entregas_sem_data)} entrega(s) sem data de conclusao '
        f'(card concluido fora dos scripts? — mova via os .py do curso)'
        if entregas_sem_data else '')

    # KR3 — Consistencia do Scrum: eventos DESTA Sprint concluidos / (K+3).
    # Cada Sprint tem (DAILIES_POR_SPRINT + 3) cerimonias: 1 Planning +
    # K Dailies + 1 Review + 1 Retrospectiva. K vem do .env (padrao 4 =>
    # 7 eventos, Sprint de 1 semana). O check-in avalia a Sprint corrente:
    # eventos de Sprints anteriores NAO contam aqui.
    eventos_por_sprint = c.get('DAILIES_POR_SPRINT', 4) + 3
    keywords = ["Sprint Planning", "Daily", "Sprint Review", "Retrospectiva"]
    prefixo_sprint = f"Sprint {sprint_atual} -"
    _ev_vistos = set()
    eventos_ok = 0
    for c_ in (concluido_cards + _safe_get('eventos')):
        cid = c_.get('id')
        if cid in _ev_vistos:
            continue
        t = c_.get('titulo', '') or ''
        if (prefixo_sprint in t and any(kw in t for kw in keywords)
                and _is_concluido(c_)):
            _ev_vistos.add(cid)
            eventos_ok += 1

    # KR4 — Alavancagem de IA: cards [IA] EFETIVAMENTE ENTREGUES nesta
    # Sprint (NAO conta pendentes — "alavancagem" so vale o que foi feito).
    # Meta fixa 5 por Sprint.
    def _eh_ia(card):
        return '[IA]' in (card.get('titulo', '') or '').upper()

    total_ia = sum(1 for c_ in entregas if _eh_ia(c_))

    _janela = (f'{win_ini.isoformat()} → '
               f'{win_fim.isoformat() if win_fim else "agora (Sprint vigente)"}')

    return {
        'sprint': sprint_atual,
        'janela': _janela,
        'kr1': {'valor': kr1, 'meta': 80, 'unidade': '%',
                'detalhe': f'{len(entregas)} entregues de {total_planejado} '
                           f'planejados (pendentes = Sprint Backlog + Daily, '
                           f'sem META) · janela {_janela}{_aviso_sem_data}'},
        'kr2': {'valor': kr2, 'meta': '> sprint anterior', 'unidade': 'cards',
                'detalhe': f'entregas desta Sprint (janela {_janela})'
                           f'{_aviso_sem_data}'},
        'kr3': {'valor': eventos_ok, 'meta': eventos_por_sprint,
                'unidade': 'eventos',
                'detalhe': f'{eventos_ok}/{eventos_por_sprint} eventos '
                           f'concluidos na Sprint {sprint_atual} (curso '
                           f'inteiro: '
                           f'{c.get("TOTAL_SPRINTS", 3) * eventos_por_sprint}'
                           f' = {c.get("TOTAL_SPRINTS", 3)} x '
                           f'{eventos_por_sprint})'},
        'kr4': {'valor': total_ia, 'meta': 5, 'unidade': 'tarefas com [IA]',
                'detalhe': f'{total_ia}/5 cards [IA] ENTREGUES na Sprint '
                           f'{sprint_atual} (por Sprint, nao cumulativo;'
                           f' so conta entregas)'},
    }


def entregas_da_sprint(sprint_atual=None):
    """Lista cards de PRODUTO entregues nesta Sprint (status concluido +
    data_conclusao dentro da janela [Planning N, Planning N+1)).
    Exclui eventos Scrum, METAs e impedimentos. Usado por sprint_review.py
    para que a Review mostre SO o que foi entregue NESTA Sprint."""
    c = cfg()
    sprint_atual = sprint_atual or c.get('SPRINT_ATUAL', 1)

    concluido_cards = _safe_get('concluido')
    pools = (concluido_cards, _safe_get('eventos'))
    win_ini = _planning_date(sprint_atual, pools)
    if win_ini is None:
        return []
    win_fim = _planning_date(sprint_atual + 1, pools)

    _EVENT_KW = ("Sprint Planning", "Daily", "Sprint Review", "Retrospectiva",
                 "RESUMO Sprint", "RETROSPECTIVE Sprint", "CHECK-IN Sprint",
                 "RESUMO DO PROJETO", "LICOES APRENDIDAS")

    def _eh_ruido(titulo):
        t = titulo or ''
        if t.startswith('META DA SPRINT') or 'META DO PRODUTO' in t:
            return True
        if '[IMPEDIMENTO]' in t:
            return True
        return any(kw in t for kw in _EVENT_KW)

    entregas = []
    seen = set()
    for c_ in (_safe_get('sprint_backlog') + _safe_get('daily_scrum')
               + concluido_cards):
        cid = c_.get('id')
        if cid in seen:
            continue
        if _eh_ruido(c_.get('titulo', '')) or not _is_concluido(c_):
            continue
        d = _completion_date(c_)
        if d is None:
            continue
        if d < win_ini:
            continue
        if win_fim is not None and d >= win_fim:
            continue
        seen.add(cid)
        entregas.append(c_)
    return entregas


def concluidos_fora_da_lista():
    """Cards com STATUS concluido que NAO estao na lista 'Concluido'.

    Usado por verificar_krs.py para oferecer mover ao aluno (pratica Scrum:
    cartao pronto vai para a lista Concluido / Incremento). Ignora ruido
    (META, eventos Scrum etc. — esses tem fluxo proprio).
    """
    try:
        alvo = str(list_id('concluido'))
    except Exception:
        return []
    _RUIDO = ("Sprint Planning", "Daily", "Sprint Review", "Retrospectiva",
              "RESUMO Sprint", "RETROSPECTIVE Sprint", "CHECK-IN Sprint",
              "RESUMO DO PROJETO")

    def _ruido(t):
        t = t or ''
        return (t.startswith('META DA SPRINT') or 'META DO PRODUTO' in t
                or '[IMPEDIMENTO]' in t or any(k in t for k in _RUIDO))

    fora, vistos = [], set()
    for nome in ('product_backlog', 'sprint_backlog', 'daily_scrum'):
        for c_ in _safe_get(nome):
            cid = c_.get('id')
            if cid in vistos or _ruido(c_.get('titulo', '')):
                continue
            if _is_concluido(c_) and str(c_.get('lista_id')) != alvo:
                vistos.add(cid)
                fora.append(c_)
    return fora


# ─────────────────────────────────────────────────────────────────────────────
# Util: encontrar listas por nome no board (usado por configurar_board.py)
# ─────────────────────────────────────────────────────────────────────────────
def list_lists():
    c = cfg()
    _require(c, 'BOARD_ID')
    return api("GET", f"/quadros/{c['BOARD_ID']}/listas").get("dados", [])


def find_list_id_by_name(name_substring, listas=None):
    listas = listas if listas is not None else list_lists()
    for l in listas:
        if name_substring.lower() in l['nome'].lower():
            return l['id']
    return None
