"""
Together Kit — cliente da API do ProjectAdm (agnostico de curso).

Toda a comunicacao com o ProjectAdm passa por aqui: autenticacao (X-API-Key
do .env), quadros, listas, cartoes e checklists. Os scripts de cada curso
(Agile Together, BPM Together, ...) chamam estas funcoes — sem repetir HTTP.

Uso:
    from pa_client import list_lists, create_list, create_card, move_card
"""
import json
import socket
import ssl
import time
import urllib.error
import urllib.request

from env import cfg, require
from idioma import T, Tf

# ProjectAdm aceita certificado padrao; desligar verificacao evita falhas em
# redes corporativas (mesma postura dos scripts validados do Agile Together).
_ctx = ssl.create_default_context()
_ctx.check_hostname = False
_ctx.verify_mode = ssl.CERT_NONE
BASE = "https://projectadm.com/api/v1"


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


# Resiliencia de rede: tempo-limite por tentativa + novas tentativas com espera
# crescente. Sem isto, um unico pico de rede (ex.: WinError 10060) derrubava toda
# a configuracao do board no meio de um passo.
TIMEOUT = 30       # segundos por tentativa
RETRIES = 4        # tentativas antes de desistir


class ErroAPI(RuntimeError):
    """Erro devolvido pela API (4xx). Traz o codigo E a mensagem que o ProjectAdm
    escreveu — sem isto o aluno so via 'HTTP Error 403: Forbidden', que nao explica
    nada. `codigo` fica acessivel para quem precisa decidir pelo status."""

    def __init__(self, codigo, mensagem):
        super().__init__(f"{codigo}: {mensagem}")
        self.codigo = codigo
        self.mensagem = mensagem


def _erro_da_api(e):
    try:
        corpo = json.loads(e.read().decode("utf-8", "replace"))
        msg = corpo.get("mensagem") or corpo.get("message") or str(corpo)
    except Exception:
        msg = e.reason or "erro desconhecido"
    return ErroAPI(e.code, msg)


# ── Cache de leitura (dura o que dura UM script) ─────────────────────────────
# Cada chamada a API custa ~0,5s. Um passo lia a MESMA lista de cartoes 3, 4, 5 vezes
# (garantir_termo, _ordenar_termo, cards_do_termo_ids, ler_inputs...) e relia as listas
# do quadro dentro de um laco — dezenas de segundos so' relendo o que nao mudou
# (Eduardo, 2026-07-14: "esta demorando muito para completar o passo 1").
#
# Regra do cache, e por que ela e' segura: guarda so' GET, e QUALQUER escrita (POST/PUT/
# DELETE) esvazia tudo. Entao nenhum script consegue ler um dado velho depois de mudar o
# quadro — no maximo relemos algo a toa.
_cache_get = {}


def limpar_cache():
    """Esquece o que foi lido (o proximo GET vai na API)."""
    _cache_get.clear()


def api(method, path, data=None):
    """Chama a API do ProjectAdm. Le PROJECTADM_TOKEN do .env.

    Resiliente: aplica um tempo-limite e repete automaticamente quando a rede
    falha de forma transitoria (timeout, queda de conexao ou erro 5xx do
    servidor), para que um pico de rede nao interrompa o passo no meio. Erros
    4xx (ex.: token invalido) sao levantados na hora, sem repetir.

    GET e' cacheado; escrever invalida o cache inteiro (ver acima).
    """
    if method == "GET" and path in _cache_get:
        return _cache_get[path]
    if method != "GET":
        limpar_cache()
    c = cfg()
    require(c, 'PROJECTADM_TOKEN')
    payload = json.dumps(data).encode() if data else None
    ultimo = None
    for tentativa in range(1, RETRIES + 1):
        req = urllib.request.Request(
            BASE + path, payload, _headers(c['PROJECTADM_TOKEN']), method=method
        )
        try:
            with urllib.request.urlopen(req, context=_ctx, timeout=TIMEOUT) as r:
                resposta = json.loads(r.read())
            if method == "GET":
                _cache_get[path] = resposta
            return resposta
        except urllib.error.HTTPError as e:
            # 4xx = erro nosso (nao adianta repetir); 5xx = servidor, vale repetir.
            if e.code < 500 or tentativa >= RETRIES:
                raise _erro_da_api(e)
            ultimo = e
        except (urllib.error.URLError, TimeoutError, socket.timeout,
                ConnectionError, OSError) as e:
            ultimo = e
            if tentativa >= RETRIES:
                break
        espera = round(1.5 ** tentativa, 1)   # 1.5s, 2.2s, 3.4s, 5.1s
        # Mensagem sem alarme: um pico de 5xx/timeout do ProjectAdm quase sempre se
        # resolve na proxima tentativa. Dizer "falhou (HTTPError)" assusta o aluno a
        # toa — o passo se recupera sozinho. Nomeamos a causa real (servidor x rede).
        if isinstance(ultimo, urllib.error.HTTPError):
            causa = Tf("o ProjectAdm respondeu com um erro temporário ({0})", ultimo.code)
        else:
            causa = T("a conexão com o ProjectAdm oscilou")
        print(Tf("  [rede] {0} — tento de novo em {1}s (tentativa {2} de {3}; o passo continua "
                 "sozinho).", causa, espera, tentativa, RETRIES))
        time.sleep(espera)
    raise RuntimeError(
        Tf("Sem resposta do ProjectAdm apos {0} tentativas ({1} {2}). Verifique a internet/VPN "
           "e rode o mesmo passo de novo — ele e idempotente e continua de onde parou. Ultimo "
           "erro: {3}", RETRIES, method, path, ultimo)
    ) from ultimo


