"""orbe.py — a única coisa que aparece na tela no modo voz.

    "Quero que ele fique na parte inferior da tela, bem centralizado, um pouco
     maior [...] mas ele tá rodando em uma caixa, deve ser totalmente
     transparente." (23/09/2026)

── O QUE ERA A "CAIXA" ─────────────────────────────────────────────────────
Medido com uma captura da própria janela, no Tk 9 do macOS:

1. `overrideredirect(True)` NÃO tira a moldura: a janela nascia com barra de
   título, os três botões e o nome "Primo Code Voice".
2. O fundo `systemTransparent` saía PRETO.
3. O Canvas não apaga o quadro anterior sobre fundo transparente: cada
   partícula deixava um rastro, e em segundos a janela virava um borrão.

Nenhum dos três se conserta de dentro do Tk. Então, no macOS, a orb é
desenhada pelo próprio sistema (PyObjC): uma NSWindow sem borda, sem fundo e
sem sombra, e uma NSView que limpa e pinta cada quadro com transparência de
verdade — o brilho é um gradiente com alfa, não discos misturados contra uma
cor de fundo adivinhada. Só a orb aparece; o resto da janela não existe.

O Tk continua sendo quem roda o laço de eventos (é ele que o resto do modo
voz já usa), e segue como desenho de reserva fora do macOS ou sem PyObjC.

── ONDE ELA MORA ───────────────────────────────────────────────────────────
Embaixo, centralizada na ÁREA ÚTIL da tela — acima do Dock, não atrás dele.
A posição é aplicada depois de a janela existir: dada cedo demais, a janela
sem moldura ignora.

── O QUE ELA DESENHA ───────────────────────────────────────────────────────
Um núcleo com brilho suave e um enxame de partículas em órbita. A cor diz o
estado; o MOVIMENTO diz a intensidade — as partículas se afastam e aceleram
com a voz, e respiram devagar quando ele está pensando. Nenhuma palavra: quem
está falando não lê.
"""
from __future__ import annotations

import math
import queue
import random
import sys
import tkinter as tk
from typing import Callable, List, Optional, Tuple

# Os quatro estados, e a cor de cada um. A paleta é a do PrimoCode (ciano de
# marca, âmbar de aviso) para o modo voz não parecer outro produto.
CORES = {
    "esperando": ("#22b8d8", "#0e3a4a"),   # ciano calmo: pronto, sem pressa
    "ouvindo":   ("#22d3ee", "#0e7490"),   # ciano vivo: a vez é sua
    "pensando":  ("#fbbf24", "#78350f"),   # âmbar: ele está trabalhando
    "falando":   ("#67e8f9", "#0891b2"),   # ciano claro: a voz é dele
}

LARGURA = 360          # só a área da orb e do brilho — o resto é transparente
ALTURA = 220
RAIO_BASE = 34         # o núcleo
MARGEM_INFERIOR = 24   # acima da área útil (o Dock já fica de fora)
PARTICULAS = 44
FUNDO_FALSO = "#05070a"   # só no desenho de reserva, onde não há alfa


def _rgb(cor: str) -> Tuple[float, float, float]:
    return int(cor[1:3], 16) / 255, int(cor[3:5], 16) / 255, int(cor[5:7], 16) / 255


def _mistura(a: str, b: str, quanto: float) -> str:
    """Duas cores hex, uma fração — a cor no meio do caminho."""
    quanto = max(0.0, min(1.0, quanto))
    ra, ga, ba = int(a[1:3], 16), int(a[3:5], 16), int(a[5:7], 16)
    rb, gb, bb = int(b[1:3], 16), int(b[3:5], 16), int(b[5:7], 16)
    return "#%02x%02x%02x" % (
        int(ra + (rb - ra) * quanto),
        int(ga + (gb - ga) * quanto),
        int(ba + (bb - ba) * quanto),
    )


class Particula:
    """Um ponto em órbita.

    Cada uma tem ângulo, raio e velocidade PRÓPRIOS, sorteados uma vez. É o
    que separa um enxame de um relógio: com os mesmos valores para todas,
    elas giram em formação e o olho lê como engrenagem, não como vida.
    """

    def __init__(self, rnd: random.Random) -> None:
        self.ang = rnd.uniform(0, math.tau)
        self.raio = rnd.uniform(1.25, 2.3)         # múltiplo do raio do núcleo
        self.giro = rnd.uniform(-0.9, 0.9) or 0.4
        self.tam = rnd.uniform(1.3, 3.0)
        self.fase = rnd.uniform(0, math.tau)
        self.brilho = rnd.uniform(0.45, 1.0)


class Quadro:
    """Um quadro da animação, sem saber quem vai pintá-lo."""

    def __init__(self, vivo: str, fundo: str, raio: float, pulso: float,
                 particulas: List[Tuple[float, float, float, float]]):
        self.vivo, self.fundo = vivo, fundo
        self.raio, self.pulso = raio, pulso
        self.particulas = particulas       # (x, y, tamanho, intensidade 0..1)


