#!/usr/bin/env python3
# ============================================================
#  cnc_gui.py
#  Conversor DXF / SVG → G-code → Pico W 2
#  Interfaz gráfica con tkinter (incluido en Python)
#
#  Instalación de dependencias (una sola vez):
#    pip install ezdxf svgpathtools pyserial
#
#  Ejecutar:
#    python cnc_gui.py
# ============================================================

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import threading
import os
import sys
import math
import time

# ============================================================
#  CONFIGURACIÓN POR DEFECTO
# ============================================================

DEFAULTS = {
    "z_seguro":     3.0,
    "z_grabado":   -0.3,
    "feed_rapido": 1200,
    "feed_grabado": 600,
    "escala":       1.0,
    "offset_x":     0.0,
    "offset_y":     0.0,
    "arco_pasos":    20,
    "baudrate":    115200,
}

# ============================================================
#  LÓGICA DE CONVERSIÓN (misma que cnc_converter.py)
# ============================================================

class GcodeWriter:

    def __init__(self, cfg):
        self.cfg      = cfg
        self.lineas   = []
        self.pos_x    = 0.0
        self.pos_y    = 0.0
        self.grabando = False

    def _fmt(self, v):
        return f"{v:.3f}"

    def _comentario(self, t):
        self.lineas.append(f"; {t}")

    def inicio(self):
        self._comentario("CNC Grabado — generado por cnc_gui.py")
        self.lineas.append("G28") 
        self.lineas.append("G21")
        self.lineas.append(f"G0 Z{self._fmt(self.cfg['z_seguro'])}")
        self.lineas.append("G0 X0 Y0")

    def fin(self):
        self._subir()
        self.lineas.append("G0 X0 Y0")
        self._comentario("Fin")

    def _subir(self):
        if self.grabando:
            self.lineas.append(f"G0 Z{self._fmt(self.cfg['z_seguro'])}")
            self.grabando = False

    def _ir_a(self, x, y):
        self._subir()
        self.lineas.append(f"G0 X{self._fmt(x)} Y{self._fmt(y)}")
        self.pos_x = x
        self.pos_y = y

    def _bajar(self):
        if not self.grabando:
            self.lineas.append(
                f"G1 Z{self._fmt(self.cfg['z_grabado'])} "
                f"F{self.cfg['feed_grabado']}"
            )
            self.grabando = True

    def _escalar(self, x, y):
        s  = self.cfg["escala"]
        ox = self.cfg["offset_x"]
        oy = self.cfg["offset_y"]
        return x * s + ox, y * s + oy

    def linea_xy(self, x1, y1, x2, y2):
        x1, y1 = self._escalar(x1, y1)
        x2, y2 = self._escalar(x2, y2)
        if math.hypot(x1 - self.pos_x, y1 - self.pos_y) > 0.01:
            self._ir_a(x1, y1)
        self._bajar()
        self.lineas.append(
            f"G1 X{self._fmt(x2)} Y{self._fmt(y2)} "
            f"F{self.cfg['feed_grabado']}"
        )
        self.pos_x, self.pos_y = x2, y2

    def polilinea(self, puntos, cerrada=False):
        if len(puntos) < 2:
            return
        x0, y0 = self._escalar(*puntos[0])
        if math.hypot(x0 - self.pos_x, y0 - self.pos_y) > 0.01:
            self._ir_a(x0, y0)
        self._bajar()
        for px, py in puntos[1:]:
            px, py = self._escalar(px, py)
            self.lineas.append(
                f"G1 X{self._fmt(px)} Y{self._fmt(py)} "
                f"F{self.cfg['feed_grabado']}"
            )
            self.pos_x, self.pos_y = px, py
        if cerrada:
            x0, y0 = self._escalar(*puntos[0])
            self.lineas.append(
                f"G1 X{self._fmt(x0)} Y{self._fmt(y0)} "
                f"F{self.cfg['feed_grabado']}"
            )

    def arco(self, cx, cy, radio, ang_ini, ang_fin):
        pasos = self.cfg["arco_pasos"]
        cx, cy = self._escalar(cx, cy)
        r = radio * self.cfg["escala"]
        if ang_fin < ang_ini:
            ang_fin += 360
        pts = []
        for i in range(pasos + 1):
            a = math.radians(ang_ini + (ang_fin - ang_ini) * i / pasos)
            pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
        x0, y0 = pts[0]
        if math.hypot(x0 - self.pos_x, y0 - self.pos_y) > 0.01:
            self._subir()
            self.lineas.append(f"G0 X{self._fmt(x0)} Y{self._fmt(y0)}")
            self.pos_x, self.pos_y = x0, y0
        self._bajar()
        for x, y in pts[1:]:
            self.lineas.append(
                f"G1 X{self._fmt(x)} Y{self._fmt(y)} "
                f"F{self.cfg['feed_grabado']}"
            )
            self.pos_x, self.pos_y = x, y

    def texto(self):
        return "\n".join(self.lineas)


