"""
===============================================================================
Automation with Image Recognition - OpenCV + Tesseract OCR
===============================================================================

Biblioteca de automação baseada em reconhecimento visual (Image Recognition)
utilizando OpenCV, PyAutoGUI e Tesseract OCR.

Projetada para automação de sistemas desktop e web onde:
    • Não há API disponível
    • Elementos não possuem identificadores acessíveis
    • A automação depende da interface visual
    • É necessário OCR para leitura de texto na tela

-------------------------------------------------------------------------------
Principais Recursos
-------------------------------------------------------------------------------

• Template Matching (OpenCV)
• Detecção de múltiplas ocorrências
• Busca hierárquica (pai → filho)
• Extração de texto via OCR (Tesseract)
• Inspeção visual para debug
• Execução de comandos baseada em coordenadas
• Sistema baseado em Command Pattern
• Suporte a offsets e múltiplos tipos de clique
• Engine escalável com controle de velocidade (rpa_speed)
• Logs estruturados para auditoria
• Tratamento robusto de erros

-------------------------------------------------------------------------------
Dependências
-------------------------------------------------------------------------------

Instale os pacotes necessários com:
    pip install opencv-python numpy psutil pyautogui pyperclip pytesseract pywin32 pywinauto pillow

⚠ Importante:
É necessário instalar o Tesseract OCR no sistema operacional.
No Windows:
    https://github.com/UB-Mannheim/tesseract/wiki

===============================================================================
"""

import time
from abc import ABC, abstractmethod
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any

import cv2
import numpy as np
import psutil
import pyautogui
import pyperclip
import pytesseract
import win32api
import win32gui
import win32process
from pywinauto import application
from pywinauto.application import Application, ProcessNotFoundError
from pywinauto.findwindows import ElementNotFoundError
from pywinauto.timings import TimeoutError as TimeoutElementPywinauto

from logger_config import logger

pytesseract.pytesseract.tesseract_cmd = "SEU_PATH_TESSERACT"

_TEMPLATE_CACHE: dict[str, Any] = {}
ROI_VERTICAL_OFFSET = 60
DEFAULT_THRESHOLD = 0.8
DEFAULT_MAX_ATTEMPTS = 3
DEFAULT_WAIT_TIME = 2
RPA_SPEED = 1  # 1 = normal, <1 = mais lento, >1 = mais rápido
TESSERACT_CONFIG_PSM6 = r"--oem 3 " r"--psm 6 " r"-l por+eng "
TESSERACT_CONFIG_FULL_TEXT = (
    r"--oem 3 "
    r"--psm 6 "
    r"-l por+eng "
    r"-c preserve_interword_spaces=1 "
    r"-c tessedit_preserve_blanks=1 "
    r"-c tessedit_char_whitelist="
    r"0123456789"
    r"abcdefghijklmnopqrstuvwxyz"
    r"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    r"áéíóúâêîôûãõç"
    r"ÁÉÍÓÚÂÊÎÔÛÃÕÇ"
    r"ÀÈÌÒÙàèìòù"
    r" ,.-/()[]{}:"
)


@dataclass(slots=True, frozen=True)
class Coordinates:
    """
    Representa coordenadas bidimensionais (x, y).

    Atributos:
        x (int): Coordenada horizontal.
        y (int): Coordenada vertical.
    """

    x: int
    y: int

    def as_tuple(self) -> tuple[int, int]:
        """Retorna as coordenadas no formato (x, y)."""
        return self.x, self.y


class ElementChildren:
    """
    Estrutura responsável por armazenar e manipular coordenadas
    de elementos filhos retornados por uma estrutura baseada em dicionário.

    Espera uma estrutura no formato:

        {
            "children": [
                {"coords": (x1, y1)},
                {"coords": (x2, y2)},
                ...
            ]
        }

    Caso a estrutura esteja incompleta ou inválida,
    os elementos inválidos serão ignorados silenciosamente.
    """

    def __init__(self, element_dict: dict[str, Any]) -> None:
        self.children: list[Coordinates] = self._parse_children(
            element_dict.get("children", [])
        )

    @staticmethod
    def _parse_children(raw_children: Iterable[Any]) -> list[Coordinates]:
        """Converte estrutura bruta em lista de Coordinates."""
        parsed: list[Coordinates] = []

        for child in raw_children:
            try:
                coords = child.get("coords")
                if isinstance(coords, (tuple, list)) and len(coords) == 2:
                    parsed.append(
                        Coordinates(
                            x=int(coords[0]),
                            y=int(coords[1]),
                        )
                    )
            except Exception:
                # Ignora filhos malformados
                continue

        return parsed

    def get_first_child_coords(self) -> Coordinates | None:
        """
        Retorna as coordenadas do primeiro elemento filho,
        ou None caso não existam filhos válidos.
        """
        return self.children[0] if self.children else None

    def __len__(self) -> int:
        """Retorna a quantidade de filhos válidos."""
        return len(self.children)

    def __iter__(self):
        """Permite iterar diretamente sobre os filhos."""
        return iter(self.children)


def _take_screenshot_bgr() -> np.ndarray:
    """
    Captura a tela atual e retorna a imagem no formato BGR (compatível com OpenCV).

    Returns:
        np.ndarray: Screenshot no formato BGR.
    """
    try:
        # Captura via pyautogui (PIL Image)
        screenshot = pyautogui.screenshot()

        # Conversão direta e eficiente para ndarray
        screenshot_np = np.asarray(screenshot, dtype=np.uint8)

        # Conversão RGB → BGR (OpenCV)
        return cv2.cvtColor(screenshot_np, cv2.COLOR_RGB2BGR)

    except Exception as e:
        logger.error(f"Erro ao capturar screenshot: {e}")
        raise