# ── O desenho nativo do macOS ────────────────────────────────────────────

def _criar_nativa(orbe: "Orbe"):
    """A janela sem borda e sem fundo, desenhada pelo macOS. None se não der."""
    if sys.platform != "darwin":
        return None
    try:
        import objc
        from AppKit import (NSBackingStoreBuffered, NSBezierPath, NSColor,
                            NSCompositingOperationClear, NSGradient, NSMakeRect,
                            NSRectFillUsingOperation, NSScreen, NSView, NSWindow)
    except Exception:
        return None

    def cor(hexa: str, alfa: float):
        r, g, b = _rgb(hexa)
        return NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, max(0.0, min(1.0, alfa)))

    # A classe Objective-C só pode ser registrada uma vez por processo.
    try:
        Vista = objc.lookUpClass("PrimoOrbeVista")
    except objc.nosuchclass_error:
        class PrimoOrbeVista(NSView):
            def isOpaque(self):
                return False

            def acceptsFirstMouse_(self, evento):
                return True

            def drawRect_(self, rect):
                NSColor.clearColor().set()
                NSRectFillUsingOperation(self.bounds(), NSCompositingOperationClear)
                q = getattr(self, "quadro", None)
                if q is None:
                    return
                cx, cy = LARGURA / 2, ALTURA / 2
                centro = (cx, cy)

                # BRILHO: um gradiente radial com alfa, que some no nada. É o
                # que o Tk não sabia fazer — e o que tira o "disco escuro".
                alcance = q.raio * (2.6 + q.pulso * 0.9)
                brilho = NSGradient.alloc().initWithColors_(
                    [cor(q.vivo, 0.34 + q.pulso * 0.20), cor(q.vivo, 0.10), cor(q.vivo, 0.0)])
                brilho.drawFromCenter_radius_toCenter_radius_options_(centro, q.raio * 0.6, centro, alcance, 0)

                # PARTÍCULAS
                for (px, py, t, forca) in q.particulas:
                    cor(q.vivo, 0.25 + forca * 0.75).set()
                    NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(px - t, py - t, 2 * t, 2 * t)).fill()

                # NÚCLEO: gradiente do claro ao vivo, com um anel fino por fora.
                r = q.raio
                nucleo = NSGradient.alloc().initWithColors_(
                    [cor(_mistura(q.vivo, "#ffffff", 0.55), 1.0), cor(q.vivo, 1.0),
                     cor(_mistura(q.vivo, q.fundo, 0.45), 1.0)])
                nucleo.drawFromCenter_radius_toCenter_radius_options_(
                    (cx - r * 0.25, cy + r * 0.25), 0, centro, r, 0)
                cor(q.vivo, 0.55).set()
                anel = NSBezierPath.bezierPathWithOvalInRect_(
                    NSMakeRect(cx - r * 1.18, cy - r * 1.18, r * 2.36, r * 2.36))
                anel.setLineWidth_(1.5)
                anel.stroke()

        Vista = PrimoOrbeVista

    try:
        janela = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
            NSMakeRect(0, 0, LARGURA, ALTURA), 0, NSBackingStoreBuffered, False)   # 0 = sem borda
        janela.setOpaque_(False)
        janela.setBackgroundColor_(NSColor.clearColor())
        janela.setHasShadow_(False)
        janela.setLevel_(25)                       # NSStatusWindowLevel: por cima de tudo
        # O clique ATRAVESSA a orb: ela mora no pé da tela, em cima do Dock e
        # do que o cursor do Primo precisa clicar. Uma orb que segura o clique
        # faria o agente clicar nela em vez de no app.
        janela.setIgnoresMouseEvents_(True)
        janela.setCollectionBehavior_(1 | 16)      # todos os espaços; fora do Exposé
        janela.setReleasedWhenClosed_(False)
        vista = Vista.alloc().initWithFrame_(NSMakeRect(0, 0, LARGURA, ALTURA))
        janela.setContentView_(vista)

        # Embaixo, centralizada na área útil (acima do Dock). O Cocoa conta
        # de baixo para cima, então a margem é somada ao pé da área útil.
        util = NSScreen.mainScreen().visibleFrame()
        x = util.origin.x + (util.size.width - LARGURA) / 2
        y = util.origin.y + MARGEM_INFERIOR
        janela.setFrameOrigin_((x, y))
        janela.orderFrontRegardless()
        return janela, vista
    except Exception:
        return None


