"""escuta.py — ouvir a pessoa falar, e saber quando ela terminou.

Adaptado do Jarvis (src/jarvis/core/vad.py e audio_io.py), que é a parte dele
que não tem nada de macOS: numpy e sounddevice, e mais nada.

── AS DUAS COISAS QUE ERRAM FEIO SE FOREM INGÊNUAS ─────────────────────────

**O limiar de "isso é fala"** não pode ser um número fixo. Num quarto
silencioso, um número fixo fica surdo; numa sala com televisão, ele acha que a
televisão está falando com ele. Então o limiar é relativo ao barulho do
ambiente, medido um instante antes de cada frase.

**O fim da frase** não é o primeiro silêncio. Quem explica alguma coisa pausa
para pensar, e pausa de pensar passa fácil de um segundo — cortar ali é cortar
a pessoa no meio. Por isso o rabo de silêncio é de dois segundos, e cresce
quando a fala já vem longa: quem está explicando pausa mais.

E há a **histerese**: começar a gravar exige som claramente acima do ambiente,
mas CONTINUAR gravando exige bem menos. Sem isso, a sílaba fraca no fim ("…pra
mim") cai abaixo do limiar e a frase é cortada antes de acabar.

── O QUE SAIU DO JARVIS ────────────────────────────────────────────────────
A wake word saiu DAQUI, não do produto. Este módulo cuida de uma coisa só:
achar o começo e o fim de uma frase. Quem reconhece o nome é o desperta.py, e
ele só entra no modo de espera — quando a pessoa pediu para ele ficar ouvindo
em segundo plano. Quem digita /voice e vai falar agora não diz nome nenhum: a
orbe já está ouvindo.
"""
from __future__ import annotations

import threading
import time
import wave
from pathlib import Path
from typing import List, Optional

import numpy as np

TAXA = 16000
BLOCO_MS = 100
BLOCO = int(TAXA * BLOCO_MS / 1000)

PISO_FALA = 380.0          # RMS mínimo para começar a contar como fala
MULT_AMBIENTE = 1.7        # precisa se destacar do ambiente nessa proporção
TETO_FALA = 700.0          # nunca exige mais que isso (não ficar surdo)
FATOR_CONTINUAR = 0.5      # a histerese: depois de começar, o limiar cai
PRE_ROLL = 4               # blocos guardados ANTES do início da fala

RABO_SILENCIO = 2.0        # silêncio que encerra a frase
RABO_SILENCIO_LONGA = 2.8  # …e quando a fala já vem longa
FALA_LONGA = 6.0
ESPERA_COMECAR = 8.0       # tempo para a pessoa começar a falar
MAX_FRASE = 45.0
MAX_FRASE_ESTENDIDA = 100.0

# Interromper ele falando: o microfone também ouve a voz DELE saindo pelo
# alto-falante, então o limiar é relativo ao nível da própria fala dele.
CORTE_FATOR = 2.3
CORTE_BLOCOS = 3
CORTE_MINIMO = 560.0


def rms(bloco: np.ndarray) -> float:
    """Energia do bloco. Em float para não estourar em int16."""
    if bloco is None or len(bloco) == 0:
        return 0.0
    x = bloco.astype(np.float64)
    return float(np.sqrt(np.mean(x * x)))