def _load_template(template_path: str) -> np.ndarray:
    """
    Carrega uma imagem de template no formato BGR (OpenCV).

    Args:
        template_path (str): Caminho absoluto ou relativo do arquivo de imagem.

    Returns:
        np.ndarray: Imagem carregada no formato BGR.

    Raises:
        TemplateLoadError: Caso o arquivo não exista ou não possa ser lido.
    """
    if not template_path or not isinstance(template_path, str):
        logger.error("Caminho do template inválido.")
        raise Exception("Caminho do template inválido.")

    template = cv2.imread(template_path, cv2.IMREAD_COLOR)

    if template is None:
        logger.error(f"Não foi possível carregar o template: {template_path}")
        raise Exception(f"Não foi possível carregar o template: {template_path}")

    return template


def _find_all_points(
    screenshot_gray: np.ndarray,
    template_gray: np.ndarray,
    threshold: float,
) -> list[tuple[int, int]]:
    """
    Encontra todas as ocorrências de um template em uma imagem
    utilizando template matching normalizado.

    Returns:
        list[tuple[int, int]]: Lista de coordenadas (x, y) dos pontos encontrados.
    """

    if screenshot_gray.size == 0 or template_gray.size == 0:
        return []

    try:
        result = cv2.matchTemplate(
            screenshot_gray,
            template_gray,
            cv2.TM_CCOEFF_NORMED,
        )

        locations = np.where(result >= float(threshold))

        # Garante conversão explícita para int
        return [(int(x), int(y)) for x, y in zip(*locations[::-1], strict=True)]

    except Exception as e:
        logger.error(f"Erro no template matching: {e}")
        return []


def _inspect_found_elements(
    screenshot: np.ndarray,
    points: list,
    template_size: tuple,
    roi_size: tuple | None = None,
):
    """
    Desenha retângulos nos elementos encontrados para inspeção visual.

    - Verde: área do template
    - Vermelho: ROI adicional (se fornecida)
    """

    if screenshot is None or screenshot.size == 0:
        return

    debug_image = screenshot.copy()

    w, h = template_size

    for pt in points:
        x, y = int(pt[0]), int(pt[1])

        # Template (verde)
        cv2.rectangle(
            debug_image,
            (x, y),
            (x + w, y + h),
            (0, 255, 0),
            2,
        )

        # ROI (vermelho)
        if roi_size:
            cv2.rectangle(
                debug_image,
                (x, y),
                (x + roi_size[0], y + roi_size[1]),
                (0, 0, 255),
                1,
            )

    cv2.imshow("Elementos Detectados", debug_image)
    cv2.waitKey(0)
    cv2.destroyAllWindows()


def _extract_text_from_roi(
    roi: np.ndarray,
    config: str,
    timeout: int = 30,
) -> str:
    """
    Extrai texto de uma Região de Interesse (ROI) utilizando Tesseract.

    Realiza pré-processamento automático para melhorar a qualidade
    do reconhecimento (grayscale, resize e binarização).

    Returns:
        str: Texto extraído (string vazia se nada reconhecido).
    """

    if roi is None or roi.size == 0:
        return ""

    try:

        roi_gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

        roi_gray = cv2.resize(
            roi_gray,
            None,
            fx=2,
            fy=2,
            interpolation=cv2.INTER_CUBIC,
        )

        roi_gray = cv2.threshold(
            roi_gray,
            0,
            255,
            cv2.THRESH_BINARY + cv2.THRESH_OTSU,
        )[1]

        text = pytesseract.image_to_string(
            roi_gray,
            config=config,
            timeout=timeout,
        )

        return text.strip()

    except Exception as e:
        logger.error(f"Erro ao extrair texto: {e}")
        raise Exception(f"Erro ao extrair texto: {e}") from e


