# -*- coding: utf-8 -*-
import json
import os
import re
import shlex
import signal
import subprocess
import time
import uuid

import octoprint.plugin
from flask import jsonify, make_response, request
from octoprint.filemanager import FileDestinations
from octoprint.util import RepeatedTimer



__plugin_name__ = "FACTOR Plugin"
__plugin_pythoncompat__ = ">=3.8,<4"
__plugin_version__ = "2.7.5"
__plugin_identifier__ = "octoprint_factor"

        
def _as_code(x):
    try:
        v = getattr(x, "value", None)
        if isinstance(v, int):
            return v
        return int(x)
    except Exception:
        s = (str(x) if x is not None else "").strip().lower()
        if s in ("success", "normal disconnection"):
            return 0
        m = re.search(r"(\d+)", s)
        return int(m.group(1)) if m else -1


class FactorPlugin(octoprint.plugin.SettingsPlugin,
                   octoprint.plugin.AssetPlugin,
                   octoprint.plugin.TemplatePlugin,
                   octoprint.plugin.StartupPlugin,
                   octoprint.plugin.ShutdownPlugin,
                   octoprint.plugin.EventHandlerPlugin,
                   octoprint.plugin.BlueprintPlugin,
                 octoprint.plugin.WizardPlugin):
    
    def __init__(self):
        super().__init__()
        self.mqtt_client = None
        self.is_connected = False
        self._snapshot_timer = None
        self._snapshot_timer_lock = __import__("threading").Lock()
        self._gcode_jobs = {}
        # camera process state
        self._camera_proc = None
        self._camera_started_at = None
        self._camera_last_error = None
        # Temporary instance ID (not saved until registration is complete)
        self._temp_instance_id = None
        # Position tracking
        self._current_position = {"x": None, "y": None, "z": None, "e": None}  # Machine coordinates (accumulated)
        self._target_position = {"x": None, "y": None, "z": None, "e": None}   # G-code target coordinates
        self._position_offset = {"x": None, "y": None, "z": None, "e": None}   # Offset at print start
        self._position_lock = __import__("threading").Lock()
        self._is_absolute_positioning = True  # G90 (absolute) by default

        # Path history tracking (OctoPrint GCode Viewer style)
        self._path_history = []  # List of path segments: [{prevX, prevY, x, y, z, extrude, move, retract, tool}]
        self._path_history_max = 10000  # Maximum number of path segments to keep
        self._last_recorded_position = {"x": 0, "y": 0, "z": 0, "e": 0}  # Last recorded position for path tracking
    
    ##~~ SettingsPlugin mixin
    
    def get_settings_defaults(self):
        return dict(
            broker_host="factor.io.kr",
            broker_port=8883,
            broker_username="",
            broker_password="",
            broker_use_tls=True,
            broker_tls_insecure=False,
            broker_tls_ca_cert="",
            topic_prefix="octoprint",
            qos_level=0,
            retain_messages=False,
            publish_status=True,
            publish_progress=True,
            publish_temperature=True,
            publish_gcode=False,
            periodic_interval=1.0,
            instance_id="",
            registered=False,
            receive_gcode_enabled=True,
            receive_topic_suffix="gcode_in",
            receive_target_default="local_print",
            receive_timeout_sec=300,
            camera=dict(
                stream_url=""
            )

        )
    
    def get_settings_version(self):
        return 1
    
    def on_settings_save(self, data):
        octoprint.plugin.SettingsPlugin.on_settings_save(self, data)
        self._disconnect_mqtt()
        self._connect_mqtt()
        # Timer will start automatically on successful connection
    
    ##~~ AssetPlugin mixin

    def get_assets(self):
        return dict(
            js=["js/i18n.js", "js/factor.js"],
            css=["css/factor.css"]
        )
    
    ##~~ TemplatePlugin mixin
    def on_startup(self, host, port):
        self._connect_mqtt()
        try:
            self._log_api_endpoints(host, port)
        except Exception as e:
            self._logger.warning("Error logging endpoints: %s", e)
    
    def on_after_startup(self):
        """Post-startup initialization tasks"""
        # No busy-wait. Timer can be started early if needed
        # and tick can check is_connected status
        pass


    # --- Utility methods below ---
    def _log_api_endpoints(self, host: str, port: int):
        """
        Log available API endpoints to console (octoprint.log) on plugin load
        """
        # Consider baseUrl set by reverse proxy, etc.
        base_url = self._settings.global_get(["server", "baseUrl"]) or ""
        base_url = base_url.rstrip("/")

        # Based on actual internal binding address (OctoPrint service perspective)
        internal_base = f"http://{host}:{port}{base_url}"
        pid = __plugin_identifier__

        status_url = f"{internal_base}/api/plugin/{pid}/status"
        test_url   = f"{internal_base}/api/plugin/{pid}/test"

        self._logger.info("[FACTOR] REST endpoints ready:")
        self._logger.info(" - GET  %s", status_url)
        self._logger.info(" - POST %s", test_url)
        self._logger.info("   (header 'X-Api-Key' required)")

    def get_template_configs(self):
        return [
            dict(
                type="settings",
                name="FACTOR",
                template="factor_settings.jinja2",
                custom_bindings=True
            ),
            dict(
                type="wizard",
                template="factor_wizard.jinja2",
                custom_bindings=True
            )
        ]

    ##~~ WizardPlugin mixin

    def is_wizard_required(self):
        # Show wizard if device is not registered yet
        return not self._settings.get_boolean(["registered"])

    def get_wizard_version(self):
        return 1

    def get_wizard_details(self):
        return dict()

    ##~~ ShutdownPlugin mixin
    
    def on_shutdown(self):
        self._disconnect_mqtt()
    
    ##~~ EventHandlerPlugin mixin
    
    def on_event(self, event, payload):
        if not self.is_connected:
            return

        # Handle PrintStarted event to capture position offset
        if event == "PrintStarted":
            self._capture_position_offset()
            self._clear_path_history()  # Clear path history on new print
        elif event == "PrintDone" or event == "PrintFailed" or event == "PrintCancelled":
            self._reset_position_offset()

        topic_prefix = self._settings.get(["topic_prefix"])

        if event == "PrinterStateChanged":
            self._publish_status(payload, topic_prefix)
        elif event == "PrintProgress":
            self._publish_progress(payload, topic_prefix)
        elif event == "TemperatureUpdate":
            self._publish_temperature(payload, topic_prefix)
        elif event == "GcodeReceived":
            self._publish_gcode(payload, topic_prefix)
    
    ##~~ Private methods
    
    def _connect_mqtt(self):
        try:
            import paho.mqtt.client as mqtt
            import ssl

            self.mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)

            # Set authentication credentials
            username = self._settings.get(["broker_username"])
            password = self._settings.get(["broker_password"])
            if username:
                self.mqtt_client.username_pw_set(username, password)

            # TLS/SSL configuration
            use_tls = self._settings.get(["broker_use_tls"])
            if use_tls:
                tls_insecure = self._settings.get(["broker_tls_insecure"])
                ca_cert = self._settings.get(["broker_tls_ca_cert"])

                tls_context = ssl.create_default_context()
                if tls_insecure:
                    tls_context.check_hostname = False
                    tls_context.verify_mode = ssl.CERT_NONE
                    self._logger.warning("MQTT TLS certificate verification is disabled. Not recommended for production environments.")
                elif ca_cert:
                    tls_context.load_verify_locations(cafile=ca_cert)

                self.mqtt_client.tls_set_context(tls_context)
                self._logger.info("MQTT TLS/SSL enabled.")

            # Set callback functions
            self.mqtt_client.on_connect = self._on_mqtt_connect
            self.mqtt_client.on_disconnect = self._on_mqtt_disconnect
            self.mqtt_client.on_publish = self._on_mqtt_publish
            self.mqtt_client.on_log = self._on_mqtt_log
            self.mqtt_client.on_message = self._on_mqtt_message

            # Reconnection settings
            self.mqtt_client.reconnect_delay_set(min_delay=1, max_delay=120)

            # Asynchronous connection
            host = self._settings.get(["broker_host"])
            port = int(self._settings.get(["broker_port"]))
            protocol = "mqtts" if use_tls else "mqtt"
            # Username not displayed in logs (security)
            self._logger.info(f"Attempting MQTT async connection: {protocol}://{host}:{port}")

            # Start async connection with connect_async
            self.mqtt_client.connect_async(host, port, 60)
            self.mqtt_client.loop_start()

        except Exception as e:
            self._logger.error(f"MQTT connection failed: {e}")
    
    def _disconnect_mqtt(self):
        if self.mqtt_client:
            self.mqtt_client.loop_stop()
            self.mqtt_client.disconnect()
            self.mqtt_client = None
            self.is_connected = False
            self._logger.info("MQTT client disconnected.")
    
    def _subscribe_mqtt_topics(self):
        """Subscribe to MQTT topics using current instance_id"""
        if not self.is_connected or not self.mqtt_client:
            self._logger.warning("Cannot subscribe: MQTT not connected")
            return

        try:
            qos = int(self._settings.get(["qos_level"]) or 1)
            # Use temporary ID first (for setup), then saved ID (for registered devices)
            inst = self._temp_instance_id or self._settings.get(["instance_id"]) or "unknown"

            # Unsubscribe from old topics if they exist
            if hasattr(self, '_current_subscribed_id') and self._current_subscribed_id != inst:
                old_inst = self._current_subscribed_id
                old_topics = [
                    f"control/{old_inst}",
                    f"octoprint/gcode_in/{old_inst}",
                    f"camera/{old_inst}/cmd",
                    f"device/{old_inst}/registration"
                ]
                for topic in old_topics:
                    self.mqtt_client.unsubscribe(topic)
                self._logger.info(f"[FACTOR] Unsubscribed from old topics with ID: {old_inst}")

            # Subscribe to new topics
            control_topic = f"control/{inst}"
            gcode_topic = f"octoprint/gcode_in/{inst}"
            camera_cmd = f"camera/{inst}/cmd"
            registration_topic = f"device/{inst}/registration"

            self.mqtt_client.subscribe(control_topic, qos=qos)
            self.mqtt_client.subscribe(gcode_topic, qos=qos)
            self.mqtt_client.subscribe(camera_cmd, qos=qos)
            self.mqtt_client.subscribe(registration_topic, qos=qos)

            self._current_subscribed_id = inst
            self._logger.info(f"[FACTOR] Subscribed to topics with ID: {inst}")
        except Exception as e:
            self._logger.warning(f"[FACTOR] Subscribe failed: {e}")

    def _on_mqtt_connect(self, client, userdata, flags, rc, properties=None, *args, **kwargs):
        rc_i = _as_code(rc)
        self.is_connected = (rc_i == 0)
        if self.is_connected:
            inst = self._temp_instance_id or self._settings.get(["instance_id"]) or "unknown"
            self._logger.info(f"[FACTOR] MQTT broker connection OK - Instance ID: {inst}")
            self._start_snapshot_timer()     # Start here
            self._subscribe_mqtt_topics()
        else:
            self._logger.error(f"[FACTOR] MQTT connection failed rc={rc}")

    def _on_mqtt_disconnect(self, client, userdata, rc, properties=None, *args, **kwargs):
        rc_i = _as_code(rc)
        self.is_connected = False
        self._logger.warning(f"MQTT connection lost rc={rc}")
        # Timer can be kept running or stopped. Keeping it allows auto-publish after reconnect
        # Uncomment below to stop timer:
        # self._stop_snapshot_timer()
    
    def _on_mqtt_publish(self, client, userdata, mid, *args, **kwargs):
        # paho 2.0: (mid, properties)
        # paho 2.1+: (mid, reasonCode, properties)
        reasonCode = None
        properties = None
        if len(args) == 1:
            properties = args[0]
        elif len(args) >= 2:
            reasonCode, properties = args[0], args[1]

        if reasonCode is not None:
            try:
                rc_i = _as_code(reasonCode)  # 이미 위에 정의됨
            except Exception:
                rc_i = None
            if rc_i is not None:
                self._logger.debug(f"MQTT publish mid={mid} rc={rc_i}")
            else:
                self._logger.debug(f"MQTT publish mid={mid} rc={reasonCode}")
        else:
            self._logger.debug(f"MQTT publish mid={mid}")
        
    def _on_mqtt_log(self, client, userdata, level, buf):
        """MQTT log callback - for connection debugging"""
        if level == 1:  # DEBUG level
            self._logger.debug(f"MQTT: {buf}")
        elif level == 2:  # INFO level
            self._logger.info(f"MQTT: {buf}")
        elif level == 4:  # WARNING level
            self._logger.warning(f"MQTT: {buf}")
        elif level == 8:  # ERROR level
            self._logger.error(f"MQTT: {buf}")
    
    def _on_mqtt_message(self, client, userdata, msg):
        try:
            topic = msg.topic or ""
            # Use temporary ID first during setup, then saved ID for registered devices
            inst = self._temp_instance_id or self._settings.get(["instance_id"]) or "unknown"

            # Log all incoming messages for debugging
            payload_preview = str(msg.payload[:100]) if msg.payload else ""
            self._logger.debug(f"[FACTOR] MQTT message received - Topic: {topic}, Payload preview: {payload_preview}")

            # 1) Control: control/<instance_id>
            if topic == f"control/{inst}":
                payload = msg.payload.decode("utf-8", errors="ignore") if isinstance(msg.payload, (bytes, bytearray)) else str(msg.payload or "")
                try:
                    data = json.loads(payload or "{}")
                except Exception:
                    data = {}
                self._handle_control_message(data)
                return

            # 2) G-code in: octoprint/gcode_in/<instance_id>
            if topic == f"octoprint/gcode_in/{inst}":
                if not bool(self._settings.get(["receive_gcode_enabled"])):
                    return
                payload = msg.payload.decode("utf-8", errors="ignore") if isinstance(msg.payload, (bytes, bytearray)) else str(msg.payload or "")
                data = json.loads(payload or "{}")
                self._handle_gcode_message(data)
                return

            # 3) Camera control: camera/<instance_id>/cmd
            if topic == f"camera/{inst}/cmd":
                payload = msg.payload.decode("utf-8", errors="ignore") if isinstance(msg.payload, (bytes, bytearray)) else str(msg.payload or "")
                try:
                    data = json.loads(payload or "{}")
                except Exception:
                    data = {}
                # camera 명령은 control 핸들러로 위임 (type=camera)
                if isinstance(data, dict):
                    data = {"type": "camera", **data}
                else:
                    data = {"type": "camera"}
                self._handle_control_message(data)
                return

            # 4) Registration confirmation: device/<instance_id>/registration
            registration_topic = f"device/{inst}/registration"
            if self._temp_instance_id:
                registration_topic_temp = f"device/{self._temp_instance_id}/registration"
                if topic == registration_topic or topic == registration_topic_temp:
                    payload = msg.payload.decode("utf-8", errors="ignore") if isinstance(msg.payload, (bytes, bytearray)) else str(msg.payload or "")
                    self._logger.info(f"[FACTOR] Received registration message on topic {topic}: {payload}")
                    try:
                        data = json.loads(payload or "{}")
                        status = data.get("status")
                        self._logger.info(f"[FACTOR] Registration message parsed - status: {status}, data: {data}")

                        if status == "registered" or status == "registration_confirmed":
                            # Save registration permanently
                            instance_id = self._temp_instance_id or inst
                            self._settings.set(["instance_id"], instance_id)
                            self._settings.set(["registered"], True)
                            self._settings.save()

                            self._logger.info(f"✅ Device registration confirmed via MQTT: {instance_id}")

                            # Send confirmation back to server via MQTT
                            confirmation_payload = {
                                "status": "confirmed",
                                "instance_id": instance_id,
                                "confirmed_at": data.get("registered_at")
                            }
                            confirmation_topic = f"device/{instance_id}/registration/ack"
                            try:
                                self.mqtt_client.publish(
                                    confirmation_topic,
                                    json.dumps(confirmation_payload),
                                    qos=1
                                )
                                self._logger.info(f"[FACTOR] Sent registration confirmation to server: {confirmation_topic}")
                            except Exception as e:
                                self._logger.error(f"Failed to send registration confirmation: {e}")

                            # Clear temporary instance ID
                            self._temp_instance_id = None

                            # Unsubscribe from registration topic
                            self.mqtt_client.unsubscribe(topic)

                            # Send plugin message to update UI
                            self._plugin_manager.send_plugin_message(
                                self._identifier,
                                dict(
                                    type="registration_confirmed",
                                    device_name=data.get("device_name"),
                                    registered_at=data.get("registered_at")
                                )
                            )

                        elif status == "timeout" or status == "failed":
                            # Handle failure/timeout
                            error_msg = data.get("error", f"Registration {status}")
                            error_code = data.get("error_code")
                            self._logger.warning(f"❌ Device registration {status}: {error_msg}" + (f" (code: {error_code})" if error_code else ""))

                            # Unsubscribe from registration topic
                            self.mqtt_client.unsubscribe(topic)

                            # Clear temporary instance ID - ready for new registration
                            self._temp_instance_id = None

                            # Send failure notification to UI
                            self._plugin_manager.send_plugin_message(
                                self._identifier,
                                dict(
                                    type="registration_failed",
                                    status=status,
                                    error=error_msg,
                                    error_code=error_code,
                                    attempted_at=data.get("attempted_at")
                                )
                            )
                    except Exception as e:
                        self._logger.error(f"Failed to process registration message: {e}")
                    return

            # Ignore other topics
            return
        except Exception as e:
            self._logger.exception(f"[FACTOR] on_message processing error: {e}")

    def _handle_gcode_message(self, data: dict):
        # Delegate to modularized implementation
        try:
            from .mqtt_gcode import handle_gcode_message as _impl
            _impl(self, data)
        except Exception as e:
            self._logger.exception(f"GCODE handler error: {e}")

    def _handle_control_message(self, data: dict):
        t = (data.get("type") or "").lower()
        try:
            from .control import (
                pause_print as _pause,
                resume_print as _resume,
                cancel_print as _cancel,
                home_axes as _home,
                move_axes as _move,
                set_temperature as _set_temp,
                set_feed_rate as _set_feed,
                set_fan_speed as _set_fan
            )
        except Exception:
            _pause = _resume = _cancel = _home = _move = _set_temp = _set_feed = _set_fan = None
        # ---- camera control via MQTT ----
        if t == "camera":
            action = (data.get("action") or "").lower()
            opts = data.get("options") or {}
            if action == "start":
                res = self._camera_start(opts)
                self._publish_camera_state()
                self._logger.info(f"[CONTROL] camera start -> {res}")
                return
            if action == "stop":
                res = self._camera_stop(opts)
                self._publish_camera_state()
                self._logger.info(f"[CONTROL] camera stop -> {res}")
                return
            if action == "restart":
                self._camera_stop(opts)
                time.sleep(0.4)
                res = self._camera_start(opts)
                self._publish_camera_state()
                self._logger.info(f"[CONTROL] camera restart -> {res}")
                return
            if action == "state":
                self._publish_camera_state()
                return
        if t == "pause":
            res = _pause(self) if _pause else {"error": "control module unavailable"}
            self._logger.info(f"[CONTROL] pause -> {res}")
            return
        if t == "resume":
            res = _resume(self) if _resume else {"error": "control module unavailable"}
            self._logger.info(f"[CONTROL] resume -> {res}")
            return
        if t == "cancel":
            res = _cancel(self) if _cancel else {"error": "control module unavailable"}
            self._logger.info(f"[CONTROL] cancel -> {res}")
            return
        if t == "home":
            axes_s = (data.get("axes") or "XYZ")
            axes_s = axes_s if isinstance(axes_s, str) else "".join(axes_s)
            axes = []
            s = (axes_s or "").lower()
            if "x" in s: axes.append("x")
            if "y" in s: axes.append("y")
            if "z" in s: axes.append("z")
            if not axes:
                axes = ["x", "y", "z"]
            res = _home(self, axes) if _home else {"error": "control module unavailable"}
            self._logger.info(f"[CONTROL] home {axes} -> {res}")
            return
        if t == "move":
            mode = (data.get("mode") or "relative").lower()
            x = data.get("x"); y = data.get("y"); z = data.get("z"); e = data.get("e")
            feedrate = data.get("feedrate") or 1000
            res = _move(self, mode, x, y, z, e, feedrate) if _move else {"error": "control module unavailable"}
            self._logger.info(f"[CONTROL] move mode={mode} x={x} y={y} z={z} e={e} F={feedrate} -> {res}")
            return
        if t == "set_temperature":
            tool = int(data.get("tool", 0))
            temperature = float(data.get("temperature", 0))
            wait = bool(data.get("wait", False))
            res = _set_temp(self, tool, temperature, wait) if _set_temp else {"error": "control module unavailable"}
            self._logger.info(f"[CONTROL] set_temperature tool={tool} temp={temperature} wait={wait} -> {res}")
            return
        if t == "set_feed_rate" or t == "feed_rate":
            factor = float(data.get("factor") or data.get("speed") or 100)
            res = _set_feed(self, factor) if _set_feed else {"error": "control module unavailable"}
            self._logger.info(f"[CONTROL] set_feed_rate factor={factor} -> {res}")
            return
        if t == "set_fan_speed" or t == "fan_speed":
            speed = int(data.get("speed") or 0)
            res = _set_fan(self, speed) if _set_fan else {"error": "control module unavailable"}
            self._logger.info(f"[CONTROL] set_fan_speed speed={speed} -> {res}")
            return
        self._logger.warning(f"[CONTROL] unknown type={t}")

    # finalize function moved to module

    def _gc_expired_jobs(self, now: float = None):
        try:
            if now is None:
                now = time.time()
            timeout = int(self._settings.get(["receive_timeout_sec"]) or 300)
            expired = []
            for job_id, st in self._gcode_jobs.items():
                if now - (st.get("last_ts") or st.get("created_ts") or now) > timeout:
                    expired.append(job_id)
            for job_id in expired:
                self._gcode_jobs.pop(job_id, None)
            if expired:
                self._logger.warning(f"[FACTOR] Cleared expired jobs: {expired}")
        except Exception as e:
            self._logger.error(f"[FACTOR] Error cleaning up jobs: {e}")
    
    def _check_mqtt_connection_status(self):
        """Check MQTT connection status."""
        if not self.mqtt_client:
            return False

        try:
            # Check connection status
            if self.mqtt_client.is_connected():
                return True
            else:
                # If not connected, only log (reconnect is handled automatically)
                self._logger.debug("MQTT connection is down.")
                return False
        except Exception as e:
            self._logger.error(f"Error checking MQTT connection status: {e}")
            return False
    
    def _publish_status(self, payload, topic_prefix):
        if not self._settings.get(["publish_status"]):
            return
        
        import json
        inst = self._settings.get(["instance_id"]) or "unknown"
        topic = f"{topic_prefix}/status/{inst}"
        message = json.dumps(payload)
        self._publish_message(topic, message)
    
    def _publish_progress(self, payload, topic_prefix):
        if not self._settings.get(["publish_progress"]):
            return
        
        import json
        topic = f"{topic_prefix}/progress"
        message = json.dumps(payload)
        self._publish_message(topic, message)
    
    def _publish_temperature(self, payload, topic_prefix):
        if not self._settings.get(["publish_temperature"]):
            return
        
        import json
        topic = f"{topic_prefix}/temperature"
        message = json.dumps(payload)
        self._publish_message(topic, message)
    
    def _publish_gcode(self, payload, topic_prefix):
        if not self._settings.get(["publish_gcode"]):
            return
        
        import json
        topic = f"{topic_prefix}/gcode"
        message = json.dumps(payload)
        self._publish_message(topic, message)
    
    def _publish_message(self, topic, message):
        if not self.is_connected or not self.mqtt_client:
            return
        
        try:
            import paho.mqtt.client as mqtt
            qos = self._settings.get(["qos_level"])
            retain = self._settings.get(["retain_messages"])
            result = self.mqtt_client.publish(topic, message, qos=qos, retain=retain)

            if result.rc == mqtt.MQTT_ERR_SUCCESS:
                self._logger.debug(f"Message published successfully: {topic}")
            else:
                self._logger.error(f"Message publish failed: {topic}, error code: {result.rc}")

        except Exception as e:
            self._logger.error(f"Error publishing message: {e}")
    
    # ---- Camera helpers ----
    # Configure camera pipeline specifically for WebRTC(MediaMTX)
    @staticmethod
    def _safe_int(x, default=0):
        try:
            return int(x)
        except Exception:
            return default

    @staticmethod
    def _safe_bool(x, default=False):
        try:
            return bool(x)
        except Exception:
            return default


    def _pick_encoder(self, encoder_opt: str) -> list:
        enc = (encoder_opt or "").lower()
        # Raspberry Pi (Bullseye and later): v4l2m2m
        if enc in ("v4l2m2m", "h264_v4l2m2m", "v4l2"):
            return ["-c:v", "h264_v4l2m2m"]
        # Some older/custom systems: omx
        if enc in ("omx", "h264_omx"):
            return ["-c:v", "h264_omx"]
        # Default: software encoder
        return ["-c:v", "libx264", "-tune", "zerolatency"]

    def _validate_url(self, url: str) -> bool:
        """Validate URL to prevent command injection."""
        if not url:
            return False
        # Allow only safe protocols
        if not re.match(r'^(http://|https://|rtsp://|/dev/video\d+)', url):
            return False
        # Prevent command injection characters
        dangerous_chars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r']
        for char in dangerous_chars:
            if char in url:
                return False
        # Reasonable length
        if len(url) > 2048:
            return False
        return True

    def _build_webrtc_mediatx_cmd(self, opts: dict):
        input_url = (opts.get("input") or opts.get("input_url") or
                     self._settings.get(["camera", "stream_url"]) or "").strip()
        if not input_url:
            raise ValueError("missing input url")

        # Validate input URL to prevent command injection
        if not self._validate_url(input_url):
            raise ValueError("invalid or potentially dangerous input URL")

        # Stream name & server address
        name = (opts.get("name") or "cam").strip()
        # Validate stream name (alphanumeric + underscore + hyphen)
        if not re.match(r'^[a-zA-Z0-9_-]+$', name) or len(name) > 50:
            self._logger.error(f"[CAMERA] Stream name validation failed - name: '{name}', length: {len(name)}")
            raise ValueError("invalid stream name")

        rtsp_base = (opts.get("rtsp_base")
                     or os.environ.get("MEDIAMTX_RTSP_BASE")
                     or "rtsp://factor.io.kr:8554").rstrip("/")
        webrtc_base = (opts.get("webrtc_base")
                       or os.environ.get("MEDIAMTX_WEBRTC_BASE")
                       or "https://factor.io.kr/webrtc").rstrip("/")

        # Validate server URLs
        if not self._validate_url(rtsp_base):
            raise ValueError("invalid rtsp_base URL")
        if not self._validate_url(webrtc_base):
            raise ValueError("invalid webrtc_base URL")

        rtsp_url = f"{rtsp_base}/{name}"

        # Quality/frame rate/bitrate (with range validation)
        fps       = max(0, min(60, self._safe_int(opts.get("fps", 0))))  # 0-60
        width     = max(0, min(3840, self._safe_int(opts.get("width", 0))))  # 0-3840 (4K)
        height    = max(0, min(2160, self._safe_int(opts.get("height", 0))))  # 0-2160 (4K)
        bitrate_k = max(100, min(20000, self._safe_int(opts.get("bitrateKbps", 2000))))  # 100-20000 kbps
        encoder   = (opts.get("encoder") or "v4l2m2m")
        # Validate encoder option (whitelist)
        allowed_encoders = ["v4l2m2m", "h264_v4l2m2m", "v4l2", "omx", "h264_omx", "libx264"]
        if encoder not in allowed_encoders:
            encoder = "v4l2m2m"
        low_lat   = self._safe_bool(opts.get("lowLatency", True))
        force_mj  = self._safe_bool(opts.get("forceMjpeg", False))

        # Basic low-latency/network recovery options (duplicates removed)
        cmd = [
            "ffmpeg",
            "-hide_banner", "-loglevel", "info",
            "-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2",
            "-fflags", "nobuffer",
            "-use_wallclock_as_timestamps", "1",
            "-analyzeduration", "0", "-probesize", "32k",
        ]
        if low_lat:
            cmd += ["-flags", "low_delay"]

        # Input protocol specific optimizations
        if input_url.startswith("/dev/video"):
            cmd += ["-f", "v4l2"]
        elif input_url.startswith("rtsp://"):
            cmd += ["-rtsp_transport", "tcp"]

        # Specify for HTTP MJPEG
        if force_mj and input_url.startswith(("http://", "https://")):
            cmd += ["-f", "mjpeg"]

        cmd += ["-i", input_url]

        # Filter chain: fps / scale / pixel format
        vf_chain = []
        if fps > 0:
            vf_chain.append(f"fps={fps}")
        if width > 0 and height > 0:
            vf_chain.append(f"scale={width}:{height}")
        vf_chain.append("format=yuv420p")
        cmd += ["-vf", ",".join(vf_chain)]

        # Encoder
        cmd += self._pick_encoder(encoder)

        # Keyframe interval (GOP): Recommended ~2s for WebRTC
        gop = (fps * 2) if fps > 0 else 50

        # Rate control/profile
        cmd += [
            "-preset", "veryfast",
            "-profile:v", "baseline",
            "-g", str(gop), "-keyint_min", str(gop), "-sc_threshold", "0",
            "-b:v", f"{bitrate_k}k",
            "-maxrate", f"{int(bitrate_k * 11 / 10)}k",
            "-bufsize", f"{bitrate_k}k",
            "-an",  # Remove audio
        ]

        # Output: RTSP Publish → MediaMTX
        cmd += ["-f", "rtsp", "-rtsp_transport", "tcp", rtsp_url]

        # WebRTC URL hint for frontend (included in message)
        extra = {
            "play_url_webrtc": f"{webrtc_base}/{name}/",
            "publish_url_rtsp": rtsp_url,
            "name": name,
        }
        return cmd, extra

    def _build_camera_cmd(self, opts: dict):
        return self._build_webrtc_mediatx_cmd(opts)


    def _camera_status(self):
        running = bool(self._camera_proc and (self._camera_proc.poll() is None))
        pid = (self._camera_proc.pid if running and self._camera_proc else None)
        out = {
            "running": running,
            "pid": pid,
            "started_at": self._camera_started_at,
            "last_error": self._camera_last_error
        }
        if getattr(self, "_webrtc_last", None):
            out["webrtc"] = self._webrtc_last
        return out

    def _start_ffmpeg_subprocess(self, opts: dict):
        if self._camera_proc and self._camera_proc.poll() is None:
            # If already running, just update latest URL and return
            built = self._build_camera_cmd(opts)
            if isinstance(built, tuple):
                _, extra = built
                self._webrtc_last = extra or {}
            return {"success": True, "already_running": True, **self._camera_status()}

        try:
            built = self._build_camera_cmd(opts)
            if isinstance(built, tuple):
                cmd, extra = built
            else:
                cmd, extra = built, {}

            # Platform-specific process group settings
            import sys
            popen_kwargs = {
                "stdout": subprocess.DEVNULL,
                "stderr": subprocess.STDOUT
            }

            # Unix/Linux: Create process group
            if sys.platform != "win32" and hasattr(os, "setsid"):
                popen_kwargs["preexec_fn"] = os.setsid
            # Windows: Use CREATE_NEW_PROCESS_GROUP flag
            elif sys.platform == "win32":
                popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP

            self._camera_proc = subprocess.Popen(cmd, **popen_kwargs)
            self._webrtc_last = extra or {}
            self._camera_started_at = time.time()
            self._camera_last_error = None
            self._logger.info("[CAMERA] pipeline started pid=%s cmd=%s",
                              self._camera_proc.pid, " ".join(shlex.quote(c) for c in cmd))
            return {"success": True, **self._camera_status()}
        except Exception as e:
            self._camera_last_error = str(e)
            self._logger.exception("[CAMERA] start failed")
            return {"success": False, "error": str(e), **self._camera_status()}

    def _stop_ffmpeg_subprocess(self, timeout_sec: float = 5.0):
        try:
            if not (self._camera_proc and self._camera_proc.poll() is None):
                return {"success": True, "already_stopped": True, **self._camera_status()}

            import sys

            # Platform-specific process termination
            if sys.platform == "win32":
                # Windows: Send CTRL_BREAK_EVENT signal
                try:
                    self._camera_proc.send_signal(signal.CTRL_BREAK_EVENT)
                except AttributeError:
                    self._camera_proc.terminate()

                t0 = time.time()
                while (time.time() - t0) < timeout_sec:
                    if self._camera_proc.poll() is not None:
                        break
                    time.sleep(0.1)
                if self._camera_proc.poll() is None:
                    self._camera_proc.kill()
            else:
                # Unix/Linux: Send signal to process group
                try:
                    pgid = os.getpgid(self._camera_proc.pid)
                    os.killpg(pgid, signal.SIGTERM)
                except (OSError, AttributeError):
                    # Fallback: Terminate individual process
                    self._camera_proc.terminate()

                t0 = time.time()
                while (time.time() - t0) < timeout_sec:
                    if self._camera_proc.poll() is not None:
                        break
                    time.sleep(0.1)
                if self._camera_proc.poll() is None:
                    try:
                        os.killpg(pgid, signal.SIGKILL)
                    except (OSError, AttributeError):
                        self._camera_proc.kill()

            self._logger.info("[CAMERA] ffmpeg stopped")
            return {"success": True, **self._camera_status()}
        except Exception as e:
            self._camera_last_error = str(e)
            self._logger.exception("[CAMERA] stop failed")
            return {"success": False, "error": str(e), **self._camera_status()}
    # ---------------------------------------------------------
    # Choose systemd or subprocess
    def _systemctl(self, unit: str, action: str):
        try:
            r = subprocess.run(["systemctl", action, unit],
                               capture_output=True, text=True, timeout=8)
            ok = (r.returncode == 0)
            if not ok:
                self._logger.warning("[CAMERA] systemctl %s %s rc=%s out=%s err=%s",
                                     action, unit, r.returncode, r.stdout, r.stderr)
            return {"success": ok, "stdout": r.stdout, "stderr": r.stderr, "rc": r.returncode}
        except Exception as e:
            return {"success": False, "error": str(e)}

    def _camera_start(self, opts: dict):
        unit = (opts.get("systemd_unit") or "").strip()
        if unit:
            return self._systemctl(unit, "start")
        return self._start_ffmpeg_subprocess(opts)

    def _camera_stop(self, opts: dict):
        unit = (opts.get("systemd_unit") or "").strip()
        if unit:
            return self._systemctl(unit, "stop")
        return self._stop_ffmpeg_subprocess()

    def _publish_camera_state(self):
        try:
            inst = self._settings.get(["instance_id"]) or "unknown"
            topic = f"camera/{inst}/state"
            payload = json.dumps(self._camera_status())
            self._publish_message(topic, payload)
        except Exception as e:
            self._logger.debug(f"publish camera state error: {e}")
            