# ── Portfolios e quadros ─────────────────────────────────────────────────────
# VOCABULARIO (o que o aluno ve, e o que a API chama):
#   PORTFOLIO  = a "pasta" que agrupa os seus trabalhos. Na API ele se chama
#                /projetos — mas para o aluno isso e um PORTFOLIO. Nunca diga
#                "projeto" para se referir a ele: o projeto do aluno e o QUADRO.
#   QUADRO     = o board. E ELE o projeto do aluno — e o unico ID que guardamos
#                no .env (BOARD_ID).
def list_portfolios():
    """Todos os portfolios que a conta enxerga (API: GET /projetos)."""
    return api("GET", "/projetos").get("dados", [])


def can_access_portfolio(portfolio_id):
    """O token consegue MESMO abrir este portfolio?

    A listagem mostra portfolios da ORGANIZACAO — e o usuario nem sempre pode abrir
    todos. So confiamos no que responde 200 aqui. (Ate 2026-07-13 a propria API criava
    portfolios sem dar acesso a quem criou; corrigido — mas a checagem fica, porque e
    ela que impede o curso de gravar no .env um quadro que responderia 403 em tudo.)
    """
    try:
        api("GET", f"/projetos/{portfolio_id}")
        return True
    except Exception:
        return False


def accessible_portfolios():
    """Portfolios que o aluno realmente pode usar (filtrados pelo teste acima)."""
    return [p for p in list_portfolios() if can_access_portfolio(p["id"])]


def create_portfolio(nome, descricao=""):
    """Cria um portfolio e devolve o id (API: POST /projetos)."""
    r = api("POST", "/projetos", {"nome": nome, "descricao": descricao})
    return r["dados"]["id"]


def all_boards():
    """TODOS os quadros que a conta enxerga (cada um traz o seu projeto_id = portfolio)."""
    return api("GET", "/quadros").get("dados", [])


def find_board(nome, portfolio_id=None):
    """O quadro com este nome (procura no portfolio, se dado; senao em toda a conta).
    Devolve o dict do quadro, ou None."""
    alvo = nome.strip().lower()
    for q in all_boards():
        if (q.get("nome") or "").strip().lower() != alvo:
            continue
        if portfolio_id and str(q.get("projeto_id")) != str(portfolio_id):
            continue
        return q
    return None


def create_board(portfolio_id, nome, descricao=""):
    """Cria um quadro dentro do portfolio e devolve o id."""
    r = api("POST", "/quadros", {"projeto_id": portfolio_id, "nome": nome,
                                 "descricao": descricao})
    return r["dados"]["id"]


def can_use_board(board_id):
    """Da para MEXER neste quadro (ler as listas dele)?

    Um quadro dentro de um portfolio que a API nao abre responde 403 em TUDO — mesmo
    que ele apareca em GET /quadros e exista no site. Por isso conferimos ANTES de
    gravar o BOARD_ID no .env: e a unica prova de que o quadro serve ao curso.
    """
    try:
        api("GET", f"/quadros/{board_id}/listas")
        return True
    except Exception:
        return False


def rename_board(board_id, nome):
    """Renomeia o quadro (o aluno da o nome do projeto dele no passo 1)."""
    return api("PUT", f"/quadros/{board_id}", {"nome": nome})


def board_name(board_id=None):
    """Nome atual do quadro (ou '' se nao der para ler)."""
    try:
        return (api("GET", f"/quadros/{_board_id(board_id)}")
                .get("dados", {}).get("nome") or "")
    except Exception:
        return ""


# ── Quadros / listas ─────────────────────────────────────────────────────────
def _board_id(board_id=None):
    c = cfg()
    require(c, 'BOARD_ID')
    return board_id or c['BOARD_ID']


def list_lists(board_id=None):
    """Todas as listas de um quadro."""
    return api("GET", f"/quadros/{_board_id(board_id)}/listas").get("dados", [])


def find_list_id_by_name(name_substring, listas=None):
    """ID da primeira lista cujo nome contem o trecho (case-insensitive), ou None."""
    listas = listas if listas is not None else list_lists()
    for l in listas:
        if name_substring.lower() in (l.get('nome', '') or '').lower():
            return l['id']
    return None


def create_list(titulo, board_id=None):
    """Cria uma lista no quadro e devolve o id."""
    r = api("POST", "/listas", {"quadro_id": _board_id(board_id), "titulo": titulo})
    return r["dados"]["id"]


def rename_list(list_id, titulo):
    """Renomeia uma lista (preserva id, cartoes e datas)."""
    return api("PUT", f"/listas/{list_id}", {"titulo": titulo})


def delete_list(list_id):
    """Apaga uma lista. Devolve True/False (nunca interrompe o script)."""
    try:
        api("DELETE", f"/listas/{list_id}")
        return True
    except Exception as e:
        print(Tf("  [AVISO] nao consegui apagar a lista {0}: {1}", list_id, e))
        return False


def capturar_baseline(nome=None, board_id=None):
    """Congela a LINHA DE BASE nativa do quadro: um snapshot das datas (cronograma) e dos
    valores (custos) de todos os cartoes, no momento da aprovacao (Passo 5). `nome` e'
    opcional — a API gera um se faltar. Devolve os `dados` (baseline_id, total_cartoes,
    nome). E' contra esta baseline que o Passo 9 mede o custo de cada mudanca.
    (POST /quadros/{id}/baseline — endpoint nativo do ProjectAdm; GET .../baseline lista.)"""
    corpo = {"nome": nome} if nome else {}
    r = api("POST", f"/quadros/{_board_id(board_id)}/baseline", corpo)
    return r.get("dados") or {}