def find_element(
    template_path: str,
    index: int = 0,
    max_attempts: int = DEFAULT_MAX_ATTEMPTS,
    wait_time: int = DEFAULT_WAIT_TIME,
    threshold: float = DEFAULT_THRESHOLD,
    inspect: bool = False,
    extract_text: bool = False,
    config_number_psm: bool = True,
    relativa_h: int = 2,
    rpa_speed: int = RPA_SPEED,
) -> Any:
    """
    Localiza um elemento na tela utilizando template matching (OpenCV),
    com suporte a múltiplas tentativas, inspeção visual e extração opcional
    de texto via OCR (Tesseract).

    Esta função é o núcleo da automação baseada em reconhecimento de imagem,
    permitindo encontrar elementos visuais na tela e opcionalmente extrair
    texto da região associada ao template identificado.

    Args:
        template_path (str):
            Caminho do arquivo de imagem (template) que será utilizado
            como referência para busca na tela.

        index (int, opcional):
            Índice da ocorrência encontrada.
            Caso múltiplos matches sejam detectados, permite escolher qual
            deles utilizar (padrão: 0 - primeira ocorrência).

        max_attempts (int, opcional):
            Número máximo de tentativas para encontrar o elemento.
            Pode ser ajustado dinamicamente via rpa_speed.

        wait_time (int, opcional):
            Tempo de espera (em segundos) entre tentativas.

        threshold (float, opcional):
            Nível mínimo de similaridade para considerar um match válido.
            Valores mais altos exigem maior precisão visual.

        inspect (bool, opcional):
            Se True, exibe visualmente a região encontrada na tela
            (útil para debug e validação visual).

        extract_text (bool, opcional):
            Se True, em vez de retornar coordenadas, extrai texto da região
            relacionada ao template usando OCR.

        config_number_psm (bool, opcional):
            Define a configuração do Tesseract para extração de texto:
                • True → usa configuração otimizada para números/blocos simples.
                • False → usa configuração mais ampla (texto completo).

        relativa_h (int, opcional):
            Multiplicador de altura da região de interesse (ROI) para OCR.
            Define o quanto abaixo do template o texto será capturado.

        rpa_speed (int, opcional):
            Fator de velocidade do robô.
            Ajusta dinamicamente:Número de tentativas e tempo de espera entre tentativas.
            Valores maiores tornam a busca mais agressiva.

    Returns:
        Any:
            - (int, int): Coordenadas centrais do elemento encontrado
              quando extract_text=False.
            - str: Texto extraído da região do elemento
              quando extract_text=True.
            - None: Caso o elemento não seja encontrado após todas as tentativas.

    """

    max_attempts, wait_time = _scale_speed(max_attempts, wait_time, rpa_speed)

    template, template_gray = _get_template_cached(template_path)
    w, h = template.shape[1], template.shape[0]

    for attempt in range(max_attempts):
        logger.info(
            f"Tentativa {attempt + 1}/{max_attempts} para encontrar {template_path}"
        )

        screenshot, screenshot_gray = _capture_and_prepare()

        points = _match_template(screenshot_gray, template_gray, threshold)

        if points and len(points) > index:
            logger.info(f"Objeto encontrado na tentativa {attempt + 1}")
            pt = points[index]

            if inspect and not extract_text:
                _inspect_found_elements(screenshot, [pt], (w, h))

            if extract_text:
                return _handle_text_extraction(
                    screenshot,
                    pt,
                    w,
                    h,
                    relativa_h,
                    inspect,
                    config_number_psm,
                )

            return _calculate_center(pt, w, h)

        logger.info(f"Objeto não encontrado. Aguardando {wait_time}s...")

        if attempt < max_attempts - 1:
            time.sleep(wait_time)

    return None


def _scale_speed(
    max_attempts: int,
    wait_time: float,
    rpa_speed: int,
) -> tuple[int, int]:
    """Escala tentativas e tempo de espera conforme rpa_speed."""
    if rpa_speed <= 0:
        return max_attempts, int(wait_time)

    return max_attempts * rpa_speed, int(wait_time / rpa_speed)


def _get_template_cached(template_path: str):
    """Carrega template com cache para evitar reprocessamento."""
    if template_path in _TEMPLATE_CACHE:
        return _TEMPLATE_CACHE[template_path]

    template = _load_template(template_path)
    template_gray = cv2.cvtColor(template, cv2.COLOR_BGR2GRAY)

    _TEMPLATE_CACHE[template_path] = (template, template_gray)
    return template, template_gray


def _capture_and_prepare():
    """Captura screenshot e prepara versão grayscale."""
    screenshot = _take_screenshot_bgr()
    screenshot_gray = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY)
    return screenshot, screenshot_gray


def _match_template(
    screenshot_gray,
    template_gray,
    threshold: float,
):
    """Executa template matching e retorna todos os pontos encontrados."""
    return _find_all_points(screenshot_gray, template_gray, threshold)


def _handle_text_extraction(
    screenshot,
    pt,
    w: int,
    h: int,
    relativa_h: int,
    inspect: bool,
    config_number_psm: bool,
):
    """Extrai texto da região abaixo do template encontrado."""
    roi_h = h * relativa_h

    roi = screenshot[
        pt[1] + ROI_VERTICAL_OFFSET : pt[1] + roi_h,
        pt[0] : pt[0] + w,
    ]

    tesseract_config = TESSERACT_CONFIG_PSM6 if config_number_psm else ""

    extracted_text = _extract_text_from_roi(roi, config=tesseract_config)

    if inspect:
        _inspect_found_elements(
            screenshot,
            [pt],
            (w, h),
            (w, roi_h),
        )

    return extracted_text


def _calculate_center(pt, w: int, h: int):
    """Calcula centro do template encontrado."""
    center_x = pt[0] + w // 2
    center_y = pt[1] + h // 2
    return center_x, center_y


def _process_multiple_matches(
    screenshot,
    points,
    w: int,
    h: int,
    extract_text: bool,
    psm: bool,
) -> list:
    """
    Processa múltiplos pontos encontrados via template matching.

    Retorna:
        - Lista de coordenadas (x, y) se extract_text=False
        - Lista de textos extraídos se extract_text=True
    """

    results: list = []

    for pt in points:
        if extract_text:
            # ROI abaixo do template encontrado
            roi = screenshot[
                pt[1] + ROI_VERTICAL_OFFSET : pt[1] + (h * 2),
                pt[0] : pt[0] + w,
            ]

            config = TESSERACT_CONFIG_FULL_TEXT if psm else TESSERACT_CONFIG_PSM6

            try:
                text = _extract_text_from_roi(
                    roi,
                    config=config,
                    timeout=20,
                )

                results.append(text.strip() if text else "")

            except Exception as e:
                logger.warning(f"[OCR] Falha ao extrair texto no ponto {pt}: {e}")
                results.append("")
        else:
            results.append(_calculate_center(pt, w, h))

    return results