# ==== END Camera helpers ====



    def _get_sd_tree(self, force_refresh=False, timeout=0.0):
        """
        Return sdcard tree as similar as possible to /api/files?recursive=true
        """
        try:
            # Method 1: Unify as list format
            # Full file list (same format as API response)
            local_files = self._file_manager.list_files(FileDestinations.LOCAL)
            files_list = list(local_files.get("local", {}).values())
            # SD card file list (as list)
            sd_files = self._printer.get_sd_files()

            all_files_payload = {}
            all_files_payload["local"] = files_list
            all_files_payload["sdcard"] = sd_files


            return all_files_payload

        except Exception as e:
            self._logger.debug(f"Failed to retrieve SD tree: {e}")
            return {}




    def _get_printer_summary(self):
        try:
            conn = self._printer.get_current_connection() or {}
            # OctoPrint often returns (state, port, baudrate, profile) tuple
            state = None
            port = None
            baud = None
            profile = None
            if isinstance(conn, (list, tuple)) and len(conn) >= 4:
                state, port, baud, profile = conn[0], conn[1], conn[2], conn[3]
            elif isinstance(conn, dict):
                state = conn.get("state")
                port = conn.get("port")
                baud = conn.get("baudrate")
                profile = conn.get("profile") or {}
            else:
                profile = {}

            prof_id = None
            prof_name = None
            prof_model = None
            heated_bed = None
            volume = {}
            if isinstance(profile, dict):
                prof_id = profile.get("id")
                prof_name = profile.get("name")
                prof_model = profile.get("model")
                heated_bed = profile.get("heatedBed")
                volume = profile.get("volume") or {}

            size = {
                "width": volume.get("width"),
                "depth": volume.get("depth"),
                "height": volume.get("height"),
            }

            return {
                "connection": {
                    "state": state,
                    "port": port,
                    "baudrate": baud,
                    "profile": {
                        "id": prof_id,
                        "name": prof_name,
                        "model": prof_model,
                        "heatedBed": heated_bed,
                        "volume": volume,
                    },
                },
                "size": size,
            }
        except Exception as e:
            self._logger.debug(f"Failed to retrieve summary: {e}")
            return {}

    def _ensure_instance_id(self, force_new=False):
        """
        Get or create instance ID.
        If force_new=True, always generate a new temporary ID.
        Temporary IDs are NOT saved until registration is confirmed.
        """
        # Check if already registered
        saved_id = self._settings.get(["instance_id"])
        if saved_id and not force_new:
            return saved_id

        # Generate new temporary ID or use existing temporary ID
        if force_new or not self._temp_instance_id:
            self._temp_instance_id = str(uuid.uuid4())
            self._logger.info(f"Generated new temporary instance ID: {self._temp_instance_id}")

        return self._temp_instance_id

    ##~~ BlueprintPlugin mixin

    @octoprint.plugin.BlueprintPlugin.route("/setup-url", methods=["GET"])
    def get_setup_url(self):
        """Get the setup URL for this device"""
        try:
            # Get existing temporary ID or create new one (not saved)
            # Only save after registration is confirmed
            instance_id = self._ensure_instance_id(force_new=False)

            # If MQTT is already connected, resubscribe to correct topics
            if self.is_connected and self.mqtt_client:
                self._logger.info(f"[FACTOR] Resubscribing to topics with new temp ID: {instance_id}")
                self._subscribe_mqtt_topics()

            setup_url = f"https://factor.io.kr/setup/{instance_id}"

            return make_response(jsonify({
                "success": True,
                "instance_id": instance_id,
                "setup_url": setup_url
            }), 200)
        except Exception as e:
            self._logger.error(f"Setup URL generation error: {e}")
            return make_response(jsonify({"error": str(e)}), 500)

    @octoprint.plugin.BlueprintPlugin.route("/start-setup", methods=["POST"])
    def start_setup(self):
        """Called when user clicks 'Open Setup Page' button - subscribe to MQTT topics"""
        try:
            # Subscribe to MQTT topics with current temporary instance_id
            self._subscribe_mqtt_topics()

            return make_response(jsonify({
                "success": True,
                "message": "Subscribed to registration topic"
            }), 200)
        except Exception as e:
            self._logger.error(f"Start setup error: {e}")
            return make_response(jsonify({"error": str(e)}), 500)

    def get_update_information(self):
        return {
            "octoprint_factor": {
                "displayName": "FACTOR Plugin",
                "displayVersion": __plugin_version__,
                "type": "github_release",
                "user": "kangbyounggwan",
                "repo": "octoprint-factor-plugin",
                "current": __plugin_version__,
                "pip": "https://github.com/kangbyounggwan/octoprint-factor-plugin/archive/{target_version}.zip",
            }
        }
    
    def _make_snapshot(self):
        """Generate printer status snapshot."""
        import time, json

        data  = self._printer.get_current_data() or {}
        temps = self._printer.get_current_temperatures() or {}
        conn  = self._printer.get_current_connection() or {}

        progress = (data.get("progress") or {})
        job      = (data.get("job") or {})
        fileinfo = (job.get("file") or {})
        filament = (job.get("filament") or {})
        flags    = (data.get("state") or {}).get("flags", {})

        size    = fileinfo.get("size") or 0
        filepos = progress.get("filepos") or 0
        file_pct = round((filepos/size*100.0), 2) if size else None

        snapshot = {
            "ts":        time.time(),
            "state": {
                "text": (data.get("state") or {}).get("text"),
                "flags": {
                    "operational": bool(flags.get("operational")),
                    "printing":    bool(flags.get("printing")),
                    "paused":      bool(flags.get("paused")),
                    "error":       bool(flags.get("error")),
                    "ready":       bool(flags.get("ready")),
                }
            },
            "progress": {
                "completion": progress.get("completion"),      # %
                "filepos":    filepos,                         # bytes
                "file_size":  size,                            # bytes
                "file_pct":   file_pct,                        # %
                "print_time": progress.get("printTime"),       # sec
                "time_left":  progress.get("printTimeLeft"),   # sec
                "time_left_origin": progress.get("printTimeLeftOrigin"),
            },
            "job": {
                "file": {
                    "name":   fileinfo.get("name"),
                    "origin": fileinfo.get("origin"),   # local/sdcard
                    "date":   fileinfo.get("date"),
                },
                "estimated_time": job.get("estimatedPrintTime"),
                "last_time":      job.get("lastPrintTime"),
                "filament":       filament,            # Keep tool0.length/volume etc. as-is
            },
            "axes": {
                "current": self._get_job_coordinates(),  # Convert machine coords to job coords
                "target": {
                    "x": self._target_position.get("x"),
                    "y": self._target_position.get("y"),
                    "z": self._target_position.get("z"),
                    "e": self._target_position.get("e")
                },
                "machine": {  # Raw machine coordinates from M114
                    "x": self._current_position.get("x"),
                    "y": self._current_position.get("y"),
                    "z": self._current_position.get("z"),
                    "e": self._current_position.get("e")
                }
            },
            "path": self._get_path_summary(),  # Path history summary
            "temperatures": temps,                      # tool0/bed/chamber: actual/target/offset
            "connection": conn,                         # port/baudrate/printerProfile/state
            "sd": self._get_sd_tree(),                  # REST style { files: [...] }
        }
        return snapshot

    def _start_snapshot_timer(self):
        """Start snapshot transmission timer."""
        with self._snapshot_timer_lock:
            if self._snapshot_timer:  # Prevent duplicates
                return
            interval = float(self._settings.get(["periodic_interval"]) or 1.0)
            self._snapshot_timer = RepeatedTimer(interval, self._snapshot_tick, run_first=True)
            self._snapshot_timer.start()
            self._logger.info(f"[FACTOR] snapshot timer started every {interval}s")

    def _stop_snapshot_timer(self):
        """Stop snapshot transmission timer."""
        with self._snapshot_timer_lock:
            if self._snapshot_timer:
                self._snapshot_timer.cancel()
                self._snapshot_timer = None
                self._logger.info("[FACTOR] snapshot timer stopped")

    def _parse_m114_response(self, line):
        """Parse M114 response to extract position data.
        Example: Recv: ok X:6546.127 Y:2462.397 Z:10.7 E:161.602 Count: A:654612 B:246239 C:1070
        """
        import re
        try:
            # Match X, Y, Z, E values
            x_match = re.search(r'X:([-\d.]+)', line)
            y_match = re.search(r'Y:([-\d.]+)', line)
            z_match = re.search(r'Z:([-\d.]+)', line)
            e_match = re.search(r'E:([-\d.]+)', line)

            with self._position_lock:
                if x_match:
                    new_x = float(x_match.group(1))
                    self._logger.debug(f"[POSITION] Parsing X from line: {line[:100]}")
                    self._logger.debug(f"[POSITION] Setting X: {self._current_position['x']} -> {new_x}")
                    self._current_position["x"] = new_x
                if y_match:
                    new_y = float(y_match.group(1))
                    self._logger.debug(f"[POSITION] Setting Y: {self._current_position['y']} -> {new_y}")
                    self._current_position["y"] = new_y
                if z_match:
                    self._current_position["z"] = float(z_match.group(1))
                if e_match:
                    self._current_position["e"] = float(e_match.group(1))
        except Exception as e:
            self._logger.debug(f"Error parsing M114 response: {e}")

    def _capture_position_offset(self):
        """Capture machine coordinate offset at print start"""
        try:
            with self._position_lock:
                # Store current machine coordinates as offset
                self._position_offset["x"] = self._current_position.get("x")
                self._position_offset["y"] = self._current_position.get("y")
                self._position_offset["z"] = self._current_position.get("z")
                self._position_offset["e"] = self._current_position.get("e")
                self._logger.info(f"[POSITION] Captured offset at print start: {self._position_offset}")
        except Exception as e:
            self._logger.error(f"Error capturing position offset: {e}")

    def _reset_position_offset(self):
        """Reset position offset after print ends"""
        with self._position_lock:
            self._position_offset = {"x": None, "y": None, "z": None, "e": None}
            self._logger.info("[POSITION] Reset position offset")

    def _clear_path_history(self):
        """Clear path history for new print"""
        with self._position_lock:
            self._path_history = []
            self._last_recorded_position = {"x": 0, "y": 0, "z": 0, "e": 0}
            self._logger.info("[PATH] Path history cleared")

    def _add_path_segment(self, prev_x, prev_y, x, y, z, extrude=False, retract=0, tool=0):
        """Add a path segment to history (OctoPrint GCode Viewer style)

        Args:
            prev_x, prev_y: Starting coordinates
            x, y: Target coordinates
            z: Z height
            extrude: True if extruding (drawing line)
            retract: -1 for retract, 0 for none, 1 for restart
            tool: Tool/extruder number
        """
        with self._position_lock:
            # Skip if no actual movement
            if prev_x == x and prev_y == y:
                return

            segment = {
                "prevX": prev_x,
                "prevY": prev_y,
                "x": x,
                "y": y,
                "z": z,
                "extrude": extrude,
                "retract": retract,
                "tool": tool,
                "move": not extrude and retract == 0  # Travel move (no extrusion)
            }

            self._path_history.append(segment)

            # Limit history size
            if len(self._path_history) > self._path_history_max:
                # Remove oldest 10% when limit reached
                remove_count = self._path_history_max // 10
                self._path_history = self._path_history[remove_count:]

    def _get_path_history(self, limit=None, offset=0):
        """Get path history for rendering

        Args:
            limit: Maximum number of segments to return (None for all)
            offset: Starting index

        Returns:
            List of path segments
        """
        with self._position_lock:
            if limit is None:
                return self._path_history[offset:]
            return self._path_history[offset:offset + limit]

    def _get_path_summary(self):
        """Get path history summary for status reporting"""
        with self._position_lock:
            total_segments = len(self._path_history)
            extrude_count = sum(1 for s in self._path_history if s.get("extrude"))
            move_count = sum(1 for s in self._path_history if s.get("move"))
            retract_count = sum(1 for s in self._path_history if s.get("retract") == -1)

            # Calculate bounding box
            if total_segments > 0:
                min_x = min(min(s["prevX"], s["x"]) for s in self._path_history)
                max_x = max(max(s["prevX"], s["x"]) for s in self._path_history)
                min_y = min(min(s["prevY"], s["y"]) for s in self._path_history)
                max_y = max(max(s["prevY"], s["y"]) for s in self._path_history)
                bounding_box = {"minX": min_x, "maxX": max_x, "minY": min_y, "maxY": max_y}
            else:
                bounding_box = None

            return {
                "total_segments": total_segments,
                "extrude_count": extrude_count,
                "move_count": move_count,
                "retract_count": retract_count,
                "bounding_box": bounding_box
            }

    def _get_job_coordinates(self):
        """Convert machine coordinates to job coordinates using offset"""
        with self._position_lock:
            result = {"x": None, "y": None, "z": None, "e": None}

            for axis in ["x", "y", "z", "e"]:
                machine = self._current_position.get(axis)
                offset = self._position_offset.get(axis)

                if machine is not None and offset is not None:
                    # Job coordinate = Machine coordinate - Start offset
                    result[axis] = machine - offset
                elif machine is not None:
                    # No offset yet (print not started), use machine coordinate
                    result[axis] = machine

            return result

    def _request_position_update(self):
        """Request position update via M114 command"""
        try:
            # Request M114 to get machine coordinates
            if self._printer.is_operational():
                self._printer.commands("M114")
        except Exception as e:
            self._logger.debug(f"Error requesting position: {e}")

    def on_gcode_received(self, comm_instance, line, *args, **kwargs):
        """Hook to receive G-code responses from printer"""
        # Check if this is an M114 response (must contain "X:" and NOT be a G-code command)
        # M114 responses typically start with "ok" or "X:" and contain position data
        if line and "X:" in line:
            # Exclude G-code commands (G0, G1, G28, etc.) from being parsed as M114
            line_upper = line.upper().strip()
            # Only parse if it's NOT a G-code command being sent
            if not (line_upper.startswith("SEND:") or
                    line_upper.startswith("G0 ") or line_upper.startswith("G1 ") or
                    line_upper.startswith("G28") or line_upper.startswith("G29") or
                    "G0 " in line_upper or "G1 " in line_upper):
                self._parse_m114_response(line)
        return line

    def _parse_gcode_for_target_position(self, gcode, cmd):
        """Parse G0/G1 commands to extract target position and record path history.
        Example: G1 X100.5 Y50.2 Z10 E5.5 F3000

        Based on OctoPrint GCode Viewer's worker.js parsing logic.
        """
        import re
        try:
            # Check for positioning mode changes
            if gcode == "G90":
                self._is_absolute_positioning = True
                return
            elif gcode == "G91":
                self._is_absolute_positioning = False
                return

            # Only process movement commands
            if gcode not in ["G0", "G1"]:
                return

            # Parse X, Y, Z, E values from command
            x_match = re.search(r'X([-\d.]+)', cmd, re.IGNORECASE)
            y_match = re.search(r'Y([-\d.]+)', cmd, re.IGNORECASE)
            z_match = re.search(r'Z([-\d.]+)', cmd, re.IGNORECASE)
            e_match = re.search(r'E([-\d.]+)', cmd, re.IGNORECASE)

            # Calculate new position based on mode
            new_x = None
            new_y = None
            new_z = None
            new_e = None
            extrude = False
            retract = 0

            with self._position_lock:
                # Get previous position
                prev_x = self._last_recorded_position.get("x", 0) or 0
                prev_y = self._last_recorded_position.get("y", 0) or 0
                prev_z = self._last_recorded_position.get("z", 0) or 0
                prev_e = self._last_recorded_position.get("e", 0) or 0

                if self._is_absolute_positioning:
                    # Absolute mode (G90): directly set target position
                    if x_match:
                        new_x = float(x_match.group(1))
                        self._target_position["x"] = new_x
                    else:
                        new_x = prev_x

                    if y_match:
                        new_y = float(y_match.group(1))
                        self._target_position["y"] = new_y
                    else:
                        new_y = prev_y

                    if z_match:
                        new_z = float(z_match.group(1))
                        self._target_position["z"] = new_z
                    else:
                        new_z = prev_z

                    if e_match:
                        new_e = float(e_match.group(1))
                        self._target_position["e"] = new_e
                        # Check extrusion (OctoPrint GCode Viewer style)
                        e_delta = new_e - prev_e
                        if e_delta > 0:
                            extrude = True
                            retract = 0
                        elif e_delta < 0:
                            extrude = False
                            retract = -1  # Retract
                    else:
                        new_e = prev_e
                else:
                    # Relative mode (G91): calculate target from current position
                    if x_match:
                        delta_x = float(x_match.group(1))
                        new_x = prev_x + delta_x
                        self._target_position["x"] = new_x
                    else:
                        new_x = prev_x

                    if y_match:
                        delta_y = float(y_match.group(1))
                        new_y = prev_y + delta_y
                        self._target_position["y"] = new_y
                    else:
                        new_y = prev_y

                    if z_match:
                        delta_z = float(z_match.group(1))
                        new_z = prev_z + delta_z
                        self._target_position["z"] = new_z
                    else:
                        new_z = prev_z

                    if e_match:
                        delta_e = float(e_match.group(1))
                        new_e = prev_e + delta_e
                        self._target_position["e"] = new_e
                        # Check extrusion
                        if delta_e > 0:
                            extrude = True
                            retract = 0
                        elif delta_e < 0:
                            extrude = False
                            retract = -1  # Retract
                    else:
                        new_e = prev_e

                # Update last recorded position
                self._last_recorded_position["x"] = new_x
                self._last_recorded_position["y"] = new_y
                self._last_recorded_position["z"] = new_z
                self._last_recorded_position["e"] = new_e

            # Add to path history (only if X or Y movement)
            if x_match or y_match:
                self._add_path_segment(
                    prev_x=prev_x,
                    prev_y=prev_y,
                    x=new_x,
                    y=new_y,
                    z=new_z,
                    extrude=extrude,
                    retract=retract,
                    tool=0  # TODO: Track tool changes with T commands
                )

        except Exception as e:
            self._logger.debug(f"Error parsing gcode for target position: {e}")

    def on_gcode_sent(self, comm_instance, phase, cmd, cmd_type, gcode, *args, **kwargs):
        """Hook to capture sent G-code commands for target position tracking"""
        if gcode:
            self._parse_gcode_for_target_position(gcode, cmd)
        return None

    def _snapshot_tick(self):
        """Snapshot timer callback function"""
        # Do nothing if not connected (wait for MQTT reconnection)
        if not (self.is_connected and self.mqtt_client):
            return
        # Request position update before creating snapshot
        self._request_position_update()
        # Create and publish snapshot (reuse existing function)
        try:
            payload = self._make_snapshot()
            # Use temporary ID first during setup, then saved ID for registered devices
            inst = self._temp_instance_id or self._settings.get(["instance_id"]) or "unknown"
            topic = f"{self._settings.get(['topic_prefix']) or 'octoprint'}/status/{inst}"
            self._publish_message(topic, json.dumps(payload))
            self._gc_expired_jobs()
        except Exception as e:
            self._logger.debug(f"snapshot tick error: {e}")


def __plugin_load__():
    global __plugin_implementation__
    __plugin_implementation__ = FactorPlugin()

    global __plugin_hooks__
    __plugin_hooks__ = {
        "octoprint.plugin.softwareupdate.check_config":
            __plugin_implementation__.get_update_information,
        "octoprint.comm.protocol.gcode.received":
            __plugin_implementation__.on_gcode_received,
        "octoprint.comm.protocol.gcode.sent":
            __plugin_implementation__.on_gcode_sent
    }