# ── Cartoes ──────────────────────────────────────────────────────────────────
def list_cards(list_id):
    """TODOS os cartoes de uma lista (segue a paginacao da API: 100/pagina)."""
    todos, pagina = [], 1
    while True:
        r = api("GET", f"/listas/{list_id}/cartoes?pagina={pagina}&por_pagina=100")
        lote = r.get("dados", []) or []
        todos.extend(lote)
        if len(lote) < 100:
            break
        pagina += 1
        if pagina > 100:          # salvaguarda anti-loop
            break
    return todos


# ── Limites do ProjectAdm (a API RECUSA acima disto, e derrubava o passo inteiro) ──
# titulo:    acima de 255 CARACTERES  -> 422 "Invalid data".
# descricao: acima de ~10 000 BYTES   -> 400 "Erro ao criar cartão".
# O limite da descricao e' por BYTE (nao caractere) e sensivel a acento (cada acento
# = 2 bytes), e mostrou-se instavel bem no 10 000 — por isso capamos com folga. Uma IA
# prolixa (ou uma resposta que o parser juntou num item so') estourava isso e travava o
# Passo com um traceback. Capar ANTES de enviar troca o crash por um cartao com o texto
# cortado + um aviso visivel: o quadro do aluno vale mais do que um pedaco de texto.
PA_TITULO_MAX = 250
PA_DESCRICAO_MAX_BYTES = 8000
_AVISO_CORTE = "\n\n[… texto muito longo — cortado para caber no cartão]"


def _capar_titulo(titulo):
    t = titulo or ""
    return t if len(t) <= PA_TITULO_MAX else t[:PA_TITULO_MAX - 1].rstrip() + "…"


def _capar_descricao(descricao):
    d = descricao or ""
    if len(d.encode("utf-8")) <= PA_DESCRICAO_MAX_BYTES:
        return d
    orcamento = PA_DESCRICAO_MAX_BYTES - len(_AVISO_CORTE.encode("utf-8"))
    corpo = d.encode("utf-8")[:orcamento].decode("utf-8", "ignore")  # nao parte um char
    return corpo + _AVISO_CORTE


# Os capadores tambem valem para quem monta o POST fora daqui. O Agile Together tem o
# proprio `create_card`/`update_card` (em agile_together.py, herdados de antes do kit) e
# eles escreviam o titulo CRU: uma Meta do Produto de 303 caracteres derrubava o passo 2
# com "422: Invalid data" (Eduardo, 31/08/2026). Nome publico porque atravessa modulo.
capar_titulo = _capar_titulo
capar_descricao = _capar_descricao


def create_card(list_id, titulo, descricao=""):
    """Cria um cartao na lista e devolve o id."""
    r = api("POST", "/cartoes", {"lista_id": list_id, "titulo": _capar_titulo(titulo),
                                 "descricao": _capar_descricao(descricao)})
    return r["dados"]["id"]


def criar_cartoes(lista_id, itens, log=None):
    """Cria VARIOS cartoes de uma vez. itens = [(titulo, descricao, cartao_pai_id|None)].
    Devolve os ids NA MESMA ORDEM dos itens (None onde a API recusou).

    POR QUE EM PARALELO: cada POST custa ~0,7s. Montar um quadro novo sao ~37 cartoes —
    meia dupla de minutos so' esperando a rede, um atras do outro. Em paralelo, os mesmos
    37 saem em poucos segundos (medido: 12 cartoes 8,9s -> 1,5s).

    A ORDEM VISIVEL NAO VEM DE GRACA: quem cria em paralelo nao controla a ordem de
    chegada. Por isso devolvemos os ids na ordem dos itens — o chamador passa essa
    sequencia para `ordenar_cartoes` e a lista fica na ordem certa. (Uma chamada a mais,
    dezenas de segundos a menos.)

    Um cartao recusado (ex.: titulo com menos de 3 letras) nao derruba os outros: vira
    None e o passo segue — o quadro do aluno vale mais do que um cartao.
    """
    from concurrent.futures import ThreadPoolExecutor

    def _um(item):
        titulo, descricao, pai = item
        corpo = {"lista_id": lista_id, "titulo": _capar_titulo(titulo),
                 "descricao": _capar_descricao(descricao)}
        if pai:
            corpo["cartao_pai_id"] = pai
        try:
            return api("POST", "/cartoes", corpo)["dados"]["id"]
        except Exception as e:
            if log:
                log(Tf('  [aviso] não consegui criar o cartão "{0}": {1}', titulo[:40], e))
            return None

    if not itens:
        return []
    with ThreadPoolExecutor(max_workers=6) as ex:
        return list(ex.map(_um, itens))


def update_card(card_id, **fields):
    """Atualiza campos de um cartao (titulo, descricao, prioridade, data_inicio,
    data_vencimento, data_conclusao, concluido). Campos None sao ignorados."""
    payload = {k: v for k, v in fields.items() if v is not None}
    if "titulo" in payload:
        payload["titulo"] = _capar_titulo(payload["titulo"])
    if "descricao" in payload:
        payload["descricao"] = _capar_descricao(payload["descricao"])
    return api("PUT", f"/cartoes/{card_id}", payload) if payload else None


def atualizar_cartoes(itens, log=None):
    """Atualiza VARIOS cartoes de uma vez: itens = [(card_id, {campos})].

    Mesma razao do criar_cartoes: a rodada 2 do passo 1 reescreve o texto-guia de uma
    duzia de cartoes (o bloco COMO destrava, os prompts mudam de forma) — um PUT atras
    do outro sao dezenas de segundos de espera pura. Aqui nao ha ordem para preservar:
    cada cartao e' independente.
    """
    from concurrent.futures import ThreadPoolExecutor

    def _um(item):
        cid, campos = item
        try:
            return update_card(cid, **campos)
        except Exception as e:
            if log:
                log(Tf("  [aviso] não consegui atualizar o cartão {0}: {1}", cid, e))
            return None

    if not itens:
        return []
    with ThreadPoolExecutor(max_workers=6) as ex:
        return list(ex.map(_um, itens))