def find_all_elements(
    template_path: str,
    max_attempts: int = DEFAULT_MAX_ATTEMPTS,
    wait_time: int = DEFAULT_WAIT_TIME,
    threshold: float = DEFAULT_THRESHOLD,
    inspect: bool = False,
    extract_text: bool = False,
    psm: bool = False,
    rpa_speed: int = RPA_SPEED,
) -> list:
    """
    Localiza todas as ocorrências de um template na tela utilizando
    template matching (OpenCV), com suporte a múltiplas tentativas,
    inspeção visual e extração opcional de texto via OCR (Tesseract).

    Esta função retorna todas as correspondências
    encontradas na tela durante a primeira tentativa bem-sucedida,
    permitindo trabalhar com múltiplos elementos visuais simultaneamente.

    Args:
        template_path (str):
            Caminho do arquivo de imagem (template) que será utilizado
            como referência para busca na tela.

        max_attempts (int, opcional):
            Número máximo de tentativas para localizar os elementos.
            Pode ser ajustado dinamicamente via rpa_speed.

        wait_time (int, opcional):
            Tempo de espera (em segundos) entre tentativas.

        threshold (float, opcional):
            Nível mínimo de similaridade para considerar um match válido.
            Valores mais altos exigem maior precisão visual.

        inspect (bool, opcional):
            Se True, exibe visualmente todas as regiões encontradas
            na tela (útil para debug e validação visual).

        extract_text (bool, opcional):
            Se True, em vez de retornar coordenadas, extrai texto da
            região relacionada a cada template encontrado usando OCR.

        psm (bool, opcional):
            Define qual configuração do Tesseract será utilizada:
                • True → usa configuração completa para textos mistos (por+eng).
                • False → usa configuração padrão otimizada para blocos simples.

        rpa_speed (int, opcional):
            Fator de velocidade do robô.
            Ajusta dinamicamente: Número de tentativas, tempo de espera entre tentativas.
            Valores maiores tornam a busca mais agressiva.

    Returns:
        list:
            - Lista de tuplas (int, int) representando as coordenadas
              centrais de cada elemento encontrado, quando extract_text=False.
            - Lista de strings contendo os textos extraídos de cada ocorrência,
              quando extract_text=True.
            - Lista vazia caso nenhum elemento seja encontrado após
              todas as tentativas.

    """

    max_attempts, wait_time = _scale_speed(
        max_attempts,
        wait_time,
        rpa_speed,
    )

    template, template_gray = _get_template_cached(template_path)
    w, h = template.shape[1], template.shape[0]

    for attempt in range(max_attempts):
        logger.info(
            f"Tentativa {attempt + 1}/{max_attempts} "
            f"para encontrar todos os {template_path}"
        )

        screenshot, screenshot_gray = _capture_and_prepare()
        screenshot_gray = cv2.equalizeHist(screenshot_gray)
        template_gray_eq = cv2.equalizeHist(template_gray)

        points = _match_template(
            screenshot_gray,
            template_gray_eq,
            threshold,
        )

        if points:
            logger.info(f"Encontrados {len(points)} elementos.")

            found_elements = _process_multiple_matches(
                screenshot=screenshot,
                points=points,
                w=w,
                h=h,
                extract_text=extract_text,
                psm=psm,
            )

            if inspect:
                _inspect_found_elements(screenshot, points, (w, h))

            return found_elements

        logger.info(f"Nenhum elemento encontrado. Aguardando {wait_time}s...")

        if attempt < max_attempts - 1:
            time.sleep(wait_time)

    return []


def _inspect_parent_child(
    screenshot: np.ndarray,
    parent_pt: tuple[int, int],
    parent_size: tuple[int, int],
    child_pt: tuple[int, int],
    child_size: tuple[int, int],
):
    debug_image = screenshot.copy()

    px, py = parent_pt
    pw, ph = parent_size
    cx, cy = child_pt
    cw, ch = child_size

    # Pai (verde)
    cv2.rectangle(
        debug_image,
        (px, py),
        (px + pw, py + ph),
        (0, 255, 0),
        2,
    )

    # Filho (vermelho)
    cv2.rectangle(
        debug_image,
        (px + cx, py + cy),
        (px + cx + cw, py + cy + ch),
        (0, 0, 255),
        2,
    )

    cv2.imshow("Parent/Child Detection", debug_image)
    cv2.waitKey(0)
    cv2.destroyAllWindows()


