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

import flask
import octoprint.plugin
import requests
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.6.7"
__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
    
    ##~~ 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
        
        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:
            self._logger.info("MQTT broker connection OK")
            self._start_snapshot_timer()     # Start here
            self._subscribe_mqtt_topics()
        else:
            self._logger.error(f"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"

            # 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 "")
                    try:
                        data = json.loads(payload or "{}")
                        status = data.get("status")

                        if status == "registered":
                            # 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
        except Exception:
            _pause = _resume = _cancel = _home = _move = _set_temp = 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
        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)

            # DO NOT subscribe here - subscribe only when button is clicked
            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)

    @octoprint.plugin.BlueprintPlugin.route("/confirm-registration", methods=["POST"])
    def confirm_registration(self):
        """Confirm registration and save the instance ID permanently"""
        try:
            data = request.get_json()
            instance_id = data.get("instance_id")

            if not instance_id:
                return make_response(jsonify({"error": "instance_id is required"}), 400)

            # Verify this is our temporary ID
            if instance_id != self._temp_instance_id:
                saved_id = self._settings.get(["instance_id"])
                if instance_id != saved_id:
                    return make_response(jsonify({"error": "Invalid instance_id"}), 400)

            # Save permanently
            self._settings.set(["instance_id"], instance_id)
            self._settings.set(["registered"], True)
            self._settings.save()

            # Clear temporary ID
            self._temp_instance_id = None

            self._logger.info(f"Registration confirmed for device: {instance_id}")

            return make_response(jsonify({
                "success": True,
                "message": "Registration confirmed"
            }), 200)
        except Exception as e:
            self._logger.error(f"Registration confirmation error: {e}")
            return make_response(jsonify({"error": str(e)}), 500)

    @octoprint.plugin.BlueprintPlugin.route("/camera", methods=["GET"])
    def get_camera_config(self):
        try:
            url = self._settings.get(["camera", "stream_url"]) or ""
            return make_response(jsonify({"success": True, "stream_url": url}), 200)
        except Exception as e:
            return make_response(jsonify({"success": False, "error": str(e)}), 500)

    @octoprint.plugin.BlueprintPlugin.route("/camera", methods=["POST"])
    def set_camera_config(self):
        try:
            data = request.get_json(force=True, silent=True) or {}
            url = (data.get("stream_url") or "").strip()
            # Allow empty value (for reset)
            self._settings.set(["camera", "stream_url"], url)
            self._settings.save()
            return make_response(jsonify({"success": True, "stream_url": url}), 200)
        except Exception as e:
            return make_response(jsonify({"success": False, "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": {
                "currentZ": data.get("currentZ")
            },
            "temperatures": temps,                      # tool0/bed/chamber: actual/target/offset
            "connection": conn,                         # port/baudrate/printerProfile/state
            "sd": self._get_sd_tree(),                  # REST style { files: [...] }
        }
        return snapshot

    @octoprint.plugin.BlueprintPlugin.route("/snapshot", methods=["GET"])
    def get_snapshot(self):
        """Return snapshot via REST API."""
        return self._make_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 _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
        # 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
    }