class Orbe:
    """A janela da orb. Roda na thread principal, como toda interface."""

    def __init__(self, ao_fechar: Optional[Callable] = None):
        self.raiz = tk.Tk()
        self.raiz.title("Primo Code Voice")
        self.estado = "esperando"
        self.visivel = True
        self.nivel = 0.0          # 0..1, o quanto a pessoa está falando
        self._fase = 0.0
        self._fila = queue.Queue()
        self._ao_fechar = ao_fechar
        self._vivo = True
        self._rnd = random.Random(7)   # semente fixa: o enxame é sempre o mesmo
        self._particulas = [Particula(self._rnd) for _ in range(PARTICULAS)]

        nativa = _criar_nativa(self)
        if nativa:
            # O macOS desenha; a janela do Tk fica escondida e só roda o laço.
            self.janela, self.vista = nativa
            self.nativa = True
            self.raiz.withdraw()
        else:
            self.janela = self.vista = None
            self.nativa = False
            self._montar_tk()

        self.raiz.protocol("WM_DELETE_WINDOW", self.fechar)
        self.raiz.bind("<Escape>", lambda e: self.fechar())
        self._pintar()

    # ── a reserva em Tk (fora do macOS, ou sem PyObjC) ───────────────────
    def _montar_tk(self) -> None:
        self.raiz.overrideredirect(True)
        self.fundo = FUNDO_FALSO
        if sys.platform == "win32":
            # No Windows o Tk dá transparência por cor-chave, e ela funciona.
            try:
                self.raiz.attributes("-transparentcolor", FUNDO_FALSO)
            except tk.TclError:
                pass
        try:
            self.raiz.attributes("-topmost", True)
        except tk.TclError:
            pass
        self.raiz.configure(bg=self.fundo)
        self.tela = tk.Canvas(self.raiz, width=LARGURA, height=ALTURA,
                              bg=self.fundo, highlightthickness=0, bd=0)
        self.tela.pack()
        self.raiz.update_idletasks()
        self._posicionar_tk()
        self.raiz.after(120, self._posicionar_tk)
        self.raiz.bind("<Button-1>", self._pegar)
        self.raiz.bind("<B1-Motion>", self._arrastar)
        self._pego = (0, 0)

    def _posicionar_tk(self) -> None:
        x = (self.raiz.winfo_screenwidth() - LARGURA) // 2
        y = self.raiz.winfo_screenheight() - ALTURA - MARGEM_INFERIOR - 48
        try:
            self.raiz.geometry(f"{LARGURA}x{ALTURA}+{x}+{max(0, y)}")
        except tk.TclError:
            pass

    def _pegar(self, e) -> None:
        self._pego = (e.x_root - self.raiz.winfo_x(), e.y_root - self.raiz.winfo_y())

    def _arrastar(self, e) -> None:
        self.raiz.geometry(f"+{e.x_root - self._pego[0]}+{e.y_root - self._pego[1]}")

    # ── a animação ───────────────────────────────────────────────────────
    def _quadro(self) -> Quadro:
        self._fase += 0.045
        vivo, fundo = CORES.get(self.estado, CORES["esperando"])
        cx, cy = LARGURA / 2, ALTURA / 2

        # A respiração de cada estado. Quem espera respira devagar; quem
        # pensa, num ritmo próprio; quem ouve pulsa com a VOZ, não com o
        # relógio — é o que faz a orb parecer estar escutando de verdade.
        if self.estado == "ouvindo":
            pulso = 0.25 + self.nivel * 1.15
        elif self.estado == "pensando":
            pulso = 0.42 + 0.30 * math.sin(self._fase * 2.1)
        elif self.estado == "falando":
            pulso = 0.55 + 0.38 * math.sin(self._fase * 3.4)
        else:
            pulso = 0.16 + 0.10 * math.sin(self._fase * 0.9)
        raio = RAIO_BASE * (1 + pulso * 0.30)

        pontos = []
        for p in self._particulas:
            p.ang += p.giro * 0.012 * (0.6 + pulso)
            # O raio respira junto com a voz: quanto mais alto se fala, mais
            # longe o enxame se abre.
            osc = math.sin(self._fase * 1.7 + p.fase)
            r = raio * (p.raio + 0.25 * osc + pulso * 0.40)
            # Elipse leve, mais larga que alta — e dentro da janela: o que
            # passa da borda é cortado seco, e partícula cortada é defeito.
            px = cx + math.cos(p.ang) * r * 1.2
            py = cy + math.sin(p.ang) * r * 0.74
            t = p.tam * (0.75 + pulso * 0.6)
            pontos.append((px, py, t, p.brilho * (0.5 + pulso * 0.5)))
        return Quadro(vivo, fundo, raio, pulso, pontos)

    def _pintar(self) -> None:
        if not self._vivo:
            return
        while True:
            try:
                estado, nivel = self._fila.get_nowait()
                if estado == "__esconder__":
                    self._aplicarVisibilidade(False)
                    continue
                if estado == "__mostrar__":
                    self._aplicarVisibilidade(True)
                    continue
                if estado == "__fechar__":
                    self.fechar()
                    return
                if estado:
                    self.estado = estado
                if nivel is not None:
                    self.nivel = max(0.0, min(1.0, nivel))
            except queue.Empty:
                break

        q = self._quadro()
        if self.nativa:
            self.vista.quadro = q
            self.vista.setNeedsDisplay_(True)
        else:
            self._pintar_tk(q)
        self.raiz.after(33, self._pintar)

    def _pintar_tk(self, q: Quadro) -> None:
        """A reserva: forma chapada, sem alfa. Misturas contra o fundo."""
        cx, cy = LARGURA / 2, ALTURA / 2
        self.tela.delete("all")
        for i in range(6, 0, -1):
            f = i / 6
            r = q.raio * (1 + f * 1.3)
            self.tela.create_oval(cx - r, cy - r, cx + r, cy + r,
                                  outline=_mistura(self.fundo, q.vivo, (1 - f) ** 1.6 * 0.7), width=2)
        for (px, py, t, forca) in q.particulas:
            self.tela.create_oval(px - t, py - t, px + t, py + t,
                                  fill=_mistura(self.fundo, q.vivo, 0.3 + forca * 0.7), outline="")
        r = q.raio
        self.tela.create_oval(cx - r, cy - r, cx + r, cy + r,
                              fill=_mistura(q.fundo, q.vivo, 0.8), outline="")
        rn = r * 0.55
        self.tela.create_oval(cx - rn, cy - rn, cx + rn, cy + rn,
                              fill=_mistura(q.vivo, "#ffffff", 0.4), outline="")

    # ── visibilidade ─────────────────────────────────────────────────────
    def esconder(self) -> None:
        """Some da tela sem morrer.

        No modo de espera ele fica horas sem nada acontecer, e uma orb parada
        na tela todo esse tempo é estorvo. Destruir e recriar a janela a cada
        despertar pisca e, em alguns sistemas, rouba o foco de onde a pessoa
        estava digitando. Esconder guarda tudo de pé.
        """
        self._fila.put(("__esconder__", None))

    def mostrar(self) -> None:
        self._fila.put(("__mostrar__", None))

    def _aplicarVisibilidade(self, visivel: bool) -> None:
        # Só na thread da interface: nem o Tk nem o AppKit perdoam chamada de fora.
        try:
            if self.nativa:
                if visivel:
                    self.janela.orderFrontRegardless()
                else:
                    self.janela.orderOut_(None)
            elif visivel:
                self.raiz.deiconify()
                self.raiz.lift()
                if sys.platform != "linux":
                    self.raiz.attributes("-topmost", True)
            else:
                self.raiz.withdraw()
        except Exception:
            pass          # janela já fechando: nada a fazer
        self.visivel = visivel

    # ── ciclo de vida ────────────────────────────────────────────────────
    def mudar(self, estado: Optional[str] = None, nivel: Optional[float] = None) -> None:
        self._fila.put((estado, nivel))

    def fechar(self) -> None:
        if not self._vivo:
            return
        self._vivo = False
        try:
            if self._ao_fechar:
                self._ao_fechar()
        except Exception:
            pass
        try:
            if self.janela is not None:
                self.janela.orderOut_(None)
                self.janela.close()
        except Exception:
            pass
        try:
            self.raiz.destroy()
        except Exception:
            pass

    def rodar(self) -> None:
        self.raiz.mainloop()