def find_child_element(
    parent_template_path: str,
    child_template_path: str,
    max_attempts: int = DEFAULT_MAX_ATTEMPTS,
    wait_time: int = DEFAULT_WAIT_TIME,
    threshold: float = DEFAULT_THRESHOLD,
    inspect: bool = False,
    find_all: bool = False,
    rpa_speed: int = RPA_SPEED,
) -> tuple[int, int] | list[tuple[int, int]]:
    """
    Localiza um elemento filho dentro da área de um elemento pai
    utilizando template matching hierárquico (OpenCV).

    A função primeiro identifica o template do elemento pai na tela.
    Em seguida, restringe a busca do template filho à região do pai
    encontrado, aumentando precisão e reduzindo falsos positivos.
    Args:
        parent_template_path (str):
            Caminho do template do elemento pai.

        child_template_path (str):
            Caminho do template do elemento filho.

        max_attempts (int, opcional):
            Número máximo de tentativas para localizar os elementos.

        wait_time (int, opcional):
            Tempo de espera (em segundos) entre tentativas.

        threshold (float, opcional):
            Nível mínimo de similaridade para considerar um match válido.
            Valores maiores exigem maior precisão visual.

        inspect (bool, opcional):
            Se True, exibe visualmente as detecções:
                • Retângulo verde → elemento pai
                • Retângulo vermelho → elemento filho

        find_all (bool, opcional):
            Se False (padrão):
                Retorna apenas a primeira ocorrência encontrada.

            Se True:
                Retorna todas as ocorrências encontradas como lista
                de coordenadas absolutas.

        rpa_speed (int, opcional):
            Fator de velocidade do robô.
            Ajusta dinamicamente: Número de tentativas, tempo de espera entre tentativas.

    Returns:
        tuple[int, int]:
            Coordenadas centrais absolutas do filho encontrado
            quando find_all=False.

        list[tuple[int, int]]:
            Lista de coordenadas centrais absolutas de todos os filhos
            encontrados quando find_all=True.

    Raises:
        ElementNotFound:
            Caso o elemento pai ou filho não seja encontrado após
            todas as tentativas.

    """

    max_attempts, wait_time = _scale_speed(
        max_attempts,
        wait_time,
        rpa_speed,
    )

    parent_template, parent_template_gray = _get_template_cached(parent_template_path)
    child_template, child_template_gray = _get_template_cached(child_template_path)

    parent_w, parent_h = parent_template.shape[1], parent_template.shape[0]
    child_w, child_h = child_template.shape[1], child_template.shape[0]

    for attempt in range(max_attempts):
        logger.info(f"Tentativa {attempt + 1}/{max_attempts} de encontrar filho em pai")

        screenshot, screenshot_gray = _capture_and_prepare()

        # Match do pai usando engine padronizada
        parent_points = _match_template(
            screenshot_gray,
            parent_template_gray,
            threshold,
        )

        if parent_points:
            all_results: list[tuple[int, int]] = []

            for parent_pt in parent_points:
                px, py = parent_pt

                roi = screenshot[
                    py : py + parent_h,
                    px : px + parent_w,
                ]

                roi_gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

                child_points = _match_template(
                    roi_gray,
                    child_template_gray,
                    threshold,
                )

                for child_pt in child_points:
                    cx, cy = child_pt

                    absolute_x = px + cx + child_w // 2
                    absolute_y = py + cy + child_h // 2

                    all_results.append((absolute_x, absolute_y))

                    if not find_all:
                        if inspect:
                            _inspect_parent_child(
                                screenshot,
                                parent_pt,
                                (parent_w, parent_h),
                                child_pt,
                                (child_w, child_h),
                            )

                        return absolute_x, absolute_y

            if all_results:
                if inspect:
                    for parent_pt in parent_points:
                        _inspect_found_elements(
                            screenshot,
                            [parent_pt],
                            (parent_w, parent_h),
                        )

                return all_results

        logger.info(f"Aguardando {wait_time}s...")

        if attempt < max_attempts - 1:
            time.sleep(wait_time)

    logger.error("Elemento pai ou filho não encontrado.")
    raise Exception("Elemento pai ou filho não encontrado.")


def close_all(class_name: str) -> None:
    """
    Encerra todas as aplicações cuja janela principal possua
    o class_name informado.

    A função localiza janelas pelo nome da classe e finaliza
    seus respectivos processos de forma segura.

    Args:
        class_name (str): Nome da classe da janela (Window Class Name).
    """

    if not class_name:
        logger.error("class_name inválido.")
        return

    closed_count = 0

    def _enum_handler(hwnd: int, _):
        nonlocal closed_count

        try:
            if win32gui.GetClassName(hwnd) != class_name:
                return

            _, pid = win32process.GetWindowThreadProcessId(hwnd)

            if not pid:
                return

            process = psutil.Process(pid)

            logger.info(f"Encerrando processo PID {pid} " f"(class='{class_name}')")

            process.terminate()

            try:
                process.wait(timeout=2)
            except psutil.TimeoutExpired:
                logger.warning(f"Processo PID {pid} não respondeu. Forçando kill.")
                process.kill()

            closed_count += 1

        except (psutil.NoSuchProcess, psutil.AccessDenied):
            return
        except Exception as e:
            logger.error(f"Erro ao encerrar janela: {e}")

    # Enumera todas as janelas abertas
    win32gui.EnumWindows(_enum_handler, None)

    if closed_count:
        logger.info(
            f"{closed_count} aplicação(ões) com class " f"'{class_name}' encerrada(s)."
        )
    else:
        logger.info(f"Nenhuma aplicação com class " f"'{class_name}' encontrada.")

    raise Exception("Falha ao conectar na aplicação após todas as tentativas.")


def _connect_to_window_with_fallback(
    title: str,
    class_name: str,
    timeout: int = 10,
) -> Application:
    """
    Tenta conectar a uma janela utilizando múltiplos backends
    e estratégias de busca (match exato e regex).

    Raises:
        AppConnectionError:
            Caso nenhuma tentativa consiga conectar.
    """

    if not title:
        raise ValueError("title não pode ser vazio.")

    backends = ("uia", "win32")

    for backend in backends:
        app = application.Application(backend=backend)

        strategies = (
            {"title": title, "class_name": class_name},
            {"title_re": f".*{title}.*", "class_name": class_name},
        )

        for strategy in strategies:
            try:
                logger.info(
                    f"Tentando conectar via backend='{backend}' "
                    f"usando estratégia={list(strategy.keys())}"
                )

                return app.connect(
                    timeout=timeout,
                    **{k: v for k, v in strategy.items() if v},
                )

            except (ElementNotFoundError, ProcessNotFoundError):
                logger.info(
                    f"Falha ao conectar via backend='{backend}' "
                    f"com estratégia={list(strategy.keys())}"
                )
            except Exception as e:
                logger.warning(
                    f"Erro inesperado ao conectar via backend='{backend}': {e}"
                )

    logger.error(f"Não foi possível conectar à janela: '{title}'")

    raise Exception(f"Não foi possível conectar à janela: '{title}'")