# Nomes de lista que significam "terminou". Usados so' para decidir se o move
# tambem marca o cartao como concluido (ver move_card).
_LISTAS_DE_CONCLUSAO = ("conclu", "completed", "done", "finalizad", "entregue")


def _e_lista_de_conclusao(list_id):
    """A lista de destino significa 'terminou'? Decidido pelo NOME, que e' o unico
    sinal que a API devolve -- `GET /listas/{id}` nao expoe o mapeamento de status."""
    try:
        nome = (api("GET", f"/listas/{list_id}")["dados"].get("nome") or "").lower()
    except Exception:
        return False
    return any(p in nome for p in _LISTAS_DE_CONCLUSAO)


def move_card(card_id, list_id, concluir=None):
    """Move um cartao para outra lista (endpoint dedicado da API).

    ⚠️ POR QUE ISTO NAO E' SO' UM MOVE (medido em 02/09/2026, quadro 341)
    O Eduardo mapeou a lista "Completed (course)" para o status concluido, e mesmo assim
    mover um cartao para la' pela API deixa `concluido=False` e `data_conclusao=None` --
    provado criando o cartao 17259, movendo com este mesmo endpoint (HTTP 200) e lendo de
    volta. O mapeamento so' vale para quem move pela tela. Enquanto o ProjectAdm nao
    corrigir (e' do Henry), qualquer relatorio que deduza "pronto" da lista mente.

    Entao aqui a gente fecha o buraco: se o destino e' uma lista de conclusao, o move e'
    seguido de um update explicito de `concluido` + `data_conclusao`. `concluir=True/False`
    forca o comportamento; `None` (o padrao) decide pelo nome da lista.
    """
    r = api("PUT", f"/cartoes/{card_id}/mover", {"lista_id": list_id})
    if concluir is None:
        concluir = _e_lista_de_conclusao(list_id)
    if concluir:
        import datetime as _dt
        update_card(card_id, concluido=True,
                    data_conclusao=_dt.date.today().isoformat())
    return r


def ordenar_cartoes(list_id, ids_em_ordem):
    """A ordem VISIVEL dos cartoes da lista passa a ser a desta sequencia de ids.

    Renomear um cartao (o LEGADO faz isso para nao perder o texto do aluno) NAO o
    move de lugar: ele fica na posicao antiga. Sem esta chamada, um quadro migrado
    mostra os cartoes fora da ordem do termo de abertura.
    (O campo do cartao chama-se 'ordem'; o endpoint aceita a lista inteira de ids.)
    """
    ids = [str(i) for i in ids_em_ordem]
    return api("PUT", f"/listas/{list_id}/cartoes/ordenar", {"cartoes": ids}) if ids else None


def delete_card(card_id):
    """Apaga um cartao. Devolve True/False."""
    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 get_card(card_id):
    """Um cartao por id (dict 'dados')."""
    return api("GET", f"/cartoes/{card_id}").get("dados", {})


# ── Membros do cartao (o RESPONSAVEL vira um membro do cartao) ─────────────────
def card_members(card_id):
    """Os usuarios atribuidos a um cartao: [{id, nome, email}]. (Vem no GET do cartao.)"""
    return get_card(card_id).get("membros", []) or []


def assign_member(card_id, usuario_id):
    """Atribui um usuario a um cartao — ele passa a ser MEMBRO do cartao (aparece no
    cartao e no Gantt do ProjectAdm). E' assim que o responsavel de uma tarefa, no curso,
    vira uma atribuicao REAL — nao so texto na descricao.

    Idempotente: se o usuario JA e membro, a API responde 400 'ja e membro' e nos tratamos
    como sucesso (o cartao ja esta com o responsavel certo). Endpoint provado no board 270:
    POST /cartoes/{id}/membros {usuario_id} (o id vai como string). Devolve True se, ao
    fim, o usuario esta no cartao; False se nao deu (e avisa)."""
    try:
        api("POST", f"/cartoes/{card_id}/membros", {"usuario_id": str(usuario_id)})
        return True
    except ErroAPI as e:
        if e.codigo == 400 and "membro" in (e.mensagem or "").lower():
            return True                       # ja e membro (idempotente)
        print(Tf("  [AVISO] nao consegui atribuir o usuario {0} ao cartao {1}: {2}", usuario_id, card_id, e))
        return False
    except Exception as e:
        print(Tf("  [AVISO] nao consegui atribuir o usuario {0} ao cartao {1}: {2}", usuario_id, card_id, e))
        return False


def unassign_member(card_id, usuario_id):
    """Tira um usuario do cartao. Devolve True/False (nunca derruba o passo)."""
    try:
        api("DELETE", f"/cartoes/{card_id}/membros/{usuario_id}")
        return True
    except Exception:
        return False


# ── Dependencias (cronograma: liga um cartao a outro, tipos FS/SS/FF/SF) ──────
def list_dependencies(card_id):
    """Dependencias de um cartao: {'predecessores': [...], 'sucessores': [...]}.
    Cada predecessor traz id, tipo, cartao_origem_id, cartao_titulo."""
    return api("GET", f"/cartoes/{card_id}/dependencias").get("dados", {}) or {}


