"""
Taxes Lector ZK - Agente local para lectores de huella ZKTeco.

Expone una API HTTP en http://127.0.0.1:7777 que el frontend de Taxes consume
para enrolar y verificar huellas. Solo escucha en loopback. Se ejecuta como
proceso en background y muestra un icono en la bandeja del sistema (tray)
con menu para abrir estado, ver log, reiniciar y salir.

Endpoints:
    GET  /status     -> info del lector (conectado / version)
    POST /enroll     -> 3 capturas + merge, devuelve template base64
    POST /capture    -> 1 captura simple (para identify externo)
    POST /merge      -> recibe N templates y devuelve el merged
    POST /identify   -> recibe lista de templates + captura 1:N

Errores de negocio devuelven 400 con {"ok": false, "error": "..."}.

Compatible con la familia ZKTeco que usa el SDK ZKFinger 5.3:
ZK9500, ZK6500, ZK4500, ZK8500R, SLK20R.
"""

import base64
import ctypes
import io
import logging
import os
import socket
import subprocess
import sys
import threading
import time
import webbrowser

# ----------------------------------------------------------------------
# Resolucion de DLLs del SDK ZK (necesario tanto en venv como en .exe)
# ----------------------------------------------------------------------
if hasattr(sys, "_MEIPASS"):
    _meipass = sys._MEIPASS
    _meipass_sensors = os.path.join(_meipass, "ZKFPSensors")
    extra_paths = [_meipass]
    if os.path.isdir(_meipass_sensors):
        extra_paths.append(_meipass_sensors)
    os.environ["PATH"] = os.pathsep.join(extra_paths) + os.pathsep + os.environ.get("PATH", "")
    for p in extra_paths:
        try:
            os.add_dll_directory(p)
        except (AttributeError, OSError):
            pass
    try:
        os.chdir(_meipass)
    except OSError:
        pass

try:
    _system32 = os.path.join(os.environ.get("WINDIR", "C:\\Windows"), "System32")
    if os.path.isdir(_system32):
        os.add_dll_directory(_system32)
    _sys32_sensors = os.path.join(_system32, "ZKFPSensors")
    if os.path.isdir(_sys32_sensors):
        os.add_dll_directory(_sys32_sensors)
except (AttributeError, OSError):
    pass


from flask import Flask, request, jsonify
from flask_cors import CORS
from PIL import Image, ImageDraw

from zk_reader import ZKReader, ZKReaderError


# ----------------------------------------------------------------------
# Configuracion del producto
# ----------------------------------------------------------------------
APP_NAME = "Taxes Lector ZK"
APP_TAG = "taxes_lector_zk"
VERSION = "1.0.0"
HOST = "127.0.0.1"
PORT = 7777

LOG_FILE = os.path.join(os.path.expanduser("~"), f"{APP_TAG}.log")


# ----------------------------------------------------------------------
# Logging
# ----------------------------------------------------------------------
_log_handlers = [logging.FileHandler(LOG_FILE, encoding="utf-8")]
if sys.stdout is not None and hasattr(sys.stdout, "write"):
    _log_handlers.append(logging.StreamHandler(sys.stdout))

logging.basicConfig(
    level=logging.INFO,
    format="[%(asctime)s] %(levelname)s %(name)s :: %(message)s",
    handlers=_log_handlers,
)
log = logging.getLogger(APP_TAG)


# ----------------------------------------------------------------------
# Single instance: si ya hay otro agente corriendo, mostrar mensaje y salir
# ----------------------------------------------------------------------
def _is_already_running():
    """True si el puerto 7777 ya esta ocupado por otra instancia."""
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(0.5)
    try:
        result = s.connect_ex((HOST, PORT))
        return result == 0
    except Exception:
        return False
    finally:
        try:
            s.close()
        except Exception:
            pass


def _show_message_box(title, msg):
    """Mensaje al usuario sin necesidad de consola."""
    try:
        ctypes.windll.user32.MessageBoxW(0, msg, title, 0x40)  # MB_ICONINFORMATION
    except Exception:
        pass


# ----------------------------------------------------------------------
# Flask app
# ----------------------------------------------------------------------
app = Flask(__name__)
CORS(app, resources={r"/*": {"origins": "*"}})


@app.after_request
def add_private_network_header(response):
    """
    Chrome bloquea fetch desde origen HTTPS publico hacia loopback (127.0.0.1)
    salvo que el preflight responda con este header (Private Network Access).
    """
    response.headers["Access-Control-Allow-Private-Network"] = "true"
    return response


reader = ZKReader()


def _b64(data):
    if data is None:
        return None
    return base64.b64encode(data).decode("ascii")


# Dimensiones tipicas de los lectores ZK (raw greyscale 8-bit)
ZK_IMAGE_DIMS = [
    (300, 375),  # ZK9500
    (256, 360),  # ZK4500
    (288, 360),  # SLK20R
]