def wait_for_window(
    app: Application,
    window_title: str,
    max_attempts: int = 3,
    timeout: int = 10,
):
    """
    Aguarda até que uma janela específica apareça e esteja pronta
    para interação.

    A função tenta localizar a janela utilizando regex no título,
    aguarda o estado "ready" e define foco automaticamente.

    Args:
        app (Application):
            Instância conectada do pywinauto.

        window_title (str):
            Regex do título da janela.

        max_attempts (int, opcional):
            Número máximo de tentativas.

        timeout (int, opcional):
            Tempo máximo (em segundos) para cada tentativa.

    Returns:
        WindowSpecification:
            Janela encontrada e focada.

    Raises:
        ElementNotFound:
            Caso a janela não seja encontrada após todas as tentativas.
    """

    if not window_title:
        raise ValueError("window_title não pode ser vazio.")

    last_error: Exception | None = None

    for attempt in range(1, max_attempts + 1):
        try:
            logger.info(
                f"[WAIT_WINDOW] Tentativa {attempt}/{max_attempts} "
                f"para localizar '{window_title}'"
            )

            window = app.window(title_re=window_title)

            # Aguarda estado pronto
            window.wait("ready", timeout=timeout)

            # Garante wrapper válido
            window.wrapper_object()

            # Define foco
            window.set_focus()

            logger.info(f"[WAIT_WINDOW] Janela encontrada e pronta: '{window_title}'")

            return window

        except (TimeoutElementPywinauto, ElementNotFoundError) as e:
            last_error = e

            logger.warning(
                f"[WAIT_WINDOW] Falha na tentativa " f"{attempt}/{max_attempts}: {e}"
            )

            if attempt < max_attempts:
                time.sleep(2)

        except Exception as e:
            last_error = e
            logger.error(f"[WAIT_WINDOW] Erro inesperado: {e}")
            break

    logger.error(
        f"Janela '{window_title}' não encontrada após " f"{max_attempts} tentativas."
    )

    raise Exception(f"Janela '{window_title}' não encontrada.") from last_error


def close_window(window_title: str, window_class: str) -> None:
    """
    Fecha uma janela específica utilizando título e classe.

    A função tenta conectar à janela usando múltiplos backends
    e, caso encontrada, define foco e executa o fechamento.

    Args:
        window_title (str):
            Título (ou parte dele) da janela.

        window_class (str):
            Classe da janela.
    """

    if not window_title:
        logger.warning("window_title inválido.")
        return

    try:
        logger.info(
            f"[CLOSE_WINDOW] Tentando fechar "
            f"'{window_title}' (class='{window_class}')"
        )

        app = _connect_to_window_with_fallback(
            window_title,
            window_class,
        )

        window = app.window(title_re=f".*{window_title}.*")

        # Garante objeto válido
        window.wrapper_object()

        window.set_focus()
        window.close()

        logger.info(f"[CLOSE_WINDOW] Janela '{window_title}' fechada com sucesso.")

    except Exception as e:
        logger.error(
            f"[CLOSE_WINDOW] Erro inesperado ao fechar " f"'{window_title}': {e}"
        )


