"""
Wrapper sobre pyzkfp para lectores ZKTeco serie ZK9500 / ZK4500 / SLK20R.

Mantiene una sola instancia del SDK abierta y ofrece operaciones
thread-safe para captura, enrolamiento (3 muestras + merge) e
identificacion 1:N contra una base en memoria.

Requiere ZKFinger SDK 5.3 instalado en el sistema (registra libzkfp.dll).

Variables de entorno opcionales:
    ZKFP_DEVICE_INDEX  -> indice del dispositivo a abrir (default: ultimo).
                          Util cuando el SDK detecta dispositivos fantasma
                          y hay que forzar uno especifico.
"""

import os
import threading
import time
import logging

try:
    from pyzkfp import ZKFP2
except ImportError as e:
    raise RuntimeError(
        "Falta el paquete 'pyzkfp'. Instalar con: pip install pyzkfp"
    ) from e


log = logging.getLogger(__name__)


class ZKReaderError(Exception):
    pass


class ZKReader:
    """
    Wrapper alrededor de pyzkfp.ZKFP2.
    Todas las llamadas al SDK estan protegidas por un lock para evitar
    race conditions cuando el agente atiende multiples requests en simultaneo.
    """

    def __init__(self):
        self._zkfp = None
        self._open = False
        self._lock = threading.Lock()

    # ------------------------------------------------------------------
    # Ciclo de vida
    # ------------------------------------------------------------------
    def open(self):
        with self._lock:
            if self._open:
                return
            try:
                log.info("ZKReader.open: instanciando ZKFP2()...")
                zkfp = ZKFP2()
                log.info("ZKReader.open: llamando Init() (CWD=%s)...", os.getcwd())
                zkfp.Init()
                log.info("ZKReader.open: Init() OK")
            except Exception as e:
                # System.DllNotFoundException u otra excepcion de pythonnet/SDK.
                msg = str(e)
                exc_type = type(e).__name__
                log.exception("ZKReader.open: fallo inicializando (type=%s)", exc_type)
                if "libzkfp.dll" in msg or "DllNotFound" in exc_type:
                    raise ZKReaderError(
                        "Falta 'libzkfp.dll'. Instala ZKFinger SDK 5.3 desde la pagina "
                        "oficial de ZKTeco y reinicia Windows. Ver README.md."
                    ) from e
                raise ZKReaderError(f"Error inicializando SDK ZK: {msg}") from e

            try:
                count = zkfp.GetDeviceCount()
            except Exception as e:
                try:
                    zkfp.Terminate()
                except Exception:
                    pass
                raise ZKReaderError(f"Error consultando dispositivos: {e}") from e

            if count == 0:
                try:
                    zkfp.Terminate()
                except Exception:
                    pass
                raise ZKReaderError(
                    "No se detecto ningun lector ZK conectado por USB."
                )

            # Determinar indice del dispositivo a abrir.
            # Default = 0 (el USB fisico real, confirmado por los logs del SDK
            # calibrando exposure/LED al inicializar). Indices superiores en
            # ZK suelen ser dispositivos fantasma que tiran "Invalid Handle".
            env_idx = os.environ.get("ZKFP_DEVICE_INDEX")
            if env_idx is not None and env_idx.strip() != "":
                try:
                    device_index = int(env_idx)
                    log.info("Usando ZKFP_DEVICE_INDEX=%d (override por env var)", device_index)
                except ValueError:
                    log.warning("ZKFP_DEVICE_INDEX invalido (%r), ignorando", env_idx)
                    device_index = 0
            else:
                device_index = 0

            log.info(
                "Dispositivos detectados: %d. Abriendo indice %d.",
                count, device_index,
            )

            try:
                zkfp.OpenDevice(device_index)
            except Exception as e:
                try:
                    zkfp.Terminate()
                except Exception:
                    pass
                raise ZKReaderError(
                    f"Error abriendo el dispositivo {device_index}: {e}. "
                    f"Probar con otro indice via ZKFP_DEVICE_INDEX (validos: 0..{count-1})."
                ) from e

            # Algunos lectores (incluido ZK9500 con drivers nuevos) requieren
            # un "Light()" o operacion inicial para despertar el sensor.
            # Si el metodo no esta disponible o falla, no es critico.
            for color in ("green", "red"):
                try:
                    zkfp.Light(color)
                    log.debug("Light(%s) OK", color)
                except Exception as e:
                    log.debug("Light(%s) no soportado o fallo: %s", color, e)

            self._zkfp = zkfp
            self._open = True
            log.info("Lector ZK abierto correctamente en indice %d.", device_index)

    def close(self):
        with self._lock:
            if not self._open:
                return
            try:
                self._zkfp.CloseDevice()
            except Exception:
                pass
            try:
                self._zkfp.Terminate()
            except Exception:
                pass
            self._zkfp = None
            self._open = False
            log.info("Lector ZK cerrado")

    def is_open(self):
        return self._open

    # ------------------------------------------------------------------
    # Captura
    # ------------------------------------------------------------------
    def capture_one(self, timeout=15.0, poll_interval=0.05):
        """
        Bloquea hasta que el usuario apoya el dedo o se cumple el timeout.
        Devuelve (template_bytes, image_bytes).
        """
        if not self._open:
            raise ZKReaderError("Lector no abierto")

        log.info("capture_one: esperando huella (timeout=%.1fs)", timeout)
        deadline = time.time() + timeout
        last_heartbeat = time.time()
        polls = 0
        while time.time() < deadline:
            with self._lock:
                result = self._zkfp.AcquireFingerprint()
            polls += 1
            if result:
                template, image = result
                # pyzkfp devuelve arrays de .NET (System.Byte[]); pasamos
                # a bytes Python nativos para que base64/serializadores andem.
                template = bytes(template) if template is not None else None
                image = bytes(image) if image is not None else None
                tpl_len = len(template) if template else 0
                img_len = len(image) if image else 0
                log.info(
                    "capture_one: HUELLA DETECTADA tras %d polls (tpl=%d bytes, img=%d bytes)",
                    polls, tpl_len, img_len,
                )
                return template, image
            # Heartbeat cada 2 segundos para mostrar que esta vivo
            if time.time() - last_heartbeat >= 2.0:
                log.info("capture_one: aun esperando huella (%d polls hechos)", polls)
                last_heartbeat = time.time()
            time.sleep(poll_interval)

        log.warning("capture_one: TIMEOUT tras %d polls sin detectar huella", polls)
        raise ZKReaderError("Timeout esperando huella (apoye el dedo en el lector).")

    def enroll(self, on_progress=None, between_captures=0.4, timeout_each=15.0):
        """
        Captura 3 muestras del mismo dedo y devuelve un template unico
        producto del merge interno del SDK.

        on_progress(step, total, image_bytes) -> callback opcional para feedback.
        """
        if not self._open:
            raise ZKReaderError("Lector no abierto")

        templates = []
        last_image = None

        for i in range(3):
            tpl, img = self.capture_one(timeout=timeout_each)
            templates.append(tpl)
            last_image = img
            if on_progress:
                try:
                    on_progress(i + 1, 3, img)
                except Exception:
                    log.exception("on_progress fallo")
            # Pausa breve para permitir levantar el dedo antes de la siguiente captura
            if i < 2:
                time.sleep(between_captures)
                self._wait_finger_lifted(max_wait=2.0)

        with self._lock:
            # DBMerge devuelve (template_bytes, template_length)
            merged_template, _merged_len = self._zkfp.DBMerge(*templates)

        # Convertir arrays .NET a bytes Python nativos (sino base64 falla)
        if merged_template is not None and not isinstance(merged_template, (bytes, bytearray)):
            merged_template = bytes(merged_template)

        return merged_template, last_image

    def _wait_finger_lifted(self, max_wait=2.0, poll_interval=0.05):
        """Espera a que el usuario levante el dedo, hasta max_wait segundos."""
        deadline = time.time() + max_wait
        while time.time() < deadline:
            with self._lock:
                if not self._zkfp.AcquireFingerprint():
                    return
            time.sleep(poll_interval)

    # ------------------------------------------------------------------
    # Match
    # ------------------------------------------------------------------
    def match_1_to_1(self, template_a, template_b):
        with self._lock:
            return self._zkfp.DBMatch(template_a, template_b)

    def merge_templates(self, template_list):
        """Mergea una lista de templates (3 esperados) en uno final via DBMerge."""
        if not self._open:
            raise ZKReaderError("Lector no abierto")
        if len(template_list) < 2:
            raise ZKReaderError("Se requieren al menos 2 templates para merge")
        with self._lock:
            merged_template, _ = self._zkfp.DBMerge(*template_list)
        if merged_template is not None and not isinstance(merged_template, (bytes, bytearray)):
            merged_template = bytes(merged_template)
        return merged_template

    def identify(self, templates_dict, sample_template):
        """
        Identificacion 1:N usando la base en memoria del SDK.
        templates_dict: {empleado_id: template_bytes}
        Devuelve (empleado_id_match | None, score).
        """
        if not self._open:
            raise ZKReaderError("Lector no abierto")

        with self._lock:
            try:
                self._zkfp.DBClear()
            except Exception:
                pass

            for emp_id, tpl in templates_dict.items():
                try:
                    self._zkfp.DBAdd(int(emp_id), tpl)
                except Exception as e:
                    log.warning("No se pudo agregar template empleado=%s: %s", emp_id, e)

            # DBIdentify devuelve (id, score) si matchea, lanza excepcion si no.
            try:
                emp_id, score = self._zkfp.DBIdentify(sample_template)
                return int(emp_id), int(score)
            except Exception as e:
                log.debug("DBIdentify sin match: %s", e)
                return None, 0
            finally:
                try:
                    self._zkfp.DBClear()
                except Exception:
                    pass