def _image_raw_to_png_b64(raw_bytes):
    if not raw_bytes:
        return None
    n = len(raw_bytes)
    dims = next(((w, h) for (w, h) in ZK_IMAGE_DIMS if w * h == n), None)
    if dims is None:
        log.warning("Tamano de imagen no reconocido (%d bytes)", n)
        return None
    try:
        img = Image.frombytes("L", dims, bytes(raw_bytes))
        buf = io.BytesIO()
        img.save(buf, format="PNG", optimize=True)
        return base64.b64encode(buf.getvalue()).decode("ascii")
    except Exception as e:
        log.exception("Error convirtiendo imagen a PNG: %s", e)
        return None


def _ensure_open():
    if not reader.is_open():
        reader.open()


# ----------------------------------------------------------------------
# Endpoints
# ----------------------------------------------------------------------
@app.route("/status", methods=["GET"])
def status():
    try:
        _ensure_open()
        return jsonify({
            "ok": True,
            "connected": True,
            "version": VERSION,
            "modelo": "ZKTeco (familia ZKFinger)",
        })
    except ZKReaderError as e:
        return jsonify({
            "ok": False,
            "connected": False,
            "version": VERSION,
            "error": str(e),
        }), 200


@app.route("/enroll", methods=["POST"])
def enroll():
    try:
        _ensure_open()
        body = request.get_json(silent=True) or {}
        finger = int(body.get("finger", 0))
        progress_log = []

        def on_progress(step, total, image):
            progress_log.append(step)
            log.info("Enrolamiento %d/%d", step, total)

        merged, last_image = reader.enroll(on_progress=on_progress)
        return jsonify({
            "ok": True,
            "finger": finger,
            "template": _b64(merged),
            "image": _image_raw_to_png_b64(last_image),
            "captures_done": len(progress_log),
        })
    except ZKReaderError as e:
        log.warning("Enrolamiento fallido: %s", e)
        return jsonify({"ok": False, "error": str(e)}), 400
    except Exception as e:
        log.exception("Error inesperado en /enroll")
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/capture", methods=["POST"])
def capture():
    try:
        _ensure_open()
        tpl, img = reader.capture_one()
        return jsonify({
            "ok": True,
            "template": _b64(tpl),
            "image": _image_raw_to_png_b64(img),
        })
    except ZKReaderError as e:
        return jsonify({"ok": False, "error": str(e)}), 400
    except Exception as e:
        log.exception("Error inesperado en /capture")
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/merge", methods=["POST"])
def merge():
    try:
        _ensure_open()
        body = request.get_json(force=True) or {}
        raw_templates = body.get("templates") or []
        if len(raw_templates) < 2:
            return jsonify({"ok": False, "error": "Se requieren al menos 2 templates"}), 400
        templates = [base64.b64decode(t) for t in raw_templates]
        merged = reader.merge_templates(templates)
        return jsonify({"ok": True, "template": _b64(merged)})
    except ZKReaderError as e:
        return jsonify({"ok": False, "error": str(e)}), 400
    except Exception as e:
        log.exception("Error inesperado en /merge")
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/identify", methods=["POST"])
def identify():
    try:
        _ensure_open()
        body = request.get_json(force=True) or {}
        raw_templates = body.get("templates") or []
        if not raw_templates:
            return jsonify({"ok": False, "error": "Sin templates registrados"}), 400
        templates = {}
        for t in raw_templates:
            try:
                emp_id = int(t["id"])
                templates[emp_id] = base64.b64decode(t["template"])
            except (KeyError, ValueError, TypeError) as e:
                log.warning("Template invalido en payload: %s", e)
        if not templates:
            return jsonify({"ok": False, "error": "Templates invalidos"}), 400

        tpl, img = reader.capture_one()
        emp_id_match, score = reader.identify(templates, tpl)

        return jsonify({
            "ok": True,
            "matched": emp_id_match is not None,
            "id": emp_id_match,
            "score": score,
            "image": _image_raw_to_png_b64(img),
        })
    except ZKReaderError as e:
        return jsonify({"ok": False, "error": str(e)}), 400
    except Exception as e:
        log.exception("Error inesperado en /identify")
        return jsonify({"ok": False, "error": str(e)}), 500


# ----------------------------------------------------------------------
# System tray icon
# ----------------------------------------------------------------------
def _make_tray_image(connected):
    """
    Genera el icono del tray (64x64). Verde si esta conectado al lector,
    gris si no. Texto 'ZK' en blanco al centro.
    """
    color = (34, 197, 94) if connected else (148, 163, 184)  # verde / gris
    img = Image.new("RGB", (64, 64), color=color)
    draw = ImageDraw.Draw(img)
    # Borde redondeado simulado con un rectangulo solido
    draw.rectangle([(2, 2), (61, 61)], outline="white", width=2)
    # Texto ZK en blanco grande
    try:
        from PIL import ImageFont
        font = ImageFont.truetype("arialbd.ttf", 30)
        draw.text((14, 14), "ZK", fill="white", font=font)
    except Exception:
        # Fallback sin font especifico
        draw.text((20, 22), "ZK", fill="white")
    return img