def create_dependency(origem_id, destino_id, tipo="finish_to_start"):
    """Cria uma dependencia REAL (aparece no Gantt do ProjectAdm): o cartao `origem`
    precede o `destino`. tipo: finish_to_start|start_to_start|finish_to_finish|
    start_to_finish. Devolve o dict criado, ou None (nunca derruba o passo).

    'Ja existe' (400) NAO e' erro e NAO vira aviso: a dependencia esta la — pode ter sido
    um POST que a API respondeu 5xx mas gravou (a API do ProjectAdm faz isso; ver os
    retries no log do Eduardo, board 270) ou uma re-rodada. Quem chama (garantir_dependencia)
    confere o tipo. So avisamos de erros REAIS."""
    try:
        r = api("POST", "/dependencias", {"cartao_origem_id": origem_id,
                                          "cartao_destino_id": destino_id, "tipo": tipo})
        return r.get("dados", r)
    except ErroAPI as e:
        if e.codigo == 400 and "exist" in (e.mensagem or "").lower():
            return None   # ja existe (idempotente) — silencioso de proposito
        print(Tf("  [AVISO] nao consegui criar dependencia {0}->{1}: {2}", origem_id, destino_id, e))
        return None
    except Exception as e:
        print(Tf("  [AVISO] nao consegui criar dependencia {0}->{1}: {2}", origem_id, destino_id, e))
        return None


def delete_dependency(dep_id):
    try:
        api("DELETE", f"/dependencias/{dep_id}")
        return True
    except Exception:
        return False


def garantir_dependencia(origem_id, destino_id, tipo="finish_to_start"):
    """Upsert: cria a dependencia origem->destino se ainda nao existe (idempotente — o
    Passo 4 pode ser rodado varias vezes sem duplicar). Se ja existe mas com um TIPO
    diferente (o aluno reajustou TI->II, por ex.), CORRIGE: apaga a antiga e recria com
    o tipo certo. Assim o re-run acerta o tipo, nao so evita duplicata.

    Devolve o ESTADO do link, para quem chama poder contar E avisar:
      "criada"          — link novo;
      "existe"          — o link ja estava la, com o tipo certo;
      "tipo_divergente" — o link existe, mas NAO no tipo pedido (nao consegui trocar:
                          a remocao da dependencia antiga falhou no ProjectAdm);
      None              — nao ha link (o create falhou de fato).
    "criada"/"existe"/"tipo_divergente" sao todos verdadeiros — numa re-rodada em que
    tudo ja existe, o Passo 4 ainda diz quantos links o cronograma tem; mas o chamador
    deve tratar "tipo_divergente" a parte e AVISAR (o tipo pedido nao foi aplicado)."""
    origem_id, destino_id = str(origem_id), str(destino_id)

    def _existente():
        for p in (list_dependencies(destino_id).get("predecessores") or []):
            if str(p.get("cartao_origem_id")) == origem_id:
                return p
        return None

    p = _existente()
    if p:
        if (p.get("tipo") or "finish_to_start") == tipo:
            return "existe"                  # ja existe com o tipo certo
        if p.get("id") is not None:          # tipo mudou -> apaga p/ recriar
            delete_dependency(p["id"])
    r = create_dependency(origem_id, destino_id, tipo)
    if r is not None:
        return "criada"
    # create devolveu None: ou "ja existe" (idempotente, ok) ou a antiga sobreviveu a um
    # delete que falhou (houve um bug 500 deterministico no DELETE /dependencias do
    # ProjectAdm). Confere o tipo pela leitura e corrige UMA vez se preciso.
    p = _existente()
    if p and (p.get("tipo") or "finish_to_start") != tipo and p.get("id") is not None:
        delete_dependency(p["id"])
        create_dependency(origem_id, destino_id, tipo)
        p = _existente()                     # confirma se o tipo mudou de verdade
    if not p:
        return None
    if (p.get("tipo") or "finish_to_start") == tipo:
        return "existe"
    return "tipo_divergente"                 # link presente, mas com o tipo antigo


# ── Checklists ───────────────────────────────────────────────────────────────
def create_checklist(card_id, titulo):
    r = api("POST", "/checklists", {"cartao_id": card_id, "titulo": titulo})
    return r["dados"]["id"]


def add_checklist_item(checklist_id, titulo):
    return api("POST", f"/checklists/{checklist_id}/itens", {"titulo": titulo})


def list_checklists(card_id):
    """Os checklists de um cartao (cada um com os seus itens)."""
    return api("GET", f"/cartoes/{card_id}/checklists").get("dados", []) or []


def garantir_checklist(card_id, titulo, itens):
    """Upsert de um checklist: cria o que falta e NUNCA apaga o que ja existe.

    Os scripts do curso sao rodados varias vezes (o Passo 1, por exemplo, e rodado
    duas vezes de proposito) — sem isto, cada rodada criava outro checklist igual.

    E nao reconstruimos o checklist do zero porque o item marcado como concluido e
    estado do ALUNO (ele atingiu aquele Key Result). Regra da casa: texto nosso pode
    ser reescrito; o que o aluno fez, jamais. Item que sumiu do OKR fica no cartao —
    e ele apaga se quiser.

    Devolve (checklist_id, quantos itens foram adicionados agora).

    ATENCAO: isto sozinho nao basta quando o TITULO do checklist muda. Ver
    sincronizar_checklists() logo abaixo — e' ele que recolhe o checklist orfao.
    """
    existentes = {(c.get("titulo") or ""): c for c in list_checklists(card_id)}
    chk = existentes.get(titulo)
    if chk:
        chk_id = chk["id"]
        # O texto do item vem no campo `descricao` da API (nao `titulo`). Ler a chave
        # errada deixava `ja_la` vazio -> como o Passo 1 roda >=2x, TODO item era
        # re-adicionado a cada rodada (checklist duplicado no cartao 1.2 — Eduardo,
        # board 270, 2026-07-17). Tolerante a variantes por seguranca.
        ja_la = {(i.get("descricao") or i.get("titulo") or i.get("texto") or "").strip()
                 for i in (chk.get("itens") or chk.get("checklist_itens") or [])}
    else:
        chk_id, ja_la = create_checklist(card_id, titulo), set()
    novos = 0
    for it in itens:
        if (it or "").strip() and it.strip() not in ja_la:
            add_checklist_item(chk_id, it.strip())
            novos += 1
    return chk_id, novos