def convertir_dxf(ruta, writer, log):
    try:
        import ezdxf
    except ImportError:
        raise ImportError("Instala ezdxf:  pip install ezdxf")

    doc = ezdxf.readfile(ruta)
    msp = doc.modelspace()
    n = 0

    for e in msp:
        t = e.dxftype()
        try:
            if t == "LINE":
                writer.linea_xy(
                    e.dxf.start.x, e.dxf.start.y,
                    e.dxf.end.x,   e.dxf.end.y
                )
                n += 1
            elif t in ("LWPOLYLINE", "POLYLINE"):
                pts = [(v[0], v[1]) for v in e.get_points()]
                writer.polilinea(pts, e.is_closed)
                n += 1
            elif t == "CIRCLE":
                writer.arco(
                    e.dxf.center.x, e.dxf.center.y,
                    e.dxf.radius, 0, 360
                )
                n += 1
            elif t == "ARC":
                writer.arco(
                    e.dxf.center.x, e.dxf.center.y,
                    e.dxf.radius,
                    e.dxf.start_angle, e.dxf.end_angle
                )
                n += 1
            elif t == "SPLINE":
                pts = [(p[0], p[1]) for p in e.flattening(0.1)]
                writer.polilinea(pts)
                n += 1
            else:
                log(f"  [omitido] {t}")
        except Exception as ex:
            log(f"  [error en {t}] {ex}")

    log(f"DXF: {n} entidades convertidas")


def convertir_svg(ruta, writer, log):
    try:
        from svgpathtools import svg2paths
    except ImportError:
        raise ImportError("Instala svgpathtools:  pip install svgpathtools")

    paths, _ = svg2paths(ruta)
    n = 0

    for path in paths:
        for seg in path:
            tipo = type(seg).__name__
            try:
                if tipo == "Line":
                    writer.linea_xy(
                        seg.start.real, -seg.start.imag,
                        seg.end.real,   -seg.end.imag
                    )
                    n += 1
                elif tipo in ("CubicBezier", "QuadraticBezier", "Arc"):
                    pasos = writer.cfg["arco_pasos"]
                    pts = [
                        (seg.point(i / pasos).real,
                         -seg.point(i / pasos).imag)
                        for i in range(pasos + 1)
                    ]
                    writer.polilinea(pts)
                    n += 1
            except Exception as ex:
                log(f"  [error en {tipo}] {ex}")

    log(f"SVG: {n} segmentos convertidos")


# ============================================================
#  INTERFAZ GRÁFICA
# ============================================================

