#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ROTEIRO — motor de foco p/ laudar (TUI retro multi-maquina)
============================================================
Te leva estrutura por estrutura, na ordem do seu laudo, e NAO deixa
avancar enquanto voce nao resolver a atual. No fim, gera o LAUDO
FORMATADO (metodo hehe.beneditoonline.org) e exporta .doc/.txt.

Alterna TC / RM / RX (tecla M). Temas: Telescope / Atari / C64 / BSD / IBM.
Roda em Windows (cmd/Windows Terminal) e Unix. Somente stdlib.
Nada e enviado a lugar nenhum; so grava arquivo quando VOCE manda exportar.
NAO digite dados que identifiquem o paciente.

Uso:
    python roteiro.py                 # boot + menu (TUI)
    python roteiro.py --theme c64     # tema (telescope|atari|c64|bsd|ibm)
    python roteiro.py --mod RM        # modalidade inicial (TC|RM|RX)
    python roteiro.py ombro           # abre um roteiro direto
    python roteiro.py --no-boot ombro

Modo CLI (linha a linha, sem ANSI — funciona em pipe/ssh/terminal burro):
    python roteiro.py --cli ombro           # walk interativo por input()
    python roteiro.py --listar-modelos [filtro...]
    python roteiro.py --modelo cranio idoso            # imprime no stdout
    python roteiro.py --modelo cranio idoso --doc out.doc
    python roteiro.py --modelo rm ombro --txt laudo.txt
"""
import os, sys, time

HELP = """ROTEIRO Terminal Edition

Uso:
  roteiro.py [opções] [roteiro]        abre a TUI
  roteiro.py --cli [roteiro]           walk por perguntas de linha
  roteiro.py --cli [roteiro] --prompt-claude ARQ   exporta prompt Claude (.md)
  roteiro.py --listar-modelos [filtro]
  roteiro.py --modelo <filtro> [--doc ARQ] [--txt ARQ]
  roteiro.py --edit [arquivo]
  roteiro.py --serve [porta] [--dir D]  serve a UI web (padrão 8666; modelos bloqueados)

Opções da TUI:
  --theme NOME    telescope, atari, c64, bsd ou ibm
  --mod MOD       TC, RM ou RX
  --no-boot       pula a animação inicial
  --no-color      saída sem cores (modos de linha)
  -h, --help      mostra esta ajuda
