import time
import threading
import serial
import serial.tools.list_ports
import subprocess
import sys, os
import configparser
import customtkinter as ctk
from tkinter import filedialog, messagebox
import winsound
import pygame
import pyperclip
import re
import csv
from datetime import datetime
import json
from print.print_label import print_label
from box_manager import (
    create_box,
    get_open_boxes,
    get_box_by_id,
)
from box_manager import increment_box
from csv_manager import append_production_row
from print.print_label import print_box_closure_label
from box_manager import get_closed_boxes, get_box_by_id
from box_manager import close_box

# ==========================
#  APARÊNCIA / CONSTANTES
# ==========================
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")

CONFIG_FILE = "firmware_tool_config.ini"
DEFAULT_CLI_PATH = r"C:\Program Files\STMicroelectronics\STM32Cube\STM32CubeProgrammer\bin\STM32_Programmer_CLI.exe"

def get_app_root():
    if getattr(sys, 'frozen', False):
        # Programa compilado — salvar ao lado do EXE
        return os.path.dirname(sys.executable)
    else:
        # Rodando via Python normal
        return os.path.dirname(os.path.abspath(__file__))

APP_ROOT = get_app_root()

FIRMWARES_DIR = os.path.join(APP_ROOT, "firmwares")

# Mapeia o "modelo" para o sufixo do arquivo
FIRMWARE_MODELOS = {
    "SI-EMA": "ema.hex",
    "SI-EMR": "emr.hex",
    "SI-EMR 8P": "emr8p.hex",
    "ST-EAM-01B": "eam.hex",
    "SI-EMA Q": "si_ema_q.bin",   # ESP32-S3 (Projeto Qualidade) — merged-bin
}

# Qual gravador cada modelo usa. Padrão: STM32CubeProgrammer (linha PIMA).
# O modelo de qualidade é ESP32-S3, gravado via esptool.
FLASHER_STM32 = "stm32"
FLASHER_ESP32 = "esp32"
MODEL_FLASHER = {
    "SI-EMA Q": FLASHER_ESP32,
}
ESP32_CHIP = "esp32s3"          # --chip do esptool
ESP32_FLASH_ADDR = "0x0"        # merged-bin grava a partir de 0x0

def flasher_for(modelo: str) -> str:
    return MODEL_FLASHER.get(modelo, FLASHER_STM32)

MODELOS = ["Selecione o Modelo", *FIRMWARE_MODELOS.keys(), "Custom"]
BOR_OPTIONS = ["Não gravar BOR", "0", "1", "2", "3", "4"]


# ==========================
#  HELPERS
# ==========================
def resource_path(relative_path):
    if getattr(sys, "frozen", False):
        base_path = sys._MEIPASS
    else:
        base_path = os.path.dirname(os.path.abspath(__file__))
    return os.path.join(base_path, relative_path)


def load_cli_path():
    config = configparser.ConfigParser()
    if os.path.isfile(CONFIG_FILE):
        config.read(CONFIG_FILE)
        if "paths" in config and "cli" in config["paths"]:
            saved_path = config["paths"]["cli"]
            if os.path.isfile(saved_path):
                return saved_path
    return DEFAULT_CLI_PATH


def save_cli_path(path):
    config = configparser.ConfigParser()
    config["paths"] = {"cli": path}
    with open(CONFIG_FILE, "w") as f:
        config.write(f)


def load_esptool_cmd():
    """Resolve o prefixo de comando do esptool (usado p/ gravar ESP32).
    Ordem: caminho salvo no config -> esptool.exe ao lado do app -> módulo
    Python (fallback de desenvolvimento)."""
    config = configparser.ConfigParser()
    if os.path.isfile(CONFIG_FILE):
        config.read(CONFIG_FILE)
        if "paths" in config and "esptool" in config["paths"]:
            p = config["paths"]["esptool"]
            if os.path.isfile(p):
                return f'"{p}"'

    # esptool.exe ao lado do app (permite trocar a versão sem rebuild)
    local = os.path.join(APP_ROOT, "esptool.exe")
    if os.path.isfile(local):
        return f'"{local}"'

    # esptool.exe embarcado no bundle (PyInstaller _MEIPASS) ou na pasta do projeto
    bundled = resource_path("esptool.exe")
    if os.path.isfile(bundled):
        return f'"{bundled}"'

    # fallback: roda como módulo (requer esptool instalado no Python)
    return f'"{sys.executable}" -m esptool'


def load_sound_path():
    config = configparser.ConfigParser()
    if os.path.isfile(CONFIG_FILE):
        config.read(CONFIG_FILE)
        if "sound" in config and "file" in config["sound"]:
            path = config["sound"]["file"]
            if os.path.isfile(path):
                return path
    return ""


def save_sound_path(path):
    config = configparser.ConfigParser()
    if os.path.isfile(CONFIG_FILE):
        config.read(CONFIG_FILE)
    if "sound" not in config:
        config["sound"] = {}
    config["sound"]["file"] = path
    with open(CONFIG_FILE, "w") as f:
        config.write(f)

def open_serial_log_file(app):
    """Cria arquivo .log com timestamp para gravação contínua do monitor serial."""
    logs_dir = os.path.join(APP_ROOT, "logs")
    os.makedirs(logs_dir, exist_ok=True)

    timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    filename = f"serial_{timestamp}.log"
    app.serial_log_path = os.path.join(logs_dir, filename)

    app.serial_log_file = open(app.serial_log_path, "a", encoding="utf-8")
    app.log_message(app.serial_text, f"[TOOL] Gravando log em: {app.serial_log_path}", tag="tool")
    app.log_message(app.log_text,   f"[TOOL] Gravando log em: {app.serial_log_path}", tag="tool")    