# ── como processo próprio (10.4.4) ──────────────────────────────────────
# A orb voltou, mas SÓ como desenho: "é a bolinha do primo que aparece na
# tela quando falo" (Joel, 10/10). Ela saiu na 10.0 junto com o cérebro
# paralelo do modo voz; o cérebro continua fora. Roda num processo à parte
# (a janela precisa da thread principal, e a do primo_voz é do microfone),
# e só obedece: uma linha de JSON por mudança no stdin.
#
#     {"estado": "ouvindo", "nivel": 0.6}    esperando | ouvindo | pensando | falando
#     {"esconder": true} / {"mostrar": true}
#
# O stdin fechar (o modo voz acabou) fecha a orb.
def _ler_comandos(orbe: "Orbe") -> None:
    import json as _json
    for linha in sys.stdin:
        try:
            msg = _json.loads(linha)
        except ValueError:
            continue
        if msg.get("esconder"):
            orbe.mudar("__esconder__")
        elif msg.get("mostrar"):
            orbe.mudar("__mostrar__")
        else:
            nivel = msg.get("nivel")
            orbe.mudar(msg.get("estado"), float(nivel) if nivel is not None else None)
    orbe.mudar("__fechar__")


if __name__ == "__main__":
    import threading as _threading
    _orbe = Orbe()
    _threading.Thread(target=_ler_comandos, args=(_orbe,), daemon=True).start()
    _orbe.rodar()