def _open_status_in_browser(icon=None, item=None):
    webbrowser.open(f"http://{HOST}:{PORT}/status")


def _open_log_in_notepad(icon=None, item=None):
    try:
        subprocess.Popen(["notepad.exe", LOG_FILE])
    except Exception as e:
        log.exception("No se pudo abrir el log: %s", e)


def _restart_reader(icon=None, item=None):
    """Cierra el lector y lo vuelve a abrir. Util si entra en estado zombie."""
    log.info("Reinicio del lector solicitado desde el tray.")
    try:
        reader.close()
    except Exception:
        pass
    try:
        reader.open()
        log.info("Lector reabierto correctamente.")
        if icon is not None:
            icon.icon = _make_tray_image(True)
            icon.title = f"{APP_NAME} - Conectado"
    except ZKReaderError as e:
        log.warning("Reinicio fallido: %s", e)
        _show_message_box(
            APP_NAME,
            "No se pudo reiniciar el lector:\n\n"
            f"{e}\n\n"
            "Probá desenchufar el lector USB, esperar 10 segundos y reconectarlo. "
            "Despues, click derecho en el icono del tray y volve a tocar 'Reiniciar lector'.",
        )


def _quit_app(icon, item=None):
    """Cierre limpio: stop tray, close reader, exit Flask."""
    log.info("Salida solicitada desde el tray.")
    try:
        icon.stop()
    except Exception:
        pass
    try:
        reader.close()
    except Exception:
        pass
    # Flask corre en daemon thread, sale solo. Forzamos exit por las dudas.
    os._exit(0)


def _build_tray_menu():
    import pystray
    from pystray import MenuItem as item, Menu

    return Menu(
        item(f"{APP_NAME} v{VERSION}", None, enabled=False),
        Menu.SEPARATOR,
        item("Abrir estado en el navegador", _open_status_in_browser, default=True),
        item("Ver log", _open_log_in_notepad),
        item("Reiniciar lector", _restart_reader),
        Menu.SEPARATOR,
        item("Salir", _quit_app),
    )


def _update_tray_periodic(icon):
    """Actualiza el icono y tooltip cada 5s segun conexion del lector."""
    while True:
        time.sleep(5)
        try:
            connected = reader.is_open()
            icon.icon = _make_tray_image(connected)
            icon.title = (
                f"{APP_NAME} - {'Conectado al lector' if connected else 'Sin lector'}"
            )
        except Exception:
            pass


def _run_flask_forever():
    """Flask corre en un thread daemon."""
    try:
        app.run(host=HOST, port=PORT, threaded=True, debug=False, use_reloader=False)
    except Exception:
        log.exception("Flask termino con error")


# ----------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------
def main():
    log.info("=" * 60)
    log.info("%s v%s arrancando...", APP_NAME, VERSION)
    log.info("Escuchando en http://%s:%d  (loopback)", HOST, PORT)

    # Single instance check
    if _is_already_running():
        log.warning("Ya hay otra instancia corriendo en el puerto %d. Saliendo.", PORT)
        _show_message_box(
            APP_NAME,
            f"{APP_NAME} ya esta corriendo en esta computadora.\n\n"
            "Mira el icono en la bandeja del sistema (al lado del reloj).\n"
            "Click derecho ahi para ver el menu de opciones.",
        )
        return

    # Abrir lector (no bloqueante: si falla, igual arranca el agente)
    try:
        reader.open()
    except ZKReaderError as e:
        log.warning("Lector no disponible al arranque: %s", e)
    except Exception as e:
        log.exception("Error inesperado al abrir el lector: %s", e)

    # Lanzar Flask en thread daemon
    flask_thread = threading.Thread(target=_run_flask_forever, daemon=True)
    flask_thread.start()

    # Tray icon en main thread (bloqueante)
    try:
        import pystray
    except ImportError:
        log.error("pystray no esta instalado. Corriendo en modo headless.")
        try:
            flask_thread.join()
        except KeyboardInterrupt:
            pass
        return

    icon = pystray.Icon(
        APP_TAG,
        _make_tray_image(reader.is_open()),
        f"{APP_NAME} - {'Conectado al lector' if reader.is_open() else 'Sin lector'}",
        menu=_build_tray_menu(),
    )

    # Updater periodico del icono/tooltip
    updater = threading.Thread(target=_update_tray_periodic, args=(icon,), daemon=True)
    updater.start()

    log.info("Tray icon listo. Click derecho en el icono para ver opciones.")
    icon.run()  # bloquea hasta que se llame icon.stop()


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        log.exception("Excepcion no controlada en main(): %s", e)
        try:
            reader.close()
        except Exception:
            pass