# ==========================
#  APP
# ==========================
class FirmwareToolApp(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("SIGEP v1.0.0")
        self.geometry("1200x750")
        self.minsize(1100, 680)
        try:
            self.iconbitmap(resource_path(os.path.join("arquivos", "logo.ico")))
        except Exception:
            pass

        # Estado
        self.STM32_CLI_PATH = load_cli_path()
        self._job_return_serial = None
        self._job_border_reset = None
        self.sound_path = load_sound_path()
        self._audio_inited = False
        self._audio_lock = threading.Lock()
        self.selected_port_name = None
        self.serial_port = None
        self.serial_thread = None
        self.serial_thread_running = False
        self.seaiot_label_printed = False
        self.generate_logs_var = ctk.BooleanVar(value=False)
        # Toggle de Modo Produção (automação)
        self.production_enabled = False
        # Caixa ativa (produção)
        self.active_box_id = None
        # Lote atual da produção
        self.lote_var = ctk.StringVar(value="000001")
        self.manual_lote_session = "000001"
        
        self._prod_seen_deveuis = set()
        self._prod_seen_box_id = None

        # ===== LOG EXTERNO DO SERIAL =====
        self.serial_log_file = None
        self.serial_log_path = None

        self.seaiot_fw_path = ""  # caminho final para app
        self.base_fw_path = ""  # caminho final para base

        # Seletor manual (modo Custom)
        self.custom_seaiot_fw = ""
        self.custom_base_fw = ""

        # Produção
        self.auto_copy_var = ctk.BooleanVar(value=True)
        self.save_csv_var = ctk.BooleanVar(value=False)

        # Carregar do config.ini
        self.load_production_settings()

        self.is_executing = False

        self.create_layout()
        self.update_ports()
        self.check_stm32_cli()

    # --------------------------
    #  HELPERS DE UI
    # --------------------------

    def _schedule_return_to_serial(self, ms=2000):
        if self._job_return_serial is not None:
            try: self.after_cancel(self._job_return_serial)
            except: pass
            self._job_return_serial = None
        self._job_return_serial = self.after(ms, self._return_to_serial)

    def _schedule_border_reset(self, ms=4000):
        if getattr(self, "_job_border_reset", None) is not None:
            try:
                self.after_cancel(self._job_border_reset)
            except:
                pass
            self._job_border_reset = None

        self._job_border_reset = self.after(
            ms, lambda: self.status_frame.configure(border_color="#0078d4")
        )

    def _return_to_serial(self):
        self.tabview.set("Monitor Serial")
        self.start_serial_thread()
        self.serial_status_label.configure(
            text=f"Monitorando {self.selected_port_name}", text_color="white"
        )

    def _ensure_audio(self) -> bool:
        """Inicializa o mixer UMA vez. Retorna True se OK."""
        if getattr(self, "_audio_inited", False):
            return True
        try:
            # buffer menor = menos latência
            pygame.mixer.pre_init(44100, -16, 2, 512)
            pygame.mixer.init()
            self._audio_inited = True
            return True
        except Exception as e:
            self._audio_inited = False
            self.log_tool(f"[TOOL] Falha ao iniciar áudio (pygame): {e}")
            return False


    def _play_finish_sound_now(self):
        """Executa o som de fato (deve rodar no thread da UI)."""
        path = (getattr(self, "sound_path", "") or "").strip()

        # Som personalizado
        if path and os.path.isfile(path):
            ext = os.path.splitext(path)[1].lower()

            try:
                if ext == ".wav":
                    winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
                    return

                if ext == ".mp3":
                    if not self._ensure_audio():
                        raise RuntimeError("pygame mixer não inicializou")

                    pygame.mixer.music.stop()
                    pygame.mixer.music.load(path)
                    pygame.mixer.music.play()
                    return

                # extensão desconhecida -> cai no fallback
                raise RuntimeError(f"Extensão não suportada: {ext}")

            except Exception as e:
                self.log_tool(f"[TOOL] Falha ao tocar som personalizado: {e}")

        # Fallback padrão (beeps)
        try:
            winsound.Beep(880, 200)
            winsound.Beep(1320, 300)

        except Exception as e:
            self.log_tool(f"[TOOL] Falha no beep padrão: {e}")


    def play_finish_sound(self):
        """
        Pode ser chamada de qualquer thread.
        Agenda o som para o thread principal (UI) e evita sobreposição.
        """
        if not hasattr(self, "_audio_lock"):
            self._audio_lock = threading.Lock()

        def _run():
            with self._audio_lock:
                self._play_finish_sound_now()

        # sempre toca no thread da UI (resolve travas/latência)
        self.after(0, _run)


    BOX_PLACEHOLDER = "Selecione uma Caixa"

    def _box_id_from_value(self, value: str) -> str | None:
        if not value:
            return None
        if value == self.BOX_PLACEHOLDER:
            return None
        # esperado: "000012 (60/100)"
        return value.split(" ", 1)[0].strip()

    def on_modelo_change(self, *_):
        # Atualiza caminho do firmware / custom row
        self.refresh_firmware_selection()

        # Atualiza caixas somente se produção estiver ativa
        if self.production_enabled:
            self.refresh_box_list()

        # Revalida botão Executar + mensagem de status
        self.validate_ready_state()

    def on_toggle_production(self):
        self.production_enabled = bool(self.production_switch.get())

        if self.production_enabled:
            self.apply_layout_production()
            self.log_tool("[TOOL] Modo PRODUÇÃO ATIVADO")
        else:
            self.apply_layout_normal()
            self.log_tool("[TOOL] Modo PRODUÇÃO DESATIVADO")

    def refresh_box_list(self):
        model = self.modelo_var.get()

        # sem modelo válido
        if model in ("Selecione o Modelo", ""):
            self.box_combo.configure(values=[self.BOX_PLACEHOLDER])
            self.box_var.set(self.BOX_PLACEHOLDER)
            self.active_box_id = None
            self.box_counter_label.configure(text="")
            return

        boxes = get_open_boxes(model=model)

        # sem caixas
        if not boxes:
            self.box_combo.configure(values=[self.BOX_PLACEHOLDER])
            self.box_var.set(self.BOX_PLACEHOLDER)
            self.active_box_id = None
            self.box_counter_label.configure(text="")
            return

        # monta map id->display
        id_to_display = {
            b["id"]: f"{b['id']} ({b['count']}/{b['capacity']})"
            for b in boxes
        }

        values = [self.BOX_PLACEHOLDER] + list(id_to_display.values())
        self.box_combo.configure(values=values)

        # mantém seleção se existir
        if self.active_box_id and self.active_box_id in id_to_display:
            self.box_var.set(id_to_display[self.active_box_id])
            b = next(x for x in boxes if x["id"] == self.active_box_id)
            self.box_counter_label.configure(text=f"{b['count']}/{b['capacity']}")
        else:
            # SEMPRE começa em placeholder (não auto-seleciona)
            self.box_var.set(self.BOX_PLACEHOLDER)
            self.active_box_id = None
            self.box_counter_label.configure(text="")

    def on_box_selected(self, value: str):
        box_id = self._box_id_from_value(value)
        if not box_id:
            self.active_box_id = None
            self.box_counter_label.configure(text="")
            return

        box = get_box_by_id(box_id)
        if not box:
            self.active_box_id = None
            self.box_counter_label.configure(text="")
            return

        # se trocou de caixa, reseta dedupe (por caixa)
        if self._prod_seen_box_id != self.active_box_id:
            self._prod_seen_deveuis.clear()

        self._prod_seen_box_id = self.active_box_id
        self.box_counter_label.configure(text=f"{box['count']}/{box['capacity']}")
        self.log_tool(f"[TOOL] Caixa ativa selecionada: {box_id}")


    def create_new_box_ui(self):
        model = self.modelo_var.get()

        if model in ("Selecione o Modelo", ""):
            messagebox.showwarning(
                "Aviso",
                "Selecione um modelo antes de criar uma caixa."
            )
            return

        lote = self.lote_var.get().strip() or "000000"

        box = create_box(model=model, lote=lote)

        self.log_tool(f"[TOOL] Nova caixa criada: {box['id']} (modelo {model})")

        self.refresh_box_list()

    def open_manual_print_dialog(self):
        win = ctk.CTkToplevel(self)
        win.title("Impressão Manual")
        win.geometry("620x500")
        win.grab_set()

        title = ctk.CTkLabel(win, text="Impressão Manual", font=("Segoe UI", 18, "bold"))
        title.pack(anchor="w", padx=16, pady=(16, 8))

        mode_var = ctk.StringVar(value="device")

        top = ctk.CTkFrame(win, fg_color="transparent")
        top.pack(fill="x", padx=16)

        def set_mode(m: str):
            mode_var.set(m)
            render()

        ctk.CTkButton(
            top, text="Device (JSON)", width=160,
            command=lambda: set_mode("device")
        ).pack(side="left")

        ctk.CTkButton(
            top, text="Caixa (fechada)", width=160,
            command=lambda: set_mode("box")
        ).pack(side="left", padx=10)

        body = ctk.CTkFrame(win)
        body.pack(fill="both", expand=True, padx=16, pady=16)

        # ================== DEVICE ==================
        device_frame = ctk.CTkFrame(body, fg_color="transparent")

        # ===== GRID SUPERIOR =====
        top_grid = ctk.CTkFrame(device_frame, fg_color="transparent")
        top_grid.pack(fill="x", pady=(0, 14))

        # ---------- CARD LOTE ----------
        lote_card = ctk.CTkFrame(top_grid, corner_radius=10)
        lote_card.pack(side="left", padx=(0, 14))

        ctk.CTkLabel(
            lote_card,
            text="Lote",
            font=("Segoe UI", 12, "bold")
        ).pack(anchor="w", padx=14, pady=(12, 4))

        lote_entry = ctk.CTkEntry(lote_card, width=160)
        lote_entry.pack(padx=14, pady=(0, 14))
        lote_entry.insert(0, self.manual_lote_session)

        def update_manual_lote(event=None):
            val = lote_entry.get().strip()
            if val:
                self.manual_lote_session = val

        lote_entry.bind("<Return>", update_manual_lote)
        lote_entry.bind("<FocusOut>", update_manual_lote)

        # ---------- CARD PREVIEW ----------
        preview_card = ctk.CTkFrame(top_grid, corner_radius=10)
        preview_card.pack(side="left", fill="x", expand=True)

        ctk.CTkLabel(
            preview_card,
            text="Device (pré-visualização)",
            font=("Segoe UI", 12, "bold")
        ).pack(anchor="w", padx=14, pady=(12, 6))

        # 🔹 Variáveis reais
        self.manual_prev_deveui = ctk.StringVar(value="—")
        self.manual_prev_appkey = ctk.StringVar(value="—")
        self.manual_prev_model  = ctk.StringVar(value="—")

        # 🔹 Variáveis SOMENTE para exibição (upper)
        self.manual_prev_deveui_view = ctk.StringVar(value="—")
        self.manual_prev_appkey_view = ctk.StringVar(value="—")

        def _upper_trace(src, dst):
            def _(*_):
                val = src.get()
                dst.set(val.upper() if isinstance(val, str) else val)
            src.trace_add("write", _)

        _upper_trace(self.manual_prev_deveui, self.manual_prev_deveui_view)
        _upper_trace(self.manual_prev_appkey, self.manual_prev_appkey_view)

        def preview_row(label, var):
            r = ctk.CTkFrame(preview_card, fg_color="transparent")
            r.pack(anchor="w", padx=14)
            ctk.CTkLabel(
                r,
                text=f"{label}:",
                width=70,
                anchor="w",
                font=("Segoe UI", 14, "bold"),
                text_color="gray"
            ).pack(side="left")
            ctk.CTkLabel(
                r,
                textvariable=var,
                font=("Consolas", 14)
            ).pack(side="left")

        preview_row("DevEUI", self.manual_prev_deveui_view)
        preview_row("AppKey", self.manual_prev_appkey_view)
        preview_row("Modelo", self.manual_prev_model)  # já vem upper


        # ----- JSON -----
        ctk.CTkLabel(
            device_frame,
            text="Cole o JSON do device:",
            font=("Segoe UI", 13, "bold")
        ).pack(anchor="w", pady=(0, 6))

        json_box = ctk.CTkTextbox(device_frame, height=230)
        json_box.pack(fill="both", expand=True)

        def print_device():
            raw = json_box.get("1.0", "end").strip()
            lote = lote_entry.get().strip()

            if not lote or lote == "000000":
                messagebox.showwarning(
                    "Aviso",
                    "Informe um Lote válido.\n\n"
                    "O valor '000000' é apenas um placeholder."
                )
                lote_entry.focus()
                lote_entry.select_range(0, "end")
                return

            try:
                data = json.loads(raw)

                if not all(k in data for k in ("deveui", "model")):
                    raise ValueError("Campos obrigatórios ausentes")

                # atualiza preview (appkey é opcional — modelos GSM não têm)
                self.manual_prev_deveui.set(data["deveui"])
                self.manual_prev_appkey.set(data.get("appkey", ""))
                self.manual_prev_model.set(data["model"])

                self.handle_manual_print(raw, lote, win)

            except Exception as e:
                messagebox.showerror(
                    "Erro na impressão",
                    f"JSON inválido:\n{e}"
                )

        def on_enter_print(event):
            if event.state & 0x0001:  # Shift+Enter
                return
            update_manual_lote()
            print_device()
            return "break"

        json_box.bind("<Return>", on_enter_print)

        # ================== BOX ==================
        box_frame = ctk.CTkFrame(body, fg_color="transparent")

        row1 = ctk.CTkFrame(box_frame, fg_color="transparent")
        row1.pack(fill="x", pady=(0, 10))

        ctk.CTkLabel(row1, text="Modelo:", font=("Segoe UI", 12, "bold")).pack(side="left")

        model_values = ["(Todos)"] + [m for m in MODELOS if m != "Selecione o Modelo"]

        model_var = ctk.StringVar(value="(Todos)")

        model_combo = ctk.CTkComboBox(
            row1,
            width=200,
            values=model_values,
            variable=model_var,
            command=lambda *_: refresh_closed(),
        )
        model_combo.pack(side="left", padx=(10, 15))

        row2 = ctk.CTkFrame(box_frame, fg_color="transparent")
        row2.pack(fill="x")

        ctk.CTkLabel(row2, text="Caixa:", font=("Segoe UI", 12, "bold")).pack(side="left")

        PLACEHOLDER = "Selecione uma Caixa"
        box_var = ctk.StringVar(value=PLACEHOLDER)
        box_combo = ctk.CTkComboBox(row2, width=420, values=[PLACEHOLDER], variable=box_var)
        box_combo.pack(side="left", padx=(10, 0))

        closed_map = {}

        def refresh_closed():
            nonlocal closed_map
            closed_map = {}

            model = model_var.get()
            if model == "(Todos)":
                model = None

            boxes = get_closed_boxes(model=model)

            if not boxes:
                box_combo.configure(values=[PLACEHOLDER])
                box_var.set(PLACEHOLDER)
                return

            values = [PLACEHOLDER]
            for b in boxes:
                disp = (
                    f"{b['id']} ({b.get('count',0)}/{b.get('capacity',100)})  "
                    f"LOTE {b.get('lote','000000')}  MOD {b.get('model','')}"
                )
                values.append(disp)
                closed_map[disp] = b

            box_combo.configure(values=values)
            box_var.set(PLACEHOLDER)

        def print_box():
            pick = box_var.get()
            if pick == PLACEHOLDER or pick not in closed_map:
                messagebox.showwarning("Aviso", "Selecione uma caixa fechada.")
                return

            b = closed_map[pick]
            q = int(b.get("count", b.get("capacity", 100)))

            try:
                print_box_closure_label(
                    box_id=b["id"],
                    lote=b.get("lote", "000000"),
                    model=b.get("model", ""),
                    quantity=q,
                )
                self.log_tool(f"[TOOL] Reimpressão da etiqueta da caixa OK: {b['id']}")
            except Exception as e:
                messagebox.showerror("Erro", f"Falha ao imprimir etiqueta da caixa:\n{e}")

        # ================== FOOTER ==================
        footer = ctk.CTkFrame(win, fg_color="transparent")
        footer.pack(fill="x", padx=16, pady=(0, 16))

        def on_print():
            if mode_var.get() == "device":
                print_device()
            else:
                print_box()

        ctk.CTkButton(
            footer,
            text="Imprimir",
            width=180,
            fg_color="#198754",
            hover_color="#157347",
            command=on_print,
        ).pack(side="right")

        def render():
            for w in body.winfo_children():
                w.pack_forget()

            if mode_var.get() == "device":
                device_frame.pack(fill="both", expand=True)
                json_box.focus()
            else:
                box_frame.pack(fill="both", expand=True)
                refresh_closed()

        render()

    def handle_manual_print(self, raw_text: str, lote: str, dialog):
        try:
            data = json.loads(raw_text)

            # validação mínima — appkey opcional (modelos GSM não têm)
            if not all(k in data for k in ("deveui", "model")):
                raise ValueError("Campos obrigatórios ausentes")

            # === atualiza PREVIEW da impressão manual (se existir) ===
            if hasattr(self, "manual_prev_deveui"):
                self.manual_prev_deveui.set(data["deveui"])
                self.manual_prev_appkey.set(data.get("appkey", ""))
                self.manual_prev_model.set(data["model"])

            payload = {
                "deveui": data["deveui"],
                "appkey": data.get("appkey", ""),
                "model":  data["model"],
                "lote":   lote,  # sempre existe
            }

            print_label(payload)

            self.log_tool(
                f"[TOOL] Impressão manual | DevEUI {payload['deveui']} | Lote {lote}"
            )

            # limpa apenas o JSON para próxima impressão
            for widget in dialog.winfo_children():
                if isinstance(widget, ctk.CTkTextbox):
                    widget.delete("1.0", "end")
                    widget.focus()

        except Exception as e:
            messagebox.showerror(
                "Erro na impressão",
                f"JSON inválido ou erro ao imprimir:\n{e}"
            )

    def load_production_settings(self):
        config = configparser.ConfigParser()
        auto_copy = False
        save_csv = False
        generate_logs = False

        if os.path.isfile(CONFIG_FILE):
            config.read(CONFIG_FILE)

            if "production" in config:
                auto_copy = config["production"].get("auto_copy", "yes") == "yes"
                save_csv = config["production"].get("save_csv", "no") == "yes"

            if "logs" in config:
                generate_logs = config["logs"].get("enabled", "no") == "yes"

        self.auto_copy_var.set(auto_copy)
        self.save_csv_var.set(save_csv)
        self.generate_logs_var.set(generate_logs)

    def save_production_settings(self):
        config = configparser.ConfigParser()
        if os.path.isfile(CONFIG_FILE):
            config.read(CONFIG_FILE)

        if "production" not in config:
            config["production"] = {}

        if "logs" not in config:
            config["logs"] = {}

        config["production"]["auto_copy"] = "yes" if self.auto_copy_var.get() else "no"
        config["production"]["save_csv"] = "yes" if self.save_csv_var.get() else "no"
        config["logs"]["enabled"] = "yes" if self.generate_logs_var.get() else "no"

        with open(CONFIG_FILE, "w") as f:
            config.write(f)

    def center_toplevel(self, win, width, height):
        """Centraliza um CTkToplevel em relação à janela principal."""
        try:
            self.update_idletasks()
            x = self.winfo_rootx() + (self.winfo_width() - width) // 2
            y = self.winfo_rooty() + (self.winfo_height() - height) // 2
            win.geometry(f"{width}x{height}+{x}+{y}")
        except Exception:
            # fallback: só aplica tamanho se algo der errado
            win.geometry(f"{width}x{height}")

    def apply_production_selection(self):
        # lote: pega direto do Entry (não depende de focusout)
        lote = (self.lote_entry.get() or "").strip()
        if not lote:
            lote = "000000"
        self.lote_var.set(lote)

        selected = self.box_var.get()
        box_id = self._box_id_from_value(selected)
        if not box_id:
            self.active_box_id = None
            self.box_counter_label.configure(text="")
            self.log_error("[ERRO] Selecione uma caixa antes de aplicar.")
            return

        box = get_box_by_id(box_id)
        if not box:
            self.active_box_id = None
            self.box_counter_label.configure(text="")
            self.log_error("[ERRO] Caixa inválida.")
            return

        self.active_box_id = box_id
        self.box_counter_label.configure(text=f"{box['count']}/{box['capacity']}")
        self.log_tool(f"[TOOL] Produção aplicada: Caixa {box_id} | Lote {lote}") 

    def handle_box_closed(self, box: dict, model: str):
        # popup (OK) e só depois imprime
        messagebox.showinfo(
            "Caixa finalizada",
            f"Caixa {box['id']} finalizada ({box.get('count', 0)}/{box.get('capacity', 100)}).",
        )

        try:
            print_box_closure_label(
                box_id=box["id"],
                lote=box.get("lote", self.lote_var.get()),
                model=model or self.modelo_var.get(),
                quantity=int(box.get("count", box.get("capacity", 100))),  # ✅ quantidade real
            )
            self.log_tool(f"[TOOL] Etiqueta de fechamento impressa: Caixa {box['id']}")
        except Exception as e:
            self.log_error(f"[ERRO] Falha ao imprimir etiqueta de fechamento: {e}")

        self.active_box_id = None
        self._prod_seen_deveuis.clear()
        self._prod_seen_box_id = None
        self.refresh_box_list()

    def close_active_box_ui(self):
        # tenta fechar pelo selecionado (combo). fallback: active_box_id
        selected = self.box_var.get()
        box_id = self._box_id_from_value(selected) or self.active_box_id

        if not box_id:
            messagebox.showwarning("Aviso", "Selecione uma caixa para fechar.")
            return

        box = get_box_by_id(box_id)
        if not box:
            messagebox.showerror("Erro", "Caixa não encontrada.")
            return

        if box.get("status") != "ABERTA":
            messagebox.showwarning("Aviso", "Essa caixa já está fechada.")
            return

        # um OK só (e já imprime depois)
        ok = messagebox.askokcancel(
            "Fechar caixa",
            f"Deseja fechar a caixa {box_id} agora?\n\n"
            f"Modelo: {box.get('model','')}\n"
            f"Lote: {box.get('lote','')}\n"
            f"Quantidade atual: {box.get('count',0)}/{box.get('capacity',100)}\n\n"
            f"Após confirmar, será impressa a etiqueta de fechamento."
        )
        if not ok:
            return

        try:
            closed = close_box(box_id)
            # ✅ reutiliza o fluxo que já imprime após OK (aqui já teve OK, então imprime direto)
            print_box_closure_label(
                box_id=closed["id"],
                lote=closed.get("lote", self.lote_var.get()),
                model=closed.get("model", self.modelo_var.get()),
                quantity=int(closed.get("count", 0)),
            )
            self.log_tool(f"[TOOL] Caixa fechada manualmente + etiqueta impressa: {closed['id']}")
        except Exception as e:
            self.log_error(f"[ERRO] Falha ao fechar/imprimir caixa: {e}")
            return

        self.active_box_id = None
        self._prod_seen_deveuis.clear()
        self._prod_seen_box_id = None
        self.refresh_box_list()

    # --------------------------
    #  LAYOUT
    # --------------------------
    def create_layout(self):

        # ==========================
        #  SEÇÃO SERIAL / TOPO
        # ==========================
        serial_frame = ctk.CTkFrame(self, corner_radius=10)
        serial_frame.pack(fill="x", padx=20, pady=(12, 12))

        top_row = ctk.CTkFrame(serial_frame, fg_color="transparent")
        top_row.pack(fill="x", padx=15, pady=(10, 8))

        ctk.CTkLabel(top_row, text="Porta Serial:", font=("Segoe UI", 15, "bold")).pack(side="left")

        self.port_list = ctk.CTkOptionMenu(
            top_row,
            values=["Selecione a Porta Serial"],
            command=self.on_port_select,
            width=320,
        )
        self.port_list.pack(side="left", padx=(8, 10))

        self.refresh_btn = ctk.CTkButton(
            top_row,
            text="Atualizar",
            width=140,
            command=self.update_ports,
        )
        self.refresh_btn.pack(side="left")

        # Toggle Modo Produção
        self.production_switch = ctk.CTkSwitch(
            top_row,
            text="Modo Produção",
            command=self.on_toggle_production
        )
        self.production_switch.pack(side="left", padx=(25, 10))

        # Toggle Depuração (logs seriais)
        self.debug_switch = ctk.CTkSwitch(
            top_row,
            text="Habilitar Depuração",
            variable=self.generate_logs_var,
            command=self.save_production_settings,
        )
        self.debug_switch.pack(side="left")

        # Botão Impressão manual
        self.print_btn = ctk.CTkButton(
            top_row,
            text="Impressão",
            width=140,
            command=self.open_manual_print_dialog
        )
        self.print_btn.pack(side="right", padx=(10, 0))

        # Botão BOR Level (popup)
        self.bor_popup_btn = ctk.CTkButton(
            top_row,
            text="BOR Level",
            width=120,
            command=lambda: self.open_bor_dialog(),
            state="disabled",
        )
        self.bor_popup_btn.pack(side="right", padx=(10, 0))

        # Botão de configurações ⚙️
        self.settings_btn = ctk.CTkButton(
            top_row,
            text="⚙️",
            width=50,
            command=self.open_settings_dialog,
            fg_color="#2d2d2d",
            hover_color="#1f6aa5",
        )
        self.settings_btn.pack(side="right", padx=(0, 0))

        # ==========================
        #  LINHA DE OPÇÕES (Modelo / BOR / Produção / Executar)
        # ==========================
        self.options_row = ctk.CTkFrame(self, corner_radius=10)
        self.options_row.pack(fill="x", padx=20, pady=(0, 10))

        # ===== FRAME CONFIG (lado esquerdo) =====
        self.config_frame = ctk.CTkFrame(self.options_row, fg_color="transparent")

        # Modelo
        self.mid = ctk.CTkFrame(self.config_frame, fg_color="transparent")
        ctk.CTkLabel(self.mid, text="Modelo", font=("Segoe UI", 13, "bold")).pack(anchor="w")
        self.modelo_var = ctk.StringVar(value=MODELOS[0])
        self.modelo_combo = ctk.CTkComboBox(
        self.mid,
        values=MODELOS,                 # ✅ inclui "Selecione o Modelo"
        variable=self.modelo_var,
        command=self.on_modelo_change,  # ✅ já recebe o valor
        width=220,
        )
        self.modelo_combo.set(MODELOS[0])  # ✅ garante placeholder visível no start
        
        self.modelo_combo.pack(anchor="w", pady=2)

        # BOR
        self.bor_row = ctk.CTkFrame(self.config_frame, fg_color="transparent")
        ctk.CTkLabel(self.bor_row, text="BOR Level", font=("Segoe UI", 13, "bold")).pack(anchor="w")
        self.bor_level_var = ctk.StringVar(value=BOR_OPTIONS[0])
        self.bor_level_combo = ctk.CTkComboBox(
            self.bor_row,
            values=BOR_OPTIONS,
            variable=self.bor_level_var,
            width=160,
        )
        self.bor_level_combo.pack(anchor="w", pady=2)

        # Executar (fica fora do config_frame para poder ir pro final no layout produção)
        self.exec_ctn = ctk.CTkFrame(self.options_row, fg_color="transparent")
        self.exec_btn = ctk.CTkButton(
            self.exec_ctn,
            text="▶ Executar (F7)",
            height=40,
            width=180,
            state="disabled",
            command=self.handle_execute,
        )
        self.exec_btn.pack()

        self.bind("<F7>", lambda e: self.exec_btn.invoke())

        # ===== FRAME PRODUÇÃO (Caixa + Lote) =====
        self.production_frame = ctk.CTkFrame(self.options_row)

        # Caixa
        self.box_frame = ctk.CTkFrame(self.production_frame, fg_color="transparent")
        self.box_frame.pack(fill="x", padx=10, pady=(8, 0))

        ctk.CTkLabel(self.box_frame, text="Caixa Ativa", font=("Segoe UI", 13, "bold")).pack(anchor="w")

        self.box_var = ctk.StringVar(value="Nenhuma caixa aberta")
        self.box_combo = ctk.CTkComboBox(
            self.box_frame,
            variable=self.box_var,
            values=[],
            command=self.on_box_selected,
            width=280,
        )
        self.box_combo.pack(side="left", pady=6)

        self.box_counter_label = ctk.CTkLabel(self.box_frame, text="", text_color="gray")
        self.box_counter_label.pack(side="left", padx=(10, 0))

        ctk.CTkButton(
            self.box_frame,
            text="Nova Caixa",
            width=120,
            command=self.create_new_box_ui,
        ).pack(side="left", padx=(10, 0))
        
        ctk.CTkButton(
            self.box_frame,
            text="Fechar Caixa",
            width=120,
            fg_color="#b00020",
            hover_color="#8a0018",
            command=self.close_active_box_ui,
        ).pack(side="left", padx=(10, 0))

        # Lote
        self.lote_frame = ctk.CTkFrame(self.production_frame, fg_color="transparent")
        self.lote_frame.pack(fill="x", padx=10, pady=(0, 10))

        ctk.CTkLabel(self.lote_frame, text="Lote", font=("Segoe UI", 13, "bold")).pack(anchor="w")
        self.lote_entry = ctk.CTkEntry(self.lote_frame, textvariable=self.lote_var, width=200)
        self.lote_entry.pack(side="left", pady=6)

        ctk.CTkButton(
        self.lote_frame,
        text="Aplicar",
        width=110,
        fg_color="#198754",
        hover_color="#157347",
        command=self.apply_production_selection,
        ).pack(side="left", padx=(10, 0))

        # ==========================
        #  SELETOR MANUAL (CUSTOM)
        # ==========================
        self.custom_row = ctk.CTkFrame(self, corner_radius=10)
        self.custom_row.pack_forget()  # começa escondido

        self.custom_fw_info = ctk.CTkLabel(
            self.custom_row,
            text="Selecione os firmwares Custom.",
            font=("Segoe UI", 12),
            text_color="gray",
        )
        self.custom_fw_info.pack(anchor="w", padx=16, pady=(10, 0))

        btns = ctk.CTkFrame(self.custom_row, fg_color="transparent")
        btns.pack(fill="x", padx=16, pady=10)

        self.btn_sel_fw_app = ctk.CTkButton(
            btns,
            text="Selecionar Firmware SeAIoT (.hex)",
            width=240,
            command=self.select_custom_seaiot_fw,
        )
        self.btn_sel_fw_base = ctk.CTkButton(
            btns,
            text="Selecionar Firmware Base (.hex)",
            width=240,
            command=self.select_custom_base_fw,
        )

        self.btn_sel_fw_app.pack(side="left", padx=(0, 10))
        self.btn_sel_fw_base.pack(side="left", padx=(0, 10))

        self.lbl_sel_app = ctk.CTkLabel(self.custom_row, text="", text_color="gray")
        self.lbl_sel_base = ctk.CTkLabel(self.custom_row, text="", text_color="gray")
        self.lbl_sel_app.pack(anchor="w", padx=16, pady=(0, 2))
        self.lbl_sel_base.pack(anchor="w", padx=16, pady=(0, 10))

        # ==========================
        #  TABS (Monitor / Log)
        # ==========================
        self.tabview = ctk.CTkTabview(self, corner_radius=10)
        self.tabview.pack(fill="both", expand=True, padx=20, pady=(0, 10))

        tab_serial = self.tabview.add("Monitor Serial")
        tab_log    = self.tabview.add("Log de Processo")

        # ===== ABA: Monitor Serial =====
        serial_body = ctk.CTkFrame(tab_serial, fg_color="transparent")
        serial_body.pack(fill="both", expand=True, padx=10, pady=(10, 0))

        self.serial_text = ctk.CTkTextbox(serial_body)
        self.serial_text.pack(fill="both", expand=True)
        self.serial_text.configure(state="disabled")

        serial_bar = ctk.CTkFrame(tab_serial, fg_color="transparent")
        serial_bar.pack(fill="x", padx=10, pady=(8, 10))

        self.btn_clear_serial = ctk.CTkButton(
            serial_bar,
            text="Limpar Serial",
            width=140,
            command=lambda: self.clear_log(self.serial_text),
        )
        self.btn_clear_serial.pack(side="right")

        # ===== ABA: Log de Processo =====
        log_body = ctk.CTkFrame(tab_log, fg_color="transparent")
        log_body.pack(fill="both", expand=True, padx=10, pady=(10, 0))

        self.log_text = ctk.CTkTextbox(log_body)
        self.log_text.pack(fill="both", expand=True)
        self.log_text.configure(state="disabled")

        log_bar = ctk.CTkFrame(tab_log, fg_color="transparent")
        log_bar.pack(fill="x", padx=10, pady=(8, 10))

        self.btn_clear_log = ctk.CTkButton(
            log_bar,
            text="Limpar Log",
            width=140,
            command=lambda: self.clear_log(self.log_text),
        )
        self.btn_clear_log.pack(side="right")

        # ===== TAGS DE CORES (Serial e Log) =====
        def _tb(w):
            return getattr(w, "_textbox", w)

        for w in (self.serial_text, self.log_text):
            try:
                tb = _tb(w)
                tb.tag_configure("tool", foreground="#00e676")   # verde
                tb.tag_configure("error", foreground="#ff4d4d")  # vermelho
            except Exception:
                pass

        # Status do serial (embaixo do tabview)
        self.serial_status_label = ctk.CTkLabel(
            self,
            text="Aguardando conexão serial...",
            text_color="gray",
            font=("Segoe UI", 12),
        )
        self.serial_status_label.pack(anchor="w", padx=24, pady=(0, 6))

        # Status principal
        self.status_frame = ctk.CTkFrame(self, corner_radius=8, border_width=2, border_color="#0078d4")
        self.status_frame.pack(fill="x", padx=24, pady=(0, 10))

        self.status_label = ctk.CTkLabel(
            self.status_frame,
            text="Aguardando seleção de porta serial...",
            text_color="gray",
            font=("Segoe UI Semibold", 14),
        )
        self.status_label.pack(padx=10, pady=8)

        # Layout inicial
        self.apply_layout_normal()

    def apply_layout_normal(self):
        """Layout padrão (produção OFF):
        - Modelo e BOR lado a lado
        - Executar ao lado
        - Caixa/Lote escondidos
        """
        # segurança (evita crash se algo ainda não existir por qualquer motivo)
        if not hasattr(self, "config_frame"):
            return

        # some tudo primeiro
        try:
            self.config_frame.pack_forget()
        except Exception:
            pass
        try:
            self.production_frame.pack_forget()
        except Exception:
            pass
        try:
            self.exec_ctn.pack_forget()
        except Exception:
            pass

        # limpa packs internos
        try:
            self.mid.pack_forget()
            self.bor_row.pack_forget()
        except Exception:
            pass

        # === config_frame aparece à esquerda ===
        self.config_frame.pack(side="left", padx=16, pady=10)

        # Modelo e BOR lado a lado
        self.mid.pack(side="left", padx=(0, 16))
        self.bor_row.pack(side="left", padx=(0, 16))

        # Executar ao lado (no final da linha)
        self.exec_ctn.pack(side="left", padx=(0, 0), pady=(18, 0))


    def apply_layout_production(self):
        """Layout produção (produção ON):
        - Modelo em cima de BOR (coluna esquerda)
        - Caixa em cima de Lote (coluna do meio)
        - Executar no final (coluna direita)
        """
        if not hasattr(self, "config_frame"):
            return

        # some tudo primeiro
        try:
            self.config_frame.pack_forget()
        except Exception:
            pass
        try:
            self.production_frame.pack_forget()
        except Exception:
            pass
        try:
            self.exec_ctn.pack_forget()
        except Exception:
            pass

        # limpa packs internos
        try:
            self.mid.pack_forget()
            self.bor_row.pack_forget()
        except Exception:
            pass

        # === coluna esquerda: Modelo + BOR empilhados ===
        self.config_frame.pack(side="left", padx=16, pady=10)

        self.mid.pack(fill="x", pady=(0, 12))      # mais espaço (modelo estava “apertado”)
        self.bor_row.pack(fill="x", pady=(0, 0))

        # === coluna do meio: Caixa + Lote ===
        self.production_frame.pack(side="left", padx=26, pady=10)

        # === coluna direita: Executar no final ===
        self.exec_ctn.pack(side="left", padx=(10, 0), pady=(18, 0))

        # sempre que entrar em produção, atualiza lista de caixas do modelo atual
        try:
            self.refresh_box_list()
        except Exception:
            pass

    # --------------------------
    #  UI STATE
    # --------------------------

    # def process_production_label(self, label_payload: dict):
    #     if self.seaiot_label_printed:
    #         return

    #     if not self.active_box_id:
    #         self.log_error("[ERRO] Produção ativa sem caixa selecionada — operação bloqueada")
    #         return

    #     try:
    #         # trava pra não duplicar em caso de repetir linha
    #         self.seaiot_label_printed = True

    #         box = increment_box(self.active_box_id)
    #         label_payload["lote"] = box["lote"]

    #         self.box_counter_label.configure(text=f"{box['count']}/{box['capacity']}")
    #         self.refresh_box_list()
            
    #         print_label(label_payload)

    #         append_production_row(
    #             deveui=label_payload["deveui"],
    #             appkey=label_payload["appkey"],
    #             model=label_payload["model"],
    #             lote=box["lote"],
    #             box_id=box["id"],
    #         )

    #         self.log_tool(f"[PRODUÇÃO] OK | Caixa {box['id']} ({box['count']}/{box['capacity']})")

    #         # só troca de caixa se fechou
    #         if box.get("status") == "FECHADA":
    #             # ✅ Thread-safe: agenda no thread principal (UI)
    #             box_copy = dict(box)
    #             model_now = label_payload.get("model", self.modelo_var.get())

    #             self.after(0, lambda b=box_copy, m=model_now: self.handle_box_closed(b, m))

    #     except Exception as e:
    #         self.seaiot_label_printed = False
    #         self.log_error(f"[ERRO PRODUÇÃO] {e}")

    def open_production_dialog(self):
        win = ctk.CTkToplevel(self)
        win.title("Configurações de Produção")
        width, height = 420, 240
        win.geometry(f"{width}x{height}")
        win.resizable(False, False)
        win.grab_set()
        self.center_toplevel(win, width, height)

        ctk.CTkLabel(
            win, text="Configurações de Produção", font=("Segoe UI Semibold", 18)
        ).pack(pady=(15, 12))

        frame = ctk.CTkFrame(win)
        frame.pack(padx=20, pady=10, fill="x")

        copy_cb = ctk.CTkCheckBox(
            frame,
            text="Copiar automaticamente DevEUI",
            variable=self.auto_copy_var,
            command=self.save_production_settings,
            font=("Segoe UI", 14),
        )
        copy_cb.pack(anchor="w", pady=6)

        save_cb = ctk.CTkCheckBox(
            frame,
            text="Salvar histórico de DevEUI em CSV",
            variable=self.save_csv_var,
            command=self.save_production_settings,
            font=("Segoe UI", 14),
        )
        save_cb.pack(anchor="w", pady=6)

    def save_deveui_to_csv(self, deveui):
        csv_path = os.path.join(APP_ROOT, "production_log.csv")

        # Se o arquivo existe, verificar duplicatas antes de salvar
        if os.path.isfile(csv_path):
            with open(csv_path, "r", encoding="utf-8") as f:
                for line in f:
                    if deveui in line:
                        # Já existe → não salva novamente
                        return

        # Se não existe (ou não encontrou duplicata), salva
        file_exists = os.path.isfile(csv_path)
        with open(csv_path, "a", newline="", encoding="utf-8") as csvfile:
            writer = csv.writer(csvfile, delimiter=';')
            if not file_exists:
                writer.writerow(["Data", "DevEUI"])
            writer.writerow([datetime.now().strftime("%d/%m/%Y %H:%M:%S"), deveui])

    def set_options_enabled(self, enabled: bool):
        state = "normal" if enabled else "disabled"
        for w in (
            self.modelo_combo,
            self.exec_btn,
            self.bor_level_combo,
        ):
            try:
                w.configure(state=state)
            except Exception:
                pass
        if not enabled:
            self.hide_custom_row()
        self.validate_ready_state()

    def set_busy_ui(self, busy: bool):
        """Bloqueia/desbloqueia toda a interface de seleção durante execução."""
        self.is_executing = busy
        state = "disabled" if busy else "normal"
        widgets = [
            self.port_list,
            self.refresh_btn,
            self.modelo_combo,
            self.exec_btn,
            self.btn_sel_fw_app,
            self.btn_sel_fw_base,
            self.bor_level_combo,
            self.bor_popup_btn,
        ]
        for w in widgets:
            try:
                w.configure(state=state)
            except Exception:
                pass

    def set_tabs_enabled(self, enabled: bool):
        try:
            if enabled:
                self.tabview._segmented_button.configure(state="normal")
            else:
                self.tabview._segmented_button.configure(state="disabled")
        except Exception:
            pass

    def open_settings_dialog(self):
        """Abre a janela de configurações (som e tema)."""
        win = ctk.CTkToplevel(self)
        win.title("Configurações")
        width, height = 500, 330
        win.geometry(f"{width}x{height}")
        win.resizable(False, False)
        win.grab_set()
        self.center_toplevel(win, width, height)

        ctk.CTkLabel(win, text="⚙️ Configurações", font=("Segoe UI Semibold", 16)).pack(
            pady=(10, 5)
        )

        # Seção de tema
        theme_frame = ctk.CTkFrame(win)
        theme_frame.pack(fill="x", padx=20, pady=8)
        ctk.CTkLabel(theme_frame, text="Tema:", font=("Segoe UI", 13, "bold")).pack(
            anchor="w", padx=10, pady=(6, 2)
        )
        theme_switch = ctk.CTkSwitch(
            theme_frame, text="Modo Claro", command=self.toggle_theme
        )
        if ctk.get_appearance_mode() == "Light":
            theme_switch.select()
        theme_switch.pack(anchor="w", padx=10, pady=(0, 8))

        # Seção de som
        sound_frame = ctk.CTkFrame(win)
        sound_frame.pack(fill="x", padx=20, pady=8)
        ctk.CTkLabel(
            sound_frame, text="Som de finalização:", font=("Segoe UI", 13, "bold")
        ).pack(anchor="w", padx=10, pady=(6, 2))

        self.sound_path = getattr(self, "sound_path", "")
        self.sound_label = ctk.CTkLabel(
            sound_frame,
            text=(
                os.path.basename(self.sound_path)
                if self.sound_path
                else "Nenhum arquivo selecionado"
            ),
            text_color="gray",
        )
        self.sound_label.pack(anchor="w", padx=10)

        def choose_sound():
            path = filedialog.askopenfilename(
                title="Selecione um som de finalização",
                filetypes=[
                    ("Arquivos de áudio", "*.wav;*.mp3"),
                    ("Todos os arquivos", "*.*"),
                ],
            )
            if path:
                self.sound_path = path
                self.sound_label.configure(
                    text=os.path.basename(path), text_color="white"
                )
                save_sound_path(path)

        def test_sound():
            if self.sound_path and os.path.isfile(self.sound_path):
                try:
                    if self.sound_path.lower().endswith(".wav"):
                        winsound.PlaySound(
                            self.sound_path, winsound.SND_FILENAME | winsound.SND_ASYNC
                        )
                    else:
                        self._ensure_audio()
                        pygame.mixer.music.stop()
                        pygame.mixer.music.load(self.sound_path)
                        pygame.mixer.music.play()
                except Exception as e:
                    messagebox.showerror(
                        "Erro", f"Não foi possível reproduzir o som:\n{e}"
                    )
            else:
                messagebox.showwarning("Atenção", "Nenhum som selecionado.")

        def remove_sound():
            """Remove o som personalizado e restaura o padrão."""
            self.sound_path = ""
            self.sound_label.configure(
                text="Nenhum arquivo selecionado", text_color="gray"
            )
            config = configparser.ConfigParser()
            if os.path.isfile(CONFIG_FILE):
                config.read(CONFIG_FILE)
                if "sound" in config:
                    config.remove_section("sound")
                    with open(CONFIG_FILE, "w") as f:
                        config.write(f)
            messagebox.showinfo(
                "Som removido", "Som de finalização restaurado para o padrão."
            )

        # Linha de botões Escolher / Testar / Remover
        btn_row = ctk.CTkFrame(sound_frame, fg_color="transparent")
        btn_row.pack(anchor="w", padx=10, pady=(6, 8))

        ctk.CTkButton(
            btn_row, text="Escolher som...", width=140, command=choose_sound
        ).pack(side="left", padx=(0, 10))

        ctk.CTkButton(btn_row, text="▶ Testar som", width=120, command=test_sound).pack(
            side="left", padx=(0, 10)
        )

        ctk.CTkButton(
            btn_row,
            text="❌ Remover som",
            width=130,
            fg_color="#444",
            hover_color="#a60000",
            command=remove_sound,
        ).pack(side="left")

        # Botão Fechar
        ctk.CTkButton(win, text="Fechar", width=100, command=win.destroy).pack(
            pady=(8, 10)
        )

    def toggle_theme(self):
        if ctk.get_appearance_mode() == "Dark":
            ctk.set_appearance_mode("light")
        else:
            ctk.set_appearance_mode("dark")

    def validate_ready_state(self):
        """Habilita o botão Executar somente quando porta+modelo estiverem válidos."""
        cond = (
            self.selected_port_name
            and self.modelo_var.get() not in ("Selecione o Modelo", "")
            and not self.is_executing
        )

        if cond:
            self.exec_btn.configure(state="normal", fg_color=ctk.ThemeManager.theme["CTkButton"]["fg_color"])
            self.status_label.configure(text="Pronto para gravar.", text_color="gray")
        else:
            self.exec_btn.configure(state="disabled", fg_color="#444")
            if not self.selected_port_name:
                self.status_label.configure(text="Aguardando seleção de porta serial...", text_color="gray")
            else:
                self.status_label.configure(text="Selecione o Modelo.", text_color="gray")

    # --------------------------
    #  PORTAS
    # --------------------------
    def update_ports(self, *_):
        ports = list(serial.tools.list_ports.comports())
        if ports:
            opts = [f"{p.device} - {p.description}" for p in ports]
            self.port_list.configure(values=["Selecione a Porta Serial"] + opts)
            self.port_list.set("Selecione a Porta Serial")
        else:
            self.port_list.configure(values=["Nenhuma porta encontrada"])
            self.port_list.set("Nenhuma porta encontrada")
        self.selected_port_name = None
        self.bor_popup_btn.configure(state="disabled")
        self.set_options_enabled(False)

    def on_port_select(self, value):
        if not value or "Selecione" in value or "Nenhuma" in value:
            self.selected_port_name = None
            self.set_options_enabled(False)
            self.bor_popup_btn.configure(state="disabled")
            return
        self.selected_port_name = value.split()[0]
        self.set_options_enabled(True)
        self.status_label.configure(
            text="Selecione o Modelo.", text_color="gray"
        )
        self.bor_popup_btn.configure(state="normal")
        # inicia monitor e vai para guia Monitor Serial
        self.start_serial_thread()
        self.tabview.set("Monitor Serial")

    # --------------------------
    #  MODELO / MODO
    # --------------------------


    def refresh_firmware_selection(self):
        modelo = self.modelo_var.get()

        self.seaiot_fw_path = ""

        if modelo == "Custom":
            self.show_custom_row()
            if self.custom_seaiot_fw and os.path.isfile(self.custom_seaiot_fw):
                self.seaiot_fw_path = self.custom_seaiot_fw
        else:
            self.hide_custom_row()
            if modelo in FIRMWARE_MODELOS:
                fw_name = FIRMWARE_MODELOS[modelo]
                fw_path = os.path.join(FIRMWARES_DIR, fw_name)
                if os.path.isfile(fw_path):
                    self.seaiot_fw_path = fw_path

        # ✅ deixa a lógica do status e do botão centralizada aqui
        self.validate_ready_state()

    def show_custom_row(self):
        if not self.custom_row.winfo_ismapped():
            self.custom_row.pack(fill="x", padx=20, pady=(0, 8))
        self.lbl_sel_app.configure(
            text=f"SeAIoT: {os.path.basename(self.custom_seaiot_fw) if self.custom_seaiot_fw else '(não selecionado)'}"
        )
        self.lbl_sel_base.configure(
            text=f"Base: {os.path.basename(self.custom_base_fw) if self.custom_base_fw else '(não selecionado)'}"
        )

    def hide_custom_row(self):
        if self.custom_row.winfo_ismapped():
            self.custom_row.pack_forget()

    def select_custom_seaiot_fw(self):
        path = filedialog.askopenfilename(
            title="Selecione o firmware SeAIoT (.hex)", filetypes=[("HEX", "*.hex")]
        )
        if path:
            self.custom_seaiot_fw = path
            self.lbl_sel_app.configure(text=f"SeAIoT: {os.path.basename(path)}")
            self.refresh_firmware_selection()

    def select_custom_base_fw(self):
        path = filedialog.askopenfilename(
            title="Selecione o firmware Base (.hex)", filetypes=[("HEX", "*.hex")]
        )
        if path:
            self.custom_base_fw = path
            self.lbl_sel_base.configure(text=f"Base: {os.path.basename(path)}")
            self.refresh_firmware_selection()


    # --------------------------
    #  IMPRESSÃO
    # --------------------------

    def parse_seaiot_label(self, line: str):
        if "[SEAIOT_LABEL]" not in line:
            return None

        try:
            json_part = line.split("[SEAIOT_LABEL]", 1)[1].strip()
            data = json.loads(json_part)

            # validação mínima — appkey é opcional (ex.: modelos GSM usam MAC
            # como serial e não possuem appkey LoRa)
            if not all(k in data for k in ("deveui", "model")):
                return None

            return {
                "deveui": data["deveui"],
                "appkey": data.get("appkey", ""),
                "model":  data["model"],
            }

        except Exception as e:
            self.log_tool(f"[TOOL] Erro ao parsear SEAIOT_LABEL: {e}")
            return None

    # --------------------------
    #  EXECUÇÃO
    # --------------------------
    def handle_execute(self):
        if not self.selected_port_name:
            messagebox.showwarning("Aviso", "Selecione uma porta serial.")
            return

        # valida seleções
        if self.modelo_var.get() in ("Selecione o Modelo", ""):
            messagebox.showwarning(
                "Aviso", "Selecione o Modelo."
            )
            return

        # reset da impressão por ciclo
        self.seaiot_label_printed = False

        # Fecha monitor e vai pra guia de log; bloqueia UI
        self.stop_serial_thread()
        self.tabview.set("Log de Processo")
        self.set_busy_ui(True)
        self.set_tabs_enabled(False)
        self.exec_btn.configure(text="⏳ Executando...")

        # ---- valida firmware ----
        if not self.seaiot_fw_path or not os.path.isfile(self.seaiot_fw_path):
            self.finish_with_error("Firmware SeAIoT não encontrado.")
            return

        # ---- status ----
        if self.production_enabled:
            self.status_label.configure(text="Produção: gravando firmware...", text_color="#0dcaf0")
        else:
            self.status_label.configure(text="Gravando firmware...", text_color="#0dcaf0")

        threading.Thread(
            target=self.flash_sequence,
            daemon=True,
        ).start()

    def finish_with_error(self, msg):
        messagebox.showerror("Erro", msg)
        self.set_busy_ui(False)
        self.set_tabs_enabled(True)
        self.exec_btn.configure(text="▶ Executar (F7)")
        self.validate_ready_state()

    def flash_sequence(self):
        import time

        # ESP32 (Projeto Qualidade) usa esptool, não CubeProgrammer, e não tem BOR.
        if flasher_for(self.modelo_var.get()) == FLASHER_ESP32:
            self.esp32_flash_sequence()
            return

        steps = [
            (
                "Apagando a Flash...",
                f'"{self.STM32_CLI_PATH}" -c port={self.selected_port_name} -e all',
                "Flash apagada com sucesso."
            ),
            (
                "Gravando firmware...",
                f'"{self.STM32_CLI_PATH}" -c port={self.selected_port_name} -d "{self.seaiot_fw_path}" 0x08000000',
                "Firmware gravado com sucesso."
            ),
        ]

        for status_text, cmd, ok_msg in steps:
            self.after(0, lambda t=status_text: self.status_label.configure(
                text=t,
                text_color="#0dcaf0"
            ))

            ok = self.exec_cmd_with_bor(cmd, ok_msg)

            if not ok:
                return

            time.sleep(0.5)

        # BOR só no final do processo inteiro
        self.apply_bor_if_selected()

        hora = time.strftime("%d/%m/%Y %H:%M:%S")

        self.after(0, lambda: self.status_label.configure(
            text=f"✅ Gravação finalizada — {hora}",
            text_color="#00b050"
        ))
        self.after(0, lambda: self.status_frame.configure(border_color="#00b050"))

        self.after(0, lambda: self.exec_btn.configure(
            text="▶ Executar (F7)",
            state="normal",
            fg_color=ctk.ThemeManager.theme["CTkButton"]["fg_color"],
            hover_color="#1558b0",
            text_color="white",
        ))

        self.after(0, lambda: self.set_busy_ui(False))
        self.after(0, lambda: self.set_tabs_enabled(True))

        self.after(0, self.play_finish_sound)
        self.after(0, lambda: self._schedule_border_reset(4000))
        self.after(0, lambda: self._schedule_return_to_serial(2000))

    def esp32_flash_sequence(self):
        """Grava o modelo ESP32 (Projeto Qualidade) via esptool (merged-bin).
        Sem etapa de apagar separada nem BOR — o write_flash já cuida disso."""
        import time

        esptool = load_esptool_cmd()
        cmd = (
            f'{esptool} --chip {ESP32_CHIP} --port {self.selected_port_name} '
            f'write_flash {ESP32_FLASH_ADDR} "{self.seaiot_fw_path}"'
        )

        self.after(0, lambda: self.status_label.configure(
            text="Gravando firmware (ESP32)...", text_color="#0dcaf0"
        ))

        ok = self.exec_cmd_with_bor(cmd, "Firmware gravado com sucesso.")
        if not ok:
            return

        hora = time.strftime("%d/%m/%Y %H:%M:%S")

        self.after(0, lambda: self.status_label.configure(
            text=f"✅ Gravação finalizada — {hora}",
            text_color="#00b050"
        ))
        self.after(0, lambda: self.status_frame.configure(border_color="#00b050"))

        self.after(0, lambda: self.exec_btn.configure(
            text="▶ Executar (F7)",
            state="normal",
            fg_color=ctk.ThemeManager.theme["CTkButton"]["fg_color"],
            hover_color="#1558b0",
            text_color="white",
        ))

        self.after(0, lambda: self.set_busy_ui(False))
        self.after(0, lambda: self.set_tabs_enabled(True))

        self.after(0, self.play_finish_sound)
        self.after(0, lambda: self._schedule_border_reset(4000))
        self.after(0, lambda: self._schedule_return_to_serial(2000))

        # A etiqueta é capturada do boot do device: o firmware espera
        # BOOT_SERIAL_DELAY_MS (~5s) antes de imprimir o [SEAIOT_LABEL], dando
        # tempo do SIGEP religar o monitor serial acima (~2s). Comando LABEL
        # segue disponível no firmware para reimpressão manual.


    # --------------------------
    #  BOR VIA CLI
    # --------------------------
    def program_bor_level(self, level: str, show_global_status=True):
        """
        Aplica BOR_LEV via STM32_Programmer_CLI.
        show_global_status=False → usado pelo popup (não altera status principal)
        """

        cmd = f'"{self.STM32_CLI_PATH}" -c port={self.selected_port_name} -ob BOR_LEV={level}'

        # STATUS PRINCIPAL (APENAS SE NÃO FOR POPUP)
        if show_global_status:
            self.status_label.configure(
                text=f"Configurando BOR level ({level})...", text_color="#0dcaf0"
            )
            self.status_frame.configure(border_color="#0dcaf0")

        # LOG PRINCIPAL (apenas quando global)
        if show_global_status:
            self.log_message(self.log_text, f"> {cmd}")

        try:
            bor_res = subprocess.run(
                cmd, shell=True, capture_output=True, text=True, timeout=15
            )
            output = bor_res.stdout + bor_res.stderr

            # Log só quando for global (execução normal)
            if show_global_status:
                self.log_message(self.log_text, output)

            if (
                "Option Bytes successfully programmed" in output
                and bor_res.returncode == 0
            ):
                if show_global_status:
                    self.log_message(
                        self.log_text,
                        f"✅ BOR configurado com sucesso (nível {level}).",
                    )
                return True
            else:
                if show_global_status:
                    self.log_message(
                        self.log_text,
                        "⚠️ BOR configurado sem confirmação explícita ou com possível erro.",
                    )
                return False

        except subprocess.TimeoutExpired:
            if show_global_status:
                self.log_message(
                    self.log_text,
                    "⚠️ Timeout ao aplicar BOR — verifique se foi concluído.",
                )
            return False

        except Exception as e:
            if show_global_status:
                self.log_message(self.log_text, f"Erro ao aplicar BOR: {e}")
            return False

        finally:
            if show_global_status:
                self.after(
                    4000, lambda: self.status_frame.configure(border_color="#0078d4")
                )

    def apply_bor_if_selected(self):
        """Lê a seleção do combo BOR Level e aplica se diferente de 'Não gravar BOR'."""
        if not hasattr(self, "bor_level_var"):
            return
        val = self.bor_level_var.get().strip()
        if val in ("", "Não gravar BOR"):
            self.log_message(
                self.log_text, "ℹ️ BOR Level não será alterado (opção 'Não gravar BOR')."
            )
            return
        if val not in ("0", "1", "2", "3", "4"):
            self.log_message(
                self.log_text,
                f"⚠️ Valor de BOR inválido selecionado: {val}. Ignorando alteração.",
            )
            return
        self.program_bor_level(val)

    # --------------------------
    #  EXECUÇÃO COM BOR
    # --------------------------
    def exec_cmd_with_bor(self, flash_cmd, success_msg):
        self.log_message(self.log_text, f"> {flash_cmd}")

        result = subprocess.run(
            flash_cmd,
            shell=True,
            capture_output=True,
            text=True
        )

        output = result.stdout + result.stderr
        self.log_message(self.log_text, output)

        if (
            result.returncode != 0
            or "Error:" in output
            or "KO" in output
            or "Timeout" in output
        ):
            self.after(0, lambda: self.status_label.configure(
                text="❌ Erro ao conectar — verifique o modo BOOT e tente novamente.",
                text_color="#ff4d4d"
            ))
            self.after(0, lambda: self.status_frame.configure(border_color="#ff4d4d"))

            self.log_message(
                self.log_text,
                "⚠️ Interrompendo gravação devido a erro detectado."
            )

            self.after(0, lambda: self.exec_btn.configure(
                text="▶ Executar (F7)",
                state="normal",
                fg_color=ctk.ThemeManager.theme["CTkButton"]["fg_color"],
                hover_color="#1558b0",
                text_color="white",
            ))

            self.after(0, lambda: self.set_busy_ui(False))
            self.after(0, lambda: self.set_tabs_enabled(True))
            self.after(0, lambda: self._schedule_border_reset(4000))

            return False

        self.log_message(self.log_text, f"✅ {success_msg}")
        return True

    def exec_recovery_with_bor(self):
        etapas = [
            (
                "Apagando memória flash...",
                f'"{self.STM32_CLI_PATH}" -c port={self.selected_port_name} -e all',
            ),
            (
                "Gravando firmware base...",
                f'"{self.STM32_CLI_PATH}" -c port={self.selected_port_name} -d "{self.base_fw_path}" 0x08000000',
            ),
            (
                "Gravando firmware do dispositivo...",
                f'"{self.STM32_CLI_PATH}" -c port={self.selected_port_name} -d "{self.seaiot_fw_path}" 0x08006000',
            ),
        ]

        for texto, cmd in etapas:
            self.status_label.configure(text=texto, text_color="#0dcaf0")
            self.log_message(self.log_text, f"> {cmd}")

            result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
            output = result.stdout + result.stderr
            self.log_message(self.log_text, output)

            if (
                result.returncode != 0
                or "Error:" in output
                or "KO" in output
                or "Timeout" in output
            ):
                self.status_label.configure(
                    text="❌ Erro durante a execução — verifique o modo boot e tente novamente.",
                    text_color="#ff4d4d",
                )
                self.status_frame.configure(border_color="#ff4d4d")
                self.after(
                    4000, lambda: self.status_frame.configure(border_color="#0078d4")
                )
                self.log_message(
                    self.log_text,
                    "⚠️ Interrompendo recuperação devido a erro detectado.",
                )
                self.exec_btn.configure(
                    text="▶ Executar (F7)",
                    state="normal",
                    fg_color=ctk.ThemeManager.theme["CTkButton"]["fg_color"],
                    hover_color="#1558b0",
                    text_color="white",
                )
                self.set_busy_ui(False)
                self.set_tabs_enabled(True)
                return  # encerra a função sem continuar

        # Aplica BOR se selecionado
        self.apply_bor_if_selected()

        # Finaliza com sucesso
        hora = time.strftime("%d/%m/%Y %H:%M:%S")
        self.status_label.configure(
            text=f"✅ Recuperação concluída — {hora}", text_color="#00b050"
        )
        self.status_frame.configure(border_color="#00b050")
        self._schedule_border_reset(4000)(border_color="#0078d4")

        self.exec_btn.configure(
            text="▶ Executar (F7)",
            state="normal",
            fg_color=ctk.ThemeManager.theme["CTkButton"]["fg_color"],
            hover_color="#1558b0",
            text_color="white",
        )
        self.set_busy_ui(False)
        self.set_tabs_enabled(True)

        # Som de sucesso
        self.play_finish_sound()

        # Volta para guia Serial e reinicia monitor
        self._schedule_return_to_serial(2000)

    # --------------------------
    #  POPUP BOR LEVEL (LER/GRAVAR)
    # --------------------------
    def open_bor_dialog(self):
        if not self.selected_port_name:
            messagebox.showwarning(
                "Aviso", "Selecione uma porta serial antes de configurar o BOR."
            )
            return

        # Ir para log e pausar monitor serial
        self.tabview.set("Log de Processo")
        self.stop_serial_thread()

        # ================================
        #   JANELA POPUP
        # ================================
        win = ctk.CTkToplevel(self)
        win.title("BOR Level (Option Bytes)")
        width, height = 450, 340
        win.geometry(f"{width}x{height}")
        win.resizable(False, False)
        win.grab_set()
        self.center_toplevel(win, width, height)

        card = ctk.CTkFrame(win, corner_radius=16, fg_color="#0f0f0f")
        card.pack(fill="both", expand=True, padx=20, pady=20)

        ctk.CTkLabel(
            card,
            text="Configuração de BOR Level",
            font=("Segoe UI Semibold", 20),
        ).pack(pady=(10, 10))

        # ================================
        #   STATUS (sempre visível acima da lista)
        # ================================
        bor_status = ctk.CTkLabel(
            card,
            text="Lendo BOR...",
            font=("Segoe UI", 14),
            text_color="gray",
        )
        bor_status.pack(pady=(0, 8))

        # ================================
        #   COMBO DO BOR
        # ================================
        BOR_DISPLAY = [
            "0 (1.7V)",
            "1 (2.0V)",
            "2 (2.2V)",
            "3 (2.5V)",
            "4 (2.8V)",
        ]

        def level_to_display(level: str):
            return BOR_DISPLAY[int(level)]

        def display_to_level(txt: str):
            return txt.split()[0]

        bor_value = ctk.StringVar(value=BOR_DISPLAY[0])

        row = ctk.CTkFrame(card, fg_color="transparent")
        row.pack(pady=(0, 15))

        ctk.CTkLabel(row, text="BOR Level:", font=("Segoe UI", 15, "bold")).pack(
            side="left", padx=(0, 10)
        )

        bor_dropdown = ctk.CTkOptionMenu(
            row,
            values=BOR_DISPLAY,
            variable=bor_value,
            width=160,
        )
        bor_dropdown.pack(side="left")

        # ================================
        #   FUNÇÃO → LER BOR
        # ================================
        def ler_bor():
            bor_status.configure(text="Lendo BOR...", text_color="gray")

            def worker():
                cmd = f'"{self.STM32_CLI_PATH}" -c port={self.selected_port_name} -ob displ'
                try:
                    res = subprocess.run(
                        cmd, shell=True, capture_output=True, text=True, timeout=15
                    )
                    output = res.stdout + res.stderr

                    level = None
                    for line in output.splitlines():
                        if "BOR_LEV" in line:
                            clean = line.replace(" ", "")
                            if "BOR_LEV:" in clean:
                                hex_part = clean.split("BOR_LEV:")[1][:3]
                                if hex_part.startswith("0x"):
                                    level = str(int(hex_part, 16))
                                    break

                    if level is None:
                        bor_status.configure(
                            text="BOR não encontrado", text_color="#ffbf00"
                        )
                        return

                    # Atualiza a lista — ISSO é suficiente
                    bor_value.set(level_to_display(level))

                    bor_status.configure(
                        text="BOR lido com sucesso", text_color="white"
                    )

                except:
                    bor_status.configure(text="Erro ao ler BOR", text_color="#ff4d4d")

            threading.Thread(target=worker, daemon=True).start()

        # ================================
        #   FUNÇÃO → GRAVAR BOR
        # ================================
        def gravar_bor():
            val_display = bor_value.get()
            level = display_to_level(val_display)

            bor_status.configure(text="Gravando BOR...", text_color="#0dcaf0")

            def worker():
                ok = self.program_bor_level(level, show_global_status=False)
                if ok:
                    bor_status.configure(
                        text="✓ BOR gravado com sucesso",
                        text_color="#00e676",
                    )
                else:
                    bor_status.configure(
                        text="Erro ao gravar BOR",
                        text_color="#ff4d4d",
                    )

            threading.Thread(target=worker, daemon=True).start()

        # ================================
        #   BOTÕES
        # ================================
        buttons = ctk.CTkFrame(card, fg_color="transparent")
        buttons.pack()

        ctk.CTkButton(
            buttons,
            text="🔍 Ler BOR",
            width=140,
            command=ler_bor,
        ).pack(side="left", padx=8)

        ctk.CTkButton(
            buttons,
            text="💾 Gravar BOR",
            width=140,
            command=gravar_bor,
        ).pack(side="left", padx=8)

        ctk.CTkButton(
            card,
            text="Fechar",
            width=120,
            fg_color="#1f6aa5",
            command=lambda: (win.destroy(), self.restart_serial_after_bor()),
        ).pack(pady=(15, 5))

        # Ler automaticamente ao abrir
        ler_bor()

    def restart_serial_after_bor(self):
        self.start_serial_thread()
        self.serial_status_label.configure(
            text=f"Monitorando {self.selected_port_name}", text_color="white"
        )
        self.tabview.set("Monitor Serial")

    # --------------------------
    #  SERIAL
    # --------------------------
    def start_serial_thread(self):
        self.stop_serial_thread()

        # === abrir arquivo .log SOMENTE se depuração estiver ativa ===
        if self.generate_logs_var.get():
            open_serial_log_file(self)
        else:
            self.serial_log_file = None
            self.serial_log_path = None

        self.serial_thread_running = True
        self.serial_thread = threading.Thread(target=self.serial_loop, daemon=True)
        self.serial_thread.start()

    def stop_serial_thread(self):
        # Indica para a thread parar
        self.serial_thread_running = False

        # Fecha porta serial com segurança
        if self.serial_port and self.serial_port.is_open:
            try:
                self.serial_port.close()
            except:
                pass

        # Fecha arquivo de log externo
        if hasattr(self, "serial_log_file") and self.serial_log_file:
            try:
                self.serial_log_file.close()
            except:
                pass
            self.serial_log_file = None
    
    def _ts_ms(self):
        return datetime.now().strftime("%H:%M:%S.%f")[:-3]  # 10:49:12.654        

    def serial_loop(self):
        rx_buf = ""      # buffer só para quebrar em linhas
        detect_buf = ""  # buffer para detectar DevEUI (pode ser menor)

        while self.serial_thread_running:
            try:
                self.serial_port = serial.Serial(self.selected_port_name, 115200, timeout=0.2)
                self.serial_status_label.configure(
                    text=f"Monitorando {self.selected_port_name}", text_color="white"
                )

                while self.serial_thread_running:
                    if self.serial_port.in_waiting:
                        data = self.serial_port.read(self.serial_port.in_waiting)
                        if data:
                            text = data.decode(errors="ignore")

                            rx_buf += text
                            rx_buf = rx_buf.replace("\r\n", "\n").replace("\r", "\n")

                            # Processa linha por linha
                            while "\n" in rx_buf:
                                raw_line, rx_buf = rx_buf.split("\n", 1)  # raw_line pode ser ""

                                ts = self._ts_ms()
                                out_line = f"{ts} -> {raw_line}"

                                # 1) mostra no monitor (1 linha = 1 timestamp)
                                self.log_message(self.serial_text, out_line)

                                # 2) grava no arquivo .log (sempre, independente de produção)
                                if self.serial_log_file:
                                    try:
                                        self.serial_log_file.write(out_line + "\n")
                                        self.serial_log_file.flush()
                                    except:
                                        pass

                                # 3) PRODUÇÃO: detecta SEAIOT_LABEL linha a linha
                                if self.production_enabled:
                                    label_payload = self.parse_seaiot_label(raw_line)

                                    if label_payload:
                                        if not self.active_box_id:
                                            self.log_error("[ERRO] Produção ativa sem caixa selecionada — operação bloqueada")
                                        else:
                                            try:
                                                # garante que o dedupe está atrelado à caixa atual
                                                if self._prod_seen_box_id != self.active_box_id:
                                                    self._prod_seen_deveuis.clear()
                                                    self._prod_seen_box_id = self.active_box_id

                                                deveui_key = (label_payload.get("deveui") or "").strip().lower()
                                                if not deveui_key:
                                                    self.log_error("[ERRO PRODUÇÃO] DevEUI vazio no SEAIOT_LABEL")
                                                    # ainda pode tentar imprimir sem contar, se quiser:
                                                    # print_label(label_payload)
                                                else:
                                                    # Duplicata: imprime, mas NÃO incrementa BOX e NÃO salva CSV
                                                    if deveui_key in self._prod_seen_deveuis:
                                                        box_now = get_box_by_id(self.active_box_id)
                                                        if box_now:
                                                            label_payload["lote"] = box_now.get("lote", self.lote_var.get())
                                                        else:
                                                            label_payload["lote"] = self.lote_var.get()

                                                        print_label(label_payload)
                                                        self.seaiot_label_printed = True
                                                        self.log_tool(f"[TOOL] Duplicata SEAIOT_LABEL ({deveui_key}) -> ignorado BOX/CSV, reimpressão OK")
                                                    else:
                                                        # Primeira vez desse DevEUI na caixa -> conta e salva
                                                        self._prod_seen_deveuis.add(deveui_key)

                                                        box = increment_box(self.active_box_id)
                                                        label_payload["lote"] = box["lote"]

                                                        self.box_counter_label.configure(text=f"{box['count']}/{box['capacity']}")
                                                        self.refresh_box_list()

                                                        print_label(label_payload)
                                                        self.seaiot_label_printed = True

                                                        append_production_row(
                                                            deveui=label_payload["deveui"],
                                                            appkey=label_payload["appkey"],
                                                            model=label_payload["model"],
                                                            lote=box["lote"],
                                                            box_id=box["id"],
                                                        )

                                                        self.log_tool(f"[PRODUÇÃO] OK | Caixa {box['id']} ({box['count']}/{box['capacity']})")

                                                        if box.get("status") == "FECHADA":
                                                            # ✅ agenda no thread principal: popup + impressão após OK
                                                            box_copy = dict(box)
                                                            model_now = label_payload.get("model", self.modelo_var.get())

                                                            self.log_tool(f"[TOOL] Caixa FECHADA detectada: {box_copy['id']} -> preparando etiqueta de fechamento")

                                                            self.after(0, lambda b=box_copy, m=model_now: self.handle_box_closed(b, m))

                                            except Exception as e:
                                                self.log_error(f"[ERRO PRODUÇÃO] {e}")

                                # 4) DevEUI detect (sempre, independente de produção)
                                detect_buf = (detect_buf + raw_line + "\n")[-3000:]
                                pattern = r"DevEUI[^:]*:\s*([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){7})"
                                match = re.search(pattern, detect_buf)

                                if match:
                                    deveui = match.group(1)
                                    deveui_clean = deveui.replace(":", "")

                                    if self.auto_copy_var.get():
                                        pyperclip.copy(deveui_clean)
                                        self.log_tool(f"[TOOL] DevEUI copiado para área de transferência: {deveui_clean}")

                                    if self.save_csv_var.get():
                                        self.save_deveui_to_csv(deveui_clean)
                                        self.log_tool("[TOOL] Registro salvo em production_log.csv")

                                    self.serial_status_label.configure(
                                        text=f"DevEUI detectado: {deveui_clean}",
                                        text_color="#00ffaa",
                                    )

                                    # limpa só o necessário (evita re-detectar o mesmo DevEUI)
                                    detect_buf = detect_buf[match.end():]

                    time.sleep(0.05)

            except Exception:
                self.serial_status_label.configure(text="Aguardando conexão serial...", text_color="gray")
                time.sleep(1)


    def enviar_comando_serial(self, cmd):
        try:
            if self.serial_port and self.serial_port.is_open:
                self.serial_port.write((cmd + "\r\n").encode())
                self.log_message(self.serial_text, f"> {cmd}")
        except Exception as e:
            self.log_message(self.serial_text, f"Erro ao enviar comando: {e}")

    def gravar_deveui(self):
        deveui = self.deveui_entry.get().strip()
        if not deveui:
            messagebox.showwarning("Campo vazio", "Insira um DEVEUI válido.")
            return
        self.enviar_comando_serial(f"AT+DEVEUI={deveui}")
        self.deveui_entry.delete(0, "end")

    # --------------------------
    #  LOG / CLI / MISC
    # --------------------------
    def ui(self, fn):
        if threading.current_thread() is threading.main_thread():
            fn()
        else:
            self.after(0, fn)

    def log_message(self, widget, msg, tag=None):
        # ✅ garante UI thread
        if threading.current_thread() is not threading.main_thread():
            self.after(0, lambda: self.log_message(widget, msg, tag))
            return

        tb = getattr(widget, "_textbox", widget)

        # auto-tag
        if tag is None:
            if ("[TOOL]" in msg) or ("[SEAIOT_LABEL]" in msg) or ("[PRODUÇÃO]" in msg):
                tag = "tool"
            elif ("[ERRO" in msg) or ("KO" in msg) or ("Error" in msg) or ("❌" in msg):
                tag = "error"

        tb.configure(state="normal")
        if tag in ("tool", "error"):
            tb.insert("end", msg + "\n", (tag,))
        else:
            tb.insert("end", msg + "\n")
        tb.see("end")
        tb.configure(state="disabled")


    def log_tool(self, msg: str):
        """Loga mensagens do TOOL/PRODUÇÃO nos dois painéis."""
        try:
            self.log_message(self.serial_text, msg, tag="tool")
        except Exception:
            pass
        try:
            self.log_message(self.log_text, msg, tag="tool")
        except Exception:
            pass


    def log_error(self, msg: str):
        """Loga erros nos dois painéis."""
        try:
            self.log_message(self.serial_text, msg, tag="error")
        except Exception:
            pass
        try:
            self.log_message(self.log_text, msg, tag="error")
        except Exception:
            pass

    def clear_log(self, widget):
        widget.configure(state="normal")
        widget.delete("1.0", "end")
        widget.configure(state="disabled")

    def check_stm32_cli(self):
        while not os.path.isfile(self.STM32_CLI_PATH):
            dlg = CustomCliDialog(self)
            if dlg.result == "buscar":
                path = filedialog.askopenfilename(
                    title="Selecione o STM32_Programmer_CLI.exe",
                    filetypes=[("STM32 CLI", "STM32_Programmer_CLI.exe")],
                )
                if path:
                    self.STM32_CLI_PATH = path
                    save_cli_path(path)
                    break
            elif dlg.result == "instalar":
                inst_path = os.path.join(APP_ROOT, "stm32_installer")
                if os.path.isdir(inst_path):
                    try:
                        os.startfile(inst_path)
                    except Exception:
                        pass
                messagebox.showinfo(
                    "Instalar", "Instale o STM32CubeProgrammer e reinicie o programa."
                )
            else:
                self.destroy()
                sys.exit(0)

    def on_close(self):
        self.stop_serial_thread()
        self.destroy()


# --------------------------
#  DIALOGO CLI
# --------------------------
class CustomCliDialog:
    def __init__(self, master):
        self.result = None
        win = ctk.CTkToplevel(master)
        win.title("STM32CubeProgrammer não encontrado")
        width, height = 420, 280
        win.geometry(f"{width}x{height}")
        win.resizable(False, False)
        win.grab_set()

        # centraliza em relação à janela principal
        try:
            master.update_idletasks()
            x = master.winfo_rootx() + (master.winfo_width() - width) // 2
            y = master.winfo_rooty() + (master.winfo_height() - height) // 2
            win.geometry(f"{width}x{height}+{x}+{y}")
        except Exception:
            pass

        txt = (
            "O utilitário STM32CubeProgrammer não foi encontrado.\n\nEscolha uma opção:"
        )
        ctk.CTkLabel(
            win, text=txt, font=("Segoe UI", 13), wraplength=380, justify="left"
        ).pack(padx=22, pady=20)
        row = ctk.CTkFrame(win, fg_color="transparent")
        row.pack(side="bottom", pady=16)
        ctk.CTkButton(
            row,
            text="Buscar executável",
            width=140,
            command=lambda: self._set(win, "buscar"),
        ).pack(side="left", padx=10)
        ctk.CTkButton(
            row,
            text="Instalar STM32Cube",
            width=140,
            command=lambda: self._set(win, "instalar"),
        ).pack(side="left", padx=10)
        ctk.CTkButton(
            row, text="Cancelar", width=100, command=lambda: self._set(win, "cancel")
        ).pack(side="left", padx=10)
        win.protocol("WM_DELETE_WINDOW", lambda: self._set(win, "cancel"))
        win.wait_window()

    def _set(self, win, value):
        self.result = value
        win.destroy()


# --------------------------
#  MAIN
# --------------------------
if __name__ == "__main__":
    app = FirmwareToolApp()
    app.protocol("WM_DELETE_WINDOW", app.on_close)
    app.mainloop()