def delete_checklist(checklist_id):
    """Apaga um checklist inteiro. Devolve True/False (nunca interrompe o script).

    CUIDADO (medido no quadro 326 em 2026-08-08): hoje isto e' um SOFT DELETE no
    servidor. O checklist some da API (`GET /cartoes/{id}/checklists` deixa de traze-lo)
    e os itens dele somem de vez — mas a TELA do quadro continua desenhando a linha,
    vazia, com "Nenhum item adicionado". Como a API nao devolve mais o registro, nem o
    id sobra para tentar de novo: nao ha como limpar por aqui.
    Por isso a regra passou a ser NAO PRECISAR apagar checklist: ver o titulo estavel
    em sincronizar_checklists(). Isto aqui ficou para o caso raro (um objetivo a menos).
    """
    try:
        api("DELETE", f"/checklists/{checklist_id}")
        return True
    except Exception as e:
        print(Tf("  [AVISO] nao consegui apagar o checklist {0}: {1}", checklist_id, e))
        return False


def delete_checklist_item(item_id):
    """Apaga UM item de checklist. Devolve True/False.

    Este apaga de verdade — some da API e some da tela (ao contrario do checklist).
    E' o que permite sincronizar o conteudo sem mexer no checklist que o contem.
    """
    try:
        api("DELETE", f"/checklist-itens/{item_id}")
        return True
    except Exception as e:
        print(Tf("  [AVISO] nao consegui apagar o item {0}: {1}", item_id, e))
        return False


def _texto_item(i):
    return (i.get("descricao") or i.get("titulo") or i.get("texto") or "").strip()


def _item_concluido(i):
    return i.get("concluido") in (True, 1, "1", "true", "True")


def _tem_item_concluido(chk):
    return any(_item_concluido(i)
               for i in (chk.get("itens") or chk.get("checklist_itens") or []))


def sincronizar_checklists(card_id, esperados):
    """Deixa o cartao com EXATAMENTE os checklists esperados — e nada de uma versao velha.

    `esperados` = [(titulo, [itens]), ...] — o conjunto COMPLETO que o cartao deve ter.

    O PROBLEMA QUE ISTO RESOLVE (Eduardo, quadro 326, cartao 1.2, 2026-08-08)
    ------------------------------------------------------------------------
    garantir_checklist() casa o checklist pelo TITULO e nunca remove nada. Enquanto o
    titulo do OKR foi "Objetivo N — <o texto do objetivo>", reescrever o objetivo mudava
    o titulo: a rodada seguinte CRIAVA outro checklist e o antigo ficava no cartao para
    sempre. Tres revisoes do OKR — que o proprio curso manda fazer ("revisar apos...") —
    deixaram 9 checklists e 29 itens, 15 deles de OKRs mortos, com DOIS "Objetivo 1" ao
    mesmo tempo dizendo coisas diferentes.

    A CORRECAO DE VERDADE foi tirar o texto do titulo (ver 01_iniciar_projeto.py): o
    checklist passou a se chamar so' "Objetivo 1". Titulo estavel = reescrever o OKR nao
    cria nem apaga checklist nenhum, so' sincroniza os ITENS dentro dele. Isso importa
    porque `DELETE /checklists/{id}` e' soft delete no servidor e deixa uma linha vazia
    na tela do quadro que a API nao ve mais (ver delete_checklist). Apagar item, nao:
    esse apaga de verdade nos dois lados.

    Regra da casa em tudo isto: texto NOSSO pode ser reescrito; o que o ALUNO fez, jamais.
      - item que saiu do OKR e nao esta' marcado -> apaga (e' texto nosso, virou ruido);
      - item que saiu do OKR e ESTA' marcado     -> fica, e avisamos. Ele atingiu aquilo.
      - checklist inteiro que sobrou (um objetivo a menos, ou o formato antigo com o texto
        no titulo) -> apaga se nao houver nada marcado dentro; senao fica e avisamos.

    Devolve um dict: itens_novos, itens_apagados, chk_apagados, mantidos (lista de
    titulos que ficaram por terem progresso do aluno).
    """
    titulos_ok = {t for t, _ in esperados}
    r = {"itens_novos": 0, "itens_apagados": 0, "chk_apagados": 0, "mantidos": []}

    for titulo, itens in esperados:
        chk_id, novos = garantir_checklist(card_id, titulo, itens)
        r["itens_novos"] += novos
        # Agora o INVERSO do upsert: o que esta' la' dentro e nao esta' mais no OKR.
        atual = next((c for c in list_checklists(card_id)
                      if (c.get("titulo") or "").strip() == titulo), None)
        if not atual:
            continue
        esperado_txt = {(i or "").strip() for i in itens if (i or "").strip()}
        for i in (atual.get("itens") or atual.get("checklist_itens") or []):
            if _texto_item(i) in esperado_txt:
                continue
            if _item_concluido(i):
                r["mantidos"].append(_texto_item(i)[:60])
            elif delete_checklist_item(i["id"]):
                r["itens_apagados"] += 1

    for chk in list_checklists(card_id):
        titulo = (chk.get("titulo") or "").strip()
        if titulo in titulos_ok:
            continue
        if _tem_item_concluido(chk):
            r["mantidos"].append(titulo)
        elif delete_checklist(chk["id"]):
            r["chk_apagados"] += 1
    return r