"""

# ============================================================
# 1) MODALIDADES  — titulo, tecnica e rotulo do corpo por metodo
# ============================================================
MODS = {
 "TC":{"titulo":"TOMOGRAFIA COMPUTADORIZADA","corpo":"Relatório",
       "tecnica":"Realizada aquisição volumétrica dos dados, sem a administração endovenosa do meio de contraste iodado."},
 "RM":{"titulo":"RESSONÂNCIA MAGNÉTICA","corpo":"Análise",
       "tecnica":"Foram obtidas imagens multiplanares e multissequenciais {tec}."},
 "RX":{"titulo":"RADIOGRAFIA","corpo":"Achados",
       "tecnica":"Radiografia realizada em duas incidências."},
}
MOD_ORDER = ["TC","RM","RX"]

# ============================================================
# 2) OS ROTEIROS  — edite a vontade.
#    passo: {"t":titulo, "itens":[...], "dica":..., "normal":"frase p/ laudo", "meta":True}
#    meta:True = passo de trabalho (nao entra no corpo do laudo)
# ============================================================
ROTEIROS = [
 {"id":"cranio","nome":"Crânio","exame":"cranio","tail":"DE CRÂNIO","tec":"do crânio","lado":False,
  "imprNormal":"Estudo tomográfico do crânio sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Parênquima cerebral","itens":["Atenuação da substância branca/cinzenta","Isquemia / sangramento","Lesões focais"],"normal":"Parênquima cerebral com valores de atenuação preservados, sem áreas de sangramento ou isquemia."},
    {"t":"Núcleos da base / cápsulas","itens":["Densidade e simetria","Calcificações"],"normal":"Núcleos da base e regiões capsulares sem alterações."},
    {"t":"Sistema ventricular","itens":["Amplitude e simetria","Hidrocefalia","IV ventrículo"],"normal":"Cavidades ventriculares supratentoriais com densidade e amplitude conservadas; IV ventrículo anatômico."},
    {"t":"Sulcos / cisternas / linha média","itens":["Sulcos corticais","Cisternas da base","Desvio de linha média","Coleções extra-axiais"],"normal":"Sulcos corticais e cisternas da base com aspecto anatômico; estruturas da linha média centradas, sem coleções extra-axiais."},
    {"t":"Fossa posterior / cerebelo","itens":["Cerebelo e tronco","Densidade"],"normal":"Cerebelo e tronco encefálico com densidade preservada."},
    {"t":"Ossos / seios / mastoides","itens":["Calota e base do crânio","Fraturas","Seios paranasais e mastoides"],"normal":"Estruturas ósseas da calota e base do crânio sem alterações; seios paranasais e mastoides normopneumatizados."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"pescoco","nome":"Pescoço","exame":"pescoco","tail":"DE PESCOÇO","tec":"do pescoço","lado":False,
  "imprNormal":"Estudo tomográfico do pescoço sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Glândulas salivares","itens":["Parótidas","Submandibulares","Cálculos / ectasia ductal"],"normal":"Glândulas parótidas e submandibulares com forma, contornos, dimensões e atenuação preservados."},
    {"t":"Tireoide","itens":["Volume e simetria","Nódulos","Atenuação"],"normal":"Glândula tireoide com forma, contornos, dimensões e atenuação preservados."},
    {"t":"Faringe / laringe","itens":["Pregas vocais","Via aérea","Cartilagens"],"normal":"Estruturas da faringe e da laringe de aspecto habitual."},
    {"t":"Linfonodos","itens":["Níveis I-VII","Necrose / conglomerado"],"normal":"Ausência de linfonodomegalias cervicais."},
    {"t":"Espaços profundos","itens":["Parafaríngeo / retrofaríngeo","Coleções / abscesso"],"normal":"Espaços parafaríngeos e fossas pterigopalatinas sem alterações."},
    {"t":"Planos musculares e adiposos","itens":["Densificação","Partes moles"],"normal":"Planos musculares e adiposos preservados."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"face","nome":"Seios da face","exame":"seios da face","tail":"DOS SEIOS DA FACE","tec":"dos seios da face","lado":False,
  "imprNormal":"Seios paranasais normoaerados, sem sinais de sinusopatia.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Cavidades paranasais","itens":["Aeração maxilar/etmoidal/frontal/esfenoidal","Velamento / níveis"],"normal":"Cavidades paranasais normoaeradas."},
    {"t":"Vias de drenagem / complexo ostiomeatal","itens":["Infundíbulos","Recessos frontais"],"normal":"Vias de drenagem livres; complexo ostiomeatal pérvio."},
    {"t":"Septo / cornetos","itens":["Desvio septal","Concha bolhosa","Hipertrofia de cornetos"],"normal":"Septo nasal centrado; cornetos sem alterações significativas."},
    {"t":"Lâminas / lamelas","itens":["Papiráceas e crivosas","Lamelas laterais"],"normal":"Lâminas papiráceas e crivosas sem alterações; lamelas laterais simétricas."},
    {"t":"Canais ópticos / carotídeos","itens":["Relações vasculonervosas"],"normal":"Canais ópticos e carotídeos sem anormalidades."},
    {"t":"Estrutura óssea perissinusal","itens":["Integridade óssea","Erosões"],"normal":"Estrutura óssea perissinusal íntegra."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"torax","nome":"Tórax","exame":"torax","tail":"DE TÓRAX","tec":"do tórax","lado":False,
  "imprNormal":"Estudo tomográfico do tórax sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Parênquima pulmonar","itens":["Nódulos","Consolidações / opacidades","Enfisema / fibrose"],"normal":"Parênquima pulmonar com valores de atenuação preservados, sem nódulos ou consolidações."},
    {"t":"Pleura","itens":["Derrame","Espessamentos","Pneumotórax"],"normal":"Ausência de derrame pleural ou espessamentos."},
    {"t":"Vias aéreas","itens":["Traqueia e brônquios","Calibre / secreção"],"normal":"Traqueia e brônquios-fonte com calibre preservado."},
    {"t":"Mediastino / linfonodos","itens":["Linfonodomegalias","Massas"],"normal":"Ausência de linfonodomegalias mediastinais ou hilares."},
    {"t":"Grandes vasos / coração","itens":["Aorta","Área cardíaca","Calcificações coronarianas"],"normal":"Aorta de calibre normal; área cardíaca de dimensões preservadas."},
    {"t":"Parede torácica / ossos","itens":["Arcabouço ósseo","Partes moles"],"normal":"Arcabouço ósseo e partes moles da parede torácica sem alterações."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"abdomepelve","nome":"Abdome e pelve","exame":"abdome e pelve","tail":"DE ABDOME E PELVE","tec":"do abdome e pelve","lado":False,
  "imprNormal":"Estudo tomográfico do abdome e pelve sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Fígado / vias biliares","itens":["Contorno e densidade","Lesões focais","Vesícula / cálculos","Dilatação biliar"],"normal":"Fígado de volume normal, contorno regular e densidade homogênea; vesícula biliar e vias biliares sem alterações."},
    {"t":"Pâncreas / baço / adrenais","itens":["Morfologia e densidade","Lesões"],"normal":"Pâncreas, baço e adrenais sem alterações morfológicas ou de densidade."},
    {"t":"Rins / vias urinárias","itens":["Dimensões e contornos","Cálculos","Hidronefrose"],"normal":"Rins tópicos, de dimensões normais e contornos regulares, sem hidronefrose."},
    {"t":"Aorta / vasos / linfonodos","itens":["Calibre da aorta","Linfonodomegalias","Calcificações"],"normal":"Aorta de calibre normal; ausência de linfonodomegalias."},
    {"t":"Alças intestinais / cavidade","itens":["Distensão / espessamento","Líquido livre"],"normal":"Alças intestinais sem alterações; ausência de líquido livre na cavidade."},
    {"t":"Bexiga / pelve","itens":["Bexiga","Ampola retal","Fossas ísquio-retais"],"normal":"Bexiga repleta, sem alterações; ampola retal centrada; fossas ísquio-retais livres."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"cervical","nome":"Coluna cervical","exame":"coluna cervical","tail":"DA COLUNA CERVICAL","tec":"da coluna cervical","lado":False,
  "imprNormal":"Estudo tomográfico da coluna cervical sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Corpos / alinhamento","itens":["Altura e alinhamento posterior","Fraturas"],"normal":"Corpos vertebrais com altura e alinhamento posterior preservados."},
    {"t":"Pedículos","itens":["Integridade"],"normal":"Pedículos preservados."},
    {"t":"Espaços intervertebrais","itens":["Altura"],"normal":"Espaços intervertebrais mantidos."},
    {"t":"Articulações (interapofisárias / uncovertebrais)","itens":["Artrose","Uncoartrose"],"normal":"Articulações interapofisárias e uncovertebrais íntegras."},
    {"t":"Forames de conjugação","itens":["Amplitude"],"normal":"Forames de conjugação livres."},
    {"t":"Canal vertebral","itens":["Amplitude"],"normal":"Canal vertebral ósseo com amplitude conservada por toda a extensão estudada."},
    {"t":"Transição crânio-cervical","itens":["Aspecto anatômico"],"normal":"Transição crânio-cervical de aspecto anatômico."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"dorsal","nome":"Coluna dorsal","exame":"coluna dorsal","tail":"DA COLUNA DORSAL","tec":"da coluna dorsal","lado":False,
  "imprNormal":"Estudo tomográfico da coluna dorsal sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Corpos vertebrais / alinhamento","itens":["Altura","Alinhamento","Listese","Fraturas"],"normal":"Corpos vertebrais de altura preservada e alinhamento posterior mantido."},
    {"t":"Pedículos / elementos posteriores","itens":["Integridade"],"normal":"Pedículos íntegros."},
    {"t":"Espaços discais","itens":["Altura","Vácuo discal"],"normal":"Espaços intervertebrais mantidos."},
    {"t":"Articulações interapofisárias","itens":["Artrose facetária","Cistos"],"normal":"Articulações interapofisárias sem alterações apreciáveis."},
    {"t":"Forames neurais","itens":["Amplitude","Estenose"],"normal":"Forames neurais com amplitude preservada."},
    {"t":"Canal vertebral","itens":["Amplitude","Estenose"],"normal":"Canal vertebral com amplitude preservada."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"lombossacra","nome":"Coluna lombossacra","exame":"coluna lombossacra","tail":"DA COLUNA LOMBOSSACRA","tec":"da coluna lombossacra","lado":False,
  "imprNormal":"Estudo tomográfico da coluna lombossacra sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Corpos vertebrais / alinhamento","itens":["Altura","Alinhamento","Listese","Fraturas"],"normal":"Corpos vertebrais de altura preservada e alinhamento posterior mantido."},
    {"t":"Pedículos / elementos posteriores","itens":["Integridade"],"normal":"Pedículos íntegros."},
    {"t":"Espaços discais","itens":["Altura","Vácuo discal"],"normal":"Espaços intervertebrais mantidos."},
    {"t":"Articulações interapofisárias","itens":["Artrose facetária","Cistos"],"normal":"Articulações interapofisárias sem alterações apreciáveis."},
    {"t":"Forames neurais","itens":["Amplitude","Estenose"],"normal":"Forames neurais com amplitude preservada."},
    {"t":"Canal vertebral","itens":["Amplitude","Estenose"],"normal":"Canal vertebral com amplitude preservada."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"quadril","nome":"Quadril","exame":"quadril","tail":"DO QUADRIL","tec":"do quadril","lado":True,
  "imprNormal":"Estudo tomográfico do quadril{lado} sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Estruturas ósseas","itens":["Morfologia e textura","Fraturas","Lesões focais"],"normal":"Estruturas ósseas com morfologia e textura preservadas, sem fraturas."},
    {"t":"Articulação coxofemoral","itens":["Espaço articular","Derrame","Impacto CAM/PINCER"],"normal":"Espaço articular coxofemoral mantido, sem sinais de derrame."},
    {"t":"Cabeça / colo femoral","itens":["Contornos","Densidade","Necrose"],"normal":"Cabeça e colo femorais com contornos e densidade preservados, sem sinais de necrose."},
    {"t":"Partes moles / musculatura","itens":["Planos musculares","Coleções"],"normal":"Estruturas miotendíneas periarticulares e partes moles sem anormalidades (sensibilidade limitada do método)."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"mskgeral","nome":"MSK geral","exame":"exame","tail":"","tec":"da região estudada","lado":False,
  "imprNormal":"Estudo tomográfico musculoesquelético sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Pele / subcutâneo","itens":["Aspecto","Coleções"],"normal":"Pele e tecido celular subcutâneo de aspecto preservado."},
    {"t":"Estruturas ósseas","itens":["Morfologia e textura","Fraturas","Lesões"],"normal":"Estruturas ósseas íntegras, com morfologia e textura habituais, sem fraturas."},
    {"t":"Articulações","itens":["Espaços","Derrame","Corpos livres"],"normal":"Espaços articulares mantidos, sem sinais de derrame."},
    {"t":"Miotendíneas / partes moles","itens":["Musculatura","Calcificações"],"normal":"Estruturas miotendíneas periarticulares sem anormalidades (sensibilidade limitada do método)."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"mskesp","nome":"MSK específico","exame":"exame","tail":"","tec":"da região estudada","lado":True,
  "imprNormal":"Exame tomográfico sem alterações significativas.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Estrutura-alvo (foco do exame)","itens":["Achado que responde à indicação"],"normal":"Estrutura-alvo sem alterações significativas ao método.","dica":"Ajuste o título p/ a região (ombro, joelho, punho…)."},
    {"t":"Estruturas ósseas","itens":["Fraturas","Lesões focais"],"normal":"Estruturas ósseas íntegras, sem fraturas ou lesões focais."},
    {"t":"Articulação","itens":["Espaço","Derrame"],"normal":"Espaço articular mantido, sem derrame significativo."},
    {"t":"Partes moles","itens":["Vizinhança","Coleções"],"normal":"Partes moles adjacentes sem alterações."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"pelvem","nome":"Pelve masculina","exame":"pelve masculina","tail":"DA PELVE MASCULINA","tec":"da pelve masculina","lado":False,
  "imprNormal":"Estudo tomográfico da pelve masculina sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Bexiga","itens":["Repleção","Parede","Cálculos"],"normal":"Bexiga repleta, de conteúdo homogêneo, sem espessamentos parietais."},
    {"t":"Próstata / vesículas seminais","itens":["Dimensões","Densidade","Simetria"],"normal":"Próstata de dimensões e densidade preservadas; vesículas seminais simétricas."},
    {"t":"Reto / alças pélvicas","itens":["Ampola retal","Alças"],"normal":"Ampola retal centrada; alças intestinais pélvicas sem alterações."},
    {"t":"Linfonodos / vasos","itens":["Linfonodomegalias","Vasos pélvicos"],"normal":"Ausência de linfonodomegalias pélvicas; vasos pélvicos de calibre preservado."},
    {"t":"Líquido / fossas / ossos","itens":["Líquido livre","Fossas ísquio-retais","Ossos"],"normal":"Ausência de líquido livre; fossas ísquio-retais livres; estruturas ósseas sem lesões."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]},
 {"id":"pelvef","nome":"Pelve feminina","exame":"pelve feminina","tail":"DA PELVE FEMININA","tec":"da pelve feminina","lado":False,
  "imprNormal":"Estudo tomográfico da pelve feminina sem alterações.","passos":[
    {"t":"Técnica e qualidade","itens":["Método e cobertura adequados","Artefatos / movimento"],"meta":True},
    {"t":"Bexiga","itens":["Repleção","Parede"],"normal":"Bexiga repleta, de conteúdo homogêneo, sem espessamentos parietais."},
    {"t":"Útero","itens":["Dimensões","Contornos","Miométrio"],"normal":"Útero de dimensões e contornos preservados."},
    {"t":"Anexos / ovários","itens":["Massas","Coleções","Cistos"],"normal":"Regiões anexiais sem massas ou coleções; ovários sem alterações evidentes ao método."},
    {"t":"Reto / alças pélvicas","itens":["Ampola retal","Alças"],"normal":"Ampola retal centrada; alças intestinais pélvicas sem alterações."},
    {"t":"Linfonodos / vasos","itens":["Linfonodomegalias","Vasos pélvicos"],"normal":"Ausência de linfonodomegalias pélvicas; vasos pélvicos de calibre preservado."},
    {"t":"Líquido / fossas / ossos","itens":["Fundo de saco","Fossas ísquio-retais","Ossos"],"normal":"Ausência de líquido livre no fundo de saco; fossas ísquio-retais livres; estruturas ósseas sem lesões."},
    {"t":"Impressão","itens":["Sintetizar os positivos","Responder à indicação","Comparar com prévios"],"dica":"Nada de novo aqui — só o que já foi visto.","meta":True}
 ]}
]

# ============================================================
# 3) TEMAS (paletas ANSI 24-bit)  + TERMINAL
# ============================================================
NO_COLOR = ("--no-color" in sys.argv) or os.environ.get("NO_COLOR")

THEMES = {
 # accent, warn, err, muted, text  (r,g,b)
 "telescope":{"accent":(126,198,153),"warn":(212,184,106),"err":(204,122,122),"muted":(138,146,156),"text":(217,222,227),"boot":"telescope"},
 "atari":    {"accent":(255,255,255),"warn":(255,233,166),"err":(255,176,176),"muted":(185,196,242),"text":(230,236,255),"boot":"atari"},
 "c64":      {"accent":(176,164,255),"warn":(216,207,138),"err":(255,138,138),"muted":(150,140,220),"text":(139,127,240),"boot":"c64"},
 "bsd":      {"accent":(240,179,80), "warn":(224,192,96), "err":(224,122,90), "muted":(143,133,95), "text":(217,199,160),"boot":"bsd"},
 "ibm":      {"accent":(255,255,85), "warn":(255,255,85), "err":(255,85,85),  "muted":(168,168,255),"text":(255,255,255),"boot":"ibm"},
}
def parse_arg(flag, default=None):
    if flag in sys.argv:
        try: return sys.argv[sys.argv.index(flag)+1]
        except Exception: return default
    return default

THEME = (parse_arg("--theme") or "telescope").lower()
if THEME not in THEMES: THEME = "telescope"

def fg(rgb): return "" if NO_COLOR else "\x1b[38;2;%d;%d;%dm"%rgb
RESET="" if NO_COLOR else "\x1b[0m"; BOLD="" if NO_COLOR else "\x1b[1m"; DIM="" if NO_COLOR else "\x1b[2m"
def set_theme(name):
    global THEME, GREEN, WARN, ERR, MUTE, WHITE
    THEME=name; p=THEMES[name]
    GREEN=fg(p["accent"]); WARN=fg(p["warn"]); ERR=fg(p["err"]); MUTE=fg(p["muted"]); WHITE=fg(p["text"])
set_theme(THEME)

def enable_ansi():
    try: sys.stdout.reconfigure(encoding="utf-8")
    except Exception: pass
    if os.name=="nt":
        try:
            import ctypes; k=ctypes.windll.kernel32; k.SetConsoleOutputCP(65001)
            h=k.GetStdHandle(-11); m=ctypes.c_uint32(); k.GetConsoleMode(h,ctypes.byref(m))
            k.SetConsoleMode(h, m.value|0x0004)
        except Exception: pass
def ansi_available():
    """A TUI exige terminal interativo; ANSI pode ser desativado explicitamente."""
    return bool(sys.stdin.isatty() and sys.stdout.isatty() and not NO_COLOR)
def clear():
    if ansi_available(): sys.stdout.write("\x1b[2J\x1b[H"); sys.stdout.flush()
def hide_cursor():
    if ansi_available(): sys.stdout.write("\x1b[?25l")
def show_cursor():
    if ansi_available(): sys.stdout.write("\x1b[?25h")
def term_cols():
    try: return max(48, os.get_terminal_size().columns)
    except Exception: return 80

def actual_term_cols():
    """Largura física, inclusive em terminais menores que o layout da TUI."""
    try: return max(1, os.get_terminal_size().columns)
    except Exception: return 80

if os.name=="nt":
    import msvcrt
    _WKEYS={"H":"UP","P":"DOWN","K":"LEFT","M":"RIGHT","G":"HOME","O":"END",
            "I":"PGUP","Q":"PGDN","S":"DEL"}
    def getkey():
        ch=msvcrt.getwch()
        if ch in ("\x00","\xe0"):
            c=msvcrt.getwch(); return _WKEYS.get(c,"")
        if ch=="\r": return "ENTER"
        if ch=="\x1b": return "ESC"
        if ch=="\x03": raise KeyboardInterrupt
        if ch=="\x08": return "BS"
        if ch=="\t": return "TAB"
        return ch
else:
    import termios, tty, select
    _UKEYS={"[A":"UP","[B":"DOWN","[D":"LEFT","[C":"RIGHT","[H":"HOME","[F":"END",
            "[1~":"HOME","[4~":"END","[3~":"DEL","[5~":"PGUP","[6~":"PGDN"}
    _RAW=[None]   # termios salvos enquanto o modo raw fica "preso" (editor)
    def _read_key():
        ch=sys.stdin.read(1)
        if ch=="\x1b":
            seq=""
            while select.select([sys.stdin],[],[],0.02)[0]:
                seq+=sys.stdin.read(1)
                if seq in _UKEYS or (len(seq)>=2 and seq[-1] in "~ABCDHF"): break
                if len(seq)>4: break
            return _UKEYS.get(seq,"ESC") if seq else "ESC"
        if ch in ("\r","\n"): return "ENTER"
        if ch=="\x03": raise KeyboardInterrupt
        if ch in ("\x7f","\x08"): return "BS"
        if ch=="\t": return "TAB"
        return ch
    def getkey():
        if _RAW[0] is not None:          # raw preso (editor): lê direto
            return _read_key()
        fd=sys.stdin.fileno(); old=termios.tcgetattr(fd)
        try:
            tty.setraw(fd); return _read_key()
        finally: termios.tcsetattr(fd, termios.TCSADRAIN, old)

def raw_hold():
    """prende o terminal em raw (necessário p/ ^S/^Q não virarem XON/XOFF)."""
    if os.name!="nt" and _RAW[0] is None:
        fd=sys.stdin.fileno(); _RAW[0]=termios.tcgetattr(fd); tty.setraw(fd)
def raw_release():
    if os.name!="nt" and _RAW[0] is not None:
        termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _RAW[0]); _RAW[0]=None
if os.name=="nt":
    def raw_hold(): pass
    def raw_release(): pass

def read_line(prompt):
    show_cursor()
    try: return input(prompt)
    except (EOFError, KeyboardInterrupt): return ""
    finally: hide_cursor()

def clip_copy(text):
    try:
        if os.name=="nt": subprocess.run("clip", input=text.encode("utf-16-le"), check=True); return True
        if sys.platform=="darwin": subprocess.run("pbcopy", input=text.encode("utf-8"), check=True); return True
        for cmd in (["xclip","-selection","clipboard"],["xsel","--clipboard","--input"],["wl-copy"]):
            try: subprocess.run(cmd, input=text.encode("utf-8"), check=True); return True
            except Exception: continue
    except Exception: pass
    return False

# ============================================================
# 4) DESENHO
# ============================================================
BLK,LGT="█","░"; GLY={1:"✓",2:"⚠",3:"○",0:"·"}
NORMAL,ALT,NA=1,2,3
def fmt_time(s): s=int(max(0,s)); return "%02d:%02d"%(s//60,s%60)
def bar(done,total,w=34):
    f=int(round(done/total*w)) if total else 0; return BLK*f+LGT*(w-f)
def rule(w=None,ch="─"): return MUTE+ch*(w or min(term_cols()-2,76))+RESET
def _strip(s):
    import re; return re.sub(r"\x1b\[[0-9;]*m","",s)
def type_out(s,d=.006):
    for ch in s: sys.stdout.write(ch); sys.stdout.flush(); time.sleep(d)
    sys.stdout.write("\n")

BOOTS = {
 "telescope":[("LAUDÁRIO BIOS v6.66  (C) baltazar systems",True),("Radiologia / foco / disciplina de leitura",False),
   ("CPU ...................... foco@sustentado  OK",False),("Satisfação de busca ...... DESATIVADA  OK",False),
   ("Viés de ancoragem ........ SUPRIMIDO  OK",False),("> um passo por vez",True)],
 "atari":[("ATARI 800XL",True),("ATARI COMPUTER — MEMO PAD",False),("SELF TEST ................ OK",False),
   ("ROTEIRO.OSS CARREGADO",False),("READY",True)],
 "c64":[("**** COMMODORE 64 BASIC V2 ****",True),("64K RAM SYSTEM  38911 BASIC BYTES FREE",False),
   ('LOAD"ROTEIRO",8,1',False),("SEARCHING FOR ROTEIRO ... LOADING ... READY.",False),("RUN",True)],
 "bsd":[("FreeBSD/amd64 (laudario) — boot",True),("Copyright (c) 1992-2026 baltazar systems",False),
   ("CPU: foco@sustentado (2400.00-MHz)",False),("Trap: satisfacao_de_busca -> SIGKILL",False),
   ("module viés_de_ancoragem: unloaded",False),("login: radiologista",True)],
 "ibm":[("IBM  Personal Computer",True),("Phoenix ROM BIOS PLUS v6.66",False),("640K OK",False),
   ("Starting MS-DOS...",False),("C:\\> ROTEIRO.EXE",True)],
}

SABOTAGE_ASCII = """%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@%*%%%@+%%%@@@%%*%@%%#%#*%%%%%%#%%%#%%%*%%%%%#%#%- .........
%%%%%%%%%%@@@@@@@@@@@@@@@@@@**@@@@*%%%@#*%%@@%%%*%@%+*%+#%%%%%%##%%##%%*%%%%%#%#%- .........
%%%%%%%%%%@%@@@@@@@@@@@@@@@@#=*+##**+%%@**%@@%%*+***#%++@%%%%%%##%%##%%*%%%%%*%##: .......  
%%%%%%%%%%%%%%@@@@@@@@@@@@@@@#=+#%-#**@%@+#%##@#.-#@%+++%%*#%@%##%%##%%*%%%%%*%%#: :.  .    
%%%%%%%%%%%%%%@@@@@@@#%@@@@@@@=#=#+:#=#@@*=#%%#=.%@%=+@#=+####%##%%*#%%*%%%%%##%#: ..--+-.  
%%%%%%%%%%%%%@@@@@@@@#*#@@@@@@%+*==:-#+#@*-@%=--*@#=*%%+:*#####%#%%*#%%*%%%%%##%#.  .+***:  
%%%%%%%%%%%%%@@@%%%@%@@#+@@@@@@%:====-%=#=:+=-*=#+*%++--%%%%%@%%#%%**%%#%%%%%##%*. ..:-+=.  
%%%%%%%%%%%%%%%@%#*#*###==%@@@%@==+:=.::-:.-::::-:*==+=#%%%%#####%%**%%#%%%%%##%*........   
%%%%%%%%%%%%%%%%@@@@@#%@%*-**%@@+...:..::..::---:-.:--%%###=*##*+*#**%%#%%%%%#*%+.... ..    
%%%%%%%%%%%%%%%%%%%%@#*#%@%-=+*-..:-==++===+****+=-: .=+**+=%###+-+++#%#%%%%%#*%= . .....   
%%%%%%%%%%%%%%%%%%@@%@@#**#***: .:--=-==++++**++==-::. -+==+###+=*%#*%%#%%%%%#*%= .   ..    
%%%%%%%%%%%%%%%%@##%%*#@@@#++=. ..:.::::-===++=----:....+#####+#%#%#*%%#%%%%%#*%+           
%%%%%%%%%%%%%%%%%%%%##++##%%*-   .::::::--==-==-==-::.. -+*#**%%##%#*%%#%%%%%#*%+   .       
%%%%%%%%%%%%%%%%%%%@@%%#*+##*. ... ...:---==--:....::.. +#-*+=*###%##%%#%%%%%%*#+           
%%%%%%%%%%%%%%%%%%%%%%@@%+*+=:  .......::::::....:...:..=*+-*+-=***###%#%%%%%#*#+           
%%%%%%%%%%%%%%%%%%%%%%*=-. ...  .....-:...-:.....==..:. -+%+:++-=*+=+##%%%%%%%*#*           
%%%%%%%%%%%%%%%%%%%#+=++--:::   ....:+-:.:**---::::::::  :=#*-++-+++=+++#%%%%%*#*..... .    
%%%%%%%%%%%%%%%%#**+==-=+==+%:...........:+#=---:::--:.. .:=*==+=----=-:+%%%%#+#*........   
%%%%%%%%%%%%%%*--=#+:==:+:--@*....:::::.::-+=-::----::...*+:-===----==-:+%%###+**........   
%%%%%%%%%%%%%%*.:=*%+-::+=::#%-....:..:...:-:.--:::....--+=.:---=--==--:=%%###+**           
%%%%%%%%%%%%%%@=.-=*%%*-:==-*%*: . ....:....::-=--:......  ..:-=-=-----::##*##+**----:-:::::
%%%%%%%%%%%%%%%%:.:=*##*=*#*+-  ..  .......:::::::.... .......:---=----:.=**#*+*#***+++**+=-
%%%%%%%%%%%%%%%%+.:-=+===+++=: .-.   .......::::.....  .... ...:---=---:::*##*******+**=:.  
%%%%%%%%%%%#+-:..::-----:::::. :-     .....::::.......        ..:-------::+#***+*****+:     
#%%%##%%#+:.   .:-------::.    :-.      ........... :.       . ..:-==---::.:+#*******:      
##%#%%*-.   ...::::---::.       -.      ..........  :            .::---:..  .*###*++-       
###%#-         .::--::..        .-       ..... .   :.             ..:-:.     .+%*+*-       .
#%%+. ...       .:--:..          .:  ..           .:              .....        -+++-  ... ..
*+-  ..           ..:.            .:  ..          :.               ..           .-=-:.......
   ......           .::.           .:             -           .                  .--:..=---:
...    ...            ..      .     ::           :: .         .            .    .  :***%#+-:
"""

def boot():
    clear(); hide_cursor()
    width=actual_term_cols()
    for line in SABOTAGE_ASCII.splitlines():
        sys.stdout.write(GREEN+line[:width]+RESET+"\n")
    sys.stdout.flush()
    for txt,accent in BOOTS.get(THEME,BOOTS["telescope"]):
        type_out((GREEN if accent else MUTE)+txt+RESET, .012 if accent else .006)
        time.sleep(.03)
    sys.stdout.write("\n"+GREEN+"Montando trilho de análise "+RESET); sys.stdout.flush()
    W=26
    for n in range(W+1):
        sys.stdout.write("\r"+GREEN+"Montando trilho de análise ["+BLK*n+"."*(W-n)+"]"+RESET); sys.stdout.flush(); time.sleep(.02)
    sys.stdout.write("\n\n"+GREEN+"> pronto."+RESET+"\n"); sys.stdout.flush(); time.sleep(.4)

def header(right=""):
    cols=min(term_cols(),78)
    left=BOLD+GREEN+"ROTEIRO"+RESET
    pad=cols-8-len(_strip(right))
    print(left+" "*max(1,pad)+MUTE+right+RESET)
    print(rule(cols,"═"))

# ============================================================
# 5) LAUDO FORMATADO (método hehe)
# ============================================================
def lado_suffix(st): return (" "+st["lado"].upper()) if st.get("lado") else ""
def lado_impr(st):   return (" "+st["lado"].lower()) if st.get("lado") else ""
def titulo_laudo(r, st):
    t=MODS[st["mod"]]["titulo"]
    if r["tail"]: t+=" "+r["tail"]
    if r["lado"]: t+=lado_suffix(st)
    return t.strip()
def tecnica_laudo(r, st):
    return MODS[st["mod"]]["tecnica"].replace("{tec}", r["tec"] or "da região estudada")
def build_laudo(r, st):
    body=[]; impr=[]
    for i,p in enumerate(r["passos"]):
        if p.get("meta"): continue
        mk=st["marks"].get(i)
        if mk==NORMAL and p.get("normal"): body.append(p["normal"])
        elif mk==ALT:
            n=(st["notes"].get(i,"") or "").strip()
            body.append(n or ("[descrever: %s]"%p["t"]))
            impr.append(n or p["t"])
    imprtxt = "\n".join("- "+x for x in impr) if impr else "- "+r["imprNormal"].replace("{lado}", lado_impr(st))
    return {"titulo":titulo_laudo(r,st),"indicacao":st.get("indicacao","").strip(),
            "tecnica":tecnica_laudo(r,st),"corpoLabel":MODS[st["mod"]]["corpo"],
            "corpo":"\n".join(body),"impressao":imprtxt}
def laudo_texto(L):
    out=L["titulo"]+"\n\n"
    if L["indicacao"]: out+="Indicação clínica: "+L["indicacao"]+"\n\n"
    out+="Técnica: "+L["tecnica"]+"\n\n"
    out+=L["corpoLabel"]+":\n"+(L["corpo"] or "—")+"\n\n"
    out+="Impressão:\n"+(L["impressao"] or "—")+"\n"
    return out
def laudo_doc(L):
    S="margin:0cm 0cm 6.0pt 0cm;line-height:12.0pt;mso-line-height-rule:exactly;font-family:Arial,sans-serif;font-size:11.0pt;"
    esc=lambda s: s.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
    P=lambda h,a="justify": '<p style="%stext-align:%s">%s</p>'%(S,a,h)
    BL='<p style="%s">&nbsp;</p>'%S
    b=P("<b>"+esc(L["titulo"])+"</b>","center")+BL
    if L["indicacao"]: b+=P("<b>Indicação clínica:</b> "+esc(L["indicacao"]))+BL
    b+=P("<b>Técnica:</b> "+esc(L["tecnica"]))+BL
    b+=P("<b>"+esc(L["corpoLabel"])+":</b>")
    for l in (L["corpo"] or "—").split("\n"):
        if l.strip(): b+=P(esc(l))
    b+=BL+P("<b>Impressão:</b>")
    for l in (L["impressao"] or "—").split("\n"):
        if l.strip(): b+=P(esc(l))
    return ("﻿<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' "
            "xmlns='http://www.w3.org/TR/REC-html40'><head><meta charset='utf-8'></head><body>"+b+"</body></html>")
def slug(s):
    import re, unicodedata
    s=unicodedata.normalize("NFD",s).encode("ascii","ignore").decode()
    return re.sub(r"(^-|-$)","",re.sub(r"[^a-z0-9]+","-",s.lower()))

# ============================================================
# 5b) BIBLIOTECA DE MODELOS (modelos.json — hehe + laudário + construtores)
#     Opcional: se o arquivo não estiver ao lado do script, o menu esconde.
# ============================================================
def load_modelos():
    """Procura modelos.json: ao lado do .exe (PyInstaller), embutido
    (_MEIPASS) ou ao lado do .py. Ausente = biblioteca desabilitada."""
    import json
    cands = []
    if getattr(sys, "frozen", False):
        cands.append(os.path.join(os.path.dirname(sys.executable), "modelos.json"))
        mp = getattr(sys, "_MEIPASS", "")
        if mp: cands.append(os.path.join(mp, "modelos.json"))
    cands.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "modelos.json"))
    for p in cands:
        try:
            with open(p, encoding="utf-8") as f: return json.load(f)
        except Exception: continue
    return []
MODELOS = load_modelos()

def load_biblioteca():
    """Procura biblioteca.json (snapshot offline do Notion — wikis, compêndio,
    base de conhecimento): ao lado do .exe, embutido (_MEIPASS) ou ao lado do
    .py. Ausente = biblioteca offline desabilitada. LGPD: privado."""
    import json
    cands = []
    if getattr(sys, "frozen", False):
        cands.append(os.path.join(os.path.dirname(sys.executable), "biblioteca.json"))
        mp = getattr(sys, "_MEIPASS", "")
        if mp: cands.append(os.path.join(mp, "biblioteca.json"))
    cands.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "biblioteca.json"))
    for p in cands:
        try:
            with open(p, encoding="utf-8") as f: return json.load(f)
        except Exception: continue
    return []
BIBLIOTECA = load_biblioteca()

def _macc(s):
    import unicodedata
    return unicodedata.normalize("NFD", s or "").encode("ascii","ignore").decode().lower()

def load_sugestoes(path=None):
    """Carrega somente JSON local; formato inválido equivale a arquivo ausente."""
    import json
    path = path or os.path.join(os.path.dirname(os.path.abspath(__file__)), "sugestoes.json")
    try:
        with open(path, encoding="utf-8") as f: data=json.load(f)
        if not isinstance(data, dict): return {}
        return data
    except (OSError, ValueError, TypeError):
        return {}

def fallback_sugestoes(passo):
    """Reutiliza texto do próprio passo, sem compor ou inventar frase clínica."""
    out=[]
    normal=passo.get("normal")
    if isinstance(normal, str) and normal.strip(): out.append(normal.strip())
    for item in passo.get("itens", []):
        if isinstance(item, str) and item.strip() and item.strip() not in out:
            out.append(item.strip())
    return out

def sugestoes_do_passo(base, roteiro_id, passo_indice, passo):
    por_roteiro=base.get(roteiro_id, {}) if isinstance(base, dict) else {}
    frases=por_roteiro.get(str(passo_indice), []) if isinstance(por_roteiro, dict) else []
    validas=[x.strip() for x in frases if isinstance(x, str) and x.strip()] if isinstance(frases, list) else []
    return validas or fallback_sugestoes(passo)

def filtrar_sugestoes(frases, consulta):
    termos=_macc(consulta).split()
    return [frase for frase in frases if all(t in _macc(frase) for t in termos)]

def selecionar_sugestao(frases, indice):
    return frases[indice] if isinstance(indice, int) and 0 <= indice < len(frases) else None

# ============================================================
# 5b-bis) WORKBENCH DE ALTERAÇÕES — helpers PUROS
#   Cesta, favoritos, recentes, composição determinística e prompt Claude.
#   Nunca inventa frase clínica; só concatena o que o usuário escolheu.
# ============================================================
def snippet_hash(texto):
    """Hash estável (whitespace normalizado) de um snippet da base.
    Usado como ID em favoritos/recentes — nunca guarda texto de paciente."""
    import hashlib
    norm=" ".join((texto or "").split())
    return hashlib.sha1(norm.encode("utf-8")).hexdigest()[:16]

def snippets_do_passo(base, roteiro_id, passo_indice, passo):
    """Lista estruturada [{texto, fonte, hash}] p/ o Workbench.
    fonte='local' (sugestoes.json) ou 'fallback' (texto do próprio passo)."""
    por_roteiro=base.get(roteiro_id, {}) if isinstance(base, dict) else {}
    frases=por_roteiro.get(str(passo_indice), []) if isinstance(por_roteiro, dict) else []
    validas=[x.strip() for x in frases if isinstance(x, str) and x.strip()] if isinstance(frases, list) else []
    fonte="local" if validas else "fallback"
    textos=validas or fallback_sugestoes(passo)
    return [{"texto":t, "fonte":fonte, "hash":snippet_hash(t)} for t in textos]

def filtrar_snippets(snippets, consulta):
    """Filtro estável, sem acento, por múltiplos tokens sobre o texto."""
    termos=_macc(consulta).split()
    return [s for s in snippets if all(t in _macc(s.get("texto","")) for t in termos)]

def favoritos_como_snippets(favoritos):
    """Converte favoritos {hash,texto} em snippets exibíveis (fonte='favorito')."""
    return [{"texto":f.get("texto",""), "fonte":"favorito", "hash":f.get("hash","")}
            for f in favoritos if isinstance(f, dict) and f.get("texto")]

def indexar_snippets(*grupos):
    """dict hash->snippet a partir de vários grupos (o 1º ganha em colisão)."""
    mapa={}
    for grupo in grupos:
        for s in grupo:
            h=s.get("hash")
            if h and h not in mapa: mapa[h]=s
    return mapa

def resolver_recentes(recentes_hashes, indice):
    """Snippets recentes na ordem de recência; ignora hashes desconhecidos."""
    out=[]
    for h in recentes_hashes:
        s=indice.get(h)
        if s: out.append(s)
    return out

def cesta_contem(cesta, texto):
    return (texto or "").strip() in cesta

def cesta_toggle(cesta, texto):
    """Adiciona/remove `texto` da cesta (igualdade exata do texto já limpo)."""
    t=(texto or "").strip()
    if not t: return list(cesta)
    if t in cesta: return [x for x in cesta if x != t]
    return list(cesta)+[t]

def cesta_remover(cesta, indice):
    if 0 <= indice < len(cesta): return cesta[:indice]+cesta[indice+1:]
    return list(cesta)

def cesta_mover(cesta, indice, delta):
    """Move o item `indice` por delta (±1). Retorna (nova_cesta, novo_indice)."""
    n=len(cesta)
    if n==0: return [], 0
    if not (0 <= indice < n): return list(cesta), max(0,min(indice,n-1))
    novo=max(0,min(indice+delta, n-1))
    if novo==indice: return list(cesta), indice
    c=list(cesta); c[indice],c[novo]=c[novo],c[indice]
    return c, novo

def compor_alteracao(cesta):
    """Concatena os textos escolhidos NA ORDEM, unindo por '. ' de modo
    previsível: sem reescrever conteúdo e sem inventar frase nova. Não
    acrescenta ponto final se a última peça não tiver pontuação."""
    pecas=[p.strip() for p in cesta if isinstance(p, str) and p.strip()]
    if not pecas: return ""
    texto=pecas[0]
    for p in pecas[1:]:
        texto += (" " if texto.endswith((".","!","?",":",";","…")) else ". ")+p
    return texto

def favorito_contem(favoritos, h):
    return any(isinstance(f, dict) and f.get("hash")==h for f in favoritos)

def favorito_toggle(favoritos, h, texto):
    """Adiciona/remove um favorito (guarda hash + texto genérico do snippet)."""
    if favorito_contem(favoritos, h):
        return [f for f in favoritos if f.get("hash")!=h]
    return list(favoritos)+[{"hash":h, "texto":texto}]

MAX_RECENTES=100
def registrar_recente(recentes, h, limite=MAX_RECENTES):
    """Prepende o hash, remove duplicata e limita o histórico (só IDs da base)."""
    if not h: return list(recentes)[:max(1,limite)]
    return ([h]+[x for x in recentes if x!=h])[:max(1,limite)]

def parse_multi_selecao(valor, frases):
    """Interpreta '1,3,2' como seleção múltipla ORDENADA de `frases`.
    Retorna a lista de textos escolhidos; [] indica texto livre / sem número."""
    escolhidos=[]
    for parte in (valor or "").replace(";", ",").split(","):
        parte=parte.strip()
        if parte.isdigit():
            k=int(parte)
            if 1 <= k <= len(frases): escolhidos.append(frases[k-1])
    return escolhidos

def build_claude_prompt(modalidade, exame, estrutura, itens, alteracao,
                        indicacao=None, outras_alteracoes=None, modelo=None):
    """Monta (texto puro p/ .md) o prompt de REVISÃO para o Claude.
    Inclui SOMENTE dados autorizados e já explicitados pelo usuário; nunca
    conteúdo de arquivos arbitrários, modelos não selecionados, credenciais
    ou identificadores de paciente."""
    itens=[i for i in (itens or []) if isinstance(i, str) and i.strip()]
    L=["# ROTEIRO — revisão de redação radiológica", "",
       "## Instruções (obrigatórias)",
       "Você atua EXCLUSIVAMENTE como revisor de redação radiológica em português do Brasil.",
       "- NÃO invente nem assuma achados, normalidade, medidas, graus, lateralidade, técnica, contraste, diagnóstico ou comparação com exames prévios.",
       "- NÃO acrescente nenhuma informação que não esteja explícita no texto fornecido.",
       "- Preserve integralmente as incertezas, ressalvas e negações (ex.: \"não sendo possível afastar\", \"sugestivo de\").",
       "- Ajuste apenas clareza, concisão, gramática e terminologia radiológica consagrada.",
       "- Não inclua identificadores de paciente.",
       "- Devolva SOMENTE: (1) a versão revisada do texto de alteração; (2) uma lista curta de ambiguidades ou pontos a confirmar, se houver.",
       "",
       "## Contexto do exame",
       "- Modalidade: %s" % (modalidade or "—"),
       "- Exame: %s" % (exame or "—"),
       "- Estrutura em foco: %s" % (estrutura or "—")]
    if itens:
        L.append("- Itens anatômicos do passo: %s" % "; ".join(itens))
    if indicacao and indicacao.strip():
        L.append("- Indicação clínica: %s" % indicacao.strip())
    L += ["", "## Texto de alteração a revisar", (alteracao or "").strip() or "—"]
    outras=[(t, x) for (t, x) in (outras_alteracoes or [])
            if isinstance(x, str) and x.strip()]
    if outras:
        L += ["", "## Outras alterações já marcadas pelo radiologista"]
        for t, x in outras:
            L.append("- [%s] %s" % ((t or "").strip() or "—", x.strip()))
    if modelo and str(modelo).strip():
        L += ["", "## Máscara/modelo selecionado", str(modelo).strip()]
    L.append("")
    return "\n".join(L)

# --- persistência de estado do usuário (~/.roteiro): atômica, 0700/0600 ---
ROTEIRO_HOME=".roteiro"
FAVORITOS_FILE="favoritos.json"
RECENTES_FILE="recentes.json"

def roteiro_home(base=None):
    """Diretório de estado do usuário (~/.roteiro por padrão)."""
    return base or os.path.join(os.path.expanduser("~"), ROTEIRO_HOME)

def _garantir_home(base=None):
    home=roteiro_home(base)
    try:
        os.makedirs(home, exist_ok=True)
        try: os.chmod(home, 0o700)
        except OSError: pass
        return home
    except OSError:
        return None

def _ler_estado(nome, base=None):
    import json
    try:
        with open(os.path.join(roteiro_home(base), nome), encoding="utf-8") as f:
            return json.load(f)
    except (OSError, ValueError, TypeError):
        return None

def _gravar_estado(nome, data, base=None):
    """Gravação atômica (tmp + replace), 0600 quando suportado. Nunca levanta."""
    import json
    home=_garantir_home(base)
    if not home: return False
    destino=os.path.join(home, nome); tmp=destino+".tmp"
    try:
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False)
        try: os.chmod(tmp, 0o600)
        except OSError: pass
        os.replace(tmp, destino)
        return True
    except OSError:
        try: os.remove(tmp)
        except OSError: pass
        return False

def carregar_favoritos(base=None):
    data=_ler_estado(FAVORITOS_FILE, base)
    if not isinstance(data, list): return []
    return [{"hash":f["hash"], "texto":f["texto"]} for f in data
            if isinstance(f, dict) and isinstance(f.get("hash"), str) and isinstance(f.get("texto"), str)]

def salvar_favoritos(favoritos, base=None):
    return _gravar_estado(FAVORITOS_FILE, favoritos, base)

def carregar_recentes(base=None):
    data=_ler_estado(RECENTES_FILE, base)
    if not isinstance(data, list): return []
    return [h for h in data if isinstance(h, str)][:MAX_RECENTES]

def salvar_recentes(recentes, base=None):
    return _gravar_estado(RECENTES_FILE, recentes, base)

SUGESTOES = load_sugestoes()

def filtrar_modelos(modelos, consulta):
    """Filtro estável e sem acentos sobre todos os campos pesquisáveis."""
    termos=_macc(consulta).split()
    return [m for m in modelos if all(t in _macc(
        m.get("nome","")+" "+" ".join(m.get("tags",[]))+" "+m.get("corpo","")
    ) for t in termos)]

def limitar_selecao(indice, total):
    return max(0,min(indice,total-1)) if total else 0

def janela_pagina(indice, total, tamanho):
    tamanho=max(1,tamanho); indice=limitar_selecao(indice,total)
    inicio=(indice//tamanho)*tamanho
    return inicio,min(total,inicio+tamanho)

def modelo_titulo(m):
    import re as _re
    return m.get("titulo") or _re.sub(r"\s*\(v\d+\)$","",m["nome"])
def modelo_txt(m):
    return modelo_titulo(m)+"\n\n"+m["corpo"].strip()+"\n"
def modelo_doc(m):
    import re as _re
    S="margin:0cm 0cm 6.0pt 0cm;line-height:12.0pt;mso-line-height-rule:exactly;font-family:Arial,sans-serif;font-size:11.0pt;"
    esc=lambda s:s.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
    P=lambda h,a="justify": '<p style="%stext-align:%s">%s</p>'%(S,a,h)
    sec=_re.compile(r"^(Técnica|Indicação clínica|Relatório|Análise|Achados|Impressão|CONCLUSÃO|Conclusão|Nota|Observação|OBS)\s*:")
    b=P("<b>"+esc(modelo_titulo(m))+"</b>","center")+P("&nbsp;")
    for l in _re.sub(r"\n{3,}","\n\n",m["corpo"].strip()).split("\n"):
        if not l.strip(): b+=P("&nbsp;"); continue
        mm=sec.match(l)
        if mm: b+=P("<b>"+esc(mm.group(0))+"</b>"+esc(l[len(mm.group(0)):]))
        elif l.strip()==l.strip().upper() and len(l.strip())>3: b+=P("<b>"+esc(l)+"</b>")
        else: b+=P(esc(l))
    return ("﻿<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' "
            "xmlns='http://www.w3.org/TR/REC-html40'><head><meta charset='utf-8'></head><body>"+b+"</body></html>")

def biblioteca(q0=""):
    if not MODELOS:
        clear(); header("biblioteca")
        print("\n  "+WARN+"modelos.json não está ao lado do roteiro.py (versão pública)."+RESET)
        print("  "+MUTE+"Gere com harvest_modelos.py e coloque na mesma pasta."+RESET); flash(""); return
    q=q0; sel=0
    while True:
        res=filtrar_modelos(MODELOS,q); sel=limitar_selecao(sel,len(res))
        page=max(3,term_rows()-13); ini,fim=janela_pagina(sel,len(res),page)
        clear(); header("biblioteca · %d modelos"%len(MODELOS))
        print("\n  "+MUTE+"filtro:"+RESET+" "+WHITE+(q or "(todos)")+RESET)
        if res:
            print("  "+GREEN+"%d/%d"%(sel+1,len(res))+RESET+MUTE+" · itens %d-%d"%(ini+1,fim)+RESET)
        else: print("  "+WARN+"nenhum modelo encontrado"+RESET)
        for i in range(ini,fim):
            m=res[i]; cur=i==sel
            print((GREEN+" ▸ " if cur else "   ")+(BOLD+WHITE if cur else WHITE)+m.get("nome","")[:52]+RESET+MUTE+"  %s·%s"%(m.get("mod","?"),m.get("fonte","?"))+RESET)
        if res:
            preview=[x.strip() for x in res[sel].get("corpo","").splitlines() if x.strip()][:2]
            print("\n  "+MUTE+"prévia: "+RESET+WHITE+(" ".join(preview)[:min(90,term_cols()-12)] or "(vazio)")+RESET)
        print(); print(rule(min(term_cols(),78)))
        print("  "+MUTE+"[↑↓ PgUp/PgDn Home/End] navegar · [Enter] abrir · [/] filtrar · [Q] voltar"+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return
        if k=="/": q=read_line("  filtro: ").strip(); sel=0
        elif k=="UP": sel-=1
        elif k=="DOWN": sel+=1
        elif k=="PGUP": sel-=page
        elif k=="PGDN": sel+=page
        elif k=="HOME": sel=0
        elif k=="END": sel=max(0,len(res)-1)
        elif k=="ENTER" and res: modelo_view(res[sel])

def modelo_view(m):
    top=0; busca=""
    while True:
        linhas=m["corpo"].splitlines() or [""]; page=max(3,term_rows()-9)
        top=max(0,min(top,max(0,len(linhas)-page)))
        clear(); header(m["mod"]+" · "+m["fonte"])
        print("\n  "+GREEN+BOLD+m["nome"]+RESET+"\n")
        for ln in linhas[top:top+page]:
            print("  "+(GREEN if ln.strip().endswith(":") and len(ln)<40 else WHITE)+ln[:76]+RESET)
        print(); print(rule(min(term_cols(),78)))
        print("  "+MUTE+"linhas %d-%d/%d · [↑↓ PgUp/PgDn Home/End /] · "% (top+1,min(len(linhas),top+page),len(linhas))+GREEN+"[E]"+RESET+" editar  "+GREEN+"[C]"+RESET+" copiar  "+GREEN+"[D/T]"+RESET+" exportar  "+MUTE+"[Q] voltar"+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return
        if k=="UP": top-=1
        elif k=="DOWN": top+=1
        elif k=="PGUP": top-=page
        elif k=="PGDN": top+=page
        elif k=="HOME": top=0
        elif k=="END": top=len(linhas)-page
        elif k=="/":
            busca=read_line("  buscar: ").strip()
            if busca:
                achou=next((i for i,x in enumerate(linhas) if _macc(busca) in _macc(x)),None)
                if achou is not None: top=achou
        elif k in ("e","E"):
            novo=editor(modelo_txt(m), title="MODELO — ^S aplica, ^Q descarta")
            if novo is not None:
                parts=novo.split("\n",1)
                m=dict(m); m["nome"]=parts[0].strip() or m["nome"]; m.pop("titulo",None)
                m["corpo"]=parts[1].strip() if len(parts)>1 else ""
        elif k in ("c","C"):
            flash("copiado." if clip_copy(modelo_txt(m)) else "não consegui copiar.")
        elif k in ("d","D","t","T"):
            kind="doc" if k in ("d","D") else "txt"
            name="%s-%s.%s"%(slug(m["nome"]) or "modelo", time.strftime("%Y-%m-%d"), kind)
            data=modelo_doc(m) if kind=="doc" else modelo_txt(m)
            try:
                with open(os.path.abspath(name),"w",encoding="utf-8") as f: f.write(data)
                flash("salvo: "+os.path.abspath(name))
            except Exception as e: flash("falha: "+str(e))

# ============================================================
# 5b2) WIKI OFFLINE — snapshot Notion (compêndio 856 + conhecimento 159 +
#      páginas avulsas + catálogo RadAssistant). Busca em título E conteúdo.
# ============================================================
CAT_LABEL={"compendio":"Compêndio","conhecimento":"Conhecimento","caso":"Caso","pagina":"Página","ra":"RadAssistant"}

def wiki_screen(q0=""):
    if not BIBLIOTECA:
        clear(); header("wiki offline")
        print("\n  "+WARN+"biblioteca.json não está ao lado do roteiro (versão sem a wiki)."+RESET)
        print("  "+MUTE+"Gere com harvest_biblioteca.py (snapshot do Notion) — ver HANDOFF.md."+RESET); flash(""); return
    q=q0; sel=0
    while True:
        res=[e for e in BIBLIOTECA if not q or all(w in _macc(e.get("t","")+" "+e.get("sub","")+" "+e.get("cat","")+" "+e.get("x","")) for w in _macc(q).split())]
        sel=limitar_selecao(sel,len(res))
        page=max(3,term_rows()-12); ini,fim=janela_pagina(sel,len(res),page)
        clear(); header("wiki offline · %d itens"%len(BIBLIOTECA))
        print("\n  "+MUTE+"filtro (título+conteúdo):"+RESET+" "+WHITE+(q or "(todos)")+RESET)
        if res: print("  "+GREEN+"%d/%d"%(sel+1,len(res))+RESET+MUTE+" · itens %d-%d"%(ini+1,fim)+RESET)
        else: print("  "+WARN+"nada encontrado"+RESET)
        for i in range(ini,fim):
            e=res[i]; cur=i==sel
            lab=CAT_LABEL.get(e.get("cat",""),e.get("cat",""))
            print((GREEN+" ▸ " if cur else "   ")+(BOLD+WHITE if cur else WHITE)+e.get("t","")[:48]+RESET+MUTE+"  %s·%s"%(lab,e.get("sub","-"))+RESET)
        print(); print(rule(min(term_cols(),78)))
        print("  "+MUTE+"[↑↓ PgUp/PgDn Home/End] navegar · [Enter] abrir · [/] filtrar · [Q] voltar"+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return
        if k=="/": q=read_line("  filtro: ").strip(); sel=0
        elif k=="UP": sel-=1
        elif k=="DOWN": sel+=1
        elif k=="PGUP": sel-=page
        elif k=="PGDN": sel+=page
        elif k=="HOME": sel=0
        elif k=="END": sel=max(0,len(res)-1)
        elif k=="ENTER" and res: wiki_view(res[sel])

def wiki_view(e):
    top=0
    while True:
        linhas=(e.get("x") or "(catálogo — conteúdo no link: %s)"%e.get("u","")).splitlines() or [""]
        page=max(3,term_rows()-9); top=max(0,min(top,max(0,len(linhas)-page)))
        clear(); header(CAT_LABEL.get(e.get("cat",""),"wiki")+((" · "+e["sub"]) if e.get("sub") else ""))
        print("\n  "+GREEN+BOLD+e.get("t","")+RESET+"\n")
        for ln in linhas[top:top+page]:
            print("  "+(GREEN if ln.startswith("#") else WHITE)+ln[:min(term_cols()-4,110)]+RESET)
        print(); print(rule(min(term_cols(),78)))
        print("  "+MUTE+"linhas %d-%d/%d · [↑↓ PgUp/PgDn Home/End] · "%(top+1,min(len(linhas),top+page),len(linhas))+GREEN+"[C]"+RESET+" copiar tudo  "+MUTE+"[Q] voltar"+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return
        if k=="UP": top-=1
        elif k=="DOWN": top+=1
        elif k=="PGUP": top-=page
        elif k=="PGDN": top+=page
        elif k=="HOME": top=0
        elif k=="END": top=len(linhas)-page
        elif k in ("c","C"): flash("copiado." if clip_copy(e.get("x","")) else "não consegui copiar.")

# ============================================================
# 5c) NAVEGADOR LOCAL — somente leitura, sem execução ou rede
# ============================================================
MAX_VIEW_BYTES=1024*1024
_SENSITIVE_DIRS={".ssh",".gnupg",".aws",".secrets"}
_SENSITIVE_NAMES={".env","credentials","credentials.json","tokens","tokens.json",
                  ".netrc",".pgpass",".npmrc","authorized_keys",".htpasswd"}

def caminho_sensivel(path):
    """Decide pelo caminho real e bloqueia credenciais antes de ler bytes."""
    import fnmatch
    partes=[p.lower() for p in os.path.realpath(os.fspath(path)).replace("\\","/").split("/")]
    nome=partes[-1] if partes else ""
    if any(p in _SENSITIVE_DIRS for p in partes): return True
    if any(partes[i:i+2]==[".config","gh"] for i in range(len(partes)-1)): return True
    if nome in _SENSITIVE_NAMES or nome.startswith(".env."): return True
    return (fnmatch.fnmatch(nome,"*.pem") or fnmatch.fnmatch(nome,"*.key") or
            fnmatch.fnmatch(nome,"id_rsa*") or fnmatch.fnmatch(nome,"id_ed25519*") or
            "credential" in nome or "token" in nome)

def arquivo_oculto(nome):
    return nome.startswith(".") or nome.lower() in {"__pycache__","node_modules",".git"}

def listar_diretorio(path, mostrar_ocultos=False, consulta="", scandir=None):
    """Retorna (entradas, erro); nunca segue entradas para classificá-las."""
    scan=scandir or os.scandir; termos=_macc(consulta).split(); entradas=[]
    try:
        with scan(os.path.realpath(os.fspath(path))) as itens:
            for item in itens:
                if not mostrar_ocultos and arquivo_oculto(item.name): continue
                if termos and not all(t in _macc(item.name) for t in termos): continue
                try:
                    link=item.is_symlink(); diretorio=item.is_dir(follow_symlinks=False)
                    quebrado=link and not os.path.exists(os.path.realpath(item.path))
                    entradas.append({"nome":item.name,"path":item.path,"dir":diretorio,
                                     "link":link,"quebrado":quebrado})
                except OSError:
                    entradas.append({"nome":item.name,"path":item.path,"dir":False,
                                     "link":False,"quebrado":False,"ilegivel":True})
    except (OSError,PermissionError) as e: return [],str(e)
    entradas.sort(key=lambda x:(not x["dir"],_macc(x["nome"])))
    return entradas,None

def inspecionar_arquivo(path, limite=MAX_VIEW_BYTES):
    """Metadados e texto limitado; binários e segredos nunca têm conteúdo."""
    import datetime, mimetypes
    real=os.path.realpath(os.fspath(path)); info={"path":real}
    try:
        st=os.stat(real); info.update(tamanho=st.st_size,
            mtime=datetime.datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
            tipo=mimetypes.guess_type(real)[0] or "desconhecido")
        if caminho_sensivel(real): info.update(classe="sensivel",conteudo=None,truncado=False); return info
        with open(real,"rb") as f: dados=f.read(limite+1)
        truncado=len(dados)>limite; dados=dados[:limite]
        if b"\x00" in dados:
            info.update(classe="binario",conteudo=None,truncado=truncado); return info
        try: texto=dados.decode("utf-8")
        except UnicodeDecodeError:
            info.update(classe="binario",conteudo=None,truncado=truncado); return info
        info.update(classe="texto",conteudo=texto,truncado=truncado); return info
    except (OSError,PermissionError) as e:
        info.update(classe="erro",conteudo=None,truncado=False,erro=str(e)); return info

def raizes_arquivos():
    if os.name=="nt":
        import string
        return [c+":\\" for c in string.ascii_uppercase if os.path.exists(c+":\\")]
    return list(dict.fromkeys(p for p in (os.path.expanduser("~"),"/home/lucas","/mnt","/media","/")
                              if os.path.exists(p)))

def viewer_texto(info):
    linhas=info["conteudo"].splitlines() or [""]; top=0; busca=""
    while True:
        page=max(3,term_rows()-8); top=max(0,min(top,max(0,len(linhas)-page)))
        clear(); header("arquivo · somente leitura")
        print("\n  "+WHITE+info["path"]+RESET+MUTE+(" · TRUNCADO EM 1 MiB" if info["truncado"] else "")+RESET+"\n")
        for linha in linhas[top:top+page]: print("  "+WHITE+linha[:max(10,term_cols()-4)]+RESET)
        print(rule(min(term_cols(),78))); print(MUTE+"  linhas %d-%d/%d · [↑↓ PgUp/PgDn Home/End /] · [Q/Esc] voltar"%(top+1,min(len(linhas),top+page),len(linhas))+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return
        if k=="UP": top-=1
        elif k=="DOWN": top+=1
        elif k=="PGUP": top-=page
        elif k=="PGDN": top+=page
        elif k=="HOME": top=0
        elif k=="END": top=len(linhas)-page
        elif k=="/":
            busca=read_line("  buscar: ").strip()
            if busca:
                achou=next((i for i,x in enumerate(linhas) if _macc(busca) in _macc(x)),None)
                if achou is not None: top=achou

def abrir_arquivo_readonly(path):
    info=inspecionar_arquivo(path)
    if info["classe"]=="texto": return viewer_texto(info)
    clear(); header("arquivo · somente leitura")
    print("\n  "+WHITE+info["path"]+RESET)
    print("  tamanho: %s · mtime: %s · tipo: %s"%(info.get("tamanho","?"),info.get("mtime","?"),info.get("tipo","?")))
    if info["classe"]=="sensivel": print("\n  "+WARN+"SENSÍVEL — conteúdo bloqueado (credencial/chave)."+RESET)
    elif info["classe"]=="binario": print("\n  "+MUTE+"Arquivo binário: somente metadados são exibidos."+RESET)
    else: print("\n  "+WARN+"Não foi possível ler: "+info.get("erro","")+RESET)
    flash("")

def escolher_raiz(atual=None):
    roots=raizes_arquivos(); sel=roots.index(atual) if atual in roots else 0
    while roots:
        clear(); header("raízes locais")
        for i,p in enumerate(roots): print((GREEN+" ▸ " if i==sel else "   ")+WHITE+p+RESET)
        print("\n"+MUTE+"  [↑↓ Enter] escolher · [Q/Esc] voltar"+RESET); k=getkey()
        if k in ("q","Q","ESC"): return None
        if k=="UP": sel=(sel-1)%len(roots)
        elif k=="DOWN": sel=(sel+1)%len(roots)
        elif k=="ENTER": return roots[sel]
    return None

def navegador_arquivos():
    atual=escolher_raiz()
    if not atual: return
    sel=0; filtro=""; ocultos=False; visitados=set()
    while True:
        real=os.path.realpath(atual); visitados.add(real)
        itens,erro=listar_diretorio(real,ocultos,filtro); sel=limitar_selecao(sel,len(itens))
        page=max(3,term_rows()-10); ini,fim=janela_pagina(sel,len(itens),page)
        clear(); header("arquivos · somente leitura")
        print("\n  "+WHITE+real+RESET+MUTE+" · filtro: "+(filtro or "(todos)")+" · ocultos: "+("sim" if ocultos else "não")+RESET)
        if erro: print("  "+WARN+"ilegível: "+erro+RESET)
        for i in range(ini,fim):
            x=itens[i]; sufixo="/" if x["dir"] else (" → quebrado" if x.get("quebrado") else (" →" if x["link"] else ""))
            print((GREEN+" ▸ " if i==sel else "   ")+(BOLD+WHITE if i==sel else WHITE)+x["nome"]+sufixo+RESET)
        print("\n"+rule(min(term_cols(),78))); print(MUTE+"  [↑↓ PgUp/PgDn Home/End Enter] · [Backspace/←] sobe · [/] filtro · [H] ocultos · [R] raiz · [Q] volta"+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return
        if k=="UP": sel-=1
        elif k=="DOWN": sel+=1
        elif k=="PGUP": sel-=page
        elif k=="PGDN": sel+=page
        elif k=="HOME": sel=0
        elif k=="END": sel=max(0,len(itens)-1)
        elif k=="/": filtro=read_line("  filtro: ").strip(); sel=0
        elif k in ("h","H"): ocultos=not ocultos; sel=0
        elif k in ("r","R"):
            nova=escolher_raiz(); atual=nova or atual; filtro=""; sel=0
        elif k in ("BS","LEFT"):
            pai=os.path.dirname(real)
            if pai!=real: atual=pai; filtro=""; sel=0
        elif k=="ENTER" and itens:
            x=itens[sel]
            if x.get("quebrado") or x.get("ilegivel"): continue
            destino=os.path.realpath(x["path"])
            if os.path.isdir(destino):
                if destino in visitados and x["link"]: flash("loop/atalho já visitado")
                else: atual=destino; filtro=""; sel=0
            elif os.path.isfile(destino): abrir_arquivo_readonly(destino)

# ============================================================
# 5d) EDITOR DE TEXTO — completo, na TUI (estilo nano; stdlib puro)
#     ^S salva/aplica  ^Q sai  ^F busca  ^N próx  ^G linha  ^K recorta  ^U cola
# ============================================================
def term_rows():
    try: return max(12, os.get_terminal_size().lines)
    except Exception: return 30

def editor(text="", filename=None, title="EDITOR"):
    """Edita `text` (ou o arquivo `filename`). Retorna o texto final ao
    salvar/aplicar (^S; grava em disco se filename), ou None se sair com ^Q
    sem salvar. Setas/Home/End/PgUp/PgDn/Del/Backspace/Enter/Tab, busca,
    ir-para-linha, recortar/colar linha, scroll horizontal, UTF-8."""
    if filename and os.path.exists(filename):
        try: text=open(filename,encoding="utf-8").read()
        except Exception as e: text=""
    lines=text.split("\n") or [""]
    cy=cx=top=left=0; modified=False; msg=""; clipln=None; quit_arm=False
    search=""; result=None
    W=lambda: min(term_cols(),100)-1
    BODY=lambda: term_rows()-4

    def draw():
        w,h=W(),BODY()
        out=["\x1b[H"]
        name=(filename or title)+("  [modificado]" if modified else "")
        out.append(BOLD+GREEN+" ROTEIRO EDITOR "+RESET+MUTE+"| "+name+RESET+"\x1b[K")
        out.append(MUTE+"─"*w+RESET+"\x1b[K")
        for r in range(h):
            i=top+r
            if i<len(lines):
                ln=lines[i][left:left+w]
                if i==cy:
                    a=cx-left
                    a=max(0,min(a,len(ln)))
                    cur=(ln[a] if a<len(ln) else " ")
                    ln=ln[:a]+"\x1b[7m"+cur+"\x1b[27m"+(ln[a+1:] if a<len(ln) else "")
                out.append(ln+"\x1b[K")
            else:
                out.append(MUTE+"~"+RESET+"\x1b[K")
        pos="Ln %d/%d  Col %d%s"%(cy+1,len(lines),cx+1,("  ←+%d"%left) if left else "")
        out.append(MUTE+"─"*w+RESET+"\x1b[K")
        stat=msg or "^S salvar  ^Q sair  ^F busca  ^N próx  ^G linha  ^K recorta  ^U cola"
        out.append(GREEN+" "+stat+RESET+MUTE+"  ·  "+pos+RESET+"\x1b[K\x1b[J")
        sys.stdout.write("\r\n".join(out)); sys.stdout.flush()   # \r\n: raw desliga OPOST

    def clamp():
        nonlocal cy,cx,top,left
        cy=max(0,min(cy,len(lines)-1)); cx=max(0,min(cx,len(lines[cy])))
        h,w=BODY(),W()
        if cy<top: top=cy
        if cy>=top+h: top=cy-h+1
        if cx<left: left=max(0,cx-10)
        if cx>=left+w: left=cx-w+10

    def do_find(start_next):
        nonlocal search,cy,cx,msg
        if not search or not start_next:
            raw_release(); show_cursor()
            sys.stdout.write("\x1b[%d;1H\x1b[K buscar: "%(term_rows()-0)); sys.stdout.flush()
            try: s=input()
            except (EOFError,KeyboardInterrupt): s=""
            hide_cursor(); raw_hold()
            if s: search=s
        if not search: return
        low=search.lower()
        order=list(range(cy,len(lines)))+list(range(0,cy))
        first=True
        for i in order:
            frm = cx+1 if (first and i==cy and start_next) else (cx+1 if (first and i==cy) else 0)
            j=lines[i].lower().find(low, frm if i==cy and first else 0)
            first=False
            if j>=0:
                cy,cx=i,j; msg="achou: "+search; return
        msg="não achou: "+search

    hide_cursor(); clear(); raw_hold()
    try:
      while True:
        clamp(); draw(); msg=""
        k=getkey()
        if k=="\x11":                                   # ^Q
            if modified and not quit_arm:
                msg="não salvo! ^Q de novo p/ descartar, ^S p/ salvar"; quit_arm=True; continue
            clear(); return None
        quit_arm=False
        if k=="\x13":                                   # ^S
            txt="\n".join(lines)
            if filename:
                try:
                    with open(filename,"w",encoding="utf-8") as f: f.write(txt)
                    modified=False; msg="salvo: "+filename; continue
                except Exception as e: msg="ERRO: "+str(e); continue
            clear(); return txt
        elif k=="\x06": do_find(False)                  # ^F
        elif k=="\x0e": do_find(True)                   # ^N
        elif k=="\x07":                                 # ^G
            raw_release(); show_cursor()
            sys.stdout.write("\x1b[%d;1H\x1b[K linha: "%term_rows()); sys.stdout.flush()
            try: n=input()
            except Exception: n=""
            hide_cursor(); raw_hold()
            if n.strip().isdigit(): cy=int(n)-1; cx=0
        elif k=="\x0b":                                 # ^K recorta linha
            clipln=lines[cy]
            if len(lines)>1: lines.pop(cy)
            else: lines[0]=""
            modified=True
        elif k=="\x15":                                 # ^U cola linha
            if clipln is not None: lines.insert(cy,clipln); modified=True
        elif k=="UP": cy-=1
        elif k=="DOWN": cy+=1
        elif k=="LEFT":
            if cx>0: cx-=1
            elif cy>0: cy-=1; cx=len(lines[cy])
        elif k=="RIGHT":
            if cx<len(lines[cy]): cx+=1
            elif cy<len(lines)-1: cy+=1; cx=0
        elif k=="HOME": cx=0
        elif k=="END": cx=len(lines[cy])
        elif k=="PGUP": cy-=BODY()
        elif k=="PGDN": cy+=BODY()
        elif k=="ENTER":
            rest=lines[cy][cx:]; lines[cy]=lines[cy][:cx]; lines.insert(cy+1,rest)
            cy+=1; cx=0; modified=True
        elif k=="BS":
            if cx>0: lines[cy]=lines[cy][:cx-1]+lines[cy][cx:]; cx-=1; modified=True
            elif cy>0:
                cx=len(lines[cy-1]); lines[cy-1]+=lines[cy]; lines.pop(cy); cy-=1; modified=True
        elif k=="DEL":
            if cx<len(lines[cy]): lines[cy]=lines[cy][:cx]+lines[cy][cx+1:]; modified=True
            elif cy<len(lines)-1: lines[cy]+=lines[cy+1]; lines.pop(cy+1); modified=True
        elif k=="TAB": lines[cy]=lines[cy][:cx]+"    "+lines[cy][cx:]; cx+=4; modified=True
        elif k and len(k)==1 and (k.isprintable()):
            lines[cy]=lines[cy][:cx]+k+lines[cy][cx:]; cx+=1; modified=True
    finally:
        raw_release()

# ============================================================
# 5e) HUB — acessar TUDO através do roteiro.
#     Conexões pré-configuradas do usuário (edite à vontade).
#     tipo: url = abre no navegador · cmd = roda em novo console · copy = só copia
# ============================================================
HUB = [
 ("Agentes IA", [
   ("Claude Code",            "cmd",  "claude", "abre o Claude Code num novo console"),
   ("Codex (builder)",        "cmd",  "codex", "Codex CLI (CodexPortable em Downloads)"),
   ("Hermes / agent-bus",     "cmd",  "ssh baltazar-proxy", "ponte dos agentes (farol/baltazar)"),
   ("Lumo (Proton)",          "url",  "https://lumo.proton.me", "assistente da Proton"),
 ]),
 ("Laudos & imagens", [
   ("hehe — Laudário web",    "url",  "https://hehe.beneditoonline.org", "suas máscaras + export .doc"),
   ("ROTEIRO web (baltazar)", "url",  "http://100.86.208.125:8666/", "este app servido do Pi (Tailscale)"),
   ("rato — viewer DICOM",    "url",  "https://rato.beneditoonline.org", "senha do rato"),
   ("PACS (files-rip)",       "url",  "https://pacs-rip.beneditoonline.org", "orthanc/ohif no baltazar"),
   ("Acervo AngioTC",         "url",  "https://acervo-rip.beneditoonline.org", "wiki de casos"),
   ("Guiris (telelaudo)",     "url",  "https://guiris.beneditoonline.org", "plataforma de telelaudo"),
 ]),
 ("Máquinas (ssh)", [
   ("baltazar (Pi 5)",        "cmd",  "ssh baltazar-proxy", "= ssh rip.beneditoonline.org via cloudflared"),
   ("farol (droplet)",        "cmd",  "ssh epaminondas.beneditoonline.org", "agent-bus/ntfy/espelhos"),
   ("Termux (celular)",       "copy", "ssh -p 8022 <ip-tailscale-do-celular>", "ajuste o IP no tailscale status"),
 ]),
 ("Rede & túneis", [
   ("Tailscale status",       "cmd",  "tailscale status", "vê todas as suas máquinas e IPs"),
   ("SOCKS5 via baltazar",    "cmd",  "ssh -N -D 1080 baltazar-proxy", "proxy socks5 em localhost:1080"),
   ("Cloudflare túneis",      "url",  "https://one.dash.cloudflare.com", "dashboard Zero Trust (ripbalta etc.)"),
   ("dl-rip (downloads)",     "url",  "https://dl-rip.beneditoonline.org", "espelho do baltazar (CF Access)"),
   ("FileBrowser (ripbalt)",  "url",  "https://ripbalt.beneditoonline.org", "arquivos do baltazar"),
 ]),
 ("Notas & docs", [
   ("Notion",                 "url",  "https://www.notion.so", "workspace"),
   ("store666",               "url",  "https://store666.beneditoonline.org", "loja/arquivos 666"),
   ("pi (roteiro público)",   "url",  "https://pi.beneditoonline.org/roteiro/", "versão pública s/ modelos"),
 ]),
 ("Consultar (aulas & anatomia)", [
   ("🎓 Aulas — catálogo",     "url",  "https://app.notion.com/p/7f2a3b42009e4c44beb1d1d5cb5d3504", "todas as suas aulas (Notion, 482)"),
   ("📚 Wiki de Radiologia",   "url",  "https://app.notion.com/p/3854bba8f6d78105b939d0cc2dd6ebf1", "apostila por região/modalidade"),
   ("🧠 Base de conhecimento", "url",  "https://app.notion.com/p/1c7fed376dbf4cd49ea41c83a24e2e7c", "regras + frases"),
   ("🫁 Segm. broncopulmonar", "url",  "https://radiopaedia.org/articles/bronchopulmonary-segments", "Radiopaedia"),
   ("🫀 Segm. hepática (Couinaud)","url","https://radiopaedia.org/articles/couinaud-classification-of-hepatic-segments", "Radiopaedia"),
   ("🧍 IMAIOS e-Anatomy",     "url",  "https://www.imaios.com/en/e-anatomy", "atlas de imagem (8 regiões)"),
 ]),
 ("Angio", [
   ("Laudos angio (biblioteca)","edit","", "abra a biblioteca e filtre GPT=Angio (tecla B)"),
   ("Wiki AngioTC (web)",     "url",  "https://acervo-rip.beneditoonline.org", "235 casos (CF Access)"),
   ("Wiki AngioTC local",     "cmd",  r'start "" "C:\Users\Administrador\Downloads\acervo-angiotc"', "site + Obsidian + PDF"),
 ]),
 ("Rato & ratoolkit", [
   ("Rato viewer (web)",      "url",  "https://rato.beneditoonline.org", "senha do rato"),
   ("Baixar rato (666/rato)", "url",  "https://pi.beneditoonline.org/rato/", "setup/portable"),
   ("Rato portable local",    "cmd",  r'start "" "C:\Users\Administrador\Downloads\rato-portable-1.14.0.exe"', "abre o viewer neste PC"),
   ("ratoolkit — status",     "copy", "ssh baltazar-proxy 'systemctl status ratoolkit-http --no-pager'", "serviço da pasta 666"),
   ("ratoolkit — restart",    "copy", "ssh baltazar-proxy 'sudo systemctl restart ratoolkit-http'", ""),
 ]),
 ("Beelink (fallback Windows)", [
   ("ssh beelink",            "copy", "ssh <usuario>@beelink", "rode beelink-setup.ps1 lá 1x; use o nome do tailscale"),
   ("RDP beelink",            "copy", "mstsc /v:beelink", "ambiente Windows remoto (DICOM/RIS só-Windows/Chrome)"),
 ]),
 ("Ferramentas", [
   ("Editor embutido",        "edit", "", "o editor do roteiro (tecla E no menu)"),
   ("micro — instalar (Win)", "copy", "winget install zyedidia.micro", "melhor editor TUI open-source (29k*, MIT)"),
   ("micro — instalar (Pi)",  "copy", "sudo apt install micro", "no baltazar/linux"),
 ]),
]

# ---------- paleta "/" — atalhos discretos (tele, rato, subdomínios, ssh) ----------
ATALHOS=[
 ("aula","🎓 Aulas — catálogo (Notion)","url","https://app.notion.com/p/7f2a3b42009e4c44beb1d1d5cb5d3504","todas as suas aulas"),
 ("aula","📚 Wiki de Radiologia","url","https://app.notion.com/p/3854bba8f6d78105b939d0cc2dd6ebf1","apostila"),
 ("aula","🫁 Segmentação broncopulmonar","url","https://radiopaedia.org/articles/bronchopulmonary-segments","Radiopaedia"),
 ("aula","🫀 Segmentação hepática (Couinaud)","url","https://radiopaedia.org/articles/couinaud-classification-of-hepatic-segments","Radiopaedia"),
 ("aula","🧍 IMAIOS e-Anatomy","url","https://www.imaios.com/en/e-anatomy","atlas 8 regiões"),
 ("angio","Laudos angio (biblioteca)","angio-lib","","modelos angio"),
 ("angio","Wiki AngioTC (web)","url","https://acervo-rip.beneditoonline.org","235 casos"),
 ("angio","Wiki AngioTC local","cmd",r'start "" "C:\Users\Administrador\Downloads\acervo-angiotc"',"site+Obsidian+PDF"),
 ("tele","Getulio","todo","","edite a URL no bloco ATALHOS"),
 ("tele","DirectRad","url","https://www.directrad.com.br/login",""),
 ("tele","Mediaplace","url","https://201.76.166.34:8443/MediPlace/exames","RIS (cert proprio)"),
 ("tele","Pasta 333 — laudos","url","http://100.86.208.125:8080/333/","por médico · tailscale"),
 ("tele","RadExperts","todo","","edite a URL no bloco ATALHOS"),
 ("rato","Rato viewer (web)","url","https://rato.beneditoonline.org",""),
 ("rato","Baixar rato (666/rato)","url","https://pi.beneditoonline.org/rato/",""),
 ("rato","Rato portable local","cmd",r'start "" "C:\Users\Administrador\Downloads\rato-portable-1.14.0.exe"',""),
 ("sub","hehe","url","https://hehe.beneditoonline.org",""),
 ("sub","app","url","https://app.beneditoonline.org",""),
 ("sub","pi","url","https://pi.beneditoonline.org",""),
 ("sub","guiris","url","https://guiris.beneditoonline.org",""),
 ("sub","dl-rip","url","https://dl-rip.beneditoonline.org",""),
 ("sub","ripbalt","url","https://ripbalt.beneditoonline.org",""),
 ("sub","files-rip","url","https://files-rip.beneditoonline.org",""),
 ("sub","pacs-rip","url","https://pacs-rip.beneditoonline.org",""),
 ("sub","acervo-rip","url","https://acervo-rip.beneditoonline.org",""),
 ("sub","ips-rip","url","https://ips-rip.beneditoonline.org",""),
 ("sub","rip666","url","https://rip666.beneditoonline.org",""),
 ("sub","store666","url","https://store666.beneditoonline.org",""),
 ("ssh","baltazar (rip)","copy","ssh rip.beneditoonline.org",""),
 ("ssh","farol","copy","ssh epaminondas.beneditoonline.org",""),
 ("ssh","termux","copy","ssh -p 8022 <ip-do-celular>",""),
 ("beelink","ssh beelink","copy","ssh <usuario>@beelink",""),
 ("beelink","RDP beelink","copy","mstsc /v:beelink",""),
]
def atalhos_pal():
    q=""
    while True:
        ql=_macc(q).split()
        res=[a for a in ATALHOS if all(w in _macc(a[0]+" "+a[1]) for w in ql)] if ql else ATALHOS
        clear(); header("/ atalhos")
        print("\n  "+MUTE+"filtro:"+RESET+" "+WHITE+(q or "(todos)")+RESET)
        for i,a in enumerate(res[:15]):
            print("  "+GREEN+"%2d"%(i+1)+RESET+". "+MUTE+a[0].ljust(8)+RESET+WHITE+a[1][:44]+RESET)
        if len(res)>15: print("  "+MUTE+"… +%d (refine)"%(len(res)-15)+RESET)
        print(); print(rule(min(term_cols(),78)))
        print("  "+MUTE+"[/] filtrar   [1-15] abrir/copiar   [Q] voltar"+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return
        if k=="/": q=read_line("  filtro: ").strip(); continue
        if k.isdigit() and k!="0":
            n=int(k)
            if n==1 and len(res)>=10:
                extra=read_line("  número (Enter=1): ").strip()
                if extra.isdigit(): n=int("1"+extra)
            if 1<=n<=min(len(res),15):
                g,nome,kind,alvo,dica=res[n-1]
                if kind=="angio-lib": biblioteca("angio")
                elif kind=="todo": flash(dica or "edite a URL no bloco ATALHOS do roteiro.py")
                else: flash(hub_run(kind,alvo))

def hub_run(kind, alvo):
    try:
        if kind=="edit":
            f=read_line("  arquivo (Enter=novo sem nome): ").strip()
            editor(filename=f or None, title="NOVO TEXTO — ^S salva"); return "editor fechado"
        if kind=="url":
            import webbrowser; webbrowser.open(alvo); return "abrindo no navegador…"
        if kind=="cmd":
            if os.name=="nt":
                subprocess.Popen(["cmd","/c","start","","cmd","/k",alvo]); return "novo console: "+alvo
            clip_copy(alvo); return "copiado (rode no seu shell): "+alvo
        clip_copy(alvo); return "copiado: "+alvo
    except Exception as e:
        clip_copy(alvo); return "falhou (%s) — comando copiado"%e

def hub_screen():
    flat=[(g,n,k,a,d) for g,items in HUB for (n,k,a,d) in items]
    sel=0
    while True:
        clear(); header("HUB — tudo que é seu, daqui")
        print()
        i=0; last=None
        for g,n,k,a,d in flat:
            if g!=last: print("  "+GREEN+BOLD+g.upper()+RESET); last=g
            cur = i==sel
            mark = GREEN+" ▸ " if cur else "   "
            kind = {"url":"🌐","cmd":">_","copy":"⧉ ","edit":"✎ "}.get(k,"» ")
            line = mark+(WHITE+BOLD if cur else WHITE)+n.ljust(24)+RESET+MUTE+kind+" "+(d or a)+RESET
            print(line)
            i+=1
        print(); print(rule(min(term_cols(),78)))
        print("  "+MUTE+"[↑↓] navegar   [Enter] abrir/rodar   [C] copiar alvo   [Q] voltar"+RESET)
        print("  "+DIM+MUTE+"edite a lista no bloco HUB do roteiro.py"+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return
        elif k=="UP": sel=(sel-1)%len(flat)
        elif k=="DOWN": sel=(sel+1)%len(flat)
        elif k in ("c","C"):
            _,_,_,a,_=flat[sel]; flash("copiado: "+a if clip_copy(a) else "não consegui copiar")
        elif k=="ENTER":
            _,_,kk,a,_=flat[sel]; flash(hub_run(kk,a))

# ============================================================
# 5e) AGENTES — Claude Code, Codex e Hermes direto da TUI
#     Claude/Codex rodam NO MESMO terminal (a TUI suspende e volta).
#     Hermes conversa pelo agent-bus (farol; do Windows via ProxyJump baltazar).
# ============================================================
FAROL = "lucas@100.86.206.85"
def _bus_ssh():
    base=["ssh","-o","BatchMode=yes","-o","ConnectTimeout=15"]
    if os.name=="nt": base+=["-J","baltazar-proxy"]
    return base+[FAROL]

def run_no_terminal(cmd_list, titulo):
    """suspende a TUI e roda o comando interativo aqui mesmo; volta ao sair."""
    clear(); show_cursor()
    print(GREEN+"> "+titulo+RESET+"   "+MUTE+"(saia do programa p/ voltar ao roteiro)"+RESET+"\n")
    try: rc=subprocess.call(cmd_list, shell=isinstance(cmd_list,str))
    except FileNotFoundError: rc=127; print(WARN+"comando não encontrado neste PC"+RESET)
    except KeyboardInterrupt: rc=130
    hide_cursor()
    print("\n"+MUTE+"[fim · exit %s] qualquer tecla volta ao roteiro"%rc+RESET); getkey()

def hermes_enviar():
    txt=read_line("\n  "+WARN+"mensagem p/ Hermes: "+RESET).strip()
    if not txt: return "nada enviado"
    import json as _json
    js=_json.dumps({"ts":time.strftime("%Y-%m-%d %H:%M:%S"),"from":"claude-tui",
                    "to":"hermes","text":txt},ensure_ascii=False)+"\n"
    p=subprocess.run(_bus_ssh()+["cat >> ~/.agent-bus/to-hermes.jsonl"],
                     input=js.encode("utf-8"))
    return "enviado ao agent-bus" if p.returncode==0 else "falhou (exit %d) — bus só alcançável deste Windows"%p.returncode

def bus_ler(arq, n=12):
    p=subprocess.run(_bus_ssh()+["tail -n %d ~/.agent-bus/%s 2>/dev/null"%(n,arq)],
                     capture_output=True)
    if p.returncode!=0: return None
    return p.stdout.decode("utf-8","replace").strip()

def agentes_screen():
    itens=[("Claude Code — rodar aqui", "claude"),
           ("Codex — rodar aqui",       "codex"),
           ("Hermes — enviar mensagem", "hermes-send"),
           ("agent-bus — últimas p/ mim (to-claude)", "bus-claude"),
           ("agent-bus — últimas p/ Hermes (to-hermes)", "bus-hermes"),
           ("ssh baltazar (rip)", "ssh")]
    sel=0
    while True:
        clear(); header("AGENTES — protocolo de laudo: LAUDAR.md")
        print()
        for i,(nome,_) in enumerate(itens):
            cur=i==sel
            print((GREEN+" ▸ " if cur else "   ")+(WHITE+BOLD if cur else WHITE)+nome+RESET)
        print(); print(rule(min(term_cols(),78)))
        print("  "+MUTE+"[↑↓] [Enter] abrir   [Q] voltar   ·   Claude/Codex leem AGENTS.md/LAUDAR.md sozinhos"+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return
        elif k=="UP": sel=(sel-1)%len(itens)
        elif k=="DOWN": sel=(sel+1)%len(itens)
        elif k=="ENTER":
            _,acao=itens[sel]
            if acao=="claude": run_no_terminal("claude" if os.name=="nt" else ["claude"], "claude")
            elif acao=="codex": run_no_terminal("codex" if os.name=="nt" else ["codex"], "codex")
            elif acao=="ssh": run_no_terminal(["ssh","baltazar-proxy"] if os.name=="nt" else ["bash"], "ssh baltazar")
            elif acao=="hermes-send": flash(hermes_enviar())
            elif acao in ("bus-claude","bus-hermes"):
                arq="to-claude.jsonl" if acao=="bus-claude" else "to-hermes.jsonl"
                out=bus_ler(arq)
                clear(); header("agent-bus · "+arq)
                print()
                if out is None: print("  "+WARN+"bus inacessível daqui (precisa da chave p/ o farol; use o Windows)"+RESET)
                elif not out: print("  "+MUTE+"(vazio)"+RESET)
                else:
                    for ln in out.split("\n")[-12:]:
                        print("  "+WHITE+ln[:min(term_cols()-4,110)]+RESET)
                print(); print("  "+MUTE+"[qualquer tecla] voltar"+RESET); getkey()

# ============================================================
# 6) MENU
# ============================================================
def menu(st):
    sel=0; mais=False
    while True:
        clear(); header("modalidade: %s  ·  tema: %s"%(st["mod"],THEME))
        print()
        print(MUTE+" Escolha a região. Um passo por vez, na ordem do seu laudo."+RESET)
        print()
        for i,r in enumerate(ROTEIROS):
            cur=(i==sel)
            mark=GREEN+" ▸ " if cur else "   "
            idx=(BOLD+GREEN if cur else MUTE)+("%d"%(i+1))+RESET
            name=(WHITE+BOLD if cur else WHITE)+r["nome"]+RESET
            meta=MUTE+("   %d passos%s"%(len(r["passos"])," · lateralidade" if r["lado"] else ""))+RESET
            print(mark+idx+". "+name+meta)
        print()
        if mais:
            if MODELOS:
                print("   "+GREEN+"B"+RESET+". "+WHITE+"Biblioteca de modelos"+RESET+MUTE+"   %d laudos prontos (hehe + laudário + GPTs)"%len(MODELOS)+RESET)
            print("   "+GREEN+"A"+RESET+". "+WHITE+"Agentes"+RESET+MUTE+"                 Claude Code · Codex · Hermes (bus)"+RESET)
            print("   "+GREEN+"H"+RESET+". "+WHITE+"HUB"+RESET+MUTE+"                     túneis, ssh, socks5…"+RESET)
            print("   "+GREEN+"F"+RESET+". "+WHITE+"Arquivos"+RESET+MUTE+"                navegador somente leitura"+RESET)
            if BIBLIOTECA:
                print("   "+GREEN+"W"+RESET+". "+WHITE+"Wiki offline"+RESET+MUTE+"            %d itens (compêndio + conhecimento + Notion)"%len(BIBLIOTECA)+RESET)
            print("   "+GREEN+"E"+RESET+". "+WHITE+"Editor de texto"+RESET+MUTE+"         completo, no terminal (^S ^Q ^F ^K…)"+RESET)
            print(); print(rule(min(term_cols(),78)))
            print(MUTE+" [1-%d] abrir · [B] modelos · [A] agentes · [H] hub · [F] arquivos · [W] wiki · [E] editor · [M] mod · [T] tema · [0] menos · [Q] sair"%len(ROTEIROS)+RESET)
        else:
            print("   "+DIM+MUTE+"0. mais…"+RESET)
            print(); print(rule(min(term_cols(),78)))
            print(MUTE+" [1-%d]/[↑↓ Enter] abrir · [0] mais · [/] atalhos · [Q] sair"%len(ROTEIROS)+RESET)
        print(DIM+MUTE+" nada gravado sem exportar · não digite dados do paciente"+RESET)
        k=getkey()
        if k in ("0","+","."): mais=not mais; continue
        elif k=="/": atalhos_pal(); continue
        if k in ("q","Q","ESC"): clear(); show_cursor(); sys.exit(0)
        elif k in ("b","B"): biblioteca()
        elif k in ("a","A"): agentes_screen()
        elif k in ("h","H"): hub_screen()
        elif k in ("f","F"): navegador_arquivos()
        elif k in ("w","W"): wiki_screen()
        elif k in ("e","E"):
            f=read_line("  arquivo (Enter=novo): ").strip()
            editor(filename=f or None, title="NOVO TEXTO — ^S salva (dê um nome via arquivo)")
        elif k=="UP": sel=(sel-1)%len(ROTEIROS)
        elif k=="DOWN": sel=(sel+1)%len(ROTEIROS)
        elif k in ("m","M"): st["mod"]=MOD_ORDER[(MOD_ORDER.index(st["mod"])+1)%3]
        elif k in ("t","T"): nn=list(THEMES.keys()); set_theme(nn[(nn.index(THEME)+1)%len(nn)])
        elif k=="ENTER": return sel
        elif k and k.isdigit() and 1<=int(k)<=len(ROTEIROS): return int(k)-1

# ============================================================
# 7) FOCO — passo a passo com trava
# ============================================================
def _wb_export_txt(texto):
    """Exporta SOMENTE o texto da alteração para um .txt escolhido pelo usuário."""
    destino=read_line("\n  salvar alteração (.txt) em: ").strip()
    if not destino: return
    if not destino.lower().endswith(".txt"): destino+=".txt"
    try:
        with open(os.path.abspath(destino), "w", encoding="utf-8") as f:
            f.write((texto or "").strip()+"\n")
        flash("salvo: "+os.path.abspath(destino))
    except OSError as e:
        flash("falha ao salvar: "+str(e))

def _wb_export_prompt(prompt_txt):
    """Exporta o prompt pronto para o Claude em .md; nunca chama a rede."""
    destino=read_line("\n  salvar prompt Claude (.md) em: ").strip()
    if not destino: return
    if not destino.lower().endswith(".md"): destino+=".md"
    try:
        with open(os.path.abspath(destino), "w", encoding="utf-8") as f:
            f.write(prompt_txt)
        flash("salvo: "+os.path.abspath(destino))
    except OSError as e:
        flash("falha ao salvar: "+str(e))

def _wb_prompt_do_estado(r, passo_indice, st, alteracao):
    """Monta o prompt Claude a partir de dados JÁ explicitados no estado."""
    passo=r["passos"][passo_indice]
    outras=[]
    for j, p in enumerate(r["passos"]):
        if j==passo_indice: continue
        if st.get("marks", {}).get(j)==ALT:
            nota=(st.get("notes", {}).get(j) or "").strip()
            if nota: outras.append((p["t"], nota))
    indicacao=(st.get("indicacao") or "").strip() or None
    modelo=st.get("modelo_sel") or None       # só se houver seleção explícita
    return build_claude_prompt(modalidade=st.get("mod", "TC"), exame=r.get("exame", ""),
                               estrutura=passo["t"], itens=passo.get("itens", []),
                               alteracao=alteracao, indicacao=indicacao,
                               outras_alteracoes=outras, modelo=modelo)

def _wb_registrar_recentes(cesta, base_snips, favoritos, recentes, base):
    """Guarda em recentes apenas hashes de snippets DA BASE (nunca texto livre)."""
    conhecidos=indexar_snippets(base_snips, favoritos_como_snippets(favoritos))
    mudou=False
    for texto in cesta:
        h=snippet_hash(texto)
        if h in conhecidos:
            recentes[:]=registrar_recente(recentes, h); mudou=True
    if mudou:
        try: salvar_recentes(recentes, base)
        except Exception: pass

def workbench_composicao(cesta):
    """Tela de composição/preview. Retorna (cesta, ação) onde ação ∈
    {'confirmar','editar','voltar'}. Setas reordenam; Delete remove."""
    import textwrap
    sel=0
    while True:
        n=len(cesta); sel=max(0,min(sel,n-1)) if n else 0
        preview=compor_alteracao(cesta)
        clear(); header("composição · %d snippet(s)"%n)
        print()
        if not cesta:
            print("  "+WARN+"cesta vazia — volte e adicione snippets com Espaço."+RESET)
        for idx, texto in enumerate(cesta):
            cur=idx==sel
            print((GREEN+" ▸ " if cur else "   ")+(BOLD+WHITE if cur else WHITE)
                  +("%d. "%(idx+1))+texto[:max(10,term_cols()-8)]+RESET)
        print("\n  "+MUTE+"prévia:"+RESET)
        largura=max(12,min(actual_term_cols()-4,92))
        for linha in (textwrap.wrap(preview, width=largura) or ["(vazio)"]):
            print("  "+WHITE+linha+RESET)
        print("\n"+rule(min(term_cols(),78)))
        print("  "+MUTE+"[↑↓] reordenar · [1-9/Home/End] selecionar · [Del] remover · "
              +RESET+GREEN+"[E]"+RESET+MUTE+" editor · "+RESET+GREEN+"[Enter]"+RESET
              +MUTE+" confirmar · [Q] voltar"+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return cesta, "voltar"
        if k in ("e","E"): return cesta, "editar"
        if not cesta: continue
        if k=="UP": cesta, sel = cesta_mover(cesta, sel, -1)
        elif k=="DOWN": cesta, sel = cesta_mover(cesta, sel, +1)
        elif k=="HOME": sel=0
        elif k=="END": sel=n-1
        elif k=="DEL": cesta=cesta_remover(cesta, sel); sel=max(0,min(sel,len(cesta)-1))
        elif k=="ENTER": return cesta, "confirmar"
        elif k and k.isdigit():
            d=int(k)
            if 1<=d<=n: sel=d-1

def workbench(r, passo_indice, st, base=None):
    """Workbench de Alterações da estrutura atual. Devolve o texto final da
    alteração (str) p/ virar nota Alterado, ou None ao cancelar. Só concatena
    o que o usuário escolheu — nenhuma frase clínica é gerada aqui."""
    import textwrap
    passo=r["passos"][passo_indice]
    base_snips=snippets_do_passo(SUGESTOES, r["id"], passo_indice, passo)
    try: favoritos=carregar_favoritos(base)
    except Exception: favoritos=[]
    try: recentes=carregar_recentes(base)
    except Exception: recentes=[]
    cesta=[]; consulta=""; sel=0; modo="todos"; modos=["todos","favoritos","recentes"]

    def pool_atual():
        favs=favoritos_como_snippets(favoritos)
        indice=indexar_snippets(base_snips, favs)
        if modo=="favoritos": alvo=favs
        elif modo=="recentes": alvo=resolver_recentes(recentes, indice)
        else: alvo=base_snips
        return filtrar_snippets(alvo, consulta)

    def texto_selecionado(itens):
        if cesta: return cesta
        return [itens[sel]["texto"]] if itens else []

    while True:
        itens=pool_atual(); sel=limitar_selecao(sel, len(itens))
        page=max(3, term_rows()-16); ini,fim=janela_pagina(sel, len(itens), page)
        clear(); header("workbench · %s"%passo["t"])
        print("\n  "+MUTE+"filtro:"+RESET+" "+WHITE+(consulta or "(todos)")+RESET
              +MUTE+"  · vista: "+RESET+GREEN+modo+RESET
              +MUTE+"  · cesta: "+RESET+GREEN+str(len(cesta))+RESET)
        if itens:
            print("  "+GREEN+"%d/%d"%(sel+1,len(itens))+RESET+MUTE+" · itens %d-%d"%(ini+1,fim)+RESET)
        else:
            print("  "+WARN+"nenhum snippet nesta vista"+RESET)
        for i in range(ini,fim):
            s=itens[i]; cur=i==sel
            box="●" if cesta_contem(cesta, s["texto"]) else "○"
            star=(WARN+"★"+RESET) if favorito_contem(favoritos, s["hash"]) else " "
            head=(GREEN+" ▸ " if cur else "   ")+(GREEN if cur else MUTE)+box+RESET+" "+star+" "
            print(head+(BOLD+WHITE if cur else WHITE)+s["texto"][:max(10,term_cols()-14)]+RESET
                  +MUTE+"  ·"+s["fonte"]+RESET)
        if itens:
            largura=max(12,min(actual_term_cols()-6,92))
            print("\n  "+MUTE+"texto:"+RESET)
            for linha in (textwrap.wrap(itens[sel]["texto"], width=largura) or [""]):
                print("   "+WHITE+linha+RESET)
        print("\n"+rule(min(term_cols(),78)))
        print("  "+MUTE+"[↑↓ PgUp/PgDn Home/End] navegar · [Espaço] cesta · [F] favorito · [Tab] vista · [/] filtrar"+RESET)
        print("  "+MUTE+"[C] compor · [E] editor · [X] .txt · [G] prompt.md · "+RESET
              +GREEN+"[Enter]"+RESET+MUTE+" confirmar · [Q] cancelar"+RESET)
        k=getkey()
        if k in ("q","Q","ESC"): return None
        elif k=="UP": sel-=1
        elif k=="DOWN": sel+=1
        elif k=="PGUP": sel-=page
        elif k=="PGDN": sel+=page
        elif k=="HOME": sel=0
        elif k=="END": sel=max(0,len(itens)-1)
        elif k=="TAB": modo=modos[(modos.index(modo)+1)%len(modos)]; sel=0
        elif k=="/": consulta=read_line("  filtro: ").strip(); sel=0
        elif k==" " and itens: cesta=cesta_toggle(cesta, itens[sel]["texto"])
        elif k in ("f","F") and itens:
            s=itens[sel]; favoritos=favorito_toggle(favoritos, s["hash"], s["texto"])
            try: salvar_favoritos(favoritos, base)
            except Exception: pass
        elif k in ("c","C"):
            cesta, acao = workbench_composicao(cesta)
            if acao=="confirmar" and cesta:
                _wb_registrar_recentes(cesta, base_snips, favoritos, recentes, base)
                return compor_alteracao(cesta).strip()
            if acao=="editar":
                novo=editor(compor_alteracao(cesta), title="ALTERAÇÃO — ^S confirma, ^Q volta")
                if novo is not None:
                    _wb_registrar_recentes(cesta, base_snips, favoritos, recentes, base)
                    return novo.strip()
        elif k in ("e","E"):
            escolha=texto_selecionado(itens)
            novo=editor(compor_alteracao(escolha),
                        title="ALTERAÇÃO — ^S confirma, ^Q volta")
            if novo is not None:
                _wb_registrar_recentes(escolha, base_snips, favoritos, recentes, base)
                return novo.strip()
        elif k in ("x","X"):
            _wb_export_txt(compor_alteracao(texto_selecionado(itens)))
        elif k in ("g","G"):
            texto=compor_alteracao(texto_selecionado(itens))
            _wb_export_prompt(_wb_prompt_do_estado(r, passo_indice, st, texto))
        elif k=="ENTER":
            escolha=texto_selecionado(itens)
            if not escolha: continue
            _wb_registrar_recentes(escolha, base_snips, favoritos, recentes, base)
            return compor_alteracao(escolha).strip()

def run_roteiro(ridx, st):
    r=ROTEIROS[ridx]; n=len(r["passos"])
    st["marks"]={}; st["notes"]={}; st.pop("laudo_txt", None)
    if not r["lado"]: st["lado"]=""
    elif not st.get("lado"): st["lado"]=ask_lado()
    i=0; t0=time.time(); tstep=time.time()

    def view_edit_laudo():
        L=build_laudo(r, st)
        txt=st.get("laudo_txt") or laudo_texto(L)
        novo=editor(txt, title="LAUDO ATÉ AQUI — ^S aplica, ^Q volta")
        if novo is not None: st["laudo_txt"]=novo

    def draw(msg=""):
        clear(); header("%s · %s · passo %d/%d"%(MODS[st["mod"]]["titulo"], r["nome"], min(i+1,n), n))
        print()
        print(" "+GREEN+"["+bar(i,n)+"]"+RESET+"  "+MUTE+"%d/%d   passo %s · total %s"%(i,n,fmt_time(time.time()-tstep),fmt_time(time.time()-t0))+RESET)
        print()
        p=r["passos"][i]
        print("   "+GREEN+"ESTRUTURA %d DE %d"%(i+1,n)+RESET)
        print("   "+GREEN+"▌"+RESET+" "+BOLD+WHITE+p["t"]+RESET); print()
        for it in p["itens"]: print("     "+GREEN+"▸ "+RESET+WHITE+it+RESET)
        if p.get("dica"): print("     "+MUTE+"↳ "+p["dica"]+RESET)
        if p.get("normal"): print("     "+MUTE+"se normal → "+p["normal"]+RESET)
        elif p.get("meta"): print("     "+DIM+MUTE+"passo de trabalho (não entra no corpo do laudo)"+RESET)
        print(); print(rule(min(term_cols(),78))); print(" "+MUTE+"roteiro:"+RESET)
        for j,pp in enumerate(r["passos"]):
            m=st["marks"].get(j,0)
            if j==i: g=GREEN+"●"+RESET; col=WHITE+BOLD
            else:
                col={1:MUTE,2:WARN,3:MUTE}.get(m,MUTE+DIM)
                g={1:GREEN+GLY[1]+RESET,2:WARN+GLY[2]+RESET,3:MUTE+GLY[3]+RESET}.get(m,MUTE+"·"+RESET)
            print("  "+g+" "+col+("%2d. %s"%(j+1,pp["t"]))+RESET)
        print(rule(min(term_cols(),78)))
        res=st["marks"].get(i)
        def btn(a,key,lab,color):
            return (color+"["+key+"] "+lab+RESET+GREEN+" ◄"+RESET) if res==a else (MUTE+"["+key+"] "+lab+RESET)
        print("  "+btn(NORMAL,"N","Normal",GREEN)+"   "+btn(ALT,"A","Alterado",WARN)+"   "+btn(NA,"P","Pular",MUTE))
        # laudo ao vivo (resumo compacto p/ nao estourar a altura do terminal; [E] abre inteiro)
        Lp=build_laudo(r, st)
        nc=len([x for x in Lp["corpo"].split("\n") if x.strip()])
        ni=len([x for x in Lp["impressao"].split("\n") if x.strip()])
        edt="  "+WARN+"(editado)"+RESET if st.get("laudo_txt") else ""
        print("  "+MUTE+"laudo até aqui: %d no corpo · %d na impressão"%(nc,ni)+RESET+"   "+GREEN+"[E]"+RESET+MUTE+" ver/editar"+RESET+edt)
        trava=st.get("trava",True)
        nav=(MUTE if i==0 else WHITE)+"[←] voltar"+RESET+"    "
        nav+=(GREEN+BOLD if (res or not trava) else MUTE+DIM)+("[→/Enter/Espaço] gerar laudo" if i==n-1 else "[→/Enter/Espaço] avançar")+RESET
        nav+="    "+MUTE+"[G] trava "+("ON " if trava else "off")+" · [M] "+st["mod"]+" · [Q] menu"+RESET
        print(nav)
        if msg: print("\n  "+WARN+msg+RESET)
        sys.stdout.flush()

    while True:
        draw(); k=getkey()
        if k in ("n","N"): st["marks"][i]=NORMAL
        elif k in ("a","A"):
            note=workbench(r, i, st)
            if note is not None:
                st["marks"][i]=ALT
                if note.strip(): st["notes"][i]=note.strip()
        elif k in ("p","P"): st["marks"][i]=NA
        elif k in ("m","M"): st["mod"]=MOD_ORDER[(MOD_ORDER.index(st["mod"])+1)%3]
        elif k in ("g","G"): st["trava"]=not st.get("trava",True)
        elif k in ("e","E"): view_edit_laudo()
        elif k in ("RIGHT","ENTER"," "):
            if st.get("trava",True) and not st["marks"].get(i):
                draw("resolva esta estrutura antes de avançar  [N] [A] [P]   (ou [G] destrava)"); getkey(); continue
            if i==n-1: return laudo_screen(ridx, st, time.time()-t0)
            i+=1; tstep=time.time()
        elif k=="LEFT":
            if i>0: i-=1; tstep=time.time()
        elif k in ("q","Q","ESC"): return

def ask_lado():
    clear(); header("lateralidade")
    print("\n  "+WHITE+"Lado do exame:"+RESET+"   "+GREEN+"[D]"+RESET+" direito   "+GREEN+"[E]"+RESET+" esquerdo   "
          +GREEN+"[B]"+RESET+" bilateral   "+MUTE+"[Enter] nenhum"+RESET)
    k=getkey()
    return {"d":"Direito","D":"Direito","e":"Esquerdo","E":"Esquerdo","b":"Bilateral","B":"Bilateral"}.get(k,"")

# ============================================================
# 8) TELA DO LAUDO + EXPORT
# ============================================================
def laudo_screen(ridx, st, elapsed):
    r=ROTEIROS[ridx]; n=len(r["passos"])
    nn=sum(1 for j in range(n) if st["marks"].get(j)==NORMAL)
    na=sum(1 for j in range(n) if st["marks"].get(j)==ALT)
    np_=sum(1 for j in range(n) if st["marks"].get(j)==NA)
    while True:
        L=build_laudo(r, st)
        txt = st.get("laudo_txt") or laudo_texto(L)
        clear(); header("%s · %s · laudo"%(MODS[st["mod"]]["titulo"], r["nome"]))
        print()
        edited = "  ·  "+WARN+"editado"+RESET if st.get("laudo_txt") else ""
        print("  "+GREEN+BOLD+"▚ LAUDO GERADO"+RESET+"   "+MUTE+"%d normais · %d alterados · %d pulados · %s"%(nn,na,np_,fmt_time(elapsed))+RESET+edited)
        print(rule(min(term_cols(),78)))
        for ln in txt.split("\n"):
            low=ln.strip().lower()
            if ln==L["titulo"] or (ln and ln==ln.upper() and len(ln)>10 and not ln.startswith("-")): print("  "+GREEN+BOLD+ln+RESET)
            elif low.startswith(("técnica:","indicação","impressão:")) or ln.strip()==L["corpoLabel"]+":":
                print("  "+GREEN+ln+RESET)
            elif ln.strip().startswith("- "): print("  "+WARN+ln+RESET)
            else: print("  "+WHITE+ln+RESET)
        print(rule(min(term_cols(),78)))
        print("  "+GREEN+"[E]"+RESET+" editar   "+GREEN+"[D]"+RESET+" .doc   "+GREEN+"[T]"+RESET+" .txt   "+GREEN+"[C]"+RESET+" copiar   "
              +WHITE+"[I]"+RESET+" indicação   "+WHITE+"[L]"+RESET+" lado   "+WHITE+"[M]"+RESET+" "+st["mod"]+"   "
              +WHITE+"[R]"+RESET+" refazer   "+MUTE+"[Q] menu"+RESET)
        k=getkey()
        if k in ("e","E"):
            novo=editor(txt, title="LAUDO — ^S aplica, ^Q descarta")
            if novo is not None: st["laudo_txt"]=novo
        elif k in ("d","D"): export(r, st, "doc")
        elif k in ("t","T"): export(r, st, "txt")
        elif k in ("c","C"):
            ok=clip_copy(txt); flash("copiado p/ área de transferência." if ok else "não consegui copiar (veja a prévia acima).")
        elif k in ("i","I"):
            st["indicacao"]=read_line("\n  "+WARN+"Indicação clínica: "+RESET).strip(); st.pop("laudo_txt",None)
        elif k in ("l","L"):
            if r["lado"]: st["lado"]=ask_lado(); st.pop("laudo_txt",None)
            else: flash("este roteiro não usa lateralidade.")
        elif k in ("m","M"): st["mod"]=MOD_ORDER[(MOD_ORDER.index(st["mod"])+1)%3]; st.pop("laudo_txt",None)
        elif k in ("r","R"): return run_roteiro(ridx, st)
        elif k in ("q","Q","ESC"): return

def export(r, st, kind):
    L=build_laudo(r, st)
    base=slug(MODS[st["mod"]]["titulo"]+"-"+(r["exame"] or "laudo")) or "laudo"
    name="%s-%s.%s"%(base, time.strftime("%Y-%m-%d"), kind)
    path=os.path.abspath(name)
    ov=st.get("laudo_txt")
    if ov:                              # laudo editado no editor
        if kind=="txt": data=ov
        else:
            parts=ov.split("\n",1)
            data=modelo_doc({"nome":parts[0].strip() or L["titulo"], "corpo":parts[1].strip() if len(parts)>1 else ""})
    else:
        data=laudo_doc(L) if kind=="doc" else laudo_texto(L)
    try:
        with open(path,"w",encoding="utf-8") as f: f.write(data)
        flash("salvo: "+path)
    except Exception as e:
        flash("falha ao salvar: "+str(e))

def flash(msg):
    print("\n  "+GREEN+msg+RESET+"\n  "+MUTE+"[qualquer tecla] voltar"+RESET); getkey()

# ============================================================
# 8b) MODO CLI — linha a linha, sem ANSI/tty. Funciona em pipe e ssh.
# ============================================================
def cli_escolher_sugestao(r, passo_indice):
    frases=sugestoes_do_passo(SUGESTOES, r["id"], passo_indice, r["passos"][passo_indice])[:10]
    if frases:
        print("       sugestões:")
        for j, frase in enumerate(frases, 1): print("         %d. %s"%(j,frase))
        print("       (pode escolher vários na ordem: ex. 1,3,2)")
    valor=input("       número(s) ou texto livre (Enter=sem texto): ").strip()
    escolhidos=parse_multi_selecao(valor, frases)
    if escolhidos:
        composto=compor_alteracao(escolhidos)
        novo=input("       editar composição (Enter mantém): ").strip()
        return novo or composto
    return valor

def cli_walk(rid=None, prompt_path=None):
    """Walk completo via input(): imprime cada estrutura, pergunta n/a/p,
    no fim imprime o laudo e oferece salvar. Zero ANSI, zero getkey.
    Com `prompt_path`, exporta o prompt Claude da alteração final."""
    print("ROTEIRO (modo CLI) — um passo por vez. Ctrl+C aborta.\n")
    r = None
    if rid:
        r = next((x for x in ROTEIROS if x["id"] == rid.lower()), None)
    while r is None:
        for i, x in enumerate(ROTEIROS):
            print("  %d. %s (%d passos)" % (i+1, x["nome"], len(x["passos"])))
        try: n = input("roteiro [1-%d]: " % len(ROTEIROS)).strip()
        except EOFError: return
        if n.isdigit() and 1 <= int(n) <= len(ROTEIROS): r = ROTEIROS[int(n)-1]
    st = {"mod": "TC", "lado": "", "indicacao": "", "marks": {}, "notes": {}}
    m = input("modalidade [TC/rm/rx]: ").strip().upper()
    if m in MODS: st["mod"] = m
    if r["lado"]:
        l = input("lado [D/e/b/Enter=nenhum]: ").strip().lower()
        st["lado"] = {"d": "Direito", "e": "Esquerdo", "b": "Bilateral"}.get(l, "")
    ind = input("indicação clínica (opcional): ").strip()
    if ind: st["indicacao"] = ind
    n = len(r["passos"])
    print()
    for i, p in enumerate(r["passos"]):
        print("[%d/%d] %s" % (i+1, n, p["t"]))
        for it in p["itens"]: print("       - %s" % it)
        if p.get("dica"): print("       dica: %s" % p["dica"])
        while True:
            try: k = input("       [N]ormal / [a]lterado / [p]ular > ").strip().lower()
            except EOFError: k = "n"
            if k in ("", "n"): st["marks"][i] = NORMAL; break
            if k == "a":
                st["marks"][i] = ALT
                nt = cli_escolher_sugestao(r, i)
                if nt: st["notes"][i] = nt
                break
            if k == "p": st["marks"][i] = NA; break
        print()
    L = build_laudo(r, st)
    txt = laudo_texto(L)
    print("=" * 60); print(txt); print("=" * 60)
    out = input("salvar? [Enter=não | d=doc | t=txt | dt=ambos]: ").strip().lower()
    for kind in ("doc", "txt"):
        if kind[0] in out:
            base = slug(MODS[st["mod"]]["titulo"] + "-" + (r["exame"] or "laudo")) or "laudo"
            name = "%s-%s.%s" % (base, time.strftime("%Y-%m-%d"), kind)
            data = laudo_doc(L) if kind == "doc" else txt
            with open(os.path.abspath(name), "w", encoding="utf-8") as f: f.write(data)
            print("salvo:", os.path.abspath(name))
    if prompt_path:
        alt_steps=[(idx, r["passos"][idx]["t"], st["notes"].get(idx, ""))
                   for idx in range(n)
                   if st["marks"].get(idx)==ALT and st["notes"].get(idx)]
        if not alt_steps:
            print("nenhuma alteração marcada; prompt Claude não gerado.")
        else:
            foco=alt_steps[-1]
            outras=[(t, txt) for (i2, t, txt) in alt_steps[:-1]]
            prompt=build_claude_prompt(
                modalidade=st["mod"], exame=r.get("exame", ""),
                estrutura=foco[1], itens=r["passos"][foco[0]].get("itens", []),
                alteracao=foco[2], indicacao=(st.get("indicacao") or "").strip() or None,
                outras_alteracoes=outras, modelo=None)
            try:
                with open(os.path.abspath(prompt_path), "w", encoding="utf-8") as f:
                    f.write(prompt)
                print("prompt Claude salvo:", os.path.abspath(prompt_path))
            except OSError as e:
                print("falha ao salvar prompt:", str(e))

def cli_modelos(argv):
    """--listar-modelos [filtro] | --modelo <filtro> [--doc F|--txt F]"""
    if not MODELOS:
        print("modelos.json ausente (gere com harvest_modelos.py)"); return 1
    if "--listar-modelos" in argv:
        i = argv.index("--listar-modelos")
        ql = [_macc(w) for w in argv[i+1:] if not w.startswith("-")]
        res = [m for m in MODELOS if all(w in _macc(m["nome"]+" "+" ".join(m["tags"])) for w in ql)]
        for m in res: print("%-6s %-9s %s" % (m["mod"], m["fonte"], m["nome"]))
        print("-- %d/%d modelos" % (len(res), len(MODELOS)))
        return 0
    i = argv.index("--modelo")
    try:
        out_doc = argv[argv.index("--doc")+1] if "--doc" in argv else None
        out_txt = argv[argv.index("--txt")+1] if "--txt" in argv else None
    except IndexError:
        print("--doc e --txt exigem um caminho de saída")
        return 2
    stop = [j for j, a in enumerate(argv) if j > i and a.startswith("--")]
    ql = [_macc(w) for w in argv[i+1:(stop[0] if stop else len(argv))]]
    if not ql: print("uso: --modelo <palavras do filtro>"); return 1
    res = [m for m in MODELOS if all(w in _macc(m["nome"]+" "+" ".join(m["tags"])) for w in ql)]
    if not res:
        res = [m for m in MODELOS if all(w in _macc(m["nome"]+" "+" ".join(m["tags"])+" "+m["corpo"]) for w in ql)]
    if not res: print("nenhum modelo p/ filtro:", " ".join(ql)); return 1
    m = res[0]
    if len(res) > 1:
        sys.stderr.write("(%d matches; usando: %s)\n" % (len(res), m["nome"]))
    if out_doc:
        with open(out_doc, "w", encoding="utf-8") as f:
            f.write(modelo_doc(m))
        print("salvo:", out_doc)
    if out_txt:
        with open(out_txt, "w", encoding="utf-8") as f:
            f.write(modelo_txt(m))
        print("salvo:", out_txt)
    if not out_doc and not out_txt:
        print(modelo_txt(m))
    return 0

# ============================================================
# 8c) SERVIDOR EMBUTIDO — roteiro --serve [porta] [--dir D]
#     Serve a UI publica p/ a rede (Beelink/qualquer PC), SEM Python extra.
#     LGPD: bloqueia modelos.js/modelos.json sempre, seja qual for a pasta.
# ============================================================
def cli_serve(argv):
    import http.server, socketserver, socket, functools
    # porta: arg numerico explicito > env PORT (preview/launch.json autoPort) > 8666
    port = int(os.environ.get("PORT") or 8666)
    for a in argv[argv.index("--serve")+1:]:
        if a.isdigit(): port = int(a); break
    d = None
    if "--dir" in argv:
        try: d = argv[argv.index("--dir")+1]
        except Exception: d = None
    if not d:
        base = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) \
               else os.path.dirname(os.path.abspath(__file__))
        web = os.path.join(base, "web")
        d = web if os.path.isdir(web) else base
    class H(http.server.SimpleHTTPRequestHandler):
        def _blocked(self):
            p = self.path.split("?")[0].lower()
            return ("modelos.js" in p) or ("modelos.json" in p)
        def do_GET(self):
            if self._blocked(): self.send_error(404); return
            if self.path in ("/", ""):
                for idx in ("index.html", "roteiro.html"):
                    if os.path.exists(os.path.join(d, idx)): self.path = "/"+idx; break
            return super().do_GET()
        def do_HEAD(self):
            if self._blocked(): self.send_error(404); return
            return super().do_HEAD()
        def log_message(self, *a): pass
    Handler = functools.partial(H, directory=d)
    ips = []
    try:
        for info in socket.getaddrinfo(socket.gethostname(), None):
            ip = info[4][0]
            if ":" not in ip and ip not in ips: ips.append(ip)
    except Exception: pass
    print("ROTEIRO serve — pasta: %s  (modelos bloqueados, LGPD)" % d)
    print("  http://localhost:%d/" % port)
    for ip in ips: print("  http://%s:%d/" % (ip, port))
    # ThreadingHTTPServer: navegadores abrem varias conexoes em paralelo (html +
    # scripts). Um servidor single-thread (TCPServer) TRAVA nessa concorrencia.
    srv_cls = getattr(http.server, "ThreadingHTTPServer", None) or socketserver.TCPServer
    srv_cls.allow_reuse_address = True
    try:
        with srv_cls(("0.0.0.0", port), Handler) as srv:
            try: srv.serve_forever()
            except KeyboardInterrupt: pass
    except OSError as e:
        print("nao consegui abrir a porta %d: %s" % (port, e)); return 1
    return 0

# ============================================================
# 9) MAIN
# ============================================================
def main():
    if "--help" in sys.argv or "-h" in sys.argv:
        print(HELP)
        return
    # editor direto: roteiro --edit arquivo.txt  (vira um editor de terminal)
    if "--edit" in sys.argv:
        enable_ansi()
        i=sys.argv.index("--edit")
        f=sys.argv[i+1] if len(sys.argv)>i+1 and not sys.argv[i+1].startswith("-") else None
        if not f:
            sys.stderr.write("--edit requer um arquivo.\n")
            return 2
        try: editor(filename=f, title="EDITOR")
        finally: show_cursor(); print(RESET)
        return
    if "--serve" in sys.argv:
        try: sys.stdout.reconfigure(encoding="utf-8")
        except Exception: pass
        sys.exit(cli_serve(sys.argv) or 0)
    # modos CLI (sem tty/ANSI) antes de qualquer coisa
    if "--listar-modelos" in sys.argv or "--modelo" in sys.argv:
        try: sys.stdout.reconfigure(encoding="utf-8")
        except Exception: pass
        sys.exit(cli_modelos(sys.argv) or 0)
    if "--cli" in sys.argv:
        try: sys.stdout.reconfigure(encoding="utf-8")
        except Exception: pass
        prompt_path=parse_arg("--prompt-claude")
        args=[]; skip=False
        for a in sys.argv[1:]:
            if skip: skip=False; continue
            if a=="--prompt-claude": skip=True; continue
            if a.startswith("-"): continue
            args.append(a)
        try: cli_walk(args[0] if args else None, prompt_path=prompt_path)
        except (KeyboardInterrupt, EOFError): print("\nabortado.")
        return
    if not (sys.stdin.isatty() and sys.stdout.isatty()):
        sys.stderr.write("A TUI requer stdin e stdout interativos. Use --cli ou --help.\n")
        return 2
    enable_ansi(); hide_cursor()
    st={"mod": (parse_arg("--mod") or "TC").upper(), "lado":"", "indicacao":"", "marks":{}, "notes":{}}
    if st["mod"] not in MODS: st["mod"]="TC"
    try:
        if "--no-boot" not in sys.argv and "-b" not in sys.argv: boot()
        args=[a for a in sys.argv[1:] if not a.startswith("-") and a.upper() not in MODS and a.lower() not in THEMES]
        if args:
            for idx,r in enumerate(ROTEIROS):
                if r["id"]==args[0].lower(): run_roteiro(idx, st); break
        while True:
            run_roteiro(menu(st), st)
    except KeyboardInterrupt: pass
    finally: show_cursor(); print(RESET)

if __name__=="__main__":
    sys.exit(main() or 0)