class CNCApp:

    def __init__(self, root):
        self.root  = root
        self.gcode = ""

        root.title("CNC Converter — DXF/SVG a G-code")
        root.resizable(True, True)
        root.minsize(700, 600)

        self._construir_ui()
        self._actualizar_puertos()

    # ──────────────────────────────────────────────────────
    # Construcción de la UI
    # ──────────────────────────────────────────────────────

    def _construir_ui(self):
        root = self.root

        # Barra superior — archivo
        frm_archivo = ttk.LabelFrame(root, text="Archivo de entrada", padding=8)
        frm_archivo.pack(fill="x", padx=12, pady=(12, 4))

        self.var_archivo = tk.StringVar()
        ttk.Entry(frm_archivo, textvariable=self.var_archivo,
                  state="readonly", width=55).pack(side="left", fill="x", expand=True)
        ttk.Button(frm_archivo, text="Abrir DXF / SVG",
                   command=self._abrir_archivo).pack(side="left", padx=(8, 0))

        # Parámetros en dos columnas
        frm_params = ttk.LabelFrame(root, text="Parámetros de grabado", padding=8)
        frm_params.pack(fill="x", padx=12, pady=4)

        params_izq = [
            ("Z seguro (mm)",       "z_seguro"),
            ("Z grabado (mm)",      "z_grabado"),
            ("Feed grabado (mm/min)", "feed_grabado"),
            ("Feed rápido (mm/min)", "feed_rapido"),
        ]
        params_der = [
            ("Escala",              "escala"),
            ("Offset X (mm)",       "offset_x"),
            ("Offset Y (mm)",       "offset_y"),
            ("Pasos por arco",      "arco_pasos"),
        ]

        self.vars_param = {}

        col_izq = ttk.Frame(frm_params)
        col_izq.pack(side="left", fill="x", expand=True, padx=(0, 16))
        col_der = ttk.Frame(frm_params)
        col_der.pack(side="left", fill="x", expand=True)

        for etiqueta, clave in params_izq:
            self._campo(col_izq, etiqueta, clave)
        for etiqueta, clave in params_der:
            self._campo(col_der, etiqueta, clave)

        # Puerto serial
        frm_serial = ttk.LabelFrame(root, text="Puerto Serial (Pico)", padding=8)
        frm_serial.pack(fill="x", padx=12, pady=4)

        self.var_puerto = tk.StringVar()
        self.cb_puertos = ttk.Combobox(
            frm_serial, textvariable=self.var_puerto, width=22
        )
        self.cb_puertos.pack(side="left")

        ttk.Button(frm_serial, text="Actualizar",
                   command=self._actualizar_puertos).pack(side="left", padx=6)

        ttk.Label(frm_serial, text="Baudrate:").pack(side="left", padx=(16, 4))
        self.var_baud = tk.StringVar(value=str(DEFAULTS["baudrate"]))
        ttk.Combobox(
            frm_serial,
            textvariable=self.var_baud,
            values=["9600", "115200", "250000"],
            width=9
        ).pack(side="left")

        # Botones de acción
        frm_botones = ttk.Frame(root)
        frm_botones.pack(fill="x", padx=12, pady=8)

        self.btn_convertir = ttk.Button(
            frm_botones, text="Convertir",
            command=self._convertir
        )
        self.btn_convertir.pack(side="left", padx=(0, 6))

        self.btn_enviar = ttk.Button(
            frm_botones, text="Enviar al Pico",
            command=self._enviar, state="disabled"
        )
        self.btn_enviar.pack(side="left", padx=(0, 6))

        self.btn_guardar = ttk.Button(
            frm_botones, text="Guardar .nc",
            command=self._guardar, state="disabled"
        )
        self.btn_guardar.pack(side="left", padx=(0, 6))

        ttk.Button(
            frm_botones, text="Limpiar log",
            command=self._limpiar_log
        ).pack(side="right")

        # Barra de progreso
        self.var_progreso = tk.DoubleVar(value=0)
        self.progressbar = ttk.Progressbar(
            root, variable=self.var_progreso, maximum=100
        )
        self.progressbar.pack(fill="x", padx=12, pady=(0, 4))

        # Etiqueta de estado
        self.var_estado = tk.StringVar(value="Listo.")
        ttk.Label(root, textvariable=self.var_estado,
                  anchor="w").pack(fill="x", padx=12)

        # Log
        frm_log = ttk.LabelFrame(root, text="Log", padding=4)
        frm_log.pack(fill="both", expand=True, padx=12, pady=(4, 12))

        self.txt_log = scrolledtext.ScrolledText(
            frm_log, height=10, state="disabled",
            font=("Courier", 10)
        )
        self.txt_log.pack(fill="both", expand=True)

    def _campo(self, parent, etiqueta, clave):
        frm = ttk.Frame(parent)
        frm.pack(fill="x", pady=2)
        ttk.Label(frm, text=etiqueta, width=22, anchor="w").pack(side="left")
        var = tk.StringVar(value=str(DEFAULTS[clave]))
        ttk.Entry(frm, textvariable=var, width=10).pack(side="left")
        self.vars_param[clave] = var

    # ──────────────────────────────────────────────────────
    # Helpers
    # ──────────────────────────────────────────────────────

    def _log(self, msg):
        """Escribir en el log de forma thread-safe"""
        def _write():
            self.txt_log.configure(state="normal")
            self.txt_log.insert("end", msg + "\n")
            self.txt_log.see("end")
            self.txt_log.configure(state="disabled")
        self.root.after(0, _write)

    def _estado(self, msg):
        self.root.after(0, lambda: self.var_estado.set(msg))

    def _progreso(self, pct):
        self.root.after(0, lambda: self.var_progreso.set(pct))

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

    def _cfg(self):
        """Leer los parámetros de los campos de texto"""
        cfg = {}
        for clave, var in self.vars_param.items():
            try:
                cfg[clave] = float(var.get())
            except ValueError:
                cfg[clave] = DEFAULTS[clave]
        return cfg

    def _bloquear(self, estado):
        s = "disabled" if estado else "normal"
        self.btn_convertir.configure(state=s)

    # ──────────────────────────────────────────────────────
    # Acciones
    # ──────────────────────────────────────────────────────

    def _abrir_archivo(self):
        ruta = filedialog.askopenfilename(
            title="Seleccionar archivo",
            filetypes=[
                ("Archivos CNC", "*.dxf *.svg"),
                ("DXF", "*.dxf"),
                ("SVG", "*.svg"),
                ("Todos", "*.*"),
            ]
        )
        if ruta:
            self.var_archivo.set(ruta)
            self.btn_enviar.configure(state="disabled")
            self.btn_guardar.configure(state="disabled")
            self.gcode = ""
            self._log(f"Archivo seleccionado: {os.path.basename(ruta)}")

    def _actualizar_puertos(self):
        try:
            import serial.tools.list_ports
            puertos = [p.device for p in serial.tools.list_ports.comports()]
            self.cb_puertos["values"] = puertos
            if puertos and not self.var_puerto.get():
                self.var_puerto.set(puertos[0])
        except ImportError:
            self.cb_puertos["values"] = []
            self._log("pyserial no instalado — instala con: pip install pyserial")

    def _convertir(self):
        ruta = self.var_archivo.get()
        if not ruta:
            messagebox.showwarning("Sin archivo", "Selecciona un archivo DXF o SVG primero.")
            return

        self._bloquear(True)
        self._progreso(0)
        self._estado("Convirtiendo...")
        self._limpiar_log()
        self.gcode = ""

        def tarea():
            try:
                cfg = self._cfg()
                writer = GcodeWriter(cfg)
                writer.inicio()

                ext = os.path.splitext(ruta)[1].lower()
                if ext == ".dxf":
                    convertir_dxf(ruta, writer, self._log)
                elif ext == ".svg":
                    convertir_svg(ruta, writer, self._log)
                else:
                    raise ValueError(f"Formato no soportado: {ext}")

                writer.fin()
                self.gcode = writer.texto()

                n_lineas = len([
                    l for l in self.gcode.split("\n")
                    if l.strip() and not l.startswith(";")
                ])

                self._log(f"\nG-code listo: {n_lineas} comandos")
                self._estado(f"Listo — {n_lineas} comandos generados")
                self._progreso(100)

                self.root.after(0, lambda: self.btn_enviar.configure(state="normal"))
                self.root.after(0, lambda: self.btn_guardar.configure(state="normal"))

            except ImportError as e:
                self._log(f"\nERROR: {e}")
                self._estado("Error — falta librería")
                messagebox.showerror("Librería faltante", str(e))

            except Exception as e:
                self._log(f"\nERROR: {e}")
                self._estado("Error en la conversión")
                messagebox.showerror("Error", str(e))

            finally:
                self.root.after(0, lambda: self._bloquear(False))

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

    def _guardar(self):
        if not self.gcode:
            return
        ruta = filedialog.asksaveasfilename(
            title="Guardar G-code",
            defaultextension=".nc",
            filetypes=[("G-code", "*.nc *.gcode"), ("Todos", "*.*")]
        )
        if ruta:
            with open(ruta, "w") as f:
                f.write(self.gcode)
            self._log(f"Guardado en: {ruta}")
            self._estado(f"Guardado: {os.path.basename(ruta)}")

    def _enviar(self):
        if not self.gcode:
            return

        puerto = self.var_puerto.get()
        if not puerto:
            messagebox.showwarning(
                "Sin puerto",
                "Selecciona el puerto serial del Pico."
            )
            return

        try:
            baud = int(self.var_baud.get())
        except ValueError:
            baud = 115200

        self._bloquear(True)
        self.btn_enviar.configure(state="disabled")
        self.btn_guardar.configure(state="disabled")
        self._progreso(0)
        self._estado("Enviando...")

        def tarea():
            try:
                import serial
            except ImportError:
                self._log("ERROR: instala pyserial:  pip install pyserial")
                self.root.after(0, lambda: self._bloquear(False))
                return

            lineas = [
                l for l in self.gcode.split("\n")
                if l.strip() and not l.startswith(";")
            ]
            total = len(lineas)

            try:
                ser = serial.Serial(puerto, baud, timeout=30)
            except serial.SerialException as e:
                self._log(f"ERROR al abrir {puerto}: {e}")
                self._estado("Error de conexión")
                self.root.after(0, lambda: self._bloquear(False))
                return

            time.sleep(2)
            ser.reset_input_buffer()

            # Leer mensaje de inicio del Pico
            while ser.in_waiting:
                self._log(f"Pico: {ser.readline().decode().strip()}")

            self._log(f"Enviando {total} líneas a {puerto}...")

            for i, linea in enumerate(lineas):
                linea = linea.strip()
                if not linea:
                    continue

                ser.write((linea + "\n").encode())
                respuesta = ser.readline().decode().strip()

                pct = (i + 1) / total * 100
                self._progreso(pct)
                self._estado(f"Enviando... {int(pct)}%  ({i+1}/{total})")
                self._log(f"→ {linea}   ← {respuesta}")

                if "ERROR" in respuesta.upper():
                    self._log(f"\nERROR en línea {i+1} — enviando G0 Z5")
                    ser.write(b"G0 Z5\n")
                    ser.readline()
                    break

            ser.close()
            self._log("\nEnvío completado.")
            self._estado("Envío completado.")
            self._progreso(100)
            self.root.after(0, lambda: self.btn_enviar.configure(state="normal"))
            self.root.after(0, lambda: self.btn_guardar.configure(state="normal"))
            self.root.after(0, lambda: self._bloquear(False))

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


# ============================================================
#  MAIN
# ============================================================

if __name__ == "__main__":
    root = tk.Tk()
    app  = CNCApp(root)
    root.mainloop()