# ── Etiquetas (labels) ───────────────────────────────────────────────────────
# Usadas pela auditoria para MARCAR no quadro os cartoes que precisam de revisao
# (risco sem avaliacao, pacote sem data, parte sem plano...). Sao a forma nao
# destrutiva de sinalizar: nao mexem em status, prioridade nem datas do cartao, sao
# visiveis em qualquer visao e o aluno pode FILTRAR o quadro por elas. (O status do
# cartao — 'Revisao' etc. — nao e' exposto pela API de update; so' etiquetas sao.)
def list_etiquetas(board_id=None):
    """As etiquetas (labels) do quadro: [{id, nome, cor}, ...]."""
    return api("GET", f"/quadros/{_board_id(board_id)}/etiquetas").get("dados", []) or []


def criar_etiqueta(nome, cor, board_id=None):
    """Cria uma etiqueta no quadro e devolve o id (ou levanta)."""
    r = api("POST", "/etiquetas",
            {"quadro_id": _board_id(board_id), "nome": nome, "cor": cor})
    d = r.get("dados", r)
    return d.get("id") if isinstance(d, dict) else None


def garantir_etiqueta(nome, cores, board_id=None):
    """Upsert de etiqueta: reusa a que ja existe (por nome, sem acento/caixa) ou cria.

    `cores` pode ser UMA cor ou uma LISTA de formatos a tentar (ex.: ['#E53935',
    'vermelho', 'red']) — como o formato exato de `cor` da API pode variar, tentamos
    um a um ate a API aceitar. Devolve o id, ou None se nao deu para criar (falha
    SEGURA: sem etiqueta, a auditoria apenas nao marca — nunca derruba o passo)."""
    alvo = (nome or "").strip().lower()
    try:
        for e in list_etiquetas(board_id):
            if (e.get("nome") or "").strip().lower() == alvo:
                return e.get("id")
    except Exception:
        pass
    for cor in (cores if isinstance(cores, (list, tuple)) else [cores]):
        try:
            eid = criar_etiqueta(nome, cor, board_id)
            if eid:
                return eid
        except Exception:
            continue
    return None


def anexar_etiqueta(card_id, etiqueta_id):
    """Prende a etiqueta no cartao. 'Ja anexada' (400/409) NAO e' erro (idempotente)."""
    try:
        return api("POST", f"/cartoes/{card_id}/etiquetas", {"etiqueta_id": etiqueta_id})
    except ErroAPI as e:
        if e.codigo in (400, 409) and "exist" in (e.mensagem or "").lower():
            return None
        raise


def desanexar_etiqueta(card_id, etiqueta_id):
    """Tira a etiqueta do cartao. Devolve True/False (nunca derruba o passo)."""
    try:
        api("DELETE", f"/cartoes/{card_id}/etiquetas/{etiqueta_id}")
        return True
    except Exception:
        return False


def etiquetas_do_cartao(card_id):
    """As etiquetas atualmente no cartao: [{id, nome, cor}, ...] (do GET do cartao)."""
    return get_card(card_id).get("etiquetas", []) or []


# ── Campos personalizados (custom fields) ────────────────────────────────────
# A UX de correcao do aluno: em vez de digitar 'Probabilidade: alta' na descricao, ele
# escolhe num DROPDOWN (campo tipo 'selecao'); a Prioridade e' um numero que o python
# calcula. A API (em producao desde 2026-06-17): CRUD de campos em /quadros/{id}/
# campos-personalizados e valores por cartao em /cartoes/{id}/campos-valores/{campoId}.
#
# AS OPCOES de um campo 'selecao' tiveram DOIS leitores incompativeis ate 2026-07-30, e
# isso moldou este arquivo (fica registrado porque explica o codigo):
#   • a TELA (o modal do cartao) sempre leu/gravou as opcoes como ARRAY JSON. Com qualquer
#     outro formato ela nao conseguia separar e desenhava UM item so' — o menu virava uma
#     linha unica com tudo junto (foi o que o Eduardo viu no quadro 270 em 23/07).
#   • o VALIDADOR da API separava as opcoes por VIRGULA e, com elas em JSON, recusava
#     qualquer valor com 422. O curso convivia com os dois abrindo uma "janela" (virgula
#     para gravar, JSON de volta para o aluno escolher).
# O Henry alinhou os dois (item 0d do NEEDS_FOR_HENRY). Medido em 30/07 num quadro
# descartavel: o servidor NORMALIZA as opcoes para array JSON, seja o que for que se envie
# (JSON, virgula ou lista), e valida os valores contra elas — inclusive uma opcao que
# CONTEM virgula ('Suporte, infra e API (Henry)' grava 200, era 422). A janela morreu:
# ficou so' `opcoes_menu`, e escrever valor nao mexe mais no campo.
def opcoes_menu(labels):
    """As opcoes no formato canonico do ProjectAdm (array JSON) — o que a tela desenha
    como menu e o que o validador da API usa para aceitar um valor."""
    return json.dumps([str(x) for x in labels], separators=(",", ":"))


def opcoes_lidas(raw):
    """As opcoes de um campo -> lista, seja qual for o formato gravado (JSON, uma por
    linha ou separadas por virgula). E' o que permite reconhecer um campo legado."""
    txt = (raw or "").strip()
    if not txt:
        return []
    if txt.startswith("["):
        try:
            return [str(x).strip() for x in json.loads(txt) if str(x).strip()]
        except Exception:
            pass
    sep = "\n" if "\n" in txt else ","
    return [p.strip() for p in txt.split(sep) if p.strip()]