class Gravador:
    """Junta os blocos de uma frase, do começo ao silêncio que a encerra.

    ── O RELÓGIO É O ÁUDIO, NÃO A PAREDE ───────────────────────────────────
    O Jarvis media tudo com `time.monotonic()`, porque lá o áudio chegava em
    tempo real do microfone e as duas coisas andavam juntas. Só que elas nem
    sempre andam: o sistema entrega áudio em rajada quando a thread perde a
    vez, e aí o relógio de parede diz "dois segundos de silêncio" no meio de
    uma frase que acabou de chegar inteira de uma vez.

    Aqui cada bloco vale exatamente 100 ms de áudio, e o tempo avança por
    bloco consumido. A conta passa a ser exata, imune a soluço de
    agendamento — e, de quebra, testável: dá para alimentar uma frase inteira
    num laço e medir onde ela seria cortada, sem esperar em tempo real.
    """

    def __init__(self) -> None:
        self._blocos: List[np.ndarray] = []
        self._pre: List[np.ndarray] = []
        self._falando = False
        self._relogio = 0.0        # segundos de ÁUDIO consumidos
        self._ultimo_som = 0.0
        self._ambiente = PISO_FALA
        self._amostras_ambiente: List[float] = []
        self.encerrou_por = ""

    # O ambiente é medido ENQUANTO ele espera a pessoa começar. Medir antes de
    # abrir o microfone daria o silêncio do processo, não o do cômodo.
    def _limiar(self) -> float:
        base = max(PISO_FALA, min(TETO_FALA, self._ambiente * MULT_AMBIENTE))
        return base * (FATOR_CONTINUAR if self._falando else 1.0)

    def comecar(self) -> None:
        self._blocos = []
        self._pre = []
        self._falando = False
        self._relogio = 0.0
        self._ultimo_som = 0.0
        self._amostras_ambiente = []
        self.encerrou_por = ""

    def alimentar(self, bloco: np.ndarray) -> bool:
        """Devolve True quando a frase acabou."""
        self._relogio += BLOCO_MS / 1000.0
        agora = self._relogio
        nivel = rms(bloco)

        if not self._falando:
            # Guarda os últimos blocos: sem eles, a frase começa cortada na
            # primeira sílaba, que é a que o reconhecedor mais precisa.
            self._pre.append(bloco)
            if len(self._pre) > PRE_ROLL:
                self._pre.pop(0)

            self._amostras_ambiente.append(nivel)
            if len(self._amostras_ambiente) > 20:
                self._amostras_ambiente.pop(0)
            if len(self._amostras_ambiente) >= 5:
                self._ambiente = float(np.median(self._amostras_ambiente))

            if nivel > self._limiar():
                self._falando = True
                self._blocos = list(self._pre)
                self._ultimo_som = agora
            elif agora > ESPERA_COMECAR:
                self.encerrou_por = "ninguem-falou"
                return True
            return False

        self._blocos.append(bloco)
        if nivel > self._limiar():
            self._ultimo_som = agora

        duracao = len(self._blocos) * BLOCO_MS / 1000.0
        rabo = RABO_SILENCIO_LONGA if duracao > FALA_LONGA else RABO_SILENCIO

        if agora - self._ultimo_som > rabo:
            self.encerrou_por = "silencio"
            return True
        # Ainda falando ao bater o limite: estende em vez de cortar no meio da
        # frase. Cortar no meio devolve um pedido pela metade, e o agente
        # obedece ao pedido pela metade.
        if duracao > MAX_FRASE and agora - self._ultimo_som > 0.6:
            self.encerrou_por = "longa-demais"
            return True
        if duracao > MAX_FRASE_ESTENDIDA:
            self.encerrou_por = "teto"
            return True
        return False

    @property
    def segundos(self) -> float:
        return len(self._blocos) * BLOCO_MS / 1000.0

    def gravar_wav(self, caminho: Path) -> Optional[Path]:
        if not self._blocos:
            return None
        dados = np.concatenate(self._blocos)
        caminho.parent.mkdir(parents=True, exist_ok=True)
        with wave.open(str(caminho), "wb") as w:
            w.setnchannels(1)
            w.setsampwidth(2)
            w.setframerate(TAXA)
            w.writeframes(dados.astype(np.int16).tobytes())
        return caminho


class Corte:
    """Percebe a pessoa falando por cima enquanto ele responde.

    O limiar é relativo ao nível da fala DELE, medido ao vivo — com limiar
    fixo, ou ele nunca é interrompido (alto-falante alto) ou se interrompe
    sozinho o tempo todo (a própria voz dele passa do limiar).
    """

    def __init__(self) -> None:
        self.nivel_dele = 0.0
        self._seguidos = 0

    def ele_falou(self, nivel: float) -> None:
        # Média móvel: a voz dele oscila, e o limiar não pode oscilar junto.
        self.nivel_dele = self.nivel_dele * 0.85 + nivel * 0.15

    def alguem_cortou(self, nivel: float) -> bool:
        limiar = max(CORTE_MINIMO, self.nivel_dele * CORTE_FATOR)
        if nivel > limiar:
            self._seguidos += 1
        else:
            self._seguidos = 0
        if self._seguidos >= CORTE_BLOCOS:
            self._seguidos = 0
            return True
        return False


class Microfone:
    """O microfone aberto, entregando blocos de 100 ms.

    Um único fluxo para a sessão inteira: abrir e fechar a cada frase custa
    centenas de milissegundos e, em alguns sistemas, faz um estalo.
    """

    def __init__(self, ao_receber):
        import sounddevice as sd
        self._sd = sd
        self._ao_receber = ao_receber
        self._fluxo = None

    def abrir(self) -> None:
        def retorno(dados, quadros, tempo, estado):  # noqa: ARG001
            self._ao_receber(dados[:, 0].copy())

        self._fluxo = self._sd.InputStream(
            samplerate=TAXA, channels=1, dtype="int16",
            blocksize=BLOCO, callback=retorno,
        )
        self._fluxo.start()

    def fechar(self) -> None:
        if self._fluxo is not None:
            try:
                self._fluxo.stop()
                self._fluxo.close()
            except Exception:
                pass
            self._fluxo = None