def center_window(window_title: str, window_class: str) -> None:
    """
    Centraliza uma janela específica na tela principal.

    A função conecta à janela utilizando fallback de backend,
    garante que ela esteja restaurada (não minimizada) e
    reposiciona no centro da tela mantendo tamanho atual.

    Args:
        window_title (str):
            Título (ou parte dele) da janela.

        window_class (str):
            Classe da janela.
    """

    if not window_title:
        logger.warning("[CENTER_WINDOW] window_title inválido.")
        return

    try:
        logger.info(
            f"[CENTER_WINDOW] Centralizando '{window_title}' "
            f"(class='{window_class}')"
        )

        app = _connect_to_window_with_fallback(
            window_title,
            window_class,
        )

        window = app.window(title_re=f".*{window_title}.*")

        wrapper = window.wrapper_object()

        # Garante que não está minimizada
        wrapper.restore()
        wrapper.set_focus()

        # Dimensões da tela principal
        screen_width = win32api.GetSystemMetrics(0)
        screen_height = win32api.GetSystemMetrics(1)

        rect = wrapper.rectangle()
        width = rect.width()
        height = rect.height()

        # Calcula centro
        x = max((screen_width - width) // 2, 0)
        y = max((screen_height - height) // 2, 0)

        wrapper.move_window(
            x,
            y,
            width=width,
            height=height,
            repaint=True,
        )

        logger.info(f"[CENTER_WINDOW] Janela '{window_title}' centralizada.")

    except Exception as e:
        logger.error(
            f"[CENTER_WINDOW] Erro inesperado ao centralizar " f"'{window_title}': {e}"
        )


def click(
    coord_x: int,
    coord_y: int,
    offset_x: int = 0,
    offset_y: int = 0,
    *,
    double_click: bool = False,
    button: str = "left",
) -> None:
    """
    Move o cursor do mouse para as coordenadas especificadas
    e executa um clique simples ou duplo.

    Args:
        coord_x (int):
            Coordenada X absoluta.

        coord_y (int):
            Coordenada Y absoluta.

        offset_x (int, opcional):
            Offset aplicado ao eixo X.

        offset_y (int, opcional):
            Offset aplicado ao eixo Y.

        double_click (bool, opcional):
            Se True, executa clique duplo.
            Padrão: False.

        button (str, opcional):
            Botão do mouse a ser utilizado.
            Valores permitidos:
                - "left"
                - "right"
                - "middle"
            Padrão: "left".
    """

    if button not in ("left", "right", "middle"):
        raise ValueError("button deve ser 'left', 'right' ou 'middle'.")

    x = int(coord_x) + int(offset_x)
    y = int(coord_y) + int(offset_y)

    pyautogui.moveTo(x, y)

    pyautogui.click(
        clicks=2 if double_click else 1,
        button=button,
    )


def click_center_screen(
    offset_x: int = 0,
    offset_y: int = 0,
) -> None:
    """
    Move o mouse para o centro da tela e executa um clique,
    aplicando offsets opcionais.

    Args:
        offset_x (int, opcional):
            Deslocamento horizontal a partir do centro da tela.

        offset_y (int, opcional):
            Deslocamento vertical a partir do centro da tela.

    """

    width, height = pyautogui.size()

    center_x = width // 2
    center_y = height // 2

    x = center_x + int(offset_x)
    y = center_y + int(offset_y)

    pyautogui.moveTo(x, y)
    pyautogui.click()


def writer(message: str, interval: float = 0.02) -> None:
    """
    Digita uma mensagem com intervalo entre teclas.

    Estratégia:
        • Se o texto for ASCII puro → usa pyautogui.write()
        • Se contiver caracteres Unicode (acentos, símbolos, etc.) →
          usa copiar/colar (pyperclip + Ctrl+V)

    Args:
        message (str):
            Texto a ser digitado.

        interval (float, opcional):
            Intervalo entre teclas quando usar digitação direta.
            Padrão: 0.02 segundos.
    """

    if not message:
        return

    is_ascii = message.isascii()

    if is_ascii:
        pyautogui.write(message, interval=interval)
    else:
        # Fallback seguro para Unicode
        pyperclip.copy(message)
        pyautogui.hotkey("ctrl", "v")

        # Pequeno delay para estabilidade
        time.sleep(0.2)


def send_key(
    key: str | Iterable[str],
    *,
    repeat: int = 1,
    interval: float = 0.0,
    hotkey: bool = False,
    post_delay: float = 0.0,
) -> None:
    """
    Envia tecla(s) globalmente, sem depender de handle de janela.

    Args:
        key (str | Iterable[str]): Tecla única ("enter") ou sequência
            ("ctrl", "c") se hotkey=True.
        repeat (int, opcional): Número de repetições. Padrão: 1.
        interval (float, opcional): Intervalo entre repetições (segundos).
        hotkey (bool, opcional): Se True, trata `key` como combinação
            (ex: ["ctrl", "c"]). Padrão: False.
        post_delay (float, opcional): Tempo de espera após execução.

    Raises:
        ValueError: Se parâmetros inválidos forem fornecidos.
    """

    if repeat <= 0:
        raise ValueError("repeat deve ser > 0.")

    if interval < 0:
        raise ValueError("interval não pode ser negativo.")

    if post_delay < 0:
        raise ValueError("post_delay não pode ser negativo.")

    if hotkey:
        if not isinstance(key, Iterable):
            raise ValueError("Para hotkey=True, key deve ser iterável.")

        keys = list(key)

        if not keys:
            raise ValueError("Lista de teclas não pode ser vazia.")

        for _ in range(repeat):
            pyautogui.hotkey(*keys)
            if interval > 0:
                time.sleep(interval)

    else:
        if not isinstance(key, str):
            raise ValueError("Para hotkey=False, key deve ser str.")

        for _ in range(repeat):
            pyautogui.press(key)
            if interval > 0:
                time.sleep(interval)

    if post_delay > 0:
        time.sleep(post_delay)


def get_input_text() -> str:
    """
    Obtém o texto atual de um campo de input ativo.
    Returns:
        str: Texto atualmente presente no campo ativo.
    """

    time.sleep(0.2)
    pyautogui.hotkey("ctrl", "a")
    time.sleep(0.1)
    pyautogui.hotkey("ctrl", "c")
    time.sleep(0.1)

    text = pyperclip.paste()

    return text


class ElementCommand(ABC):
    """Interface para comandos a serem executados em elementos."""

    @abstractmethod
    def execute(self, coordinates: tuple[int, int]) -> None:
        """Executa o comando nas coordenadas dadas."""


class ClickCommand(ElementCommand):
    """Comando para clicar em um elemento."""

    def execute(self, coordinates: tuple[int, int]) -> None:
        click(coordinates[0], coordinates[1])


class DoubleClickCommand(ElementCommand):
    """Comando para dar clique duplo em um elemento."""

    def execute(self, coordinates: tuple[int, int]) -> None:
        click(coordinates[0], coordinates[1], double_click=True)


class ClickRelativeCommand(ElementCommand):
    """
    Comando para clicar em uma posição relativa a um elemento.

    Permite aplicar offsets e configurar tipo de clique.
    """

    def __init__(
        self,
        offset_x: int = 0,
        offset_y: int = 0,
        *,
        double_click: bool = False,
        button: str = "left",
    ):
        self.offset_x = offset_x
        self.offset_y = offset_y
        self.double_click = double_click
        self.button = button

    def execute(self, coordinates: tuple[int, int]) -> None:
        click(
            coordinates[0],
            coordinates[1],
            offset_x=self.offset_x,
            offset_y=self.offset_y,
            double_click=self.double_click,
            button=self.button,
        )


class DoubleClickRelativeCommand(ElementCommand):
    """
    Comando para executar clique duplo em um elemento.

    Permite aplicar offsets e configurar o botão do mouse.
    """

    def __init__(
        self,
        offset_x: int = 0,
        offset_y: int = 0,
        *,
        button: str = "left",
    ):
        self.offset_x = offset_x
        self.offset_y = offset_y
        self.button = button

    def execute(self, coordinates: tuple[int, int]) -> None:
        click(
            coordinates[0],
            coordinates[1],
            offset_x=self.offset_x,
            offset_y=self.offset_y,
            double_click=True,
            button=self.button,
        )


class TypeTextCommand(ElementCommand):
    """
    Comando para clicar em um elemento, limpar o campo
    e digitar um texto.

    Permite configurar offsets e comportamento do clique.
    """

    def __init__(
        self,
        text: str,
        offset_x: int = 0,
        offset_y: int = 0,
        *,
        double_click: bool = False,
        button: str = "left",
        clear_delay: float = 0.1,
    ):
        self.text = text
        self.offset_x = offset_x
        self.offset_y = offset_y
        self.double_click = double_click
        self.button = button
        self.clear_delay = clear_delay

    def execute(self, coordinates: tuple[int, int]) -> None:
        # Clica no campo
        click(
            coordinates[0],
            coordinates[1],
            offset_x=self.offset_x,
            offset_y=self.offset_y,
            double_click=self.double_click,
            button=self.button,
        )

        # Seleciona tudo
        pyautogui.hotkey("ctrl", "a")
        time.sleep(self.clear_delay)

        # Limpa conteúdo
        pyautogui.press("backspace")
        time.sleep(self.clear_delay)

        # Digita novo texto
        writer(self.text)


class PressCommand(ElementCommand):
    """
    Comando para clicar em um elemento e pressionar uma tecla.

    Permite configurar offsets, tipo de clique e repetição da tecla.
    """

    def __init__(
        self,
        key: str,
        offset_x: int = 0,
        offset_y: int = 0,
        *,
        double_click: bool = False,
        button: str = "left",
        repeat: int = 1,
        interval: float = 0.0,
    ):
        self.key = key
        self.offset_x = offset_x
        self.offset_y = offset_y
        self.double_click = double_click
        self.button = button
        self.repeat = repeat
        self.interval = interval

    def execute(self, coordinates: tuple[int, int]) -> None:
        # Clica no elemento
        click(
            coordinates[0],
            coordinates[1],
            offset_x=self.offset_x,
            offset_y=self.offset_y,
            double_click=self.double_click,
            button=self.button,
        )

        # Pressiona tecla(s)
        for _ in range(self.repeat):
            send_key(self.key)
            if self.interval > 0:
                time.sleep(self.interval)


class Scroll:
    """Classe para controlar o scroll da página."""

    @staticmethod
    def down():
        pyautogui.press("pagedown")

    @staticmethod
    def up():
        pyautogui.press("pageup")


class Cursor:
    """Classe para controlar a visibilidade e posição do cursor."""

    @staticmethod
    def hide():
        """Move o cursor para fora da tela para não interferir na automação."""
        pyautogui.FAILSAFE = False
        pyautogui.moveTo(pyautogui.size()[0], pyautogui.size()[1])

    @staticmethod
    def move_to(template_name: str):
        """Move o cursor para uma coordenada específica."""
        x, y = find_element(template_path=template_name)
        pyautogui.FAILSAFE = False
        pyautogui.moveTo(x=x, y=y)


def perform_action(
    template_name: str,
    command: ElementCommand,
    max_attempts: int = DEFAULT_MAX_ATTEMPTS,
    threshold: float = DEFAULT_THRESHOLD,
) -> None:
    """
    Localiza um elemento na tela via template matching
    e executa um comando sobre ele.

    Args:
        template_name (str):
            Caminho ou nome do template a ser localizado.

        command (ElementCommand):
            Instância do comando a ser executado.

        max_attempts (int, opcional):
            Número máximo de tentativas para encontrar o elemento.

        threshold (float, opcional):
            Nível mínimo de similaridade para considerar um match válido.

    Raises:
        ElementNotFound:
            Caso o elemento não seja encontrado.
    """

    if not template_name:
        raise ValueError("template_name não pode ser vazio.")

    try:
        logger.info(f"[PERFORM_ACTION] Buscando elemento '{template_name}'")

        coordinates = find_element(
            template_name,
            max_attempts=max_attempts,
            threshold=threshold,
        )

        if coordinates is None:
            raise Exception(f"Elemento '{template_name}' não foi encontrado.")

        logger.info(
            f"[PERFORM_ACTION] Elemento encontrado em {coordinates}. "
            f"Executando comando {command.__class__.__name__}"
        )

        command.execute(coordinates)

        logger.info("[PERFORM_ACTION] Comando executado com sucesso.")

    except Exception as e:
        logger.error(f"[PERFORM_ACTION] Erro inesperado ao executar ação: {e}")
        raise


def execute_actions(
    actions: list[tuple[str, ElementCommand]],
    wait_time: int = 1,
    max_attempts: int = DEFAULT_MAX_ATTEMPTS,
) -> None:
    """
    Executa uma sequência de ações baseadas em template + comando.

    Args:
        actions (list[tuple[str, ElementCommand]]):
            Lista de tuplas no formato:
                (template_name, command)

        wait_time (int, opcional):
            Tempo de espera (em segundos) entre cada ação.

        max_attempts (int, opcional):
            Número máximo de tentativas para encontrar cada elemento.
    """

    if not actions:
        logger.warning("[EXECUTE_ACTIONS] Lista de ações vazia.")
        return

    for _, (template_name, command) in enumerate(actions, start=1):
        perform_action(
            template_name=template_name,
            command=command,
            max_attempts=max_attempts,
        )

        if wait_time > 0:
            time.sleep(wait_time)