def list_campos(board_id=None):
    """Os campos personalizados do quadro: [{id, nome, tipo, opcoes_selecao, ...}]."""
    return api("GET", f"/quadros/{_board_id(board_id)}/campos-personalizados").get("dados", []) or []


# ESCOPO POR LISTA — `listas_ids` (medido em 2026-08-05, quadro 270).
#
# Ate' aqui o campo personalizado era do QUADRO INTEIRO: nascia vazio em TODO cartao de
# TODA lista, e o aluno via um menu de "Probabilidade" no cartao da justificativa. Era o
# custo conhecido dos campos, registrado em RECOMENDACAO_CAMPOS_PERSONALIZADOS.md (R1).
# O Henry entregou: o campo aceita `listas_ids` e so' aparece nas listas dali.
#
# MEDIDO no 270 (criar -> ler -> apagar, sem deixar rastro):
#   POST com listas_ids=[1502]  -> devolve listas_ids: [1502]
#   POST sem listas_ids         -> devolve listas_ids: []   (= quadro inteiro, como antes)
#   PUT  {"listas_ids": [1502]} -> muda o escopo de um campo que ja' existe
#   DELETE /campos-personalizados/{id} -> existe (foi como o teste se limpou)
# O openapi.json ainda NAO documenta `listas_ids` — a medicao e' a fonte da verdade aqui.
#
# `listas_ids=None` (o padrao) NAO manda a chave: campo sem escopo continua sendo do
# quadro, que e' exatamente o comportamento antigo. Aditivo por construcao.
def _ids_de_listas(listas_ids):
    """[1502] / ['1502'] / 1502 -> [1502]. None ou vazio -> None (nao manda a chave)."""
    if listas_ids in (None, "", [], ()):
        return None
    if isinstance(listas_ids, (str, int)):
        listas_ids = [listas_ids]
    out = []
    for x in listas_ids:
        try:
            out.append(int(x))
        except (TypeError, ValueError):
            continue
    return out or None


def criar_campo(nome, tipo, opcoes_selecao=None, tipo_rollup="nenhum", obrigatorio=0,
                board_id=None, listas_ids=None):
    """Cria um campo personalizado. `tipo`: texto|numero|data|selecao|checkbox|url.
    `opcoes_selecao`: lista de opcoes (ou string ja' formatada) — so' para 'selecao'.
    Uma LISTA vai no formato de menu (JSON), que e' o que a tela sabe desenhar.
    `listas_ids`: em quais listas o campo aparece (ausente = quadro inteiro)."""
    corpo = {"nome": nome, "tipo": tipo, "tipo_rollup": tipo_rollup, "obrigatorio": obrigatorio}
    if opcoes_selecao:
        corpo["opcoes_selecao"] = (opcoes_menu(opcoes_selecao)
                                   if isinstance(opcoes_selecao, (list, tuple))
                                   else opcoes_selecao)
    escopo = _ids_de_listas(listas_ids)
    if escopo:
        corpo["listas_ids"] = escopo
    r = api("POST", f"/quadros/{_board_id(board_id)}/campos-personalizados", corpo)
    d = r.get("dados", r)
    return d.get("id") if isinstance(d, dict) else None


def atualizar_campo(campo_id, **campos):
    """Atualiza um campo personalizado (ex.: opcoes_selecao quando a equipe muda, ou
    `listas_ids` quando o campo ainda e' do quadro inteiro).
    `opcoes_selecao` como LISTA vira o formato de menu (JSON)."""
    payload = {k: v for k, v in campos.items() if v is not None}
    opc = payload.get("opcoes_selecao")
    if isinstance(opc, (list, tuple)):
        payload["opcoes_selecao"] = opcoes_menu(opc)
    if "listas_ids" in payload:
        payload["listas_ids"] = _ids_de_listas(payload["listas_ids"]) or []
    return api("PUT", f"/campos-personalizados/{campo_id}", payload) if payload else None


def escopo_do_campo(campo):
    """As listas em que o campo aparece, como lista de int ([] = quadro inteiro)."""
    return _ids_de_listas((campo or {}).get("listas_ids")) or []


def campos_valores(card_id):
    """{campo_id (str) -> valor} do cartao. Tolerante ao nome da chave de id do campo
    (a API pode devolver campo_personalizado_id / campo_id / id).

    O valor NAO vem numa chave 'valor': a API devolve `valor_texto` / `valor_numero` /
    `valor_data`, conforme o tipo do campo. Ler 'valor' (o que este cliente fazia ate'
    2026-07-23) devolvia None para TODO campo — com isso o curso nunca enxergava o que o
    aluno escolheu no menu: a ponte campo -> texto do Passo 5 nao fazia nada e o semear
    reescrevia por cima da escolha do aluno."""
    dados = api("GET", f"/cartoes/{card_id}/campos-valores").get("dados", []) or []
    out = {}
    for v in (dados if isinstance(dados, list) else []):
        if not isinstance(v, dict):
            continue
        cid = v.get("campo_personalizado_id") or v.get("campo_id") or v.get("id")
        if cid is None:
            continue
        for chave in ("valor", "valor_texto", "valor_numero", "valor_data"):
            if v.get(chave) not in (None, ""):
                out[str(cid)] = v[chave]
                break
    return out


def set_campo_valor(card_id, campo_id, valor):
    """Grava o valor de um campo personalizado no cartao (PUT .../campos-valores/{campoId})."""
    return api("PUT", f"/cartoes/{card_id}/campos-valores/{campo_id}", {"valor": valor})


def limpar_campo_valor(card_id, campo_id):
    try:
        api("DELETE", f"/cartoes/{card_id}/campos-valores/{campo_id}")
        return True
    except Exception:
        return False
